From 06be9d20a93046277a98e4393f0bf4f9edb12da1 Mon Sep 17 00:00:00 2001 From: maxkongerskov Date: Sat, 29 Aug 2026 10:56:04 +0200 Subject: [PATCH 1/6] fix(composer): keep the approval popup, call it Auto mode (#553) Restore the two-option list. The chip only changes its name (Ask for approval / Auto mode), not its color. Picking Auto mode still sets autoApprove, so the profile switch turns on with it. Co-authored-by: Max --- src/components/Composer.tsx | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/src/components/Composer.tsx b/src/components/Composer.tsx index ac5ae7457..628d43591 100644 --- a/src/components/Composer.tsx +++ b/src/components/Composer.tsx @@ -59,9 +59,13 @@ interface QueuedGroupSend { draft: ComposerDraftSnapshot; } +/** Composer chip for Auto mode. Same `autoApprove` bit as the profile switch + * — picking Auto mode here turns that on. The chip only changes its name, + * not its color. */ function PermissionModeSelector({ bot, onSetAuto }: { bot: Bot; onSetAuto: (auto: boolean) => void }) { const [open, setOpen] = useState(false); const wrapperRef = useRef(null); + const on = Boolean(bot.autoApprove); useEffect(() => { function handleClickOutside(e: MouseEvent) { @@ -80,21 +84,18 @@ function PermissionModeSelector({ bot, onSetAuto }: { bot: Bot; onSetAuto: (auto return () => document.removeEventListener("keydown", closeOnEscape); }, [open]); - const mode = bot.autoApprove ? "auto" : "ask"; - const Icon = mode === "auto" ? ShieldCheck : Hand; - const label = mode === "auto" ? "Approve for me" : "Ask for approval"; - return (
{open && ( @@ -110,7 +111,7 @@ function PermissionModeSelector({ bot, onSetAuto }: { bot: Bot; onSetAuto: (auto @@ -1255,7 +1263,7 @@ export function ChatView({ bot }: { bot: Bot }) { request can restore the old task without spilling into the newly selected one. ArrowUp-to-edit stays gated on busy because editing rewinds the thread, which a live turn forbids (the server 409s it). */} -
+
(null); + const composerDockRef = useRef(null); + const composerDock = useComposerDockPad(composerDockRef); const [follow, setFollow] = useState(true); const followRef = useRef(true); const previousScrollTop = useRef(0); @@ -928,13 +931,14 @@ export function GroupView({ group }: { group: Group }) { useEffect(() => setFolderOpen(false), [group.id]); useEffect(() => setMembersOpen(false), [group.id]); // deps track the FULL messages.length, so expanding the window (which only - // changes windowedMessages) can never re-trigger this bottom scrollTo + // changes windowedMessages) can never re-trigger this bottom scrollTo. + // `follow` is intentionally omitted — see ChatView. useEffect(() => { const el = scrollRef.current; if (!el || !followRef.current) return; el.scrollTo({ top: el.scrollHeight }); previousScrollTop.current = el.scrollTop; - }, [group.id, group.messages.length, streaming, group.busyBotId, follow]); + }, [group.id, group.messages.length, streaming, group.busyBotId, composerDock.pad]); // Expanding prepends rows: capture the height first, then after the commit // shift scrollTop by the growth so the message under the cursor stays put @@ -1161,7 +1165,8 @@ export function GroupView({ group }: { group: Group }) {
) : (
Jump to latest )} -
+
{ ).toBe(false); }); + it("does not re-pin in the old 48px magnet zone", () => { + expect( + shouldResumeBottomFollow({ + following: false, + previousScrollTop: 952, + scrollTop: 960, + distanceFromBottom: 40, + }), + ).toBe(false); + }); + it("does nothing when bottom-follow is already active", () => { expect( shouldResumeBottomFollow({ diff --git a/src/lib/bottom-follow.ts b/src/lib/bottom-follow.ts index 62a419d6b..cf4437d77 100644 --- a/src/lib/bottom-follow.ts +++ b/src/lib/bottom-follow.ts @@ -1,9 +1,11 @@ -export const BOTTOM_FOLLOW_THRESHOLD = 48; +/** Subpixel slack for "at the rest position" — not a magnet zone. A larger + * value used to re-pin follow ~48px early and then jump the pane to the end. */ +export const BOTTOM_FOLLOW_THRESHOLD = 4; /** - * Resume automatic bottom-follow only when the reader is moving toward the - * latest message. An upward movement can still be within the near-bottom - * threshold and must never re-pin the transcript. + * Resume automatic bottom-follow only when the reader is already at the + * rest position and still moving toward it. Re-pinning must not imply a + * snap; the scroll effect follows new content, it does not yank the viewport. */ export function shouldResumeBottomFollow({ following, diff --git a/src/lib/composer-dock.test.ts b/src/lib/composer-dock.test.ts new file mode 100644 index 000000000..cafc6b972 --- /dev/null +++ b/src/lib/composer-dock.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; + +import { TRANSCRIPT_GAP, transcriptEndPad } from "./composer-dock"; + +describe("transcriptEndPad", () => { + it("adds one gap-3 of black above the measured composer", () => { + expect(TRANSCRIPT_GAP).toBe("0.75rem"); + expect(transcriptEndPad(72)).toBe("calc(72px + 0.75rem)"); + }); + + it("ceils fractional heights so a subpixel composer cannot eat the gap", () => { + expect(transcriptEndPad(71.2)).toBe("calc(72px + 0.75rem)"); + }); + + it("does not go negative", () => { + expect(transcriptEndPad(-4)).toBe("calc(0px + 0.75rem)"); + }); +}); diff --git a/src/lib/composer-dock.ts b/src/lib/composer-dock.ts new file mode 100644 index 000000000..4439cceb3 --- /dev/null +++ b/src/lib/composer-dock.ts @@ -0,0 +1,32 @@ +import { useLayoutEffect, useState, type RefObject } from "react"; + +/** Same as Tailwind `gap-3` on the transcript stack. The last bubble sits + * this far above the composer when the pane is scrolled to the end. */ +export const TRANSCRIPT_GAP = "0.75rem"; + +const FALLBACK_COMPOSER_PX = 96; + +export function transcriptEndPad(composerHeightPx: number): string { + const height = Number.isFinite(composerHeightPx) + ? Math.max(0, Math.ceil(composerHeightPx)) + : FALLBACK_COMPOSER_PX; + return `calc(${height}px + ${TRANSCRIPT_GAP})`; +} + +/** Pad the transcript so rest-at-bottom leaves one inter-bubble gap of + * black above the docked composer. Tracks composer resizes (multiline, + * queued chip, approval takeover). */ +export function useComposerDockPad(ref: RefObject) { + const [height, setHeight] = useState(0); + useLayoutEffect(() => { + const el = ref.current; + if (!el) return; + const apply = () => setHeight(el.getBoundingClientRect().height); + apply(); + const observer = new ResizeObserver(apply); + observer.observe(el); + return () => observer.disconnect(); + }, [ref]); + const measured = height > 0 ? height : FALLBACK_COMPOSER_PX; + return { pad: transcriptEndPad(measured), height: measured }; +} From 28146f61ff5472cd24bfa4d0c0c2fb9cf9f2a2df Mon Sep 17 00:00:00 2001 From: maxkongerskov Date: Sat, 29 Aug 2026 11:02:28 +0200 Subject: [PATCH 3/6] feat(tasks): search the header task switcher (#550) * feat(tasks): search the header task switcher The picker was a 320px scroll of every context on the bot, so a long task list had no way to jump to a name. Add a search field that filters titles as you type, ranks prefix hits first, and lets Enter take the top match. * fix(tasks): use valid picker accessibility semantics --------- Co-authored-by: Max Co-authored-by: milind-soni --- src/components/TaskPicker.test.ts | 30 +++++++++++++++ src/components/TaskPicker.tsx | 64 +++++++++++++++++++++++++++++-- 2 files changed, 90 insertions(+), 4 deletions(-) diff --git a/src/components/TaskPicker.test.ts b/src/components/TaskPicker.test.ts index fe02391ac..be4fbcb5f 100644 --- a/src/components/TaskPicker.test.ts +++ b/src/components/TaskPicker.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { TASK_PICKER_DISMISS_MS, TASK_RENAME_HINT, + filterTasks, taskPickerPointerIntent, } from "./TaskPicker"; @@ -35,3 +36,32 @@ describe("task picker copy", () => { expect(TASK_PICKER_DISMISS_MS).toBeGreaterThanOrEqual(500); }); }); + +describe("filterTasks", () => { + const tasks = [ + { title: "Clean up" }, + { title: "OpenMausBot Update" }, + { title: "Investment report" }, + { title: "Report drafts" }, + ]; + + it("returns the original order when the query is empty", () => { + expect(filterTasks(tasks, "").map((task) => task.title)).toEqual(tasks.map((task) => task.title)); + expect(filterTasks(tasks, " ").map((task) => task.title)).toEqual(tasks.map((task) => task.title)); + }); + + it("matches titles case-insensitively", () => { + expect(filterTasks(tasks, "openmaus").map((task) => task.title)).toEqual(["OpenMausBot Update"]); + }); + + it("ranks prefix hits ahead of substring hits, keeping input order in each tier", () => { + expect(filterTasks(tasks, "report").map((task) => task.title)).toEqual([ + "Report drafts", + "Investment report", + ]); + }); + + it("returns nothing when nothing matches", () => { + expect(filterTasks(tasks, "zzzz")).toEqual([]); + }); +}); diff --git a/src/components/TaskPicker.tsx b/src/components/TaskPicker.tsx index 180086bcc..2c678eb01 100644 --- a/src/components/TaskPicker.tsx +++ b/src/components/TaskPicker.tsx @@ -5,7 +5,7 @@ // own transcript and its own provider session — so sensitive work, a // long job and a quick question can sit side by side under one agent. import { useEffect, useRef, useState } from "react"; -import { Check, ChevronDown, Pencil, Plus, Trash2 } from "lucide-react"; +import { Check, ChevronDown, Pencil, Plus, Search, Trash2 } from "lucide-react"; import { useStore, formatTime, type Bot, type Group, type Task } from "@/state/store"; import { cn } from "@/lib/cn"; import { COMPACT_BUBBLE } from "@/lib/compact-chip"; @@ -32,6 +32,22 @@ export function taskPickerPointerIntent( return "ignore"; } +/** Filter the task switcher. Prefix matches float first so a few letters + * still find the right row in a long list; within a tier the caller's + * order (newest first) is preserved. */ +export function filterTasks(tasks: readonly T[], query: string): T[] { + const needle = query.trim().toLowerCase(); + if (!needle) return [...tasks]; + const prefix: T[] = []; + const substring: T[] = []; + for (const task of tasks) { + const title = task.title.toLowerCase(); + if (title.startsWith(needle)) prefix.push(task); + else if (title.includes(needle)) substring.push(task); + } + return [...prefix, ...substring]; +} + /** Quiet per-task token tally — input+output combined, because one honest * total reads faster than a split; the split lives in the hover title. */ function TaskUsage({ usage }: { usage: Task["usage"] }) { @@ -68,6 +84,7 @@ function ConversationTaskPicker({ const [open, setOpen] = useState(false); const [renaming, setRenaming] = useState(null); const [draft, setDraft] = useState(""); + const [query, setQuery] = useState(""); const ref = useRef(null); const dismissTimer = useRef | null>(null); const finishingRename = useRef(false); @@ -84,6 +101,7 @@ function ConversationTaskPicker({ const closeMenu = () => { clearDismiss(); setRenaming(null); + setQuery(""); setOpen(false); }; @@ -96,7 +114,7 @@ function ConversationTaskPicker({ }, TASK_PICKER_DISMISS_MS); }; - const startRename = (task: Task) => { + const startRename = (task: PickerTask) => { clearDismiss(); finishingRename.current = false; setDraft(task.title); @@ -114,6 +132,7 @@ function ConversationTaskPicker({ dismissTimer.current = null; } setRenaming(null); + setQuery(""); return; } const onDown = (e: MouseEvent) => { @@ -184,6 +203,8 @@ function ConversationTaskPicker({ u && currentLabel ? `Switch task · ${currentLabel} (${u.input.toLocaleString()} in · ${u.output.toLocaleString()} out)` : "Switch task"; + const visible = filterTasks(tasks, query); + const looking = query.trim(); return (
@@ -207,8 +228,43 @@ function ConversationTaskPicker({ {open && (
-
- {tasks.map((task) => { +
+
+ + setQuery(e.target.value)} + onClick={(e) => e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + onKeyDown={(e) => { + if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + if (looking) setQuery(""); + else closeMenu(); + return; + } + if (e.key === "Enter" && !e.nativeEvent.isComposing) { + e.preventDefault(); + const first = visible[0]; + if (!first) return; + if (first.threadId !== threadId) onSwitch(first.threadId); + closeMenu(); + } + }} + placeholder="Search tasks" + aria-label="Search tasks" + className="w-full bg-transparent text-[12.5px] text-ink placeholder:text-ink-secondary focus:outline-none" + /> +
+
+
+ {visible.length === 0 ? ( +
+ Nothing matches “{looking}” +
+ ) : visible.map((task) => { const active = task.threadId === threadId; return (
Date: Sat, 29 Aug 2026 05:08:21 -0400 Subject: [PATCH 4/6] feat(openai-compat): pin OpenRouter upstream provider and default model (#548) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(openai-compat): pin OpenRouter upstream provider and default model The openai-compat driver sent only `{model, messages, stream}`, so there was no way to pin an OpenRouter upstream provider or seed a default model. Add two optional `openaiCompat` config fields: - `model` — seeds the picker's default selection (survives /models refresh) - `provider`— OpenRouter routing; sent as `provider: { order: [provider], allow_fallbacks: false }` Both thread through the existing workspace-default carry in instanceConfigs (parallel to `url`) and fall back to `OPENAI_COMPAT_MODEL` / `OPENAI_COMPAT_PROVIDER`. Endpoints that don't speak OpenRouter routing ignore the extra field. Adds unit tests for decode, catalog seeding, and body shape. Co-Authored-By: Claude Opus 4.8 * fix: gate OpenRouter provider routing by host; sync model/provider env on save - openai-compat driver: only serialize the OpenRouter-specific `provider` routing object when the configured URL's hostname is openrouter.ai or a subdomain (parsed via URL, not substring-matched) — strict OpenAI-compatible endpoints like Groq reject unknown top-level fields. - config: syncCredentialEnv now keeps OPENAI_COMPAT_MODEL and OPENAI_COMPAT_PROVIDER in step with a mid-session save, matching the existing key/url behavior (set when truthy, delete when cleared, untouched when absent) so boot-injected env no longer shadows a save until relaunch. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: milind-soni --- server/config.test.ts | 31 ++++ server/config.ts | 46 ++++-- server/drivers/openai-compat.test.ts | 213 +++++++++++++++++++++++++++ server/drivers/openai-compat.ts | 52 ++++++- 4 files changed, 326 insertions(+), 16 deletions(-) diff --git a/server/config.test.ts b/server/config.test.ts index 3e5700957..aa2355e8d 100644 --- a/server/config.test.ts +++ b/server/config.test.ts @@ -304,6 +304,8 @@ describe("credential env preference", () => { "XAI_API_KEY", "OPENAI_COMPAT_API_KEY", "OPENAI_COMPAT_URL", + "OPENAI_COMPAT_MODEL", + "OPENAI_COMPAT_PROVIDER", "BOX_TOKEN", "OPENCODE_API_KEY", "OMB_TTS_KEY", @@ -389,6 +391,35 @@ describe("credential env preference", () => { expect(process.env.BOX_TOKEN).toBeUndefined(); expect(process.env.OMB_TTS_KEY).toBeUndefined(); }); + + it("syncCredentialEnv keeps model and provider env in step with a save", () => { + // loadConfig() prefers OPENAI_COMPAT_MODEL/PROVIDER over the file, so a + // mid-session save must update them like key/url or the boot-injected + // values shadow the save until relaunch + process.env.OPENAI_COMPAT_MODEL = "boot-model"; + process.env.OPENAI_COMPAT_PROVIDER = "boot-provider"; + syncCredentialEnv({ + openaiCompat: { model: "vendor/just-saved", provider: "fireworks" }, + }); + expect(process.env.OPENAI_COMPAT_MODEL).toBe("vendor/just-saved"); + expect(process.env.OPENAI_COMPAT_PROVIDER).toBe("fireworks"); + }); + + it("syncCredentialEnv clears model and provider env on an empty-string save", () => { + process.env.OPENAI_COMPAT_MODEL = "boot-model"; + process.env.OPENAI_COMPAT_PROVIDER = "boot-provider"; + syncCredentialEnv({ openaiCompat: { model: "", provider: "" } }); + expect(process.env.OPENAI_COMPAT_MODEL).toBeUndefined(); + expect(process.env.OPENAI_COMPAT_PROVIDER).toBeUndefined(); + }); + + it("syncCredentialEnv leaves model and provider env untouched when absent from the patch", () => { + process.env.OPENAI_COMPAT_MODEL = "boot-model"; + process.env.OPENAI_COMPAT_PROVIDER = "boot-provider"; + syncCredentialEnv({ openaiCompat: { key: "just-saved" } }); + expect(process.env.OPENAI_COMPAT_MODEL).toBe("boot-model"); + expect(process.env.OPENAI_COMPAT_PROVIDER).toBe("boot-provider"); + }); }); describe("workspace credential env strip", () => { diff --git a/server/config.ts b/server/config.ts index a6758bbd1..e44646d3e 100644 --- a/server/config.ts +++ b/server/config.ts @@ -77,7 +77,11 @@ const instanceConfigSchema = z.object({ const instanceConfigMapSchema = z.record(z.string(), instanceConfigSchema); const appConfigSchema = z.object({ xai: z.object({ key: optionalText, url: optionalText }).optional(), - openaiCompat: z.object({ key: optionalText, url: optionalText }).optional(), + /** `model` seeds the default selection; `provider` pins an OpenRouter + * upstream (e.g. "fireworks"). Both are non-secret and optional. */ + openaiCompat: z + .object({ key: optionalText, url: optionalText, model: optionalText, provider: optionalText }) + .optional(), /** Project key used for Sessions, catalog and agent tools. userId/sessionId * are non-secret local identifiers used to reuse one Composio Session. */ composio: z.object({ apiKey: optionalText, userId: optionalText, sessionId: optionalText }).optional(), @@ -103,7 +107,7 @@ const jsonObjectSchema = z.record(z.string(), z.json()); export interface AppConfig { xai?: { key?: string; url?: string }; - openaiCompat?: { key?: string; url?: string }; + openaiCompat?: { key?: string; url?: string; model?: string; provider?: string }; composio?: { apiKey?: string; userId?: string; sessionId?: string }; box?: { token?: string }; /** A named host from the user's SSH config. Authentication stays with SSH. */ @@ -198,6 +202,8 @@ export function loadConfig(): AppConfig { cfg.openaiCompat = { ...cfg.openaiCompat }; if (process.env.OPENAI_COMPAT_API_KEY !== undefined) cfg.openaiCompat.key = process.env.OPENAI_COMPAT_API_KEY; if (process.env.OPENAI_COMPAT_URL !== undefined) cfg.openaiCompat.url = process.env.OPENAI_COMPAT_URL; + if (process.env.OPENAI_COMPAT_MODEL !== undefined) cfg.openaiCompat.model = process.env.OPENAI_COMPAT_MODEL; + if (process.env.OPENAI_COMPAT_PROVIDER !== undefined) cfg.openaiCompat.provider = process.env.OPENAI_COMPAT_PROVIDER; cfg.composio = { ...cfg.composio }; if (process.env.COMPOSIO_API_KEY !== undefined) cfg.composio.apiKey = process.env.COMPOSIO_API_KEY; cfg.box = { ...cfg.box }; @@ -233,9 +239,17 @@ export function syncCredentialEnv(patch: Partial): void { if (value) process.env[name] = value; else delete process.env[name]; } - if (patch.openaiCompat?.url !== undefined) { - if (patch.openaiCompat.url) process.env["OPENAI_COMPAT_URL"] = patch.openaiCompat.url; - else delete process.env["OPENAI_COMPAT_URL"]; + // loadConfig() also prefers env for url/model/provider, so a saved value + // must follow the same set-when-truthy / delete-when-cleared rule as keys. + const settings: Array<[value: string | undefined, name: string]> = [ + [patch.openaiCompat?.url, "OPENAI_COMPAT_URL"], + [patch.openaiCompat?.model, "OPENAI_COMPAT_MODEL"], + [patch.openaiCompat?.provider, "OPENAI_COMPAT_PROVIDER"], + ]; + for (const [value, name] of settings) { + if (value === undefined) continue; + if (value) process.env[name] = value; + else delete process.env[name]; } } @@ -457,15 +471,21 @@ export function instanceConfigs(cfg: AppConfig): InstanceConfigMap { // intentionally not consulted by ProviderRegistry when it decodes a // driver's config, so carry the workspace default into the transient // instance map while preserving a per-instance override. - if (entry.driver === "openai-compat" && cfg.openaiCompat?.url) { - const raw = entry.config; - if (raw === undefined) { - entry.config = { url: cfg.openaiCompat.url }; - } else if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) { - const current = raw as Record; - if (typeof current.url !== "string" || !current.url.trim()) { - entry.config = { ...current, url: cfg.openaiCompat.url }; + if (entry.driver === "openai-compat" && cfg.openaiCompat) { + const defaults: Record = {}; + if (cfg.openaiCompat.url) defaults.url = cfg.openaiCompat.url; + if (cfg.openaiCompat.model) defaults.model = cfg.openaiCompat.model; + if (cfg.openaiCompat.provider) defaults.provider = cfg.openaiCompat.provider; + if (Object.keys(defaults).length) { + const raw = entry.config; + const current = + typeof raw === "object" && raw !== null && !Array.isArray(raw) ? (raw as Record) : {}; + const merged = { ...current }; + // A per-instance value always wins over the workspace default. + for (const [k, v] of Object.entries(defaults)) { + if (typeof merged[k] !== "string" || !(merged[k] as string).trim()) merged[k] = v; } + entry.config = merged; } } } diff --git a/server/drivers/openai-compat.test.ts b/server/drivers/openai-compat.test.ts index eca6cf7e9..30df4e9e0 100644 --- a/server/drivers/openai-compat.test.ts +++ b/server/drivers/openai-compat.test.ts @@ -224,4 +224,217 @@ describe("OpenAICompatDriver", () => { recorder.stop(); await inst.dispose(); }); + + it("decodes a default model and provider from config", () => { + const cfg = OpenAICompatDriver.decodeConfig({ + model: "deepseek/deepseek-v4-flash-0731", + provider: "fireworks", + }); + expect(cfg.model).toBe("deepseek/deepseek-v4-flash-0731"); + expect(cfg.provider).toBe("fireworks"); + }); + + it("seeds the picker with the configured default model", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(JSON.stringify({ data: [] }), { status: 200 })), + ); + const inst = await OpenAICompatDriver.create({ + instanceId: "test-default-model", + displayName: "Default model", + enabled: true, + config: { + url: "https://openrouter.ai/api/v1", + apiKeyEnv: "TEST_KEY", + model: "deepseek/deepseek-v4-flash-0731", + }, + environment: { TEST_KEY: "secret" }, + }); + expect(inst.models.default).toBe("deepseek/deepseek-v4-flash-0731"); + expect(inst.models.options.some((o) => o.id === "deepseek/deepseek-v4-flash-0731")).toBe(true); + await inst.dispose(); + }); + + it("pins the OpenRouter upstream provider in the request body", async () => { + let sentBody: any = null; + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input); + if (url.endsWith("/models")) return new Response(JSON.stringify({ data: [] }), { status: 200 }); + sentBody = JSON.parse(String(init?.body)); + return new Response( + 'data: {"choices":[{"delta":{"content":"hi"}}]}\n' + "data: [DONE]\n", + { status: 200, headers: { "content-type": "text/event-stream" } }, + ); + }), + ); + const inst = await OpenAICompatDriver.create({ + instanceId: "test-provider-route", + displayName: "Provider route", + enabled: true, + config: { + url: "https://openrouter.ai/api/v1", + apiKeyEnv: "TEST_KEY", + provider: "fireworks", + }, + environment: { TEST_KEY: "secret" }, + }); + const recorder = recordEvents(inst.adapter); + + await inst.adapter.sendTurn({ + threadId: "thread-p", + text: "prompt", + model: "deepseek/deepseek-v4-flash-0731", + }); + await recorder.until((e) => e.type === "turn.completed"); + + expect(sentBody?.model).toBe("deepseek/deepseek-v4-flash-0731"); + expect(sentBody?.provider).toEqual({ order: ["fireworks"], allow_fallbacks: false }); + recorder.stop(); + await inst.dispose(); + }); + + it("omits provider routing on non-OpenRouter endpoints even when configured", async () => { + let sentBody: any = null; + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input); + if (url.endsWith("/models")) return new Response(JSON.stringify({ data: [] }), { status: 200 }); + sentBody = JSON.parse(String(init?.body)); + return new Response( + 'data: {"choices":[{"delta":{"content":"hi"}}]}\n' + "data: [DONE]\n", + { status: 200, headers: { "content-type": "text/event-stream" } }, + ); + }), + ); + const inst = await OpenAICompatDriver.create({ + instanceId: "test-groq-no-provider", + displayName: "Groq strict", + enabled: true, + config: { + url: "https://api.groq.com/openai/v1", + apiKeyEnv: "TEST_KEY", + provider: "fireworks", + }, + environment: { TEST_KEY: "secret" }, + }); + const recorder = recordEvents(inst.adapter); + + await inst.adapter.sendTurn({ threadId: "thread-g", text: "prompt", model: "vendor/model" }); + await recorder.until((e) => e.type === "turn.completed"); + + // Strict OpenAI-compatible endpoints (Groq et al.) reject unknown + // top-level fields — `provider` is OpenRouter-only routing. + expect(sentBody).not.toBeNull(); + expect("provider" in sentBody).toBe(false); + recorder.stop(); + await inst.dispose(); + }); + + it("does not treat a lookalike host as OpenRouter", async () => { + let sentBody: any = null; + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input); + if (url.endsWith("/models")) return new Response(JSON.stringify({ data: [] }), { status: 200 }); + sentBody = JSON.parse(String(init?.body)); + return new Response( + 'data: {"choices":[{"delta":{"content":"hi"}}]}\n' + "data: [DONE]\n", + { status: 200, headers: { "content-type": "text/event-stream" } }, + ); + }), + ); + const inst = await OpenAICompatDriver.create({ + instanceId: "test-lookalike", + displayName: "Lookalike host", + enabled: true, + config: { + // hostname is NOT openrouter.ai — substring matching on the whole + // URL would be fooled by a lookalike domain or a path segment + url: "https://notopenrouter.ai/api/v1", + apiKeyEnv: "TEST_KEY", + provider: "fireworks", + }, + environment: { TEST_KEY: "secret" }, + }); + const recorder = recordEvents(inst.adapter); + + await inst.adapter.sendTurn({ threadId: "thread-l", text: "prompt", model: "vendor/model" }); + await recorder.until((e) => e.type === "turn.completed"); + + expect(sentBody).not.toBeNull(); + expect("provider" in sentBody).toBe(false); + recorder.stop(); + await inst.dispose(); + }); + + it("pins the provider on an OpenRouter subdomain", async () => { + let sentBody: any = null; + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input); + if (url.endsWith("/models")) return new Response(JSON.stringify({ data: [] }), { status: 200 }); + sentBody = JSON.parse(String(init?.body)); + return new Response( + 'data: {"choices":[{"delta":{"content":"hi"}}]}\n' + "data: [DONE]\n", + { status: 200, headers: { "content-type": "text/event-stream" } }, + ); + }), + ); + const inst = await OpenAICompatDriver.create({ + instanceId: "test-subdomain", + displayName: "OpenRouter subdomain", + enabled: true, + config: { + url: "https://gateway.openrouter.ai/api/v1", + apiKeyEnv: "TEST_KEY", + provider: "fireworks", + }, + environment: { TEST_KEY: "secret" }, + }); + const recorder = recordEvents(inst.adapter); + + await inst.adapter.sendTurn({ threadId: "thread-s", text: "prompt", model: "vendor/model" }); + await recorder.until((e) => e.type === "turn.completed"); + + expect(sentBody?.provider).toEqual({ order: ["fireworks"], allow_fallbacks: false }); + recorder.stop(); + await inst.dispose(); + }); + + it("omits provider routing when none is configured", async () => { + let sentBody: any = null; + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input); + if (url.endsWith("/models")) return new Response(JSON.stringify({ data: [] }), { status: 200 }); + sentBody = JSON.parse(String(init?.body)); + return new Response( + 'data: {"choices":[{"delta":{"content":"hi"}}]}\n' + "data: [DONE]\n", + { status: 200, headers: { "content-type": "text/event-stream" } }, + ); + }), + ); + const inst = await OpenAICompatDriver.create({ + instanceId: "test-no-provider", + displayName: "No provider", + enabled: true, + config: { url: "https://openrouter.ai/api/v1", apiKeyEnv: "TEST_KEY" }, + environment: { TEST_KEY: "secret" }, + }); + const recorder = recordEvents(inst.adapter); + + await inst.adapter.sendTurn({ threadId: "thread-np", text: "prompt", model: "vendor/model" }); + await recorder.until((e) => e.type === "turn.completed"); + + expect(sentBody).not.toBeNull(); + expect("provider" in sentBody).toBe(false); + recorder.stop(); + await inst.dispose(); + }); }); diff --git a/server/drivers/openai-compat.ts b/server/drivers/openai-compat.ts index a54aab4f7..31776b956 100644 --- a/server/drivers/openai-compat.ts +++ b/server/drivers/openai-compat.ts @@ -39,11 +39,32 @@ export interface OpenAICompatConfig { apiKeyEnv: string; /** Direct API key if configured */ key?: string; + /** Default model when a turn doesn't specify one (seeds the picker). */ + model?: string; + /** OpenRouter upstream provider slug to pin (e.g. "fireworks"). Sent as + * `provider: { order: [provider], allow_fallbacks: false }` — but only to + * OpenRouter endpoints: strict OpenAI-compatible servers (Groq et al.) + * reject unknown top-level fields. */ + provider?: string; +} + +/** True when the configured base URL points at OpenRouter (openrouter.ai or + * a subdomain). Parses the hostname rather than substring-matching the whole + * URL, so lookalike domains and path segments don't count. */ +function isOpenRouterUrl(url: string): boolean { + try { + const host = new URL(url).hostname.toLowerCase(); + return host === "openrouter.ai" || host.endsWith(".openrouter.ai"); + } catch { + return false; + } } function decodeConfig(raw: unknown): OpenAICompatConfig { const o = (raw ?? {}) as Record; const envUrl = process.env.OPENAI_COMPAT_URL; + const envModel = process.env.OPENAI_COMPAT_MODEL; + const envProvider = process.env.OPENAI_COMPAT_PROVIDER; return { url: typeof o.url === "string" && o.url @@ -53,6 +74,8 @@ function decodeConfig(raw: unknown): OpenAICompatConfig { : "https://openrouter.ai/api/v1", apiKeyEnv: typeof o.apiKeyEnv === "string" && o.apiKeyEnv ? o.apiKeyEnv : "OPENAI_COMPAT_API_KEY", key: typeof o.key === "string" && o.key ? o.key : undefined, + model: typeof o.model === "string" && o.model ? o.model : envModel || undefined, + provider: typeof o.provider === "string" && o.provider ? o.provider : envProvider || undefined, }; } @@ -92,7 +115,16 @@ export const OpenAICompatDriver: ProviderDriver = { ""; const listeners = new Set(); const active = new Map(); - let catalog = DEFAULT_MODELS; + // A configured default model seeds the picker so the intended model is + // pre-selected before /models refreshes the catalog from the endpoint. + let catalog: ModelCatalog = config.model + ? { + default: config.model, + options: DEFAULT_MODELS.options.some((o) => o.id === config.model) + ? DEFAULT_MODELS.options + : [{ id: config.model, label: config.model }, ...DEFAULT_MODELS.options], + } + : DEFAULT_MODELS; const emit = (event: RuntimeEvent) => { for (const l of [...listeners]) l(event); @@ -124,7 +156,16 @@ export const OpenAICompatDriver: ProviderDriver = { authorization: `Bearer ${apiKey}`, "content-type": "application/json", }, - body: JSON.stringify({ model, messages, stream: opts.stream }), + body: JSON.stringify({ + model, + messages, + stream: opts.stream, + // OpenRouter routing: pin the upstream provider when configured — + // only on OpenRouter itself; strict endpoints reject the field. + ...(config.provider && isOpenRouterUrl(config.url) + ? { provider: { order: [config.provider], allow_fallbacks: false } } + : {}), + }), signal: opts.signal ?? AbortSignal.timeout(120_000), }); if (!res.ok) { @@ -221,7 +262,12 @@ export const OpenAICompatDriver: ProviderDriver = { options.push({ id, label }); } if (options.length) { - catalog = { default: options[0].id, options }; + // Keep a configured default selected; surface it even if the + // endpoint's catalog omits it. + const preferred = + config.model && (options.some((o) => o.id === config.model) ? config.model : null); + if (config.model && !preferred) options.unshift({ id: config.model, label: config.model }); + catalog = { default: config.model ?? options[0].id, options }; } } catch { // keep DEFAULT_MODELS — never fail the instance on a catalog miss From 647b27cfe32cb6d9877325a8f4ee3289504599d6 Mon Sep 17 00:00:00 2001 From: Milind Soni <46266943+milind-soni@users.noreply.github.com> Date: Sat, 29 Aug 2026 14:52:52 +0530 Subject: [PATCH 5/6] refactor(channels): clean task boundaries (#538) --- apps/docs/package.json | 2 +- package.json | 4 +- pnpm-lock.yaml | 176 ++++++++++++++++++------------------- server/group-tasks.test.ts | 9 ++ server/index.test.ts | 1 + server/index.ts | 23 ++--- server/store.test.ts | 17 ++++ server/store.ts | 35 ++++---- src/state/store.test.ts | 4 +- src/state/store.tsx | 13 ++- 10 files changed, 159 insertions(+), 125 deletions(-) diff --git a/apps/docs/package.json b/apps/docs/package.json index 74ce641aa..cc5dab936 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -29,7 +29,7 @@ "@types/node": "^26.2.0", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", - "oxlint": "^1.78.0", + "oxlint": "1.80.0", "postcss": "^8.5.26", "tailwindcss": "^4.3.3", "typescript": "^6.0.3" diff --git a/package.json b/package.json index 3eb7c94f0..f18c6f96f 100644 --- a/package.json +++ b/package.json @@ -91,7 +91,7 @@ "zod": "4.4.3" }, "devDependencies": { - "@oxlint/plugins": "1.78.0", + "@oxlint/plugins": "1.80.0", "@tailwindcss/vite": "^4.1.11", "@types/node": "^26.2.0", "@types/react": "^19.1.9", @@ -101,7 +101,7 @@ "electron-builder": "^26.15.3", "electron-updater": "^6.8.9", "esbuild": "^0.28.2", - "oxlint": "1.78.0", + "oxlint": "1.80.0", "tailwindcss": "^4.1.11", "typebox": "1.3.7", "typescript": "^5.8.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 21ac05dcb..5c7beffb8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -49,8 +49,8 @@ importers: version: 4.4.3 devDependencies: '@oxlint/plugins': - specifier: 1.78.0 - version: 1.78.0 + specifier: 1.80.0 + version: 1.80.0 '@tailwindcss/vite': specifier: ^4.1.11 version: 4.3.3(vite@7.3.6(@types/node@26.2.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.16.9)(yaml@2.9.0)) @@ -79,8 +79,8 @@ importers: specifier: ^0.28.2 version: 0.28.2 oxlint: - specifier: 1.78.0 - version: 1.78.0 + specifier: 1.80.0 + version: 1.80.0 tailwindcss: specifier: ^4.1.11 version: 4.3.3 @@ -143,8 +143,8 @@ importers: specifier: ^19.2.4 version: 19.2.4(@types/react@19.2.18) oxlint: - specifier: ^1.78.0 - version: 1.78.0 + specifier: 1.80.0 + version: 1.80.0 postcss: specifier: ^8.5.26 version: 8.5.26 @@ -1322,130 +1322,130 @@ packages: resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} engines: {node: '>=14'} - '@oxlint/binding-android-arm-eabi@1.78.0': - resolution: {integrity: sha512-Bu819lmAfZMUHErrpe0cEWj3iaefuUODHSU8+UbXy67V/r7/7f4K3FL0NmbD85E+wiFLDYuhP8Zlv0XnVeXshw==} + '@oxlint/binding-android-arm-eabi@1.80.0': + resolution: {integrity: sha512-RM3Plj+biQpxa5d1GOOX6ciDlcUROmm4OZ/pLTpitkQt2mJv4jhtY4cbgaetOm5UKWZe05/TGQ6o1Vl8EOHkrA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxlint/binding-android-arm64@1.78.0': - resolution: {integrity: sha512-CDfxZgB61B7buRdY2FJoAYYPPXCZ1EoC1LKscnC5dg3kjobdxiconvAvvN1BmHyW4PyFT3jRLDag/BY/roSNBQ==} + '@oxlint/binding-android-arm64@1.80.0': + resolution: {integrity: sha512-YlO5JEf0Yr2bUUlu8O8daVcUxtcGGbcSmyV7E7nSbJbfAdxTE0PFPwgnIlw7wXJaTYjb+qs5hI5q3jxUkI7cAw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxlint/binding-darwin-arm64@1.78.0': - resolution: {integrity: sha512-2Y2U9Ahrz+OO0Ej88f9SJYq51/jUBp1Mc7iZu0ukrbeeZ3gpRGfzIFnoqfHDY96xr0GEfNrPUBFEy0nN5aD7HA==} + '@oxlint/binding-darwin-arm64@1.80.0': + resolution: {integrity: sha512-BULDOyO3AhsmdWfQeIUCykDt3dd7XZBGLhp1eIh56skRv01O+cNjNPwXMIbeW1x4+pxcln5if72wcRgViVo7PA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxlint/binding-darwin-x64@1.78.0': - resolution: {integrity: sha512-rpych6eJq6m9jDRypTEaPD1xysaEW5h9+xuxhGK/QhOg+/xaqPZrCrTNoIl/f3nEjuJeCEmstNDlrE9rJi/3/g==} + '@oxlint/binding-darwin-x64@1.80.0': + resolution: {integrity: sha512-YJ4JzLw7N5TDSQFlA0hAQGHvnDZgyypm1yunObVWcWiF9KM7eGCJKYKLgTC2Fi/57OdnBhbj4OkzPGdFQJ6HyA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxlint/binding-freebsd-x64@1.78.0': - resolution: {integrity: sha512-IcMGrQT3QizkOESUJd5et+rOhVqSkNDfNik1cvrKDqIbzqx9KMtRswpFgkCuNTSwylCFLKhGUu8KmqY1ZnC0Dg==} + '@oxlint/binding-freebsd-x64@1.80.0': + resolution: {integrity: sha512-AYUIk5QnL0s8oWAYsREZwkRYy1SupJTXALo93J1TgzHywxQtdM99FecRMQ87MXEdPQ0j1TmEpeeq3fGNkpvMqg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxlint/binding-linux-arm-gnueabihf@1.78.0': - resolution: {integrity: sha512-/uLdoJ0IXE6vo/0f0LKjinQAp+re+VMaCWaNT8ENIv2EOCkSsc8SGaflXAuW0Jua2dq5+GLVWm1NQK7P3UFSNQ==} + '@oxlint/binding-linux-arm-gnueabihf@1.80.0': + resolution: {integrity: sha512-9hBZVANupQ89W9dXyE0n8doCyaW5pDyGn3y6XlIMPZ+rIKuyqkr3SNUXmVJIhuvUq0NBU3RBiSXXE69l4XI6KA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm-musleabihf@1.78.0': - resolution: {integrity: sha512-7xi4Wb/O8NRJhLoUXmDJMUVpNYvB5kefdhFU1Jb8rtae4QoXlTiLwI14X4YvAXVZLNZChP8m5qO9SQAlWQTbkQ==} + '@oxlint/binding-linux-arm-musleabihf@1.80.0': + resolution: {integrity: sha512-SvS2uKqzY+pbfuvAHzH4338R6Zwo805GAwrIMVvK1KxoOWCIjZUdfzTCvilD7z6JK91v011+zYMryabhDo2AsQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm64-gnu@1.78.0': - resolution: {integrity: sha512-4hFW0+fVXa3OIh1Y4A5SPkmvI4wuuBSrCVKzOyE7PTjhc7yEqZ1pmvEEeS5Lj/MaqvegFxXyF33N+6jkehxdyg==} + '@oxlint/binding-linux-arm64-gnu@1.80.0': + resolution: {integrity: sha512-tCLadyqRVL3pQTRPNg7cjXKvcvS4fbyXeQHhKk5BTJ1oftQln5/yIIWbu/Xom/DX41zv2P9QGt6+D/TtQVtY3A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-arm64-musl@1.78.0': - resolution: {integrity: sha512-oC0mvsgBJjlMijSDEhx9KuvR9zYeHXceA9MjbuXB1F8NSR78Yj2unOBrstEvTVaq+pko+kuue6DajC00eqvTdg==} + '@oxlint/binding-linux-arm64-musl@1.80.0': + resolution: {integrity: sha512-XfpCNRlOPcLlJl4Bn/FUhjqlR6BVavEykERBf/MV7YA9VZDa5g5znVqYhyviMafcxS9Pe/i/kPvHNO0U6svEHQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxlint/binding-linux-ppc64-gnu@1.78.0': - resolution: {integrity: sha512-XAllT5SUZS+ohjuZ3/5S0cwe0r7eboiuigeStCZ5DXRYx/2KVM2UvQXvAfyzXEimtQjAB7cDQ2YxDe2Zl2WNQQ==} + '@oxlint/binding-linux-ppc64-gnu@1.80.0': + resolution: {integrity: sha512-3I4yMwcFG9NeO8ioY6JBBuKsIm5GL/x7MATt1S4tVWaxPu5HcJ+XnLUbcVBTxG8q2Wu56HSj+NmXQiVYb1lp6A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-riscv64-gnu@1.78.0': - resolution: {integrity: sha512-trucMER/0QtecoXvc1y/UVqE3kwJipDwrx4oHfj+nNm3dq2zjP44WT0CfHNDPM3G1DXIkx/gY6lAD21NSCZVhA==} + '@oxlint/binding-linux-riscv64-gnu@1.80.0': + resolution: {integrity: sha512-E1wAKymkpe1/E8helzBKdm81OBOF+ezxRyXRMEuik3ZpWDER5CPOKZwF66RsdwW98uwZv8UTFremUQtC1CzdJA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-riscv64-musl@1.78.0': - resolution: {integrity: sha512-cm3O4F/HQbdzOUX5mKHqG5KDL6E5w0pnlZ+fbBy2rmLryPOowkuLagFHTopQsEIpjcaZoPOrL+BmmAytAG9HFg==} + '@oxlint/binding-linux-riscv64-musl@1.80.0': + resolution: {integrity: sha512-+gLRGD4sIo3+VA++iham5UxD9tKSoJ/VOrROCEXIcknrYtQg6iIQgvjN0cpiRF7N6UYC7pJbvHJlDnMge5LRpQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxlint/binding-linux-s390x-gnu@1.78.0': - resolution: {integrity: sha512-33wRf6HqGNsybJ3qX4cGaQN2ODPxNmc1rMa0mrTmx3eFq1VzOnvQooi9bIGVYakW8a/wmqVx1mgsUm8R2xfTiw==} + '@oxlint/binding-linux-s390x-gnu@1.80.0': + resolution: {integrity: sha512-aR0PrzHj9leW3NmzBAAP4EzdoBNoJcs9sjnIQPIwyRnBGYrRbXUIpEB5Q39AqK3PLY5JK5uEhDQDiUa1QSAstw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxlint/binding-linux-x64-gnu@1.78.0': - resolution: {integrity: sha512-rRdISSYegj6VganMZ9tjRjijowfHJ09IZU01i0toBAqr6n5LEtwHq2IeS4FjW2RoskOHlb6efB26H5izYb3GEQ==} + '@oxlint/binding-linux-x64-gnu@1.80.0': + resolution: {integrity: sha512-vSVh5cSo3Xxs6ghBCcFJlpbkbENzDog1qXtoXLa/HC3aCrR4XO76GZbXmQoCPHnu99nQpdCeC3H9tdNICfDh7A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-x64-musl@1.78.0': - resolution: {integrity: sha512-GmsP4rW0xTL6u5CVdcDsaN5Fbc7hBc382Wmar1kttbnwSEviM+rSINKOMQ+UQ6iH+AGwC+8gaAiwu134Tgh6Lg==} + '@oxlint/binding-linux-x64-musl@1.80.0': + resolution: {integrity: sha512-FfzBXpNQ8u7/ZI/p8bl73MeZ508Ax3hxWp3SiJpEFiC+BB9XcXy5FAZHTLKDPSzrUpxQZSZJAVdDmuJp/+HDBQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxlint/binding-openharmony-arm64@1.78.0': - resolution: {integrity: sha512-sy9yeYuADc8a+n4TLBayzMCZiHPW78DcIFVpOXTmdKHWQeM9xe5uzkqIIZmi326D5hY9XVwacipEB1p7tQjPAg==} + '@oxlint/binding-openharmony-arm64@1.80.0': + resolution: {integrity: sha512-zMzbkumtmprCgRwoYNzcB3iC39fXdJIMLMU33KdCjEGLlJGOEt1+LwQ4LF8ndLzAEKVz4BR0y3V6Xrkk3Nm3yA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxlint/binding-win32-arm64-msvc@1.78.0': - resolution: {integrity: sha512-rjc2hF1KfMi8fZj1X/m3AmnHbdsF3rL0v6KQg0Uc880Yb2khjz+3U14sfdZ7jWTpRnN1m1NQa/TT7uU9lJWPrA==} + '@oxlint/binding-win32-arm64-msvc@1.80.0': + resolution: {integrity: sha512-ib6iRcrXsk4t1fm3iKcwksyWh1ZkZXC/2mEzakl0ai2+6HZunf1WWMZ/xP9EJAvw9g9K4UVTC3NF/+G2qLrbTQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxlint/binding-win32-ia32-msvc@1.78.0': - resolution: {integrity: sha512-zcuXFVrEFHIafRfkCQT8w/Xe41o07ozl/vwHq7p94vB29xVzsB0sZGYORU1jhcYKv3Lr0J3HbJ2T4fHH5rWmvA==} + '@oxlint/binding-win32-ia32-msvc@1.80.0': + resolution: {integrity: sha512-xhRWBMpLxZvgKAH6+DJZmpP+W8Y8UdQOSU1JfxSWNXsaBaRGW77j+1hCuNHlzj7OH4SPN8fYd1q0o2qrDtoVyw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxlint/binding-win32-x64-msvc@1.78.0': - resolution: {integrity: sha512-Sb5ocmLSuYeOuXd+CFOToGKp/gjXUEWDnvIGwhnh8aq8wY4TMmEnKnvbogSW7RdMZv77JSARduS7/gv+khYEjA==} + '@oxlint/binding-win32-x64-msvc@1.80.0': + resolution: {integrity: sha512-yAnO7lwBYQnz2pcfBPIGQQZWIX5zd5R/1aAKIF3oE+TVj7IhoHcROjOkz3sRDngzqhfPKfFaXqug5j5rE5dn6Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@oxlint/plugins@1.78.0': - resolution: {integrity: sha512-Ypt8KeRYw+4jUtlPirfcHWMrn5ms12VrrFPD+Mds477/7tJxG1Kcz2Yrg2nVcTQEUx/GdlhS+BUg1kmxNm04Ug==} + '@oxlint/plugins@1.80.0': + resolution: {integrity: sha512-QRgH1XqQEYNHa4f1vvPQ5fAdNdncHGIUG1ZWLlGIZHky3qwCEeAKYitZNbZMtaXtAQAAFFTOwqUfzESvimqZNA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} '@peculiar/asn1-schema@2.8.0': @@ -3444,8 +3444,8 @@ packages: oniguruma-to-es@4.3.6: resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} - oxlint@1.78.0: - resolution: {integrity: sha512-QgQePuxIqKOzo1KSjG2EnITEeWvWnKAm77eq8nrMtf6AGoA+zyGc4PFYtDNJSD25g/ibOwfQ851hZ4/SPkMVoA==} + oxlint@1.80.0: + resolution: {integrity: sha512-5nTiSps4qdbCWLbxzuO00alHkEO2exR9YMN/ig6QXWrLsYSG0KaObOAM+l6oU2LcKPWoSAGYbkZIGEu1ViiWKA==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -5174,64 +5174,64 @@ snapshots: '@opentelemetry/semantic-conventions@1.43.0': {} - '@oxlint/binding-android-arm-eabi@1.78.0': + '@oxlint/binding-android-arm-eabi@1.80.0': optional: true - '@oxlint/binding-android-arm64@1.78.0': + '@oxlint/binding-android-arm64@1.80.0': optional: true - '@oxlint/binding-darwin-arm64@1.78.0': + '@oxlint/binding-darwin-arm64@1.80.0': optional: true - '@oxlint/binding-darwin-x64@1.78.0': + '@oxlint/binding-darwin-x64@1.80.0': optional: true - '@oxlint/binding-freebsd-x64@1.78.0': + '@oxlint/binding-freebsd-x64@1.80.0': optional: true - '@oxlint/binding-linux-arm-gnueabihf@1.78.0': + '@oxlint/binding-linux-arm-gnueabihf@1.80.0': optional: true - '@oxlint/binding-linux-arm-musleabihf@1.78.0': + '@oxlint/binding-linux-arm-musleabihf@1.80.0': optional: true - '@oxlint/binding-linux-arm64-gnu@1.78.0': + '@oxlint/binding-linux-arm64-gnu@1.80.0': optional: true - '@oxlint/binding-linux-arm64-musl@1.78.0': + '@oxlint/binding-linux-arm64-musl@1.80.0': optional: true - '@oxlint/binding-linux-ppc64-gnu@1.78.0': + '@oxlint/binding-linux-ppc64-gnu@1.80.0': optional: true - '@oxlint/binding-linux-riscv64-gnu@1.78.0': + '@oxlint/binding-linux-riscv64-gnu@1.80.0': optional: true - '@oxlint/binding-linux-riscv64-musl@1.78.0': + '@oxlint/binding-linux-riscv64-musl@1.80.0': optional: true - '@oxlint/binding-linux-s390x-gnu@1.78.0': + '@oxlint/binding-linux-s390x-gnu@1.80.0': optional: true - '@oxlint/binding-linux-x64-gnu@1.78.0': + '@oxlint/binding-linux-x64-gnu@1.80.0': optional: true - '@oxlint/binding-linux-x64-musl@1.78.0': + '@oxlint/binding-linux-x64-musl@1.80.0': optional: true - '@oxlint/binding-openharmony-arm64@1.78.0': + '@oxlint/binding-openharmony-arm64@1.80.0': optional: true - '@oxlint/binding-win32-arm64-msvc@1.78.0': + '@oxlint/binding-win32-arm64-msvc@1.80.0': optional: true - '@oxlint/binding-win32-ia32-msvc@1.78.0': + '@oxlint/binding-win32-ia32-msvc@1.80.0': optional: true - '@oxlint/binding-win32-x64-msvc@1.78.0': + '@oxlint/binding-win32-x64-msvc@1.80.0': optional: true - '@oxlint/plugins@1.78.0': {} + '@oxlint/plugins@1.80.0': {} '@peculiar/asn1-schema@2.8.0': dependencies: @@ -7516,27 +7516,27 @@ snapshots: regex: 6.1.0 regex-recursion: 6.0.2 - oxlint@1.78.0: + oxlint@1.80.0: optionalDependencies: - '@oxlint/binding-android-arm-eabi': 1.78.0 - '@oxlint/binding-android-arm64': 1.78.0 - '@oxlint/binding-darwin-arm64': 1.78.0 - '@oxlint/binding-darwin-x64': 1.78.0 - '@oxlint/binding-freebsd-x64': 1.78.0 - '@oxlint/binding-linux-arm-gnueabihf': 1.78.0 - '@oxlint/binding-linux-arm-musleabihf': 1.78.0 - '@oxlint/binding-linux-arm64-gnu': 1.78.0 - '@oxlint/binding-linux-arm64-musl': 1.78.0 - '@oxlint/binding-linux-ppc64-gnu': 1.78.0 - '@oxlint/binding-linux-riscv64-gnu': 1.78.0 - '@oxlint/binding-linux-riscv64-musl': 1.78.0 - '@oxlint/binding-linux-s390x-gnu': 1.78.0 - '@oxlint/binding-linux-x64-gnu': 1.78.0 - '@oxlint/binding-linux-x64-musl': 1.78.0 - '@oxlint/binding-openharmony-arm64': 1.78.0 - '@oxlint/binding-win32-arm64-msvc': 1.78.0 - '@oxlint/binding-win32-ia32-msvc': 1.78.0 - '@oxlint/binding-win32-x64-msvc': 1.78.0 + '@oxlint/binding-android-arm-eabi': 1.80.0 + '@oxlint/binding-android-arm64': 1.80.0 + '@oxlint/binding-darwin-arm64': 1.80.0 + '@oxlint/binding-darwin-x64': 1.80.0 + '@oxlint/binding-freebsd-x64': 1.80.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.80.0 + '@oxlint/binding-linux-arm-musleabihf': 1.80.0 + '@oxlint/binding-linux-arm64-gnu': 1.80.0 + '@oxlint/binding-linux-arm64-musl': 1.80.0 + '@oxlint/binding-linux-ppc64-gnu': 1.80.0 + '@oxlint/binding-linux-riscv64-gnu': 1.80.0 + '@oxlint/binding-linux-riscv64-musl': 1.80.0 + '@oxlint/binding-linux-s390x-gnu': 1.80.0 + '@oxlint/binding-linux-x64-gnu': 1.80.0 + '@oxlint/binding-linux-x64-musl': 1.80.0 + '@oxlint/binding-openharmony-arm64': 1.80.0 + '@oxlint/binding-win32-arm64-msvc': 1.80.0 + '@oxlint/binding-win32-ia32-msvc': 1.80.0 + '@oxlint/binding-win32-x64-msvc': 1.80.0 p-cancelable@2.1.1: {} diff --git a/server/group-tasks.test.ts b/server/group-tasks.test.ts index a1684d093..9e699c71a 100644 --- a/server/group-tasks.test.ts +++ b/server/group-tasks.test.ts @@ -84,6 +84,15 @@ describe("channel tasks", () => { expect(store.deleteGroupTask(channel.id, first)).toBeNull(); }); + it("normalizes a supplied task title at the store boundary", async () => { + const { store } = await freshStore(); + const bot = store.createBot(); + const channel = store.createGroup("Product", [bot.id]); + const longTitle = "x".repeat(100); + + expect(store.createGroupTask(channel.id, ` ${longTitle} `)?.title).toBe(longTitle.slice(0, 80)); + }); + it("adopts a legacy channel thread without losing its folder or pin", async () => { const { store, Store } = await freshStore(); const bot = store.createBot(); diff --git a/server/index.test.ts b/server/index.test.ts index 3fd4cd5be..cb75a4f79 100644 --- a/server/index.test.ts +++ b/server/index.test.ts @@ -729,6 +729,7 @@ describe("harness HTTP API", () => { expect(removed.body.group.tasks).toHaveLength(1); expect((await api("DELETE", `/api/groups/${room.id}/tasks/${originalThread}`)).status).toBe(400); expect((await api("POST", `/api/groups/${room.id}/tasks/missing-thread`)).status).toBe(404); + expect((await api("POST", `/api/groups/${room.id}/tasks`, { title: 42 })).status).toBe(400); } finally { await api("DELETE", `/api/groups/${room.id}`); await api("DELETE", `/api/bots/${bot.id}`); diff --git a/server/index.ts b/server/index.ts index c06699d9e..269fbc61e 100644 --- a/server/index.ts +++ b/server/index.ts @@ -109,7 +109,6 @@ import { Store, type GroupDefaultResponder, type GroupRecord, - type GroupTaskRecord, type Message, type TaskRecord, } from "./store.ts"; @@ -228,6 +227,7 @@ function authorizedComms(header: string | string[] | undefined): boolean { // A→B is allowed but B→C (and A→B→A loops) never start. const MAX_COMMS_DEPTH = 1; const MAX_WORKSPACE_BOTS = 100; +const createGroupTaskRequestSchema = z.object({ title: z.string().optional() }); // Resolved from the server root — see server/proxy-paths.ts. This descending // path happened to survive bundling, but it goes through the same anchor so // there is exactly one way proxies are located. @@ -449,11 +449,10 @@ store.seedIfEmpty(); * paired phone has even less business holding provider session identifiers * than the desktop window did. Stripped here rather than at each call site * so a new broadcast cannot forget. */ -const wireTask = ({ resumeCursors, lastInstanceId, ...task }: TaskRecord) => task; -const wireGroupTask = (task: GroupTaskRecord) => task; +const wireTask = ({ resumeCursors: _resumeCursors, lastInstanceId: _lastInstanceId, ...task }: TaskRecord) => task; const wireBot = (bot: NonNullable>) => { - const { resumeCursors, tasks, ...rest } = bot; + const { resumeCursors: _resumeCursors, tasks, ...rest } = bot; return { ...rest, avatarUrl: rest.avatarUrl ?? null, ...(tasks ? { tasks: tasks.map(wireTask) } : {}) }; }; @@ -519,7 +518,7 @@ const groupWithThread = (group: GroupRecord) => ({ ...publicGroupState(group), messages: store.messagesFor(group.threadId), activeLeafId: store.activeLeaf(group.threadId), - ...(group.dm ? {} : { tasks: store.groupTasks(group.id).map(wireGroupTask) }), + ...(group.dm ? {} : { tasks: store.groupTasks(group.id) }), }); // The store tells us what it wrote; this is the ONE place that turns those @@ -585,7 +584,7 @@ function pageSize(raw: string | null): number | null | undefined { * `/api/threads/:threadId/messages/:id/image` when it actually shows one. */ function slimMessage(message: Message): Message | Record { if (message.kind !== "screen" || !message.png) return message; - const { png, mime, ...rest } = message; + const { png: _png, mime: _mime, ...rest } = message; return { ...rest, hasImage: true }; } @@ -4022,7 +4021,7 @@ const server = createServer(async (req, res) => { if (format === "json") { // pixels stripped — an export is for reading and archiving, and a // base64 desktop frame is neither - const slim = messages.map(({ png, mime, ...rest }) => rest); + const slim = messages.map(({ png: _png, mime: _mime, ...rest }) => rest); res.writeHead(200, { "content-type": "application/json", "content-disposition": `attachment; filename="${filename}.json"`, @@ -4454,11 +4453,13 @@ const server = createServer(async (req, res) => { if (!body || typeof body !== "object" || Array.isArray(body)) { return json(res, 400, { error: "body must be a JSON object" }); } - const task = store.createGroupTask(group.id, typeof body.title === "string" ? body.title : undefined); + const request = createGroupTaskRequestSchema.safeParse(body); + if (!request.success) return json(res, 400, { error: "title must be text" }); + const task = store.createGroupTask(group.id, request.data.title); if (!task) return json(res, 500, { error: "couldn't create that task" }); const fresh = groupWithThread(store.group(group.id)!); broadcast({ kind: "group", group: fresh }); - return json(res, 201, { group: fresh, task: wireGroupTask(task) }); + return json(res, 201, { group: fresh, task }); } m = path.match(/^\/api\/groups\/([\w-]+)\/tasks\/([\w-]+)$/); @@ -4474,7 +4475,7 @@ const server = createServer(async (req, res) => { const fresh = groupWithThread(switched); broadcast({ kind: "group", group: fresh }); const responseGroup = url.searchParams.get("messages") === "0" - ? { ...publicGroupState(switched), tasks: store.groupTasks(switched.id).map(wireGroupTask) } + ? { ...publicGroupState(switched), tasks: store.groupTasks(switched.id) } : fresh; return json(res, 200, { group: responseGroup }); } @@ -4491,7 +4492,7 @@ const server = createServer(async (req, res) => { } const task = store.renameGroupTask(m[1], m[2], String(body.title ?? "")); if (!task) return json(res, 404, { error: "no such channel task" }); - return json(res, 200, { task: wireGroupTask(task) }); + return json(res, 200, { task }); } if (m && method === "DELETE") { const group = store.group(m[1]); diff --git a/server/store.test.ts b/server/store.test.ts index 1a0565e83..6e2799049 100644 --- a/server/store.test.ts +++ b/server/store.test.ts @@ -539,6 +539,23 @@ describe("Store change stream", () => { expect(events.at(-1)).toEqual({ type: "group.deleted", groupId: g.id }); }); + it("delivers each change to the listener snapshot captured before emission", () => { + const store = new Store(selection); + const bot = store.createBot(); + const seen: string[] = []; + let removeSecond = () => {}; + store.onChange(() => { + seen.push("first"); + removeSecond(); + store.onChange(() => seen.push("late")); + }); + removeSecond = store.onChange(() => seen.push("second")); + + store.patchBot(bot.id, { name: "Snapshot" }); + + expect(seen).toEqual(["first", "second"]); + }); + it("unsubscribe stops delivery", () => { const store = new Store(selection); const bot = store.createBot(); diff --git a/server/store.ts b/server/store.ts index 8575537d3..4cef9dd53 100644 --- a/server/store.ts +++ b/server/store.ts @@ -635,15 +635,14 @@ export class Store { continue; } if (!g.tasks?.length) { - g.tasks = [ - { - threadId: g.threadId, - title: this.firstUserLine(g.threadId) ?? UNTITLED_TASK, - createdAt: g.createdAt, - ...(g.pinnedCwd !== undefined ? { pinnedCwd: g.pinnedCwd } : {}), - ...(g.pinnedMessageId ? { pinnedMessageId: g.pinnedMessageId } : {}), - }, - ]; + const initialTask: GroupTaskRecord = { + threadId: g.threadId, + title: this.firstUserLine(g.threadId) ?? UNTITLED_TASK, + createdAt: g.createdAt, + }; + if (g.pinnedCwd !== undefined) initialTask.pinnedCwd = g.pinnedCwd; + if (g.pinnedMessageId) initialTask.pinnedMessageId = g.pinnedMessageId; + g.tasks = [initialTask]; groupsMigrated = true; } // Repair a malformed/stale active pointer conservatively. Every task @@ -690,7 +689,7 @@ export class Store { } private saveGroups() { - writeFileAtomic(GROUPS_FILE, JSON.stringify(this.groups.map(({ busyBotId, ...g }) => g), null, 2)); + writeFileAtomic(GROUPS_FILE, JSON.stringify(this.groups.map(({ busyBotId: _busyBotId, ...g }) => g), null, 2)); } // ── groups ──────────────────────────────────────────────────────────── @@ -737,9 +736,6 @@ export class Store { const group: GroupRecord = { id: newId(), threadId, - ...(dm - ? {} - : { tasks: [{ threadId, title: UNTITLED_TASK, createdAt }] }), name, memberIds, defaultResponder: dm @@ -751,13 +747,12 @@ export class Store { dm: dm || undefined, busyBotId: null, section, - ...(dm - ? {} - : { - setupCompletedAt: setup?.completed ? createdAt : null, - setupSkippedAt: null, - }), }; + if (!dm) { + group.tasks = [{ threadId, title: UNTITLED_TASK, createdAt }]; + group.setupCompletedAt = setup?.completed ? createdAt : null; + group.setupSkippedAt = null; + } this.groups.unshift(group); this.saveGroups(); this.emit({ type: "group", groupId: group.id }); @@ -835,7 +830,7 @@ export class Store { if (!group || group.dm) return null; const task: GroupTaskRecord = { threadId: newId(), - title: title?.trim() || UNTITLED_TASK, + title: title?.trim().slice(0, 80) || UNTITLED_TASK, createdAt: Date.now(), }; group.tasks = [task, ...(group.tasks ?? [])]; diff --git a/src/state/store.test.ts b/src/state/store.test.ts index b886872ad..928392035 100644 --- a/src/state/store.test.ts +++ b/src/state/store.test.ts @@ -88,7 +88,7 @@ describe("replacement snapshot boundary", () => { }); describe("notification routing", () => { - const bots = [{ id: "bot-1", threadId: "main-thread", tasks: [{ threadId: "detached-thread" }] }] as never; + const bots = [{ id: "bot-1", threadId: "main-thread", tasks: [{ threadId: "detached-thread" }] }]; const groups = [{ id: "room-1", threadId: "room-thread", @@ -96,7 +96,7 @@ describe("notification routing", () => { { threadId: "room-thread", title: "Current", createdAt: 1 }, { threadId: "older-room-thread", title: "Older", createdAt: 0 }, ], - }] as never; + }]; it("selects the bot and switches to the notification's exact task", () => { const dispatch = vi.fn(); diff --git a/src/state/store.tsx b/src/state/store.tsx index 60ac58a2e..a2ff6b59c 100644 --- a/src/state/store.tsx +++ b/src/state/store.tsx @@ -584,10 +584,21 @@ export type Action = patch: BotUpdatePatch; }; +interface NotificationThreadOwner { + id: string; + threadId: string; + tasks?: Array<{ threadId: string }>; +} + +interface NotificationRoutingState { + bots: NotificationThreadOwner[]; + groups: NotificationThreadOwner[]; +} + export function openNotificationTarget( dispatch: (action: Action) => void, target: NotificationTarget, - state: Pick, + state: NotificationRoutingState, ) { // A room's approval/question notification carries the asker bot with the // GROUP's thread id; asking the bot to switch to that thread would 404. From 910822a5531dc5b4dc70cc1a3d413591ce5b9514 Mon Sep 17 00:00:00 2001 From: Milind Soni <46266943+milind-soni@users.noreply.github.com> Date: Sat, 29 Aug 2026 17:02:34 +0530 Subject: [PATCH 6/6] feat(ios): add tactile action feedback (#561) --- ios/App/ChatListView.swift | 25 ++++++++++++++++++++----- ios/App/ChatView.swift | 8 +++++++- ios/App/Island.swift | 1 + ios/App/NewGroupSheet.swift | 2 ++ ios/App/UpdatesSheet.swift | 1 + 5 files changed, 31 insertions(+), 6 deletions(-) diff --git a/ios/App/ChatListView.swift b/ios/App/ChatListView.swift index 3f6b0224f..29fd10fe0 100644 --- a/ios/App/ChatListView.swift +++ b/ios/App/ChatListView.swift @@ -54,7 +54,10 @@ struct ChatListView: View { ForEach(searchHits) { hit in Button { Task { - if let chat = await session.open(hit) { path.append(chat) } + if let chat = await session.open(hit) { + Haptics.selection() + path.append(chat) + } } } label: { SearchHitRow(hit: hit) @@ -154,10 +157,14 @@ struct ChatListView: View { return } searching = true + defer { + if query == expected { searching = false } + } try? await Task.sleep(for: .milliseconds(250)) guard !Task.isCancelled, query == expected else { return } - searchHits = await session.search(expected) - searching = false + let hits = await session.search(expected) + guard !Task.isCancelled, query == expected else { return } + searchHits = hits } } } @@ -228,6 +235,7 @@ struct ChatListView: View { .buttonStyle(.plain) } Button { + Haptics.selection() showingNewGroup = true } label: { GroupTile(room: nil) @@ -280,10 +288,14 @@ struct ChatListView: View { .frame(height: 52) .glassCapsule() } else { - UpdatesPill(updates: session.state.updates) { showingUpdates = true } + UpdatesPill(updates: session.state.updates) { + Haptics.selection() + showingUpdates = true + } .frame(height: 52) GlassButton(systemImage: "magnifyingglass", size: 48, weight: .semibold) { + Haptics.selection() searchOpen = true searchFocused = true } @@ -291,7 +303,10 @@ struct ChatListView: View { GlassButton(systemImage: "square.and.pencil", size: 48, weight: .medium) { Task { - if let bot = await session.createBot() { path.append(Chat.bot(bot)) } + if let bot = await session.createBot() { + Haptics.success() + path.append(Chat.bot(bot)) + } } } .accessibilityLabel("New bot") diff --git a/ios/App/ChatView.swift b/ios/App/ChatView.swift index ed3af6391..9c8d7c45e 100644 --- a/ios/App/ChatView.swift +++ b/ios/App/ChatView.swift @@ -755,6 +755,7 @@ struct MessageRow: View { HStack(spacing: 6) { ForEach(reactionGroups(reactions), id: \.emoji) { group in Button("\(group.emoji) \(group.count)") { + Haptics.selection() Task { await session.react(to: message, in: chat.threadId, emoji: group.emoji) } } .font(.system(size: 13)) @@ -784,7 +785,10 @@ struct MessageRow: View { } .contextMenu { ForEach(Self.reactionChoices, id: \.self) { emoji in - Button(emoji) { Task { await session.react(to: message, in: chat.threadId, emoji: emoji) } } + Button(emoji) { + Haptics.selection() + Task { await session.react(to: message, in: chat.threadId, emoji: emoji) } + } } if message.role == .user, message.kind == .text, case let .bot(bot) = chat { Divider() @@ -1063,6 +1067,7 @@ struct CardView: View { HStack(spacing: 8) { ForEach(card.options, id: \.self) { option in Button { + Haptics.selection() answering = true Task { await session.answer(chat: chat, card: card, choice: option) @@ -1091,6 +1096,7 @@ struct CardView: View { // never a string invented here. if card.allowKey != nil, let allow = allowChoice, case let .bot(bot) = chat { Button("Always allow this tool") { + Haptics.selection() answering = true Task { await session.alwaysAllow(bot: bot, card: card) diff --git a/ios/App/Island.swift b/ios/App/Island.swift index 38f84f8f5..2a26dc94a 100644 --- a/ios/App/Island.swift +++ b/ios/App/Island.swift @@ -120,6 +120,7 @@ struct NeedsYouIsland: View { HStack(spacing: 8) { ForEach(card.options, id: \.self) { option in Button { + Haptics.selection() answering = true Task { await session.answer(chat: shown.chat, card: card, choice: option) diff --git a/ios/App/NewGroupSheet.swift b/ios/App/NewGroupSheet.swift index 4e53af640..42564dd78 100644 --- a/ios/App/NewGroupSheet.swift +++ b/ios/App/NewGroupSheet.swift @@ -26,6 +26,7 @@ struct NewGroupSheet: View { ForEach(bots) { bot in Button { if members.contains(bot.id) { members.remove(bot.id) } else { members.insert(bot.id) } + Haptics.selection() } label: { HStack(spacing: 12) { BotAvatarView(bot: bot, size: 36, state: .idle, animated: false) @@ -59,6 +60,7 @@ struct NewGroupSheet: View { // it defaults) follows the first bot you picked let ordered = bots.map(\.id).filter(members.contains) if let room = await session.createRoom(name: name, memberIds: ordered) { + Haptics.success() created(room) } creating = false diff --git a/ios/App/UpdatesSheet.swift b/ios/App/UpdatesSheet.swift index 4a3d306ff..2b22c1333 100644 --- a/ios/App/UpdatesSheet.swift +++ b/ios/App/UpdatesSheet.swift @@ -96,6 +96,7 @@ private struct UpdateRow: View { HStack(spacing: 8) { ForEach(card.options, id: \.self) { option in Button { + Haptics.selection() answering = true Task { await session.answer(chat: update.chat, card: card, choice: option)