From 17b5c73a38a05c97e1042615caad42251d439be0 Mon Sep 17 00:00:00 2001 From: Gustavo Schneiter Date: Thu, 3 Sep 2026 21:15:45 -0300 Subject: [PATCH 001/117] feat(app): tasks panel for background subagents and shell tools Adds a Tasks tab to the session side panel (review surface) showing live background work for the current session: subagent cards joined by the task tool's metadata.sessionId (child session id), foreground shell cards, needs-input state from pending permissions, nested (+N) counts, elapsed timers, click-to-open transcript, per-task stop, and auto-open on 0->N. Derives everything from synced reactive stores (no fetching/polling). --- packages/app/src/i18n/en.ts | 17 ++ .../app/src/pages/session/helpers.test.ts | 41 +++ packages/app/src/pages/session/helpers.ts | 7 +- .../src/pages/session/session-side-panel.tsx | 111 +++++++ packages/app/src/pages/session/tasks-data.ts | 276 ++++++++++++++++++ .../app/src/pages/session/tasks-panel.tsx | 258 ++++++++++++++++ .../pages/session/use-session-commands.tsx | 13 + 7 files changed, 722 insertions(+), 1 deletion(-) create mode 100644 packages/app/src/pages/session/tasks-data.ts create mode 100644 packages/app/src/pages/session/tasks-panel.tsx diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 62d4dc1ae6e9..c76bb691c7c3 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -147,6 +147,8 @@ export const dict = { "provider.connect.apiKey.label": "{{provider}} API key", "provider.connect.apiKey.placeholder": "API key", "provider.connect.apiKey.required": "API key is required", + "provider.connect.apiKey.labelOptional": "Label (optional)", + "provider.connect.apiKey.labelPlaceholder": "e.g. personal, work, team", "provider.connect.opencodeZen.line1": "OpenCode Zen gives you access to a curated set of reliable optimized models for coding agents.", "provider.connect.opencodeZen.line2": @@ -668,6 +670,20 @@ export const dict = { "session.tab.session": "Session", "session.tab.review": "Review", "session.tab.context": "Context", + "session.tab.tasks": "Tasks", + "session.tasks.running": "Running", + "session.tasks.completed": "Completed", + "session.tasks.kind.agent": "Agent", + "session.tasks.kind.shell": "Shell", + "session.tasks.state.running": "Running", + "session.tasks.state.needsInput": "Needs input", + "session.tasks.state.completed": "Completed", + "session.tasks.state.failed": "Failed", + "session.tasks.empty": "No background work running", + "session.tasks.openTranscript": "Open transcript", + "session.tasks.stop": "Stop task", + "session.tasks.dismiss": "Dismiss", + "command.tasks.toggle": "Toggle tasks panel", "session.tab.unknown": "Unknown Session", "session.panel.reviewAndFiles": "Review and files", "session.error.notFound": "This session cannot be found", @@ -1070,6 +1086,7 @@ export const dict = { "settings.providers.tag.config": "Config", "settings.providers.tag.custom": "Custom", "settings.providers.tag.other": "Other", + "settings.providers.addKey": "Add key", "settings.models.title": "Models", "settings.models.description": "Model settings will be configurable here.", "settings.agents.title": "Agents", diff --git a/packages/app/src/pages/session/helpers.test.ts b/packages/app/src/pages/session/helpers.test.ts index c04502562805..71723adcd00f 100644 --- a/packages/app/src/pages/session/helpers.test.ts +++ b/packages/app/src/pages/session/helpers.test.ts @@ -191,6 +191,47 @@ describe("createSessionTabs", () => { }) }) + test("treats tasks as a special tab outside file tabs", () => { + createRoot((dispose) => { + const [state] = createStore({ + active: "tasks" as string | undefined, + all: ["file://src/a.ts", "tasks"], + }) + const tabs = createMemo(() => ({ active: () => state.active, all: () => state.all })) + const result = createSessionTabs({ + tabs, + pathFromTab: (tab) => (tab.startsWith("file://") ? tab.slice("file://".length) : undefined), + normalizeTab: (tab) => tab, + }) + + expect(result.tasksOpen()).toBe(true) + expect(result.panelTabs()).toEqual(["file://src/a.ts"]) + expect(result.openedTabs()).toEqual(["file://src/a.ts"]) + expect(result.activeTab()).toBe("tasks") + expect(result.activeFileTab()).toBeUndefined() + expect(result.closableTab()).toBe("tasks") + dispose() + }) + }) + + test("falls back to tasks when open without file tabs", () => { + createRoot((dispose) => { + const [state] = createStore({ + active: undefined as string | undefined, + all: ["tasks"], + }) + const tabs = createMemo(() => ({ active: () => state.active, all: () => state.all })) + const result = createSessionTabs({ + tabs, + pathFromTab: () => undefined, + normalizeTab: (tab) => tab, + }) + + expect(result.activeTab()).toBe("tasks") + dispose() + }) + }) + test("hides the Open File placeholder when the file browser is unavailable", () => { createRoot((dispose) => { const [state] = createStore({ diff --git a/packages/app/src/pages/session/helpers.ts b/packages/app/src/pages/session/helpers.ts index c43f6bc6a365..329dabd0bdf5 100644 --- a/packages/app/src/pages/session/helpers.ts +++ b/packages/app/src/pages/session/helpers.ts @@ -33,6 +33,7 @@ export const createSessionTabs = (input: TabsInput) => { const hasReview = input.hasReview ?? (() => false) const fileBrowser = input.fileBrowser ?? (() => false) const contextOpen = createMemo(() => input.tabs().active() === "context" || input.tabs().all().includes("context")) + const tasksOpen = createMemo(() => input.tabs().active() === "tasks" || input.tabs().all().includes("tasks")) const openFileOpen = createMemo( () => fileBrowser() && @@ -45,7 +46,7 @@ export const createSessionTabs = (input: TabsInput) => { .tabs() .all() .flatMap((tab) => { - if (tab === "context" || tab === "review") return [] + if (tab === "context" || tab === "review" || tab === "tasks") return [] if (tab === SESSION_OPEN_FILE_TAB && !fileBrowser()) return [] const value = input.pathFromTab(tab) ? input.normalizeTab(tab) : tab if (seen.has(value)) return [] @@ -62,6 +63,7 @@ export const createSessionTabs = (input: TabsInput) => { const activeTab = createMemo(() => { const active = input.tabs().active() if (active === "context") return active + if (active === "tasks" && tasksOpen()) return active if (active === SESSION_OPEN_FILE_TAB && openFileOpen()) return active if (active === "review" && review()) return active if (active && input.pathFromTab(active)) return input.normalizeTab(active) @@ -69,6 +71,7 @@ export const createSessionTabs = (input: TabsInput) => { const first = openedTabs()[0] if (first) return first if (contextOpen()) return "context" + if (tasksOpen()) return "tasks" if (review() && hasReview()) return "review" return "empty" }) @@ -80,6 +83,7 @@ export const createSessionTabs = (input: TabsInput) => { const closableTab = createMemo(() => { const active = activeTab() if (active === "context") return active + if (active === "tasks") return active if (active === SESSION_OPEN_FILE_TAB && openFileOpen()) return active if (!openedTabs().includes(active)) return return active @@ -87,6 +91,7 @@ export const createSessionTabs = (input: TabsInput) => { return { contextOpen, + tasksOpen, openFileOpen, panelTabs, openedTabs, diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index 6f074fa20304..8ab3f2dc6cb5 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -56,6 +56,8 @@ import { import { setSessionHandoff } from "@/pages/session/handoff" import { useSessionLayout } from "@/pages/session/session-layout" import { SessionFileBrowserTab, type SessionFileBrowserState } from "@/pages/session/v2/session-file-browser-tab" +import { TasksPanel } from "./tasks-panel" +import { createTasksData } from "./tasks-data" type ReviewDiff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff type RenderDiff = FileDiffInfo | (SnapshotFileDiff & { file: string }) | VcsFileDiff @@ -182,7 +184,34 @@ export function SessionSidePanel(props: { fileBrowser: () => !!props.fileBrowserState, }) const contextOpen = tabState.contextOpen + const tasksOpen = tabState.tasksOpen + const tasksData = createTasksData() const openFileOpen = tabState.openFileOpen + + // Auto-open the tasks tab while background work is live (Claude tasks-pane + // parity). Lives here — not in TasksPanel — because the panel only mounts + // once its tab is already active. Level-triggered (not edge-triggered) so + // remounts and HMR can't miss it; a manual close snoozes until work drains. + let wasOpen = false + let snoozed = false + createEffect(() => { + const n = tasksData.liveCount() + const open = tasksOpen() + if (n === 0) { + snoozed = false + wasOpen = open + return + } + if (wasOpen && !open) snoozed = true + wasOpen = open + if (!open && !snoozed) { + // Panel first, then tab, then focus in a microtask so the freshly + // mounted tab strip selects Tasks instead of falling back to Review. + view().reviewPanel.open() + tabs().open("tasks") + queueMicrotask(() => tabs().setActive("tasks")) + } + }) const panelTabs = tabState.panelTabs const openedTabs = tabState.openedTabs const activeTab = tabState.activeTab @@ -391,6 +420,36 @@ export function SessionSidePanel(props: { + + + tabs().close("tasks")} + aria-label={language.t("common.closeTab")} + /> + + } + hideCloseButton + onMiddleClick={() => tabs().close("tasks")} + > +
+
{language.t("session.tab.tasks")}
+ 0}> +
{tasksData.liveCount()}
+
+
+
+
{(tab) => ( @@ -498,6 +557,14 @@ export function SessionSidePanel(props: { + + +
+ +
+
+
+ {(tab) => } @@ -605,6 +672,42 @@ export function SessionSidePanel(props: { + + + {language.t("common.closeTab")} + 0}> + + + + } + placement="bottom" + gutter={10} + > + tabs().close("tasks")} + aria-label={language.t("common.closeTab")} + /> + + } + hideCloseButton + onMiddleClick={() => tabs().close("tasks")} + > +
+
{language.t("session.tab.tasks")}
+ 0}> +
{tasksData.liveCount()}
+
+
+
+
{(tab) => ( + + +
+ +
+
+
+
["state"] + +export type TasksItemState = "running" | "needs-input" | "completed" | "failed" + +export interface TasksItem { + /** Stable key: child session id for subagents, callID for shell tools. */ + key: string + kind: "agent" | "shell" + headline: string + agent?: string + state: TasksItemState + startTime: number + endTime?: number + /** Child session id — the official join key (task.ts metadata.sessionId). */ + childId?: string + /** Parent session that spawned the work. */ + sessionId: string + /** Nested subagent count ((+N), Claude panel parity). */ + nested?: number + /** Live aggregates scanned from the child session transcript. */ + stats?: TaskStats +} + +export interface TaskStats { + model?: string + agent?: string + toolCalls: number + fails: number + tokensIn: number + tokensOut: number + cost: number +} + +/** + * Derives the background-work list for the current session from the already + * synced reactive stores — sessions (incl. children via parentID), + * message parts, session status and pending permissions. + * + * No fetching, no polling, no event wiring: every input below is a synced + * store, so the panel updates automatically with the transcript. + */ + +const toolTitle = (state: TaskToolState): string | undefined => { + if (state.status === "completed" || state.status === "running") return state.title + return undefined +} + +const toolStart = (state: TaskToolState): number | undefined => { + if (state.status === "pending") return undefined + return state.time.start +} + +const toolEnd = (state: TaskToolState): number | undefined => { + if (state.status === "completed" || state.status === "error") return state.time.end + return undefined +} + +const toolMetadata = (part: Extract): Record => { + if (part.state.status === "pending") return (part.metadata ?? {}) as Record + return (part.metadata ?? part.state.metadata ?? {}) as Record +} + +export function createTasksData() { + const sync = useSync() + const { params } = useSessionLayout() + const sessionID = createMemo(() => params.id) + + const children = createMemo(() => { + const sid = sessionID() + if (!sid) return [] + return (sync().data.session ?? []).filter((s) => s.parentID === sid) + }) + + const taskParts = createMemo(() => { + const sid = sessionID() + if (!sid) return [] + const messages = sync().data.session_message ?? {} + const partsByMessage = sync().data.part ?? {} + const out: { part: Extract; messageID: string }[] = [] + for (const msg of messages[sid] ?? []) { + for (const part of partsByMessage[msg.id] ?? []) { + if (part.type !== "tool") continue + if (part.tool !== "task" && part.tool !== "bash" && part.tool !== "shell") continue + out.push({ part, messageID: msg.id }) + } + } + return out + }) + + const grandchildren = createMemo(() => { + const map = new Map() + for (const s of sync().data.session ?? []) { + if (!s.parentID) continue + map.set(s.parentID, (map.get(s.parentID) ?? 0) + 1) + } + return map + }) + + /* Pending permissions indexed both ways: PermissionRequest carries the + owning sessionID and, when raised by a tool call, tool.callID. */ + const pendingPermissions = createMemo(() => { + const sessions = new Set() + const calls = new Set() + for (const [sid, reqs] of Object.entries(sync().data.permission ?? {})) { + if (!reqs || reqs.length === 0) continue + sessions.add(sid) + for (const req of reqs) { + if (req.tool) calls.add(req.tool.callID) + } + } + return { sessions, calls } + }) + + const items = createMemo(() => { + const sid = sessionID() + if (!sid) return { running: [] as TasksItem[], finished: [] as TasksItem[] } + const data = sync().data + const perms = pendingPermissions() + const nested = grandchildren() + const running: TasksItem[] = [] + const finished: TasksItem[] = [] + const seen = new Set() + + const needsInput = (childId: string | undefined, callID: string): boolean => + (childId !== undefined && perms.sessions.has(childId)) || perms.calls.has(callID) + + const stateOf = ( + childId: string | undefined, + callID: string, + toolState: string, + ): TasksItemState => { + if (needsInput(childId, callID)) return "needs-input" + if (toolState === "error") return "failed" + if (toolState === "completed") return "completed" + return "running" + } + + for (const { part } of taskParts()) { + const st = part.state.status + if (part.tool === "task") { + const meta = toolMetadata(part) + const childId = typeof meta.sessionId === "string" ? meta.sessionId : undefined + const key = childId ?? part.callID + if (seen.has(key)) continue + seen.add(key) + const input = (part.state.input ?? {}) as Record + const headline = + toolTitle(part.state) ?? + (typeof input.description === "string" && input.description.length > 0 + ? input.description + : "Subagent") + const agent = + typeof input.subagent_type === "string" && input.subagent_type.length > 0 + ? input.subagent_type + : undefined + const item: TasksItem = { + key, + kind: "agent", + headline, + agent, + state: stateOf(childId, part.callID, st), + startTime: toolStart(part.state) ?? Date.now(), + endTime: + st === "completed" || st === "error" ? (toolEnd(part.state) ?? Date.now()) : undefined, + childId, + sessionId: sid, + nested: childId ? nested.get(childId) || undefined : undefined, + stats: childId ? childStats(data, childId) : undefined, + } + // A live child session outranks a stale completed part (resume via task_id reuses the id). + if (childId && (st === "completed" || st === "error") && data.session_working(childId)) { + item.state = perms.sessions.has(childId) ? "needs-input" : "running" + item.endTime = undefined + } + if (item.state === "running" || item.state === "needs-input") running.push(item) + else finished.push(item) + } else { + // Foreground shell tools surface as Shell cards while running. + if (st !== "running" && st !== "pending") continue + if (seen.has(part.callID)) continue + seen.add(part.callID) + running.push({ + key: part.callID, + kind: "shell", + headline: toolTitle(part.state) ?? part.tool, + state: perms.calls.has(part.callID) ? "needs-input" : "running", + startTime: toolStart(part.state) ?? Date.now(), + sessionId: sid, + }) + } + } + + for (const child of children()) { + if (seen.has(child.id)) continue + seen.add(child.id) + const working = data.session_working(child.id) + const live = perms.sessions.has(child.id) ? "needs-input" : working ? "running" : "completed" + const item: TasksItem = { + key: child.id, + kind: "agent", + headline: child.title || "Subagent", + state: live, + startTime: child.time.created ?? Date.now(), + childId: child.id, + sessionId: sid, + nested: nested.get(child.id) || undefined, + stats: childStats(data, child.id), + } + if (live === "running" || live === "needs-input") running.push(item) + else finished.push(item) + } + + running.sort((a, b) => b.startTime - a.startTime) + finished.sort((a, b) => (b.endTime ?? 0) - (a.endTime ?? 0)) + return { running: running, finished: finished.slice(0, 12) } + }) + + const liveCount = createMemo(() => items().running.length) + + return { items, liveCount } +} + +function shortModel(providerID: string, modelID: string): string { + const short = modelID.replace(/^(anthropic|openai|google|opencode)-/i, "") + return `${providerID}/${short}` +} + +/** Scans a child session transcript into live aggregates. Pure derivation + over the synced message/part stores — no fetching. */ +export function childStats( + data: { + session_message: Record + part: Record + }, + childId: string, +): TaskStats { + const stats: TaskStats = { toolCalls: 0, fails: 0, tokensIn: 0, tokensOut: 0, cost: 0 } + const messages = data.session_message[childId] ?? [] + for (const msg of messages) { + const full = msg as unknown as { + role?: string + modelID?: string + providerID?: string + model?: string + provider?: string + modelId?: string + providerId?: string + model_id?: string + provider_id?: string + agent?: string + tokens?: { input?: number; output?: number } + cost?: number + } + if (full.role === "assistant") { + // Model fields vary across sync shapes — try every known spelling. + const modelID = full.modelID ?? full.modelId ?? full.model_id ?? full.model + const providerID = full.providerID ?? full.providerId ?? full.provider_id ?? full.provider + if (modelID && providerID) stats.model = shortModel(providerID, modelID) + if (full.agent) stats.agent = full.agent + stats.tokensIn += full.tokens?.input ?? 0 + stats.tokensOut += full.tokens?.output ?? 0 + stats.cost += full.cost ?? 0 + } + for (const part of data.part[msg.id] ?? []) { + if (part.type !== "tool") continue + stats.toolCalls += 1 + if (part.state.status === "error") stats.fails += 1 + } + } + return stats +} diff --git a/packages/app/src/pages/session/tasks-panel.tsx b/packages/app/src/pages/session/tasks-panel.tsx new file mode 100644 index 000000000000..efc2f713a9dc --- /dev/null +++ b/packages/app/src/pages/session/tasks-panel.tsx @@ -0,0 +1,258 @@ +import { For, Show, createMemo, createSignal, onCleanup } from "solid-js" +import { useNavigate } from "@solidjs/router" +import { base64Encode } from "@opencode-ai/core/util/encode" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { Mark } from "@opencode-ai/ui/logo" +import { useLanguage } from "@/context/language" +import { useSDK } from "@/context/sdk" +import { createTasksData, type TasksItem } from "./tasks-data" + +function fmtElapsed(start: number): string { + const s = Math.max(0, Math.floor((Date.now() - start) / 1000)) + if (s < 60) return `${s}s` + const m = Math.floor(s / 60) + if (m < 60) return `${m}m ${String(s % 60).padStart(2, "0")}s` + return `${Math.floor(m / 60)}h ${m % 60}m` +} + +function fmtDuration(start: number, end: number): string { + const s = Math.max(0, Math.floor((end - start) / 1000)) + if (s < 60) return `${s}s` + return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s` +} + +function StateMark(props: { state: TasksItem["state"] }) { + return ( + } + > + + + ) +} + +function TaskRow(props: { + item: TasksItem + tick: number + onOpen: (item: TasksItem) => void + onStop: (item: TasksItem) => void + onDismiss: (item: TasksItem) => void +}) { + const language = useLanguage() + const item = props.item + const live = () => item.state === "running" || item.state === "needs-input" + void props.tick + + return ( +
props.onOpen(item)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + props.onOpen(item) + } + }} + class="flex min-h-7 shrink-0 cursor-pointer items-start gap-2 rounded-md border border-transparent px-3 py-2" + classList={{ + "bg-surface-raised-base": live(), + "border-border-weaker-base": live(), + "opacity-75 hover:opacity-100": !live(), + }} + > +
+ +
+
+
+ {item.headline} + + (+{item.nested}) + +
+
+ {item.kind === "agent" ? language.t("session.tasks.kind.agent") : language.t("session.tasks.kind.shell")} + {" · "} + {item.state === "needs-input" ? ( + {language.t("session.tasks.state.needsInput")} + ) : item.state === "running" ? ( + language.t("session.tasks.state.running") + ) : item.state === "completed" ? ( + language.t("session.tasks.state.completed") + ) : ( + language.t("session.tasks.state.failed") + )} + {" · "} + + {live() ? fmtElapsed(item.startTime) : fmtDuration(item.startTime, item.endTime ?? item.startTime)} + + + + {" · "}@{item.agent} + + +
+ +
+ +
+ Model: + {item.stats!.model} +
+
+ +
+ Agent: + {item.stats!.agent} +
+
+
+ Tools: + {item.stats!.toolCalls} +
+ 0}> +
+ Fails: + {item.stats!.fails} +
+
+ 0}> +
+ Fails: + 0 +
+
+
+ Tokens: + + ↓{item.stats!.tokensIn.toLocaleString()} ↑{item.stats!.tokensOut.toLocaleString()} + +
+ 0}> +
+ Cost: + ${item.stats!.cost.toFixed(4)} +
+
+
+
+
+
e.stopPropagation()}> + props.onDismiss(item)} + aria-label={language.t("session.tasks.dismiss")} + title={language.t("session.tasks.dismiss")} + /> + } + > + + props.onStop(item)} + aria-label={language.t("session.tasks.stop")} + title={language.t("session.tasks.stop")} + /> + + +
+
+ ) +} + +export function TasksPanel() { + const language = useLanguage() + const sdk = useSDK() + const navigate = useNavigate() + const { items } = createTasksData() + const [dismissed, setDismissed] = createSignal>(new Set()) + const [tick, setTick] = createSignal(0) + + const timer = setInterval(() => setTick((t) => t + 1), 1000) + onCleanup(() => clearInterval(timer)) + + const visible = createMemo(() => { + const gone = dismissed() + return { + running: items().running, + finished: items().finished.filter((t) => !gone.has(t.key)), + } + }) + + const openItem = (item: TasksItem) => { + const dir = sdk().directory + if (!dir) return + navigate(`/${base64Encode(dir)}/session/${item.childId ?? item.sessionId}`) + } + + const stopItem = async (item: TasksItem) => { + await sdk() + .api.session.interrupt({ sessionID: item.childId ?? item.sessionId }) + .catch(() => {}) + } + + const dismissItem = (item: TasksItem) => { + setDismissed((prev) => new Set(prev).add(item.key)) + } + + return ( +
+
+ 0} + fallback={ +
+ +
+ {language.t("session.tasks.empty")} +
+
+ } + > + 0}> +
+ {language.t("session.tasks.running")} +
+ + {(item) => } + +
+ 0}> +
+ {language.t("session.tasks.completed")} +
+ + {(item) => } + +
+
+
+
+ ) +} diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index 18d5b172ff17..f73fe1b16b8c 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -565,6 +565,19 @@ export const useSessionCommands = (actions: SessionCommandContext) => { keybind: "mod+shift+r", onSelect: () => view().reviewPanel.toggle(), }), + viewCommand({ + id: "tasks.toggle", + title: language.t("command.tasks.toggle"), + onSelect: () => { + if (tabs().active() === "tasks") { + tabs().close("tasks") + return + } + tabs().open("tasks") + tabs().setActive("tasks") + view().reviewPanel.open() + }, + }), ...(shown() ? [ viewCommand({ From 27a10f3c014e022ffc668449055bb1eeec488a8a Mon Sep 17 00:00:00 2001 From: Gustavo Schneiter Date: Thu, 3 Sep 2026 21:30:45 -0300 Subject: [PATCH 002/117] feat(app): tasks model fallback to task tool metadata --- packages/app/src/pages/session/tasks-data.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/app/src/pages/session/tasks-data.ts b/packages/app/src/pages/session/tasks-data.ts index 2ba68905c339..27ac275a2233 100644 --- a/packages/app/src/pages/session/tasks-data.ts +++ b/packages/app/src/pages/session/tasks-data.ts @@ -158,6 +158,14 @@ export function createTasksData() { typeof input.subagent_type === "string" && input.subagent_type.length > 0 ? input.subagent_type : undefined + // task.ts records the resolved model in the tool call metadata — + // authoritative even before the child transcript syncs any message. + const toolModel = meta.model as { modelID?: string; providerID?: string } | undefined + const stats = childId ? childStats(data, childId) : undefined + if (stats && !stats.model && toolModel?.modelID && toolModel?.providerID) { + const short = toolModel.modelID.replace(/^(anthropic|openai|google|opencode)-/i, "") + stats.model = `${toolModel.providerID}/${short}` + } const item: TasksItem = { key, kind: "agent", @@ -170,7 +178,7 @@ export function createTasksData() { childId, sessionId: sid, nested: childId ? nested.get(childId) || undefined : undefined, - stats: childId ? childStats(data, childId) : undefined, + stats, } // A live child session outranks a stale completed part (resume via task_id reuses the id). if (childId && (st === "completed" || st === "error") && data.session_working(childId)) { From d6b6b6e273f94cb3d1599c37356b7e3dac99c21c Mon Sep 17 00:00:00 2001 From: Gustavo Schneiter Date: Thu, 3 Sep 2026 22:11:27 -0300 Subject: [PATCH 003/117] feat(app): tasks ticker only while live work present --- packages/app/src/pages/session/tasks-panel.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/app/src/pages/session/tasks-panel.tsx b/packages/app/src/pages/session/tasks-panel.tsx index efc2f713a9dc..4898291184ef 100644 --- a/packages/app/src/pages/session/tasks-panel.tsx +++ b/packages/app/src/pages/session/tasks-panel.tsx @@ -1,4 +1,4 @@ -import { For, Show, createMemo, createSignal, onCleanup } from "solid-js" +import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js" import { useNavigate } from "@solidjs/router" import { base64Encode } from "@opencode-ai/core/util/encode" import { IconButton } from "@opencode-ai/ui/icon-button" @@ -194,8 +194,13 @@ export function TasksPanel() { const [dismissed, setDismissed] = createSignal>(new Set()) const [tick, setTick] = createSignal(0) - const timer = setInterval(() => setTick((t) => t + 1), 1000) - onCleanup(() => clearInterval(timer)) + // Elapsed-time ticker exists only while live work is present: no timer, + // no re-render churn, nothing retained when the panel is idle. + createEffect(() => { + if (items().running.length === 0) return + const timer = setInterval(() => setTick((t) => t + 1), 1000) + onCleanup(() => clearInterval(timer)) + }) const visible = createMemo(() => { const gone = dismissed() From 519702ee74b034878c5d25cc165a1e2735dc45bf Mon Sep 17 00:00:00 2001 From: Gustavo Schneiter Date: Thu, 3 Sep 2026 22:25:13 -0300 Subject: [PATCH 004/117] feat(app): tasks i18n polish, locale-aware numbers --- packages/app/src/i18n/en.ts | 7 ++++++ .../app/src/pages/session/tasks-panel.tsx | 24 +++++++++---------- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index c76bb691c7c3..8d54bbe5f5da 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -671,6 +671,7 @@ export const dict = { "session.tab.review": "Review", "session.tab.context": "Context", "session.tab.tasks": "Tasks", + "session.tasks.subagent": "Subagent", "session.tasks.running": "Running", "session.tasks.completed": "Completed", "session.tasks.kind.agent": "Agent", @@ -680,6 +681,12 @@ export const dict = { "session.tasks.state.completed": "Completed", "session.tasks.state.failed": "Failed", "session.tasks.empty": "No background work running", + "session.tasks.stats.model": "Model", + "session.tasks.stats.agent": "Agent", + "session.tasks.stats.tools": "Tools", + "session.tasks.stats.fails": "Fails", + "session.tasks.stats.tokens": "Tokens", + "session.tasks.stats.cost": "Cost", "session.tasks.openTranscript": "Open transcript", "session.tasks.stop": "Stop task", "session.tasks.dismiss": "Dismiss", diff --git a/packages/app/src/pages/session/tasks-panel.tsx b/packages/app/src/pages/session/tasks-panel.tsx index 4898291184ef..a8ee9f215907 100644 --- a/packages/app/src/pages/session/tasks-panel.tsx +++ b/packages/app/src/pages/session/tasks-panel.tsx @@ -84,7 +84,7 @@ function TaskRow(props: { class="truncate text-strong" style={{ "font-size": "13px", "font-weight": "400", "line-height": "130%", "letter-spacing": "-0.04px" }} > - {item.headline} + {item.headline || language.t("session.tasks.subagent")} (+{item.nested}) @@ -112,44 +112,44 @@ function TaskRow(props: {
-
+
- Model: + {language.t("session.tasks.stats.model")}: {item.stats!.model}
- Agent: + {language.t("session.tasks.stats.agent")}: {item.stats!.agent}
- Tools: - {item.stats!.toolCalls} + {language.t("session.tasks.stats.tools")}: + {item.stats!.toolCalls.toLocaleString(language.intl())}
0}>
- Fails: - {item.stats!.fails} + {language.t("session.tasks.stats.fails")}: + {item.stats!.fails.toLocaleString(language.intl())}
0}>
- Fails: + {language.t("session.tasks.stats.fails")}: 0
- Tokens: + {language.t("session.tasks.stats.tokens")}: - ↓{item.stats!.tokensIn.toLocaleString()} ↑{item.stats!.tokensOut.toLocaleString()} + ↓{item.stats!.tokensIn.toLocaleString(language.intl())} ↑{item.stats!.tokensOut.toLocaleString(language.intl())}
0}>
- Cost: + {language.t("session.tasks.stats.cost")}: ${item.stats!.cost.toFixed(4)}
From 77d1e63a2e5c82c27ff7eafdd85bef4cf3aad794 Mon Sep 17 00:00:00 2001 From: Gustavo Schneiter Date: Thu, 3 Sep 2026 22:56:36 -0300 Subject: [PATCH 005/117] feat(app): english fallback for new tasks keys across locales Parity test requires every English key present in all locales. Real translations land via the locale sync flow; until then English source stands in (established precedent: 128 identical-EN keys in de.ts). --- packages/app/src/i18n/am.ts | 24 ++++++++++++++++++++++++ packages/app/src/i18n/ar.ts | 24 ++++++++++++++++++++++++ packages/app/src/i18n/az.ts | 24 ++++++++++++++++++++++++ packages/app/src/i18n/bg.ts | 24 ++++++++++++++++++++++++ packages/app/src/i18n/bn.ts | 24 ++++++++++++++++++++++++ packages/app/src/i18n/br.ts | 24 ++++++++++++++++++++++++ packages/app/src/i18n/bs.ts | 24 ++++++++++++++++++++++++ packages/app/src/i18n/ca.ts | 24 ++++++++++++++++++++++++ packages/app/src/i18n/cs.ts | 24 ++++++++++++++++++++++++ packages/app/src/i18n/da.ts | 24 ++++++++++++++++++++++++ packages/app/src/i18n/de.ts | 24 ++++++++++++++++++++++++ packages/app/src/i18n/dv.ts | 24 ++++++++++++++++++++++++ packages/app/src/i18n/dz.ts | 24 ++++++++++++++++++++++++ packages/app/src/i18n/el.ts | 24 ++++++++++++++++++++++++ packages/app/src/i18n/es.ts | 24 ++++++++++++++++++++++++ packages/app/src/i18n/et.ts | 24 ++++++++++++++++++++++++ packages/app/src/i18n/fa.ts | 24 ++++++++++++++++++++++++ packages/app/src/i18n/fi.ts | 24 ++++++++++++++++++++++++ packages/app/src/i18n/fo.ts | 24 ++++++++++++++++++++++++ packages/app/src/i18n/fr.ts | 24 ++++++++++++++++++++++++ packages/app/src/i18n/hi.ts | 24 ++++++++++++++++++++++++ packages/app/src/i18n/hr.ts | 24 ++++++++++++++++++++++++ packages/app/src/i18n/hu.ts | 24 ++++++++++++++++++++++++ packages/app/src/i18n/hy.ts | 24 ++++++++++++++++++++++++ packages/app/src/i18n/id.ts | 24 ++++++++++++++++++++++++ packages/app/src/i18n/is.ts | 24 ++++++++++++++++++++++++ packages/app/src/i18n/it.ts | 24 ++++++++++++++++++++++++ packages/app/src/i18n/ja.ts | 24 ++++++++++++++++++++++++ packages/app/src/i18n/ka.ts | 24 ++++++++++++++++++++++++ packages/app/src/i18n/km.ts | 24 ++++++++++++++++++++++++ packages/app/src/i18n/ko.ts | 24 ++++++++++++++++++++++++ packages/app/src/i18n/lo.ts | 24 ++++++++++++++++++++++++ packages/app/src/i18n/lt.ts | 24 ++++++++++++++++++++++++ packages/app/src/i18n/lv.ts | 24 ++++++++++++++++++++++++ packages/app/src/i18n/mk.ts | 24 ++++++++++++++++++++++++ packages/app/src/i18n/mn.ts | 24 ++++++++++++++++++++++++ packages/app/src/i18n/ms.ts | 24 ++++++++++++++++++++++++ packages/app/src/i18n/my.ts | 24 ++++++++++++++++++++++++ packages/app/src/i18n/ne.ts | 24 ++++++++++++++++++++++++ packages/app/src/i18n/nl.ts | 24 ++++++++++++++++++++++++ packages/app/src/i18n/no.ts | 24 ++++++++++++++++++++++++ packages/app/src/i18n/pa.ts | 24 ++++++++++++++++++++++++ packages/app/src/i18n/pl.ts | 24 ++++++++++++++++++++++++ packages/app/src/i18n/ro.ts | 24 ++++++++++++++++++++++++ packages/app/src/i18n/ru.ts | 24 ++++++++++++++++++++++++ packages/app/src/i18n/si.ts | 24 ++++++++++++++++++++++++ packages/app/src/i18n/sk.ts | 24 ++++++++++++++++++++++++ packages/app/src/i18n/sl.ts | 24 ++++++++++++++++++++++++ packages/app/src/i18n/sq.ts | 24 ++++++++++++++++++++++++ packages/app/src/i18n/sr.ts | 24 ++++++++++++++++++++++++ packages/app/src/i18n/sv.ts | 24 ++++++++++++++++++++++++ packages/app/src/i18n/tg.ts | 24 ++++++++++++++++++++++++ packages/app/src/i18n/th.ts | 24 ++++++++++++++++++++++++ packages/app/src/i18n/tk.ts | 24 ++++++++++++++++++++++++ packages/app/src/i18n/tr.ts | 24 ++++++++++++++++++++++++ packages/app/src/i18n/uk.ts | 24 ++++++++++++++++++++++++ packages/app/src/i18n/ur.ts | 24 ++++++++++++++++++++++++ packages/app/src/i18n/uz.ts | 24 ++++++++++++++++++++++++ packages/app/src/i18n/vi.ts | 24 ++++++++++++++++++++++++ packages/app/src/i18n/zh.ts | 24 ++++++++++++++++++++++++ packages/app/src/i18n/zht.ts | 24 ++++++++++++++++++++++++ 61 files changed, 1464 insertions(+) diff --git a/packages/app/src/i18n/am.ts b/packages/app/src/i18n/am.ts index bda48350e532..91306f45a610 100644 --- a/packages/app/src/i18n/am.ts +++ b/packages/app/src/i18n/am.ts @@ -222,6 +222,9 @@ export const dict = { "provider.connect.apiKey.description": "መለያዎን ለማገናኘት የ{{provider}} API ቁልፍዎን ያስገቡ እና {{provider}} ሞዴሎችን በOpenCode ይጠቀሙ።", "provider.connect.apiKey.label": "{{provider}} API ቁልፍ", + "provider.connect.apiKey.labelOptional": "Label (optional)", + "provider.connect.apiKey.labelPlaceholder": "e.g. personal, work, team", + "settings.providers.addKey": "Add key", "provider.connect.apiKey.placeholder": "API ቁልፍ", "provider.connect.apiKey.required": "API ቁልፍ ያስፈልጋል", "provider.connect.opencodeZen.line1": "OpenCode ዜን ለኮድ ወኪሎች የተመቻቹ አስተማማኝ ሞዴሎች ስብስብ መዳረሻ ይሰጥዎታል።", @@ -691,6 +694,27 @@ export const dict = { "session.tab.session": "ክፍለ ጊዜ", "session.tab.review": "ግምገማ", "session.tab.context": "አውድ", + "session.tab.tasks": "Tasks", + "session.tasks.subagent": "Subagent", + "session.tasks.running": "Running", + "session.tasks.completed": "Completed", + "session.tasks.kind.agent": "Agent", + "session.tasks.kind.shell": "Shell", + "session.tasks.state.running": "Running", + "session.tasks.state.needsInput": "Needs input", + "session.tasks.state.completed": "Completed", + "session.tasks.state.failed": "Failed", + "session.tasks.empty": "No background work running", + "session.tasks.openTranscript": "Open transcript", + "session.tasks.stop": "Stop task", + "session.tasks.dismiss": "Dismiss", + "session.tasks.stats.model": "Model", + "session.tasks.stats.agent": "Agent", + "session.tasks.stats.tools": "Tools", + "session.tasks.stats.fails": "Fails", + "session.tasks.stats.tokens": "Tokens", + "session.tasks.stats.cost": "Cost", + "command.tasks.toggle": "Toggle tasks panel", "session.tab.unknown": "ያልታወቀ ክፍለ ጊዜ", "session.panel.reviewAndFiles": "ግምገማ እና ፋይሎች", "session.error.notFound": "ይህ ክፍለ ጊዜ ሊገኝ አይችልም", diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index 3f55b3084088..a689cc86aaef 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -229,6 +229,9 @@ export const dict = { "provider.connect.apiKey.description": "أدخل مفتاح API الخاص بـ {{provider}} لتوصيل حسابك واستخدام نماذج {{provider}} في OpenCode.", "provider.connect.apiKey.label": "مفتاح API لـ {{provider}}", + "provider.connect.apiKey.labelOptional": "Label (optional)", + "provider.connect.apiKey.labelPlaceholder": "e.g. personal, work, team", + "settings.providers.addKey": "Add key", "provider.connect.apiKey.placeholder": "مفتاح API", "provider.connect.apiKey.required": "مفتاح API مطلوب", "provider.connect.opencodeZen.line1": @@ -690,6 +693,27 @@ export const dict = { "session.tab.session": "جلسة", "session.tab.review": "مراجعة", "session.tab.context": "سياق", + "session.tab.tasks": "Tasks", + "session.tasks.subagent": "Subagent", + "session.tasks.running": "Running", + "session.tasks.completed": "Completed", + "session.tasks.kind.agent": "Agent", + "session.tasks.kind.shell": "Shell", + "session.tasks.state.running": "Running", + "session.tasks.state.needsInput": "Needs input", + "session.tasks.state.completed": "Completed", + "session.tasks.state.failed": "Failed", + "session.tasks.empty": "No background work running", + "session.tasks.openTranscript": "Open transcript", + "session.tasks.stop": "Stop task", + "session.tasks.dismiss": "Dismiss", + "session.tasks.stats.model": "Model", + "session.tasks.stats.agent": "Agent", + "session.tasks.stats.tools": "Tools", + "session.tasks.stats.fails": "Fails", + "session.tasks.stats.tokens": "Tokens", + "session.tasks.stats.cost": "Cost", + "command.tasks.toggle": "Toggle tasks panel", "session.tab.unknown": "جلسة غير معروفة", "session.panel.reviewAndFiles": "المراجعة والملفات", "session.error.notFound": "لا يمكن العثور على هذه الجلسة", diff --git a/packages/app/src/i18n/az.ts b/packages/app/src/i18n/az.ts index d818ae57fd6c..96aaf8595633 100644 --- a/packages/app/src/i18n/az.ts +++ b/packages/app/src/i18n/az.ts @@ -225,6 +225,9 @@ export const dict = { "provider.connect.apiKey.description": "Hesabınızı qoşmaq və OpenCode-da {{provider}} modellərindən istifadə etmək üçün {{provider}} API açarınızı daxil edin.", "provider.connect.apiKey.label": "{{provider}} API açarı", + "provider.connect.apiKey.labelOptional": "Label (optional)", + "provider.connect.apiKey.labelPlaceholder": "e.g. personal, work, team", + "settings.providers.addKey": "Add key", "provider.connect.apiKey.placeholder": "API açarı", "provider.connect.apiKey.required": "API açarı tələb olunur", "provider.connect.opencodeZen.line1": @@ -713,6 +716,27 @@ export const dict = { "session.tab.session": "Sessiya", "session.tab.review": "İcmal", "session.tab.context": "Kontekst", + "session.tab.tasks": "Tasks", + "session.tasks.subagent": "Subagent", + "session.tasks.running": "Running", + "session.tasks.completed": "Completed", + "session.tasks.kind.agent": "Agent", + "session.tasks.kind.shell": "Shell", + "session.tasks.state.running": "Running", + "session.tasks.state.needsInput": "Needs input", + "session.tasks.state.completed": "Completed", + "session.tasks.state.failed": "Failed", + "session.tasks.empty": "No background work running", + "session.tasks.openTranscript": "Open transcript", + "session.tasks.stop": "Stop task", + "session.tasks.dismiss": "Dismiss", + "session.tasks.stats.model": "Model", + "session.tasks.stats.agent": "Agent", + "session.tasks.stats.tools": "Tools", + "session.tasks.stats.fails": "Fails", + "session.tasks.stats.tokens": "Tokens", + "session.tasks.stats.cost": "Cost", + "command.tasks.toggle": "Toggle tasks panel", "session.tab.unknown": "Naməlum sessiya", "session.panel.reviewAndFiles": "İcmal və fayllar", "session.error.notFound": "Bu sessiyanı tapmaq mümkün deyil", diff --git a/packages/app/src/i18n/bg.ts b/packages/app/src/i18n/bg.ts index 7f63d1fa4b3e..5bd5dcf20f2a 100644 --- a/packages/app/src/i18n/bg.ts +++ b/packages/app/src/i18n/bg.ts @@ -225,6 +225,9 @@ export const dict = { "provider.connect.apiKey.description": "Въведете своя {{provider}} API ключ, за да свържете акаунта си и да използвате {{provider}} модели в OpenCode.", "provider.connect.apiKey.label": "{{provider}} API ключ", + "provider.connect.apiKey.labelOptional": "Label (optional)", + "provider.connect.apiKey.labelPlaceholder": "e.g. personal, work, team", + "settings.providers.addKey": "Add key", "provider.connect.apiKey.placeholder": "API ключ", "provider.connect.apiKey.required": "Изисква се ключ API", "provider.connect.opencodeZen.line1": @@ -709,6 +712,27 @@ export const dict = { "session.tab.session": "Сесия", "session.tab.review": "Преглед", "session.tab.context": "Контекст", + "session.tab.tasks": "Tasks", + "session.tasks.subagent": "Subagent", + "session.tasks.running": "Running", + "session.tasks.completed": "Completed", + "session.tasks.kind.agent": "Agent", + "session.tasks.kind.shell": "Shell", + "session.tasks.state.running": "Running", + "session.tasks.state.needsInput": "Needs input", + "session.tasks.state.completed": "Completed", + "session.tasks.state.failed": "Failed", + "session.tasks.empty": "No background work running", + "session.tasks.openTranscript": "Open transcript", + "session.tasks.stop": "Stop task", + "session.tasks.dismiss": "Dismiss", + "session.tasks.stats.model": "Model", + "session.tasks.stats.agent": "Agent", + "session.tasks.stats.tools": "Tools", + "session.tasks.stats.fails": "Fails", + "session.tasks.stats.tokens": "Tokens", + "session.tasks.stats.cost": "Cost", + "command.tasks.toggle": "Toggle tasks panel", "session.tab.unknown": "Неизвестна сесия", "session.panel.reviewAndFiles": "Преглед и файлове", "session.error.notFound": "Тази сесия не може да бъде намерена", diff --git a/packages/app/src/i18n/bn.ts b/packages/app/src/i18n/bn.ts index 5f0698ab64f1..3e63d1b12992 100644 --- a/packages/app/src/i18n/bn.ts +++ b/packages/app/src/i18n/bn.ts @@ -223,6 +223,9 @@ export const dict: Record = { "provider.connect.apiKey.description": "আপনার অ্যাকাউন্ট সংযোগ করতে আপনার {{provider}} API কী লিখুন এবং OpenCode-এ {{provider}} মডেলগুলি ব্যবহার করুন৷", "provider.connect.apiKey.label": "{{provider}} API কী", + "provider.connect.apiKey.labelOptional": "Label (optional)", + "provider.connect.apiKey.labelPlaceholder": "e.g. personal, work, team", + "settings.providers.addKey": "Add key", "provider.connect.apiKey.placeholder": "API কী", "provider.connect.apiKey.required": "API কী প্রয়োজন", "provider.connect.opencodeZen.line1": @@ -703,6 +706,27 @@ export const dict: Record = { "session.tab.session": "সেশন", "session.tab.review": "পর্যালোচনা", "session.tab.context": "প্রসঙ্গ", + "session.tab.tasks": "Tasks", + "session.tasks.subagent": "Subagent", + "session.tasks.running": "Running", + "session.tasks.completed": "Completed", + "session.tasks.kind.agent": "Agent", + "session.tasks.kind.shell": "Shell", + "session.tasks.state.running": "Running", + "session.tasks.state.needsInput": "Needs input", + "session.tasks.state.completed": "Completed", + "session.tasks.state.failed": "Failed", + "session.tasks.empty": "No background work running", + "session.tasks.openTranscript": "Open transcript", + "session.tasks.stop": "Stop task", + "session.tasks.dismiss": "Dismiss", + "session.tasks.stats.model": "Model", + "session.tasks.stats.agent": "Agent", + "session.tasks.stats.tools": "Tools", + "session.tasks.stats.fails": "Fails", + "session.tasks.stats.tokens": "Tokens", + "session.tasks.stats.cost": "Cost", + "command.tasks.toggle": "Toggle tasks panel", "session.tab.unknown": "অজানা সেশন", "session.panel.reviewAndFiles": "পর্যালোচনা এবং ফাইল", "session.error.notFound": "এই সেশন খুঁজে পাওয়া যাবে না", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index 09da845ccb53..3998c1f29ef9 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -231,6 +231,9 @@ export const dict = { "provider.connect.apiKey.description": "Digite sua chave de API do {{provider}} para conectar sua conta e usar modelos do {{provider}} no OpenCode.", "provider.connect.apiKey.label": "Chave de API do {{provider}}", + "provider.connect.apiKey.labelOptional": "Label (optional)", + "provider.connect.apiKey.labelPlaceholder": "e.g. personal, work, team", + "settings.providers.addKey": "Add key", "provider.connect.apiKey.placeholder": "Chave de API", "provider.connect.apiKey.required": "A chave de API é obrigatória", "provider.connect.opencodeZen.line1": @@ -697,6 +700,27 @@ export const dict = { "session.tab.session": "Sessão", "session.tab.review": "Revisão", "session.tab.context": "Contexto", + "session.tab.tasks": "Tasks", + "session.tasks.subagent": "Subagent", + "session.tasks.running": "Running", + "session.tasks.completed": "Completed", + "session.tasks.kind.agent": "Agent", + "session.tasks.kind.shell": "Shell", + "session.tasks.state.running": "Running", + "session.tasks.state.needsInput": "Needs input", + "session.tasks.state.completed": "Completed", + "session.tasks.state.failed": "Failed", + "session.tasks.empty": "No background work running", + "session.tasks.openTranscript": "Open transcript", + "session.tasks.stop": "Stop task", + "session.tasks.dismiss": "Dismiss", + "session.tasks.stats.model": "Model", + "session.tasks.stats.agent": "Agent", + "session.tasks.stats.tools": "Tools", + "session.tasks.stats.fails": "Fails", + "session.tasks.stats.tokens": "Tokens", + "session.tasks.stats.cost": "Cost", + "command.tasks.toggle": "Toggle tasks panel", "session.tab.unknown": "Sessão desconhecida", "session.panel.reviewAndFiles": "Revisão e arquivos", "session.error.notFound": "Esta sessão não foi encontrada", diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts index feb41c651a87..338db8a3a56b 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -243,6 +243,9 @@ export const dict = { "provider.connect.apiKey.description": "Unesi svoj {{provider}} API ključ da povežeš račun i koristiš {{provider}} modele u OpenCode-u.", "provider.connect.apiKey.label": "{{provider}} API ključ", + "provider.connect.apiKey.labelOptional": "Label (optional)", + "provider.connect.apiKey.labelPlaceholder": "e.g. personal, work, team", + "settings.providers.addKey": "Add key", "provider.connect.apiKey.placeholder": "API ključ", "provider.connect.apiKey.required": "API ključ je obavezan", "provider.connect.opencodeZen.line1": @@ -750,6 +753,27 @@ export const dict = { "session.tab.session": "Sesija", "session.tab.review": "Pregled", "session.tab.context": "Kontekst", + "session.tab.tasks": "Tasks", + "session.tasks.subagent": "Subagent", + "session.tasks.running": "Running", + "session.tasks.completed": "Completed", + "session.tasks.kind.agent": "Agent", + "session.tasks.kind.shell": "Shell", + "session.tasks.state.running": "Running", + "session.tasks.state.needsInput": "Needs input", + "session.tasks.state.completed": "Completed", + "session.tasks.state.failed": "Failed", + "session.tasks.empty": "No background work running", + "session.tasks.openTranscript": "Open transcript", + "session.tasks.stop": "Stop task", + "session.tasks.dismiss": "Dismiss", + "session.tasks.stats.model": "Model", + "session.tasks.stats.agent": "Agent", + "session.tasks.stats.tools": "Tools", + "session.tasks.stats.fails": "Fails", + "session.tasks.stats.tokens": "Tokens", + "session.tasks.stats.cost": "Cost", + "command.tasks.toggle": "Toggle tasks panel", "session.tab.unknown": "Nepoznata sesija", "session.panel.reviewAndFiles": "Pregled i datoteke", "session.error.notFound": "Ova sesija nije pronađena", diff --git a/packages/app/src/i18n/ca.ts b/packages/app/src/i18n/ca.ts index 21ad325607a6..8fe75b636e9f 100644 --- a/packages/app/src/i18n/ca.ts +++ b/packages/app/src/i18n/ca.ts @@ -224,6 +224,9 @@ export const dict = { "provider.connect.apiKey.description": "Introduïu la clau API de {{provider}} per connectar el compte i utilitzar els models de {{provider}} a OpenCode.", "provider.connect.apiKey.label": "Tecla {{provider}} API.", + "provider.connect.apiKey.labelOptional": "Label (optional)", + "provider.connect.apiKey.labelPlaceholder": "e.g. personal, work, team", + "settings.providers.addKey": "Add key", "provider.connect.apiKey.placeholder": "tecla API.", "provider.connect.apiKey.required": "La clau API és necessària", "provider.connect.opencodeZen.line1": @@ -709,6 +712,27 @@ export const dict = { "session.tab.session": "Sessió", "session.tab.review": "Revisió", "session.tab.context": "Context", + "session.tab.tasks": "Tasks", + "session.tasks.subagent": "Subagent", + "session.tasks.running": "Running", + "session.tasks.completed": "Completed", + "session.tasks.kind.agent": "Agent", + "session.tasks.kind.shell": "Shell", + "session.tasks.state.running": "Running", + "session.tasks.state.needsInput": "Needs input", + "session.tasks.state.completed": "Completed", + "session.tasks.state.failed": "Failed", + "session.tasks.empty": "No background work running", + "session.tasks.openTranscript": "Open transcript", + "session.tasks.stop": "Stop task", + "session.tasks.dismiss": "Dismiss", + "session.tasks.stats.model": "Model", + "session.tasks.stats.agent": "Agent", + "session.tasks.stats.tools": "Tools", + "session.tasks.stats.fails": "Fails", + "session.tasks.stats.tokens": "Tokens", + "session.tasks.stats.cost": "Cost", + "command.tasks.toggle": "Toggle tasks panel", "session.tab.unknown": "Sessió desconeguda", "session.panel.reviewAndFiles": "Revisió i arxius", "session.error.notFound": "No es pot trobar aquesta sessió", diff --git a/packages/app/src/i18n/cs.ts b/packages/app/src/i18n/cs.ts index d79d4eb5a7f8..9ca043deb481 100644 --- a/packages/app/src/i18n/cs.ts +++ b/packages/app/src/i18n/cs.ts @@ -222,6 +222,9 @@ export const dict = { "provider.connect.apiKey.description": "Zadejte klíč API služby {{provider}}, připojte účet a používejte modely {{provider}} v OpenCode.", "provider.connect.apiKey.label": "{{provider}} API klíč", + "provider.connect.apiKey.labelOptional": "Label (optional)", + "provider.connect.apiKey.labelPlaceholder": "e.g. personal, work, team", + "settings.providers.addKey": "Add key", "provider.connect.apiKey.placeholder": "API klíč", "provider.connect.apiKey.required": "Je vyžadováno tlačítko API", "provider.connect.opencodeZen.line1": @@ -704,6 +707,27 @@ export const dict = { "session.tab.session": "Relace", "session.tab.review": "Recenze", "session.tab.context": "Kontext", + "session.tab.tasks": "Tasks", + "session.tasks.subagent": "Subagent", + "session.tasks.running": "Running", + "session.tasks.completed": "Completed", + "session.tasks.kind.agent": "Agent", + "session.tasks.kind.shell": "Shell", + "session.tasks.state.running": "Running", + "session.tasks.state.needsInput": "Needs input", + "session.tasks.state.completed": "Completed", + "session.tasks.state.failed": "Failed", + "session.tasks.empty": "No background work running", + "session.tasks.openTranscript": "Open transcript", + "session.tasks.stop": "Stop task", + "session.tasks.dismiss": "Dismiss", + "session.tasks.stats.model": "Model", + "session.tasks.stats.agent": "Agent", + "session.tasks.stats.tools": "Tools", + "session.tasks.stats.fails": "Fails", + "session.tasks.stats.tokens": "Tokens", + "session.tasks.stats.cost": "Cost", + "command.tasks.toggle": "Toggle tasks panel", "session.tab.unknown": "Neznámá relace", "session.panel.reviewAndFiles": "Recenze a soubory", "session.error.notFound": "Tuto relaci nelze najít", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index 87737b094240..998472bbbfac 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -142,6 +142,9 @@ export const dict = { "provider.connect.apiKey.description": "Indtast din API-nøgle til {{provider}} for at forbinde din konto og bruge modeller fra {{provider}} i OpenCode.", "provider.connect.apiKey.label": "API-nøgle til {{provider}}", + "provider.connect.apiKey.labelOptional": "Label (optional)", + "provider.connect.apiKey.labelPlaceholder": "e.g. personal, work, team", + "settings.providers.addKey": "Add key", "provider.connect.apiKey.placeholder": "API-nøgle", "provider.connect.apiKey.required": "API-nøgle er påkrævet", "provider.connect.opencodeZen.line1": @@ -630,6 +633,27 @@ export const dict = { "session.tab.session": "Session", "session.tab.review": "Gennemgang", "session.tab.context": "Kontekst", + "session.tab.tasks": "Tasks", + "session.tasks.subagent": "Subagent", + "session.tasks.running": "Running", + "session.tasks.completed": "Completed", + "session.tasks.kind.agent": "Agent", + "session.tasks.kind.shell": "Shell", + "session.tasks.state.running": "Running", + "session.tasks.state.needsInput": "Needs input", + "session.tasks.state.completed": "Completed", + "session.tasks.state.failed": "Failed", + "session.tasks.empty": "No background work running", + "session.tasks.openTranscript": "Open transcript", + "session.tasks.stop": "Stop task", + "session.tasks.dismiss": "Dismiss", + "session.tasks.stats.model": "Model", + "session.tasks.stats.agent": "Agent", + "session.tasks.stats.tools": "Tools", + "session.tasks.stats.fails": "Fails", + "session.tasks.stats.tokens": "Tokens", + "session.tasks.stats.cost": "Cost", + "command.tasks.toggle": "Toggle tasks panel", "session.tab.unknown": "Ukendt session", "session.panel.reviewAndFiles": "Gennemgang og filer", "session.error.notFound": "Denne session kan ikke findes", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index 00849598c5c5..85fbf8ac3ac2 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -134,6 +134,9 @@ export const dict = { "provider.connect.apiKey.description": "Geben Sie Ihren API-Schlüssel für {{provider}} ein, um Ihr Konto zu verbinden und {{provider}}-Modelle in OpenCode zu nutzen.", "provider.connect.apiKey.label": "API-Schlüssel für {{provider}}", + "provider.connect.apiKey.labelOptional": "Label (optional)", + "provider.connect.apiKey.labelPlaceholder": "e.g. personal, work, team", + "settings.providers.addKey": "Add key", "provider.connect.apiKey.placeholder": "API-Schlüssel", "provider.connect.apiKey.required": "API-Schlüssel ist erforderlich", "provider.connect.opencodeZen.line1": @@ -589,6 +592,27 @@ export const dict = { "session.tab.session": "Sitzung", "session.tab.review": "Überprüfung", "session.tab.context": "Kontext", + "session.tab.tasks": "Tasks", + "session.tasks.subagent": "Subagent", + "session.tasks.running": "Running", + "session.tasks.completed": "Completed", + "session.tasks.kind.agent": "Agent", + "session.tasks.kind.shell": "Shell", + "session.tasks.state.running": "Running", + "session.tasks.state.needsInput": "Needs input", + "session.tasks.state.completed": "Completed", + "session.tasks.state.failed": "Failed", + "session.tasks.empty": "No background work running", + "session.tasks.openTranscript": "Open transcript", + "session.tasks.stop": "Stop task", + "session.tasks.dismiss": "Dismiss", + "session.tasks.stats.model": "Model", + "session.tasks.stats.agent": "Agent", + "session.tasks.stats.tools": "Tools", + "session.tasks.stats.fails": "Fails", + "session.tasks.stats.tokens": "Tokens", + "session.tasks.stats.cost": "Cost", + "command.tasks.toggle": "Toggle tasks panel", "session.tab.unknown": "Unbekannte Sitzung", "session.panel.reviewAndFiles": "Überprüfung und Dateien", "session.error.notFound": "Diese Sitzung wurde nicht gefunden", diff --git a/packages/app/src/i18n/dv.ts b/packages/app/src/i18n/dv.ts index 27b5fea86999..64494db99f0d 100644 --- a/packages/app/src/i18n/dv.ts +++ b/packages/app/src/i18n/dv.ts @@ -226,6 +226,9 @@ export const dict = { "provider.connect.apiKey.description": "ތިބާގެ އެކައުންޓް ގުޅުވައި، OpenCode ގައި {{provider}} މޮޑެލްތައް ބޭނުންކުރުމަށް {{provider}} API ކީ ލިޔުއްވާ.", "provider.connect.apiKey.label": "{{provider}} API ކީ އެވެ", + "provider.connect.apiKey.labelOptional": "Label (optional)", + "provider.connect.apiKey.labelPlaceholder": "e.g. personal, work, team", + "settings.providers.addKey": "Add key", "provider.connect.apiKey.placeholder": "API ކީ އެވެ", "provider.connect.apiKey.required": "API ކީ ބޭނުންވެއެވެ", "provider.connect.opencodeZen.line1": @@ -715,6 +718,27 @@ export const dict = { "session.tab.session": "ސެޝަން", "session.tab.review": "ރިވިއު", "session.tab.context": "ކޮންޓެކްސްޓް", + "session.tab.tasks": "Tasks", + "session.tasks.subagent": "Subagent", + "session.tasks.running": "Running", + "session.tasks.completed": "Completed", + "session.tasks.kind.agent": "Agent", + "session.tasks.kind.shell": "Shell", + "session.tasks.state.running": "Running", + "session.tasks.state.needsInput": "Needs input", + "session.tasks.state.completed": "Completed", + "session.tasks.state.failed": "Failed", + "session.tasks.empty": "No background work running", + "session.tasks.openTranscript": "Open transcript", + "session.tasks.stop": "Stop task", + "session.tasks.dismiss": "Dismiss", + "session.tasks.stats.model": "Model", + "session.tasks.stats.agent": "Agent", + "session.tasks.stats.tools": "Tools", + "session.tasks.stats.fails": "Fails", + "session.tasks.stats.tokens": "Tokens", + "session.tasks.stats.cost": "Cost", + "command.tasks.toggle": "Toggle tasks panel", "session.tab.unknown": "ނޭނގޭ ސެޝަނެއް", "session.panel.reviewAndFiles": "ރިވިއުކޮށް ފައިލްކުރުން", "session.error.notFound": "މި ސެޝަން ހޯދިފައެއް ނުވެއެވެ", diff --git a/packages/app/src/i18n/dz.ts b/packages/app/src/i18n/dz.ts index 3b2ad46ca304..b9baabddad93 100644 --- a/packages/app/src/i18n/dz.ts +++ b/packages/app/src/i18n/dz.ts @@ -226,6 +226,9 @@ export const dict: Record = { "provider.connect.apiKey.description": "ཁྱོད་རའི་རྩིས་ཐོ་མཐུད་ནིའི་དོན་ལུ་ ཁྱོད་རའི་ {{provider}} API ལྡེ་མིག་བཙུགས་ཞིནམ་ལས་ OpenCode ནང་ལུ་ {{provider}} དཔེ་ཚད་ཚུ་ལག་ལེན་འཐབ།", "provider.connect.apiKey.label": "{{provider}} APIལྡེ་མིག།", + "provider.connect.apiKey.labelOptional": "Label (optional)", + "provider.connect.apiKey.labelPlaceholder": "e.g. personal, work, team", + "settings.providers.addKey": "Add key", "provider.connect.apiKey.placeholder": "APIལྡེ་མིག།", "provider.connect.apiKey.required": "APIལྡེ་མིག་དགོས་མཁོ་ཡོད།", "provider.connect.opencodeZen.line1": @@ -715,6 +718,27 @@ export const dict: Record = { "session.tab.session": "ལཱ་ཡུན།", "session.tab.review": "བསྐྱར་ཞིབ", "session.tab.context": "སྐབས་དོན།", + "session.tab.tasks": "Tasks", + "session.tasks.subagent": "Subagent", + "session.tasks.running": "Running", + "session.tasks.completed": "Completed", + "session.tasks.kind.agent": "Agent", + "session.tasks.kind.shell": "Shell", + "session.tasks.state.running": "Running", + "session.tasks.state.needsInput": "Needs input", + "session.tasks.state.completed": "Completed", + "session.tasks.state.failed": "Failed", + "session.tasks.empty": "No background work running", + "session.tasks.openTranscript": "Open transcript", + "session.tasks.stop": "Stop task", + "session.tasks.dismiss": "Dismiss", + "session.tasks.stats.model": "Model", + "session.tasks.stats.agent": "Agent", + "session.tasks.stats.tools": "Tools", + "session.tasks.stats.fails": "Fails", + "session.tasks.stats.tokens": "Tokens", + "session.tasks.stats.cost": "Cost", + "command.tasks.toggle": "Toggle tasks panel", "session.tab.unknown": "མ་ཤེས་པའི་ལཱ་ཡུན།", "session.panel.reviewAndFiles": "བསྐྱར་ཞིབ་དང་ཡིག་སྣོད།", "session.error.notFound": "ལཱ་ཡུན་འདི་འཚོལ་མ་ཐོབ།", diff --git a/packages/app/src/i18n/el.ts b/packages/app/src/i18n/el.ts index 8f91b46af010..e0d8b82d70c2 100644 --- a/packages/app/src/i18n/el.ts +++ b/packages/app/src/i18n/el.ts @@ -223,6 +223,9 @@ export const dict = { "provider.connect.apiKey.description": "Εισαγάγετε το κλειδί {{provider}} API για να συνδέσετε τον λογαριασμό σας και χρησιμοποιήστε μοντέλα {{provider}} στο OpenCode.", "provider.connect.apiKey.label": "{{provider}} API κλειδί", + "provider.connect.apiKey.labelOptional": "Label (optional)", + "provider.connect.apiKey.labelPlaceholder": "e.g. personal, work, team", + "settings.providers.addKey": "Add key", "provider.connect.apiKey.placeholder": "API κλειδί", "provider.connect.apiKey.required": "API απαιτείται κλειδί", "provider.connect.opencodeZen.line1": @@ -710,6 +713,27 @@ export const dict = { "session.tab.session": "Συνεδρία", "session.tab.review": "Επισκόπηση", "session.tab.context": "Πλαίσιο", + "session.tab.tasks": "Tasks", + "session.tasks.subagent": "Subagent", + "session.tasks.running": "Running", + "session.tasks.completed": "Completed", + "session.tasks.kind.agent": "Agent", + "session.tasks.kind.shell": "Shell", + "session.tasks.state.running": "Running", + "session.tasks.state.needsInput": "Needs input", + "session.tasks.state.completed": "Completed", + "session.tasks.state.failed": "Failed", + "session.tasks.empty": "No background work running", + "session.tasks.openTranscript": "Open transcript", + "session.tasks.stop": "Stop task", + "session.tasks.dismiss": "Dismiss", + "session.tasks.stats.model": "Model", + "session.tasks.stats.agent": "Agent", + "session.tasks.stats.tools": "Tools", + "session.tasks.stats.fails": "Fails", + "session.tasks.stats.tokens": "Tokens", + "session.tasks.stats.cost": "Cost", + "command.tasks.toggle": "Toggle tasks panel", "session.tab.unknown": "Άγνωστη συνεδρία", "session.panel.reviewAndFiles": "Επισκόπηση και αρχεία", "session.error.notFound": "Δεν μπορεί να βρεθεί αυτή η συνεδρία", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 9e9ff2c36750..bc9848ce72b3 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -243,6 +243,9 @@ export const dict = { "provider.connect.apiKey.description": "Introduce tu clave API de {{provider}} para conectar tu cuenta y usar modelos de {{provider}} en OpenCode.", "provider.connect.apiKey.label": "Clave API de {{provider}}", + "provider.connect.apiKey.labelOptional": "Label (optional)", + "provider.connect.apiKey.labelPlaceholder": "e.g. personal, work, team", + "settings.providers.addKey": "Add key", "provider.connect.apiKey.placeholder": "Clave API", "provider.connect.apiKey.required": "La clave API es obligatoria", "provider.connect.opencodeZen.line1": @@ -753,6 +756,27 @@ export const dict = { "session.tab.session": "Sesión", "session.tab.review": "Revisión", "session.tab.context": "Contexto", + "session.tab.tasks": "Tasks", + "session.tasks.subagent": "Subagent", + "session.tasks.running": "Running", + "session.tasks.completed": "Completed", + "session.tasks.kind.agent": "Agent", + "session.tasks.kind.shell": "Shell", + "session.tasks.state.running": "Running", + "session.tasks.state.needsInput": "Needs input", + "session.tasks.state.completed": "Completed", + "session.tasks.state.failed": "Failed", + "session.tasks.empty": "No background work running", + "session.tasks.openTranscript": "Open transcript", + "session.tasks.stop": "Stop task", + "session.tasks.dismiss": "Dismiss", + "session.tasks.stats.model": "Model", + "session.tasks.stats.agent": "Agent", + "session.tasks.stats.tools": "Tools", + "session.tasks.stats.fails": "Fails", + "session.tasks.stats.tokens": "Tokens", + "session.tasks.stats.cost": "Cost", + "command.tasks.toggle": "Toggle tasks panel", "session.tab.unknown": "Sesión desconocida", "session.panel.reviewAndFiles": "Revisión y archivos", "session.error.notFound": "No se encuentra esta sesión", diff --git a/packages/app/src/i18n/et.ts b/packages/app/src/i18n/et.ts index d78296675e3d..7d4372a96598 100644 --- a/packages/app/src/i18n/et.ts +++ b/packages/app/src/i18n/et.ts @@ -222,6 +222,9 @@ export const dict = { "provider.connect.apiKey.description": "Sisestage {{provider}} API võti, et ühendada konto ja kasutada {{provider}} mudeleid OpenCode'is.", "provider.connect.apiKey.label": "{{provider}} API klahv", + "provider.connect.apiKey.labelOptional": "Label (optional)", + "provider.connect.apiKey.labelPlaceholder": "e.g. personal, work, team", + "settings.providers.addKey": "Add key", "provider.connect.apiKey.placeholder": "API klahv", "provider.connect.apiKey.required": "Klahv API on nõutav", "provider.connect.opencodeZen.line1": @@ -699,6 +702,27 @@ export const dict = { "session.tab.session": "Seanss", "session.tab.review": "Ülevaade", "session.tab.context": "Kontekst", + "session.tab.tasks": "Tasks", + "session.tasks.subagent": "Subagent", + "session.tasks.running": "Running", + "session.tasks.completed": "Completed", + "session.tasks.kind.agent": "Agent", + "session.tasks.kind.shell": "Shell", + "session.tasks.state.running": "Running", + "session.tasks.state.needsInput": "Needs input", + "session.tasks.state.completed": "Completed", + "session.tasks.state.failed": "Failed", + "session.tasks.empty": "No background work running", + "session.tasks.openTranscript": "Open transcript", + "session.tasks.stop": "Stop task", + "session.tasks.dismiss": "Dismiss", + "session.tasks.stats.model": "Model", + "session.tasks.stats.agent": "Agent", + "session.tasks.stats.tools": "Tools", + "session.tasks.stats.fails": "Fails", + "session.tasks.stats.tokens": "Tokens", + "session.tasks.stats.cost": "Cost", + "command.tasks.toggle": "Toggle tasks panel", "session.tab.unknown": "Tundmatu seanss", "session.panel.reviewAndFiles": "Ülevaatus ja failid", "session.error.notFound": "Seda seanssi ei leitud", diff --git a/packages/app/src/i18n/fa.ts b/packages/app/src/i18n/fa.ts index 0d3d721b5324..b0d83d9669fa 100644 --- a/packages/app/src/i18n/fa.ts +++ b/packages/app/src/i18n/fa.ts @@ -223,6 +223,9 @@ export const dict = { "provider.connect.apiKey.description": "کلید API سرویس {{provider}} را وارد کنید تا حساب شما متصل شود و بتوانید از مدل‌های {{provider}} در OpenCode استفاده کنید.", "provider.connect.apiKey.label": "کلید {{provider}} API", + "provider.connect.apiKey.labelOptional": "Label (optional)", + "provider.connect.apiKey.labelPlaceholder": "e.g. personal, work, team", + "settings.providers.addKey": "Add key", "provider.connect.apiKey.placeholder": "کلید API", "provider.connect.apiKey.required": "کلید API مورد نیاز است", "provider.connect.opencodeZen.line1": @@ -703,6 +706,27 @@ export const dict = { "session.tab.session": "جلسه", "session.tab.review": "بررسی کنید", "session.tab.context": "زمینه", + "session.tab.tasks": "Tasks", + "session.tasks.subagent": "Subagent", + "session.tasks.running": "Running", + "session.tasks.completed": "Completed", + "session.tasks.kind.agent": "Agent", + "session.tasks.kind.shell": "Shell", + "session.tasks.state.running": "Running", + "session.tasks.state.needsInput": "Needs input", + "session.tasks.state.completed": "Completed", + "session.tasks.state.failed": "Failed", + "session.tasks.empty": "No background work running", + "session.tasks.openTranscript": "Open transcript", + "session.tasks.stop": "Stop task", + "session.tasks.dismiss": "Dismiss", + "session.tasks.stats.model": "Model", + "session.tasks.stats.agent": "Agent", + "session.tasks.stats.tools": "Tools", + "session.tasks.stats.fails": "Fails", + "session.tasks.stats.tokens": "Tokens", + "session.tasks.stats.cost": "Cost", + "command.tasks.toggle": "Toggle tasks panel", "session.tab.unknown": "جلسه ناشناخته", "session.panel.reviewAndFiles": "بررسی و فایل ها", "session.error.notFound": "این جلسه یافت نمی شود", diff --git a/packages/app/src/i18n/fi.ts b/packages/app/src/i18n/fi.ts index aa9c92574cff..694da94336ba 100644 --- a/packages/app/src/i18n/fi.ts +++ b/packages/app/src/i18n/fi.ts @@ -130,6 +130,9 @@ export const dict = { "provider.connect.apiKey.description": "Anna {{provider}}-API-avaimesi yhdistääksesi tilisi ja käyttääksesi {{provider}}-malleja OpenCodessa.", "provider.connect.apiKey.label": "{{provider}}-API-avain", + "provider.connect.apiKey.labelOptional": "Label (optional)", + "provider.connect.apiKey.labelPlaceholder": "e.g. personal, work, team", + "settings.providers.addKey": "Add key", "provider.connect.apiKey.placeholder": "API-avain", "provider.connect.apiKey.required": "API-avain vaaditaan", "provider.connect.opencodeZen.line1": @@ -601,6 +604,27 @@ export const dict = { "session.tab.session": "Istunto", "session.tab.review": "Tarkistus", "session.tab.context": "Konteksti", + "session.tab.tasks": "Tasks", + "session.tasks.subagent": "Subagent", + "session.tasks.running": "Running", + "session.tasks.completed": "Completed", + "session.tasks.kind.agent": "Agent", + "session.tasks.kind.shell": "Shell", + "session.tasks.state.running": "Running", + "session.tasks.state.needsInput": "Needs input", + "session.tasks.state.completed": "Completed", + "session.tasks.state.failed": "Failed", + "session.tasks.empty": "No background work running", + "session.tasks.openTranscript": "Open transcript", + "session.tasks.stop": "Stop task", + "session.tasks.dismiss": "Dismiss", + "session.tasks.stats.model": "Model", + "session.tasks.stats.agent": "Agent", + "session.tasks.stats.tools": "Tools", + "session.tasks.stats.fails": "Fails", + "session.tasks.stats.tokens": "Tokens", + "session.tasks.stats.cost": "Cost", + "command.tasks.toggle": "Toggle tasks panel", "session.tab.unknown": "Tuntematon istunto", "session.panel.reviewAndFiles": "Tarkistus ja tiedostot", "session.error.notFound": "Tätä istuntoa ei löydy", diff --git a/packages/app/src/i18n/fo.ts b/packages/app/src/i18n/fo.ts index 4329a35b4247..b17d974e9189 100644 --- a/packages/app/src/i18n/fo.ts +++ b/packages/app/src/i18n/fo.ts @@ -222,6 +222,9 @@ export const dict = { "provider.connect.apiKey.description": "Skriva {{provider}} API-lykilin fyri at knýta kontuna saman og brúka {{provider}}-modell í OpenCode.", "provider.connect.apiKey.label": "{{provider}} API lykil", + "provider.connect.apiKey.labelOptional": "Label (optional)", + "provider.connect.apiKey.labelPlaceholder": "e.g. personal, work, team", + "settings.providers.addKey": "Add key", "provider.connect.apiKey.placeholder": "API lykil", "provider.connect.apiKey.required": "API lykilin er kravdur", "provider.connect.opencodeZen.line1": @@ -704,6 +707,27 @@ export const dict = { "session.tab.session": "Seta", "session.tab.review": "Ummæli", "session.tab.context": "Samanhangur", + "session.tab.tasks": "Tasks", + "session.tasks.subagent": "Subagent", + "session.tasks.running": "Running", + "session.tasks.completed": "Completed", + "session.tasks.kind.agent": "Agent", + "session.tasks.kind.shell": "Shell", + "session.tasks.state.running": "Running", + "session.tasks.state.needsInput": "Needs input", + "session.tasks.state.completed": "Completed", + "session.tasks.state.failed": "Failed", + "session.tasks.empty": "No background work running", + "session.tasks.openTranscript": "Open transcript", + "session.tasks.stop": "Stop task", + "session.tasks.dismiss": "Dismiss", + "session.tasks.stats.model": "Model", + "session.tasks.stats.agent": "Agent", + "session.tasks.stats.tools": "Tools", + "session.tasks.stats.fails": "Fails", + "session.tasks.stats.tokens": "Tokens", + "session.tasks.stats.cost": "Cost", + "command.tasks.toggle": "Toggle tasks panel", "session.tab.unknown": "Ókend seta", "session.panel.reviewAndFiles": "Ummæli og skráir", "session.error.notFound": "Henda setan er ikki at finna", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index 3c89860a4490..6134a0fda8fd 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -231,6 +231,9 @@ export const dict = { "provider.connect.apiKey.description": "Entrez votre clé API {{provider}} pour connecter votre compte et utiliser les modèles {{provider}} dans OpenCode.", "provider.connect.apiKey.label": "Clé API {{provider}}", + "provider.connect.apiKey.labelOptional": "Label (optional)", + "provider.connect.apiKey.labelPlaceholder": "e.g. personal, work, team", + "settings.providers.addKey": "Add key", "provider.connect.apiKey.placeholder": "Clé API", "provider.connect.apiKey.required": "La clé API est requise", "provider.connect.opencodeZen.line1": @@ -707,6 +710,27 @@ export const dict = { "session.tab.session": "Session", "session.tab.review": "Revue", "session.tab.context": "Contexte", + "session.tab.tasks": "Tasks", + "session.tasks.subagent": "Subagent", + "session.tasks.running": "Running", + "session.tasks.completed": "Completed", + "session.tasks.kind.agent": "Agent", + "session.tasks.kind.shell": "Shell", + "session.tasks.state.running": "Running", + "session.tasks.state.needsInput": "Needs input", + "session.tasks.state.completed": "Completed", + "session.tasks.state.failed": "Failed", + "session.tasks.empty": "No background work running", + "session.tasks.openTranscript": "Open transcript", + "session.tasks.stop": "Stop task", + "session.tasks.dismiss": "Dismiss", + "session.tasks.stats.model": "Model", + "session.tasks.stats.agent": "Agent", + "session.tasks.stats.tools": "Tools", + "session.tasks.stats.fails": "Fails", + "session.tasks.stats.tokens": "Tokens", + "session.tasks.stats.cost": "Cost", + "command.tasks.toggle": "Toggle tasks panel", "session.tab.unknown": "Session inconnue", "session.panel.reviewAndFiles": "Revue et fichiers", "session.error.notFound": "Cette session est introuvable", diff --git a/packages/app/src/i18n/hi.ts b/packages/app/src/i18n/hi.ts index 0aaf8e14dc6d..5b471ccf997b 100644 --- a/packages/app/src/i18n/hi.ts +++ b/packages/app/src/i18n/hi.ts @@ -230,6 +230,9 @@ export const dict = { "provider.connect.apiKey.description": "अपने खाते को कनेक्ट करने के लिए अपनी {{provider}} API कुंजी दर्ज करें और OpenCode में {{provider}} मॉडल का उपयोग करें।", "provider.connect.apiKey.label": "{{provider}} API कुंजी", + "provider.connect.apiKey.labelOptional": "Label (optional)", + "provider.connect.apiKey.labelPlaceholder": "e.g. personal, work, team", + "settings.providers.addKey": "Add key", "provider.connect.apiKey.placeholder": "API कुंजी", "provider.connect.apiKey.required": "API कुंजी आवश्यक है", "provider.connect.opencodeZen.line1": @@ -713,6 +716,27 @@ export const dict = { "session.tab.session": "सेशन", "session.tab.review": "समीक्षा", "session.tab.context": "कॉन्टेक्स्ट", + "session.tab.tasks": "Tasks", + "session.tasks.subagent": "Subagent", + "session.tasks.running": "Running", + "session.tasks.completed": "Completed", + "session.tasks.kind.agent": "Agent", + "session.tasks.kind.shell": "Shell", + "session.tasks.state.running": "Running", + "session.tasks.state.needsInput": "Needs input", + "session.tasks.state.completed": "Completed", + "session.tasks.state.failed": "Failed", + "session.tasks.empty": "No background work running", + "session.tasks.openTranscript": "Open transcript", + "session.tasks.stop": "Stop task", + "session.tasks.dismiss": "Dismiss", + "session.tasks.stats.model": "Model", + "session.tasks.stats.agent": "Agent", + "session.tasks.stats.tools": "Tools", + "session.tasks.stats.fails": "Fails", + "session.tasks.stats.tokens": "Tokens", + "session.tasks.stats.cost": "Cost", + "command.tasks.toggle": "Toggle tasks panel", "session.tab.unknown": "अज्ञात सेशन", "session.panel.reviewAndFiles": "समीक्षा और फ़ाइलें", "session.error.notFound": "यह सेशन नहीं मिल सका", diff --git a/packages/app/src/i18n/hr.ts b/packages/app/src/i18n/hr.ts index 2130c88ada2d..cf4f1b8e893c 100644 --- a/packages/app/src/i18n/hr.ts +++ b/packages/app/src/i18n/hr.ts @@ -226,6 +226,9 @@ export const dict = { "provider.connect.apiKey.description": "Enter vaš {{provider}} API ključ za povezivanje vašeg računa i korištenje {{provider}} modela u OpenCode.", "provider.connect.apiKey.label": "{{provider}} API ključ", + "provider.connect.apiKey.labelOptional": "Label (optional)", + "provider.connect.apiKey.labelPlaceholder": "e.g. personal, work, team", + "settings.providers.addKey": "Add key", "provider.connect.apiKey.placeholder": "Ključ API", "provider.connect.apiKey.required": "Potreban je ključ API", "provider.connect.opencodeZen.line1": @@ -711,6 +714,27 @@ export const dict = { "session.tab.session": "Sesija", "session.tab.review": "Pregled", "session.tab.context": "Kontekst", + "session.tab.tasks": "Tasks", + "session.tasks.subagent": "Subagent", + "session.tasks.running": "Running", + "session.tasks.completed": "Completed", + "session.tasks.kind.agent": "Agent", + "session.tasks.kind.shell": "Shell", + "session.tasks.state.running": "Running", + "session.tasks.state.needsInput": "Needs input", + "session.tasks.state.completed": "Completed", + "session.tasks.state.failed": "Failed", + "session.tasks.empty": "No background work running", + "session.tasks.openTranscript": "Open transcript", + "session.tasks.stop": "Stop task", + "session.tasks.dismiss": "Dismiss", + "session.tasks.stats.model": "Model", + "session.tasks.stats.agent": "Agent", + "session.tasks.stats.tools": "Tools", + "session.tasks.stats.fails": "Fails", + "session.tasks.stats.tokens": "Tokens", + "session.tasks.stats.cost": "Cost", + "command.tasks.toggle": "Toggle tasks panel", "session.tab.unknown": "Nepoznata sesija", "session.panel.reviewAndFiles": "Pregled i datoteke", "session.error.notFound": "Ovu sesiju nije moguće pronaći", diff --git a/packages/app/src/i18n/hu.ts b/packages/app/src/i18n/hu.ts index 664abf8ee4c0..f844295f107c 100644 --- a/packages/app/src/i18n/hu.ts +++ b/packages/app/src/i18n/hu.ts @@ -226,6 +226,9 @@ export const dict = { "provider.connect.apiKey.description": "Enter az Ön {{provider}} API kulcsa fiókja összekapcsolásához és a {{provider}} modellek használatához a OpenCode-ben.", "provider.connect.apiKey.label": "{{provider}} API kulcs", + "provider.connect.apiKey.labelOptional": "Label (optional)", + "provider.connect.apiKey.labelPlaceholder": "e.g. personal, work, team", + "settings.providers.addKey": "Add key", "provider.connect.apiKey.placeholder": "API kulcs", "provider.connect.apiKey.required": "API kulcs szükséges", "provider.connect.opencodeZen.line1": @@ -709,6 +712,27 @@ export const dict = { "session.tab.session": "Munkamenet", "session.tab.review": "Tekintse át", "session.tab.context": "Kontextus", + "session.tab.tasks": "Tasks", + "session.tasks.subagent": "Subagent", + "session.tasks.running": "Running", + "session.tasks.completed": "Completed", + "session.tasks.kind.agent": "Agent", + "session.tasks.kind.shell": "Shell", + "session.tasks.state.running": "Running", + "session.tasks.state.needsInput": "Needs input", + "session.tasks.state.completed": "Completed", + "session.tasks.state.failed": "Failed", + "session.tasks.empty": "No background work running", + "session.tasks.openTranscript": "Open transcript", + "session.tasks.stop": "Stop task", + "session.tasks.dismiss": "Dismiss", + "session.tasks.stats.model": "Model", + "session.tasks.stats.agent": "Agent", + "session.tasks.stats.tools": "Tools", + "session.tasks.stats.fails": "Fails", + "session.tasks.stats.tokens": "Tokens", + "session.tasks.stats.cost": "Cost", + "command.tasks.toggle": "Toggle tasks panel", "session.tab.unknown": "Ismeretlen munkamenet", "session.panel.reviewAndFiles": "Áttekintés és fájlok", "session.error.notFound": "Ez a munkamenet nem található", diff --git a/packages/app/src/i18n/hy.ts b/packages/app/src/i18n/hy.ts index 6eac43b5e6dd..b6e538d31153 100644 --- a/packages/app/src/i18n/hy.ts +++ b/packages/app/src/i18n/hy.ts @@ -224,6 +224,9 @@ export const dict = { "provider.connect.apiKey.description": "Մուտքագրեք ձեր {{provider}} API բանալին՝ ձեր հաշիվը միացնելու և OpenCode-ում {{provider}} մոդելներ օգտագործելու համար։", "provider.connect.apiKey.label": "{{provider}} API բանալի", + "provider.connect.apiKey.labelOptional": "Label (optional)", + "provider.connect.apiKey.labelPlaceholder": "e.g. personal, work, team", + "settings.providers.addKey": "Add key", "provider.connect.apiKey.placeholder": "API բանալի", "provider.connect.apiKey.required": "API բանալի է պահանջվում", "provider.connect.opencodeZen.line1": @@ -708,6 +711,27 @@ export const dict = { "session.tab.session": "Նիստ", "session.tab.review": "Վերանայել", "session.tab.context": "Համատեքստ", + "session.tab.tasks": "Tasks", + "session.tasks.subagent": "Subagent", + "session.tasks.running": "Running", + "session.tasks.completed": "Completed", + "session.tasks.kind.agent": "Agent", + "session.tasks.kind.shell": "Shell", + "session.tasks.state.running": "Running", + "session.tasks.state.needsInput": "Needs input", + "session.tasks.state.completed": "Completed", + "session.tasks.state.failed": "Failed", + "session.tasks.empty": "No background work running", + "session.tasks.openTranscript": "Open transcript", + "session.tasks.stop": "Stop task", + "session.tasks.dismiss": "Dismiss", + "session.tasks.stats.model": "Model", + "session.tasks.stats.agent": "Agent", + "session.tasks.stats.tools": "Tools", + "session.tasks.stats.fails": "Fails", + "session.tasks.stats.tokens": "Tokens", + "session.tasks.stats.cost": "Cost", + "command.tasks.toggle": "Toggle tasks panel", "session.tab.unknown": "Անհայտ նիստ", "session.panel.reviewAndFiles": "Վերանայել և ֆայլեր", "session.error.notFound": "Այս նիստը հնարավոր չէ գտնել", diff --git a/packages/app/src/i18n/id.ts b/packages/app/src/i18n/id.ts index 96dc3c7592e4..7044ba0fc6c4 100644 --- a/packages/app/src/i18n/id.ts +++ b/packages/app/src/i18n/id.ts @@ -243,6 +243,9 @@ export const dict = { "provider.connect.apiKey.description": "Masukkan kunci API {{provider}} untuk menghubungkan akun Anda dan gunakan model {{provider}} di OpenCode.", "provider.connect.apiKey.label": "Kunci API {{provider}}", + "provider.connect.apiKey.labelOptional": "Label (optional)", + "provider.connect.apiKey.labelPlaceholder": "e.g. personal, work, team", + "settings.providers.addKey": "Add key", "provider.connect.apiKey.placeholder": "Kunci API", "provider.connect.apiKey.required": "Kunci API diperlukan", "provider.connect.opencodeZen.line1": @@ -768,6 +771,27 @@ export const dict = { "session.tab.session": "Sesi", "session.tab.review": "Tinjauan", "session.tab.context": "Konteks", + "session.tab.tasks": "Tasks", + "session.tasks.subagent": "Subagent", + "session.tasks.running": "Running", + "session.tasks.completed": "Completed", + "session.tasks.kind.agent": "Agent", + "session.tasks.kind.shell": "Shell", + "session.tasks.state.running": "Running", + "session.tasks.state.needsInput": "Needs input", + "session.tasks.state.completed": "Completed", + "session.tasks.state.failed": "Failed", + "session.tasks.empty": "No background work running", + "session.tasks.openTranscript": "Open transcript", + "session.tasks.stop": "Stop task", + "session.tasks.dismiss": "Dismiss", + "session.tasks.stats.model": "Model", + "session.tasks.stats.agent": "Agent", + "session.tasks.stats.tools": "Tools", + "session.tasks.stats.fails": "Fails", + "session.tasks.stats.tokens": "Tokens", + "session.tasks.stats.cost": "Cost", + "command.tasks.toggle": "Toggle tasks panel", "session.tab.unknown": "Sesi Tidak Dikenal", "session.panel.reviewAndFiles": "Tinjauan dan berkas", "session.error.notFound": "Sesi ini tidak dapat ditemukan", diff --git a/packages/app/src/i18n/is.ts b/packages/app/src/i18n/is.ts index a45ed9a40cb0..14b2536120de 100644 --- a/packages/app/src/i18n/is.ts +++ b/packages/app/src/i18n/is.ts @@ -226,6 +226,9 @@ export const dict = { "provider.connect.apiKey.description": "Sláðu inn API-lykilinn þinn fyrir {{provider}} til að tengja reikninginn og nota líkön frá {{provider}} í OpenCode.", "provider.connect.apiKey.label": "{{provider}} API lykill", + "provider.connect.apiKey.labelOptional": "Label (optional)", + "provider.connect.apiKey.labelPlaceholder": "e.g. personal, work, team", + "settings.providers.addKey": "Add key", "provider.connect.apiKey.placeholder": "API lykill", "provider.connect.apiKey.required": "API lykill er nauðsynlegur", "provider.connect.opencodeZen.line1": @@ -706,6 +709,27 @@ export const dict = { "session.tab.session": "Lota", "session.tab.review": "Upprifjun", "session.tab.context": "Samhengi", + "session.tab.tasks": "Tasks", + "session.tasks.subagent": "Subagent", + "session.tasks.running": "Running", + "session.tasks.completed": "Completed", + "session.tasks.kind.agent": "Agent", + "session.tasks.kind.shell": "Shell", + "session.tasks.state.running": "Running", + "session.tasks.state.needsInput": "Needs input", + "session.tasks.state.completed": "Completed", + "session.tasks.state.failed": "Failed", + "session.tasks.empty": "No background work running", + "session.tasks.openTranscript": "Open transcript", + "session.tasks.stop": "Stop task", + "session.tasks.dismiss": "Dismiss", + "session.tasks.stats.model": "Model", + "session.tasks.stats.agent": "Agent", + "session.tasks.stats.tools": "Tools", + "session.tasks.stats.fails": "Fails", + "session.tasks.stats.tokens": "Tokens", + "session.tasks.stats.cost": "Cost", + "command.tasks.toggle": "Toggle tasks panel", "session.tab.unknown": "Óþekkt lota", "session.panel.reviewAndFiles": "Yfirferð og skrár", "session.error.notFound": "Þessi lota finnst ekki", diff --git a/packages/app/src/i18n/it.ts b/packages/app/src/i18n/it.ts index a093e11485a3..b4f42300c6c7 100644 --- a/packages/app/src/i18n/it.ts +++ b/packages/app/src/i18n/it.ts @@ -131,6 +131,9 @@ export const dict = { "provider.connect.apiKey.description": "Inserisci la chiave API di {{provider}} per connettere il tuo account e utilizzare i modelli {{provider}} in OpenCode.", "provider.connect.apiKey.label": "Chiave API di {{provider}}", + "provider.connect.apiKey.labelOptional": "Label (optional)", + "provider.connect.apiKey.labelPlaceholder": "e.g. personal, work, team", + "settings.providers.addKey": "Add key", "provider.connect.apiKey.placeholder": "Chiave API", "provider.connect.apiKey.required": "È richiesta la chiave API", "provider.connect.opencodeZen.line1": @@ -619,6 +622,27 @@ export const dict = { "session.tab.session": "Sessione", "session.tab.review": "Revisione", "session.tab.context": "Contesto", + "session.tab.tasks": "Tasks", + "session.tasks.subagent": "Subagent", + "session.tasks.running": "Running", + "session.tasks.completed": "Completed", + "session.tasks.kind.agent": "Agent", + "session.tasks.kind.shell": "Shell", + "session.tasks.state.running": "Running", + "session.tasks.state.needsInput": "Needs input", + "session.tasks.state.completed": "Completed", + "session.tasks.state.failed": "Failed", + "session.tasks.empty": "No background work running", + "session.tasks.openTranscript": "Open transcript", + "session.tasks.stop": "Stop task", + "session.tasks.dismiss": "Dismiss", + "session.tasks.stats.model": "Model", + "session.tasks.stats.agent": "Agent", + "session.tasks.stats.tools": "Tools", + "session.tasks.stats.fails": "Fails", + "session.tasks.stats.tokens": "Tokens", + "session.tasks.stats.cost": "Cost", + "command.tasks.toggle": "Toggle tasks panel", "session.tab.unknown": "Sessione sconosciuta", "session.panel.reviewAndFiles": "Revisione e file", "session.error.notFound": "Impossibile trovare questa sessione", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index b96e8b544b2b..02abbf1c9b30 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -229,6 +229,9 @@ export const dict = { "provider.connect.apiKey.description": "{{provider}}のAPIキーを入力してアカウントを接続し、OpenCodeで{{provider}}モデルを使用します。", "provider.connect.apiKey.label": "{{provider}} APIキー", + "provider.connect.apiKey.labelOptional": "Label (optional)", + "provider.connect.apiKey.labelPlaceholder": "e.g. personal, work, team", + "settings.providers.addKey": "Add key", "provider.connect.apiKey.placeholder": "APIキー", "provider.connect.apiKey.required": "APIキーが必要です", "provider.connect.opencodeZen.line1": @@ -693,6 +696,27 @@ export const dict = { "session.tab.session": "セッション", "session.tab.review": "レビュー", "session.tab.context": "コンテキスト", + "session.tab.tasks": "Tasks", + "session.tasks.subagent": "Subagent", + "session.tasks.running": "Running", + "session.tasks.completed": "Completed", + "session.tasks.kind.agent": "Agent", + "session.tasks.kind.shell": "Shell", + "session.tasks.state.running": "Running", + "session.tasks.state.needsInput": "Needs input", + "session.tasks.state.completed": "Completed", + "session.tasks.state.failed": "Failed", + "session.tasks.empty": "No background work running", + "session.tasks.openTranscript": "Open transcript", + "session.tasks.stop": "Stop task", + "session.tasks.dismiss": "Dismiss", + "session.tasks.stats.model": "Model", + "session.tasks.stats.agent": "Agent", + "session.tasks.stats.tools": "Tools", + "session.tasks.stats.fails": "Fails", + "session.tasks.stats.tokens": "Tokens", + "session.tasks.stats.cost": "Cost", + "command.tasks.toggle": "Toggle tasks panel", "session.tab.unknown": "不明なセッション", "session.panel.reviewAndFiles": "レビューとファイル", "session.error.notFound": "このセッションが見つかりません", diff --git a/packages/app/src/i18n/ka.ts b/packages/app/src/i18n/ka.ts index be052930a2ec..24e3acce884f 100644 --- a/packages/app/src/i18n/ka.ts +++ b/packages/app/src/i18n/ka.ts @@ -222,6 +222,9 @@ export const dict = { "provider.connect.apiKey.description": "შეიყვანეთ თქვენი {{provider}} API გასაღები თქვენი ანგარიშის დასაკავშირებლად და გამოიყენეთ {{provider}} მოდელები OpenCode-ში.", "provider.connect.apiKey.label": "{{provider}} API გასაღები", + "provider.connect.apiKey.labelOptional": "Label (optional)", + "provider.connect.apiKey.labelPlaceholder": "e.g. personal, work, team", + "settings.providers.addKey": "Add key", "provider.connect.apiKey.placeholder": "API გასაღები", "provider.connect.apiKey.required": "API გასაღები საჭიროა", "provider.connect.opencodeZen.line1": @@ -704,6 +707,27 @@ export const dict = { "session.tab.session": "სესია", "session.tab.review": "მიმოხილვა", "session.tab.context": "კონტექსტი", + "session.tab.tasks": "Tasks", + "session.tasks.subagent": "Subagent", + "session.tasks.running": "Running", + "session.tasks.completed": "Completed", + "session.tasks.kind.agent": "Agent", + "session.tasks.kind.shell": "Shell", + "session.tasks.state.running": "Running", + "session.tasks.state.needsInput": "Needs input", + "session.tasks.state.completed": "Completed", + "session.tasks.state.failed": "Failed", + "session.tasks.empty": "No background work running", + "session.tasks.openTranscript": "Open transcript", + "session.tasks.stop": "Stop task", + "session.tasks.dismiss": "Dismiss", + "session.tasks.stats.model": "Model", + "session.tasks.stats.agent": "Agent", + "session.tasks.stats.tools": "Tools", + "session.tasks.stats.fails": "Fails", + "session.tasks.stats.tokens": "Tokens", + "session.tasks.stats.cost": "Cost", + "command.tasks.toggle": "Toggle tasks panel", "session.tab.unknown": "უცნობი სესია", "session.panel.reviewAndFiles": "მიმოხილვა და ფაილები", "session.error.notFound": "ეს სესია ვერ მოიძებნა", diff --git a/packages/app/src/i18n/km.ts b/packages/app/src/i18n/km.ts index 7f1b61fe3667..575c7dacf43e 100644 --- a/packages/app/src/i18n/km.ts +++ b/packages/app/src/i18n/km.ts @@ -222,6 +222,9 @@ export const dict = { "provider.connect.apiKey.description": "បញ្ចូល {{provider}} API key របស់អ្នក ដើម្បីភ្ជាប់គណនីរបស់អ្នក ហើយប្រើម៉ូដែល {{provider}} នៅក្នុង OpenCode។", "provider.connect.apiKey.label": "សោ API {{provider}}", + "provider.connect.apiKey.labelOptional": "Label (optional)", + "provider.connect.apiKey.labelPlaceholder": "e.g. personal, work, team", + "settings.providers.addKey": "Add key", "provider.connect.apiKey.placeholder": "សោ API", "provider.connect.apiKey.required": "ត្រូវការសោ API", "provider.connect.opencodeZen.line1": @@ -701,6 +704,27 @@ export const dict = { "session.tab.session": "សម័យ", "session.tab.review": "ពិនិត្យ", "session.tab.context": "បរិបទ", + "session.tab.tasks": "Tasks", + "session.tasks.subagent": "Subagent", + "session.tasks.running": "Running", + "session.tasks.completed": "Completed", + "session.tasks.kind.agent": "Agent", + "session.tasks.kind.shell": "Shell", + "session.tasks.state.running": "Running", + "session.tasks.state.needsInput": "Needs input", + "session.tasks.state.completed": "Completed", + "session.tasks.state.failed": "Failed", + "session.tasks.empty": "No background work running", + "session.tasks.openTranscript": "Open transcript", + "session.tasks.stop": "Stop task", + "session.tasks.dismiss": "Dismiss", + "session.tasks.stats.model": "Model", + "session.tasks.stats.agent": "Agent", + "session.tasks.stats.tools": "Tools", + "session.tasks.stats.fails": "Fails", + "session.tasks.stats.tokens": "Tokens", + "session.tasks.stats.cost": "Cost", + "command.tasks.toggle": "Toggle tasks panel", "session.tab.unknown": "សម័យមិនស្គាល់", "session.panel.reviewAndFiles": "ពិនិត្យ និងឯកសារ", "session.error.notFound": "សម័យនេះមិនអាចរកឃើញទេ។", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index b96fb4c59471..d662a9cb62d5 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -126,6 +126,9 @@ export const dict = { "provider.connect.apiKey.description": "{{provider}} API 키를 입력하여 계정을 연결하고 OpenCode에서 {{provider}} 모델을 사용하세요.", "provider.connect.apiKey.label": "{{provider}} API 키", + "provider.connect.apiKey.labelOptional": "Label (optional)", + "provider.connect.apiKey.labelPlaceholder": "e.g. personal, work, team", + "settings.providers.addKey": "Add key", "provider.connect.apiKey.placeholder": "API 키", "provider.connect.apiKey.required": "API 키가 필요합니다", "provider.connect.opencodeZen.line1": @@ -476,6 +479,27 @@ export const dict = { "session.tab.session": "세션", "session.tab.review": "검토", "session.tab.context": "컨텍스트", + "session.tab.tasks": "Tasks", + "session.tasks.subagent": "Subagent", + "session.tasks.running": "Running", + "session.tasks.completed": "Completed", + "session.tasks.kind.agent": "Agent", + "session.tasks.kind.shell": "Shell", + "session.tasks.state.running": "Running", + "session.tasks.state.needsInput": "Needs input", + "session.tasks.state.completed": "Completed", + "session.tasks.state.failed": "Failed", + "session.tasks.empty": "No background work running", + "session.tasks.openTranscript": "Open transcript", + "session.tasks.stop": "Stop task", + "session.tasks.dismiss": "Dismiss", + "session.tasks.stats.model": "Model", + "session.tasks.stats.agent": "Agent", + "session.tasks.stats.tools": "Tools", + "session.tasks.stats.fails": "Fails", + "session.tasks.stats.tokens": "Tokens", + "session.tasks.stats.cost": "Cost", + "command.tasks.toggle": "Toggle tasks panel", "session.panel.reviewAndFiles": "검토 및 파일", "session.review.filesChanged": "{{count}}개 파일 변경됨", "session.review.change.one": "변경", diff --git a/packages/app/src/i18n/lo.ts b/packages/app/src/i18n/lo.ts index acdc26df08aa..1e890abf468e 100644 --- a/packages/app/src/i18n/lo.ts +++ b/packages/app/src/i18n/lo.ts @@ -222,6 +222,9 @@ export const dict = { "provider.connect.apiKey.description": "ໃສ່ລະຫັດ API {{provider}} ຂອງທ່ານເພື່ອເຊື່ອມຕໍ່ບັນຊີຂອງທ່ານ ແລະໃຊ້ຕົວແບບ {{provider}} ໃນ OpenCode.", "provider.connect.apiKey.label": "ລະຫັດ API {{provider}}", + "provider.connect.apiKey.labelOptional": "Label (optional)", + "provider.connect.apiKey.labelPlaceholder": "e.g. personal, work, team", + "settings.providers.addKey": "Add key", "provider.connect.apiKey.placeholder": "ລະຫັດ API", "provider.connect.apiKey.required": "ຕ້ອງການລະຫັດ API", "provider.connect.opencodeZen.line1": @@ -699,6 +702,27 @@ export const dict = { "session.tab.session": "ເຊດຊັນ", "session.tab.review": "ການທົບທວນຄືນ", "session.tab.context": "ບໍລິບົດ", + "session.tab.tasks": "Tasks", + "session.tasks.subagent": "Subagent", + "session.tasks.running": "Running", + "session.tasks.completed": "Completed", + "session.tasks.kind.agent": "Agent", + "session.tasks.kind.shell": "Shell", + "session.tasks.state.running": "Running", + "session.tasks.state.needsInput": "Needs input", + "session.tasks.state.completed": "Completed", + "session.tasks.state.failed": "Failed", + "session.tasks.empty": "No background work running", + "session.tasks.openTranscript": "Open transcript", + "session.tasks.stop": "Stop task", + "session.tasks.dismiss": "Dismiss", + "session.tasks.stats.model": "Model", + "session.tasks.stats.agent": "Agent", + "session.tasks.stats.tools": "Tools", + "session.tasks.stats.fails": "Fails", + "session.tasks.stats.tokens": "Tokens", + "session.tasks.stats.cost": "Cost", + "command.tasks.toggle": "Toggle tasks panel", "session.tab.unknown": "ເຊດຊັນທີ່ບໍ່ຮູ້ຈັກ", "session.panel.reviewAndFiles": "ການທົບທວນຄືນແລະໄຟລ໌", "session.error.notFound": "ບໍ່ພົບເຊດຊັນນີ້", diff --git a/packages/app/src/i18n/lt.ts b/packages/app/src/i18n/lt.ts index ba161eb31879..723c0245bf85 100644 --- a/packages/app/src/i18n/lt.ts +++ b/packages/app/src/i18n/lt.ts @@ -226,6 +226,9 @@ export const dict = { "provider.connect.apiKey.description": "Įveskite {{provider}} API raktą, kad susietumėte paskyrą ir naudotumėte {{provider}} modelius programoje OpenCode.", "provider.connect.apiKey.label": "{{provider}} API raktas", + "provider.connect.apiKey.labelOptional": "Label (optional)", + "provider.connect.apiKey.labelPlaceholder": "e.g. personal, work, team", + "settings.providers.addKey": "Add key", "provider.connect.apiKey.placeholder": "API raktas", "provider.connect.apiKey.required": "Reikalingas API raktas", "provider.connect.opencodeZen.line1": @@ -710,6 +713,27 @@ export const dict = { "session.tab.session": "Seansas", "session.tab.review": "Apžvalga", "session.tab.context": "Kontekstas", + "session.tab.tasks": "Tasks", + "session.tasks.subagent": "Subagent", + "session.tasks.running": "Running", + "session.tasks.completed": "Completed", + "session.tasks.kind.agent": "Agent", + "session.tasks.kind.shell": "Shell", + "session.tasks.state.running": "Running", + "session.tasks.state.needsInput": "Needs input", + "session.tasks.state.completed": "Completed", + "session.tasks.state.failed": "Failed", + "session.tasks.empty": "No background work running", + "session.tasks.openTranscript": "Open transcript", + "session.tasks.stop": "Stop task", + "session.tasks.dismiss": "Dismiss", + "session.tasks.stats.model": "Model", + "session.tasks.stats.agent": "Agent", + "session.tasks.stats.tools": "Tools", + "session.tasks.stats.fails": "Fails", + "session.tasks.stats.tokens": "Tokens", + "session.tasks.stats.cost": "Cost", + "command.tasks.toggle": "Toggle tasks panel", "session.tab.unknown": "Nežinomas seansas", "session.panel.reviewAndFiles": "Apžvalga ir failai", "session.error.notFound": "Šios sesijos rasti nepavyko", diff --git a/packages/app/src/i18n/lv.ts b/packages/app/src/i18n/lv.ts index df4c5b2dcd91..f932a2e539bb 100644 --- a/packages/app/src/i18n/lv.ts +++ b/packages/app/src/i18n/lv.ts @@ -222,6 +222,9 @@ export const dict = { "provider.connect.apiKey.description": "Ievadiet savu {{provider}} API atslēgu, lai savienotu kontu un izmantotu {{provider}} modeļus OpenCode.", "provider.connect.apiKey.label": "{{provider}} API atslēga", + "provider.connect.apiKey.labelOptional": "Label (optional)", + "provider.connect.apiKey.labelPlaceholder": "e.g. personal, work, team", + "settings.providers.addKey": "Add key", "provider.connect.apiKey.placeholder": "API atslēga", "provider.connect.apiKey.required": "Nepieciešama API atslēga", "provider.connect.opencodeZen.line1": @@ -705,6 +708,27 @@ export const dict = { "session.tab.session": "Sesija", "session.tab.review": "Pārskats", "session.tab.context": "Konteksts", + "session.tab.tasks": "Tasks", + "session.tasks.subagent": "Subagent", + "session.tasks.running": "Running", + "session.tasks.completed": "Completed", + "session.tasks.kind.agent": "Agent", + "session.tasks.kind.shell": "Shell", + "session.tasks.state.running": "Running", + "session.tasks.state.needsInput": "Needs input", + "session.tasks.state.completed": "Completed", + "session.tasks.state.failed": "Failed", + "session.tasks.empty": "No background work running", + "session.tasks.openTranscript": "Open transcript", + "session.tasks.stop": "Stop task", + "session.tasks.dismiss": "Dismiss", + "session.tasks.stats.model": "Model", + "session.tasks.stats.agent": "Agent", + "session.tasks.stats.tools": "Tools", + "session.tasks.stats.fails": "Fails", + "session.tasks.stats.tokens": "Tokens", + "session.tasks.stats.cost": "Cost", + "command.tasks.toggle": "Toggle tasks panel", "session.tab.unknown": "Nezināma sesija", "session.panel.reviewAndFiles": "Pārskats un faili", "session.error.notFound": "Šī sesija nav atrodama", diff --git a/packages/app/src/i18n/mk.ts b/packages/app/src/i18n/mk.ts index 01e60baa408b..586d743a0e57 100644 --- a/packages/app/src/i18n/mk.ts +++ b/packages/app/src/i18n/mk.ts @@ -223,6 +223,9 @@ export const dict = { "provider.connect.apiKey.description": "Внесете го вашиот клуч {{provider}} API за да ја поврзете вашата сметка и да користите {{provider}} модели во OpenCode.", "provider.connect.apiKey.label": "{{provider}} API клуч", + "provider.connect.apiKey.labelOptional": "Label (optional)", + "provider.connect.apiKey.labelPlaceholder": "e.g. personal, work, team", + "settings.providers.addKey": "Add key", "provider.connect.apiKey.placeholder": "клуч API", "provider.connect.apiKey.required": "Потребен е клуч API", "provider.connect.opencodeZen.line1": @@ -707,6 +710,27 @@ export const dict = { "session.tab.session": "Сесија", "session.tab.review": "Преглед", "session.tab.context": "Контекст", + "session.tab.tasks": "Tasks", + "session.tasks.subagent": "Subagent", + "session.tasks.running": "Running", + "session.tasks.completed": "Completed", + "session.tasks.kind.agent": "Agent", + "session.tasks.kind.shell": "Shell", + "session.tasks.state.running": "Running", + "session.tasks.state.needsInput": "Needs input", + "session.tasks.state.completed": "Completed", + "session.tasks.state.failed": "Failed", + "session.tasks.empty": "No background work running", + "session.tasks.openTranscript": "Open transcript", + "session.tasks.stop": "Stop task", + "session.tasks.dismiss": "Dismiss", + "session.tasks.stats.model": "Model", + "session.tasks.stats.agent": "Agent", + "session.tasks.stats.tools": "Tools", + "session.tasks.stats.fails": "Fails", + "session.tasks.stats.tokens": "Tokens", + "session.tasks.stats.cost": "Cost", + "command.tasks.toggle": "Toggle tasks panel", "session.tab.unknown": "Непозната сесија", "session.panel.reviewAndFiles": "Преглед и датотеки", "session.error.notFound": "Оваа сесија не може да се најде", diff --git a/packages/app/src/i18n/mn.ts b/packages/app/src/i18n/mn.ts index 41c4fa085d1a..0fb0114e58b6 100644 --- a/packages/app/src/i18n/mn.ts +++ b/packages/app/src/i18n/mn.ts @@ -225,6 +225,9 @@ export const dict = { "provider.connect.apiKey.description": "Бүртгэлээ холбохын тулд {{provider}} API түлхүүрээ оруулаад OpenCode-д {{provider}} загварыг ашиглана уу.", "provider.connect.apiKey.label": "{{provider}} API түлхүүр", + "provider.connect.apiKey.labelOptional": "Label (optional)", + "provider.connect.apiKey.labelPlaceholder": "e.g. personal, work, team", + "settings.providers.addKey": "Add key", "provider.connect.apiKey.placeholder": "API түлхүүр", "provider.connect.apiKey.required": "API түлхүүр шаардлагатай", "provider.connect.opencodeZen.line1": @@ -709,6 +712,27 @@ export const dict = { "session.tab.session": "Сесс", "session.tab.review": "Хяналт", "session.tab.context": "Контекст", + "session.tab.tasks": "Tasks", + "session.tasks.subagent": "Subagent", + "session.tasks.running": "Running", + "session.tasks.completed": "Completed", + "session.tasks.kind.agent": "Agent", + "session.tasks.kind.shell": "Shell", + "session.tasks.state.running": "Running", + "session.tasks.state.needsInput": "Needs input", + "session.tasks.state.completed": "Completed", + "session.tasks.state.failed": "Failed", + "session.tasks.empty": "No background work running", + "session.tasks.openTranscript": "Open transcript", + "session.tasks.stop": "Stop task", + "session.tasks.dismiss": "Dismiss", + "session.tasks.stats.model": "Model", + "session.tasks.stats.agent": "Agent", + "session.tasks.stats.tools": "Tools", + "session.tasks.stats.fails": "Fails", + "session.tasks.stats.tokens": "Tokens", + "session.tasks.stats.cost": "Cost", + "command.tasks.toggle": "Toggle tasks panel", "session.tab.unknown": "Үл мэдэгдэх сесс", "session.panel.reviewAndFiles": "Хяналт ба файлууд", "session.error.notFound": "Энэ сесс олдохгүй байна", diff --git a/packages/app/src/i18n/ms.ts b/packages/app/src/i18n/ms.ts index 2c3783d43b7d..2ad8d63e41de 100644 --- a/packages/app/src/i18n/ms.ts +++ b/packages/app/src/i18n/ms.ts @@ -222,6 +222,9 @@ export const dict = { "provider.connect.apiKey.description": "Masukkan kunci API {{provider}} anda untuk menghubungkan akaun anda dan menggunakan model {{provider}} dalam OpenCode.", "provider.connect.apiKey.label": "Kunci API {{provider}}", + "provider.connect.apiKey.labelOptional": "Label (optional)", + "provider.connect.apiKey.labelPlaceholder": "e.g. personal, work, team", + "settings.providers.addKey": "Add key", "provider.connect.apiKey.placeholder": "Kunci API", "provider.connect.apiKey.required": "Kunci API diperlukan", "provider.connect.opencodeZen.line1": @@ -702,6 +705,27 @@ export const dict = { "session.tab.session": "Sesi", "session.tab.review": "Semak", "session.tab.context": "Konteks", + "session.tab.tasks": "Tasks", + "session.tasks.subagent": "Subagent", + "session.tasks.running": "Running", + "session.tasks.completed": "Completed", + "session.tasks.kind.agent": "Agent", + "session.tasks.kind.shell": "Shell", + "session.tasks.state.running": "Running", + "session.tasks.state.needsInput": "Needs input", + "session.tasks.state.completed": "Completed", + "session.tasks.state.failed": "Failed", + "session.tasks.empty": "No background work running", + "session.tasks.openTranscript": "Open transcript", + "session.tasks.stop": "Stop task", + "session.tasks.dismiss": "Dismiss", + "session.tasks.stats.model": "Model", + "session.tasks.stats.agent": "Agent", + "session.tasks.stats.tools": "Tools", + "session.tasks.stats.fails": "Fails", + "session.tasks.stats.tokens": "Tokens", + "session.tasks.stats.cost": "Cost", + "command.tasks.toggle": "Toggle tasks panel", "session.tab.unknown": "Sesi Tidak Diketahui", "session.panel.reviewAndFiles": "Semak dan fail", "session.error.notFound": "Sesi ini tidak dapat ditemui", diff --git a/packages/app/src/i18n/my.ts b/packages/app/src/i18n/my.ts index 6046f337ff06..1d3ebe7be41f 100644 --- a/packages/app/src/i18n/my.ts +++ b/packages/app/src/i18n/my.ts @@ -224,6 +224,9 @@ export const dict = { "provider.connect.apiKey.description": "သင့်အကောင့်ကို ချိတ်ဆက်ရန်နှင့် OpenCode တွင် {{provider}} မော်ဒယ်များကို အသုံးပြုရန် သင်၏ {{provider}} API သော့ကို ထည့်သွင်းပါ။", "provider.connect.apiKey.label": "{{provider}} API သော့", + "provider.connect.apiKey.labelOptional": "Label (optional)", + "provider.connect.apiKey.labelPlaceholder": "e.g. personal, work, team", + "settings.providers.addKey": "Add key", "provider.connect.apiKey.placeholder": "API သော့", "provider.connect.apiKey.required": "API ကီး လိုအပ်သည်။", "provider.connect.opencodeZen.line1": @@ -711,6 +714,27 @@ export const dict = { "session.tab.session": "အပိုင်း", "session.tab.review": "သုံးသပ်ချက်", "session.tab.context": "ဆက်စပ်အကြောင်းအရာ", + "session.tab.tasks": "Tasks", + "session.tasks.subagent": "Subagent", + "session.tasks.running": "Running", + "session.tasks.completed": "Completed", + "session.tasks.kind.agent": "Agent", + "session.tasks.kind.shell": "Shell", + "session.tasks.state.running": "Running", + "session.tasks.state.needsInput": "Needs input", + "session.tasks.state.completed": "Completed", + "session.tasks.state.failed": "Failed", + "session.tasks.empty": "No background work running", + "session.tasks.openTranscript": "Open transcript", + "session.tasks.stop": "Stop task", + "session.tasks.dismiss": "Dismiss", + "session.tasks.stats.model": "Model", + "session.tasks.stats.agent": "Agent", + "session.tasks.stats.tools": "Tools", + "session.tasks.stats.fails": "Fails", + "session.tasks.stats.tokens": "Tokens", + "session.tasks.stats.cost": "Cost", + "command.tasks.toggle": "Toggle tasks panel", "session.tab.unknown": "အမည်မသိ ဆက်ရှင်", "session.panel.reviewAndFiles": "ပြန်လည်သုံးသပ်ခြင်းနှင့် ဖိုင်များ", "session.error.notFound": "ဤစက်ရှင်ကို ရှာမတွေ့ပါ။", diff --git a/packages/app/src/i18n/ne.ts b/packages/app/src/i18n/ne.ts index cc60bff9f400..3ff9037933ed 100644 --- a/packages/app/src/i18n/ne.ts +++ b/packages/app/src/i18n/ne.ts @@ -223,6 +223,9 @@ export const dict: Record = { "provider.connect.apiKey.description": "आफ्नो खाता जडान गर्न आफ्नो {{provider}} API कुञ्जी प्रविष्ट गर्नुहोस् र OpenCode मा {{provider}} मोडेलहरू प्रयोग गर्नुहोस्।", "provider.connect.apiKey.label": "{{provider}} API कुञ्जी", + "provider.connect.apiKey.labelOptional": "Label (optional)", + "provider.connect.apiKey.labelPlaceholder": "e.g. personal, work, team", + "settings.providers.addKey": "Add key", "provider.connect.apiKey.placeholder": "API कुञ्जी", "provider.connect.apiKey.required": "API कुञ्जी आवश्यक छ", "provider.connect.opencodeZen.line1": @@ -704,6 +707,27 @@ export const dict: Record = { "session.tab.session": "सत्र", "session.tab.review": "समीक्षा गर्नुहोस्", "session.tab.context": "सन्दर्भ", + "session.tab.tasks": "Tasks", + "session.tasks.subagent": "Subagent", + "session.tasks.running": "Running", + "session.tasks.completed": "Completed", + "session.tasks.kind.agent": "Agent", + "session.tasks.kind.shell": "Shell", + "session.tasks.state.running": "Running", + "session.tasks.state.needsInput": "Needs input", + "session.tasks.state.completed": "Completed", + "session.tasks.state.failed": "Failed", + "session.tasks.empty": "No background work running", + "session.tasks.openTranscript": "Open transcript", + "session.tasks.stop": "Stop task", + "session.tasks.dismiss": "Dismiss", + "session.tasks.stats.model": "Model", + "session.tasks.stats.agent": "Agent", + "session.tasks.stats.tools": "Tools", + "session.tasks.stats.fails": "Fails", + "session.tasks.stats.tokens": "Tokens", + "session.tasks.stats.cost": "Cost", + "command.tasks.toggle": "Toggle tasks panel", "session.tab.unknown": "अज्ञात सत्र", "session.panel.reviewAndFiles": "समीक्षा र फाइलहरू", "session.error.notFound": "यो सत्र फेला पार्न सकिँदैन", diff --git a/packages/app/src/i18n/nl.ts b/packages/app/src/i18n/nl.ts index 6fb1dc09f24b..562240dfe2d6 100644 --- a/packages/app/src/i18n/nl.ts +++ b/packages/app/src/i18n/nl.ts @@ -223,6 +223,9 @@ export const dict = { "provider.connect.apiKey.description": "Voer je {{provider}}-API-sleutel in om je account te koppelen en {{provider}}-modellen in OpenCode te gebruiken.", "provider.connect.apiKey.label": "{{provider}}-API-sleutel", + "provider.connect.apiKey.labelOptional": "Label (optional)", + "provider.connect.apiKey.labelPlaceholder": "e.g. personal, work, team", + "settings.providers.addKey": "Add key", "provider.connect.apiKey.placeholder": "API-sleutel", "provider.connect.apiKey.required": "API-sleutel is vereist", "provider.connect.opencodeZen.line1": @@ -711,6 +714,27 @@ export const dict = { "session.tab.session": "Sessie", "session.tab.review": "Beoordeling", "session.tab.context": "Context", + "session.tab.tasks": "Tasks", + "session.tasks.subagent": "Subagent", + "session.tasks.running": "Running", + "session.tasks.completed": "Completed", + "session.tasks.kind.agent": "Agent", + "session.tasks.kind.shell": "Shell", + "session.tasks.state.running": "Running", + "session.tasks.state.needsInput": "Needs input", + "session.tasks.state.completed": "Completed", + "session.tasks.state.failed": "Failed", + "session.tasks.empty": "No background work running", + "session.tasks.openTranscript": "Open transcript", + "session.tasks.stop": "Stop task", + "session.tasks.dismiss": "Dismiss", + "session.tasks.stats.model": "Model", + "session.tasks.stats.agent": "Agent", + "session.tasks.stats.tools": "Tools", + "session.tasks.stats.fails": "Fails", + "session.tasks.stats.tokens": "Tokens", + "session.tasks.stats.cost": "Cost", + "command.tasks.toggle": "Toggle tasks panel", "session.tab.unknown": "Onbekende sessie", "session.panel.reviewAndFiles": "Beoordeling en bestanden", "session.error.notFound": "Deze sessie kan niet worden gevonden", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index 11958029dc38..5d52045d8568 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -241,6 +241,9 @@ export const dict = { "provider.connect.apiKey.description": "Skriv inn API-nøkkelen din for {{provider}} for å koble til kontoen din og bruke {{provider}}-modeller i OpenCode.", "provider.connect.apiKey.label": "{{provider}}-API-nøkkel", + "provider.connect.apiKey.labelOptional": "Label (optional)", + "provider.connect.apiKey.labelPlaceholder": "e.g. personal, work, team", + "settings.providers.addKey": "Add key", "provider.connect.apiKey.placeholder": "API-nøkkel", "provider.connect.apiKey.required": "API-nøkkel er påkrevd", "provider.connect.opencodeZen.line1": @@ -635,6 +638,27 @@ export const dict = { "session.tab.session": "Sesjon", "session.tab.review": "Gjennomgang", "session.tab.context": "Kontekst", + "session.tab.tasks": "Tasks", + "session.tasks.subagent": "Subagent", + "session.tasks.running": "Running", + "session.tasks.completed": "Completed", + "session.tasks.kind.agent": "Agent", + "session.tasks.kind.shell": "Shell", + "session.tasks.state.running": "Running", + "session.tasks.state.needsInput": "Needs input", + "session.tasks.state.completed": "Completed", + "session.tasks.state.failed": "Failed", + "session.tasks.empty": "No background work running", + "session.tasks.openTranscript": "Open transcript", + "session.tasks.stop": "Stop task", + "session.tasks.dismiss": "Dismiss", + "session.tasks.stats.model": "Model", + "session.tasks.stats.agent": "Agent", + "session.tasks.stats.tools": "Tools", + "session.tasks.stats.fails": "Fails", + "session.tasks.stats.tokens": "Tokens", + "session.tasks.stats.cost": "Cost", + "command.tasks.toggle": "Toggle tasks panel", "session.panel.reviewAndFiles": "Gjennomgang og filer", "session.review.filesChanged": "{{count}} filer endret", "session.review.change.one": "Endring", diff --git a/packages/app/src/i18n/pa.ts b/packages/app/src/i18n/pa.ts index 6298581c0aab..16a59ab2d184 100644 --- a/packages/app/src/i18n/pa.ts +++ b/packages/app/src/i18n/pa.ts @@ -229,6 +229,9 @@ export const dict = { "provider.connect.apiKey.description": "اپنے اکاؤنٹ نوں جوڑن لئی اپنی {{provider}} API کلید درج کرو تے OpenCode وچ {{provider}} ماڈل ورتو۔", "provider.connect.apiKey.label": "{{provider}} API کلید", + "provider.connect.apiKey.labelOptional": "Label (optional)", + "provider.connect.apiKey.labelPlaceholder": "e.g. personal, work, team", + "settings.providers.addKey": "Add key", "provider.connect.apiKey.placeholder": "API کلید", "provider.connect.apiKey.required": "API کلید دی لوڑ اے", "provider.connect.opencodeZen.line1": @@ -713,6 +716,27 @@ export const dict = { "session.tab.session": "سیشن", "session.tab.review": "جائزہ", "session.tab.context": "تناظر", + "session.tab.tasks": "Tasks", + "session.tasks.subagent": "Subagent", + "session.tasks.running": "Running", + "session.tasks.completed": "Completed", + "session.tasks.kind.agent": "Agent", + "session.tasks.kind.shell": "Shell", + "session.tasks.state.running": "Running", + "session.tasks.state.needsInput": "Needs input", + "session.tasks.state.completed": "Completed", + "session.tasks.state.failed": "Failed", + "session.tasks.empty": "No background work running", + "session.tasks.openTranscript": "Open transcript", + "session.tasks.stop": "Stop task", + "session.tasks.dismiss": "Dismiss", + "session.tasks.stats.model": "Model", + "session.tasks.stats.agent": "Agent", + "session.tasks.stats.tools": "Tools", + "session.tasks.stats.fails": "Fails", + "session.tasks.stats.tokens": "Tokens", + "session.tasks.stats.cost": "Cost", + "command.tasks.toggle": "Toggle tasks panel", "session.tab.unknown": "نامعلوم سیشن", "session.panel.reviewAndFiles": "جائزہ تے فائلاں", "session.error.notFound": "ایہہ سیشن نئیں لبیا جا سکدا", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index 9b3c927498d8..631b2ea8c686 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -230,6 +230,9 @@ export const dict = { "provider.connect.apiKey.description": "Wprowadź swój klucz API {{provider}}, aby połączyć konto i używać modeli {{provider}} w OpenCode.", "provider.connect.apiKey.label": "Klucz API {{provider}}", + "provider.connect.apiKey.labelOptional": "Label (optional)", + "provider.connect.apiKey.labelPlaceholder": "e.g. personal, work, team", + "settings.providers.addKey": "Add key", "provider.connect.apiKey.placeholder": "Klucz API", "provider.connect.apiKey.required": "Klucz API jest wymagany", "provider.connect.opencodeZen.line1": @@ -697,6 +700,27 @@ export const dict = { "session.tab.session": "Sesja", "session.tab.review": "Przegląd", "session.tab.context": "Kontekst", + "session.tab.tasks": "Tasks", + "session.tasks.subagent": "Subagent", + "session.tasks.running": "Running", + "session.tasks.completed": "Completed", + "session.tasks.kind.agent": "Agent", + "session.tasks.kind.shell": "Shell", + "session.tasks.state.running": "Running", + "session.tasks.state.needsInput": "Needs input", + "session.tasks.state.completed": "Completed", + "session.tasks.state.failed": "Failed", + "session.tasks.empty": "No background work running", + "session.tasks.openTranscript": "Open transcript", + "session.tasks.stop": "Stop task", + "session.tasks.dismiss": "Dismiss", + "session.tasks.stats.model": "Model", + "session.tasks.stats.agent": "Agent", + "session.tasks.stats.tools": "Tools", + "session.tasks.stats.fails": "Fails", + "session.tasks.stats.tokens": "Tokens", + "session.tasks.stats.cost": "Cost", + "command.tasks.toggle": "Toggle tasks panel", "session.tab.unknown": "Nieznana sesja", "session.panel.reviewAndFiles": "Przegląd i pliki", "session.error.notFound": "Nie można znaleźć tej sesji", diff --git a/packages/app/src/i18n/ro.ts b/packages/app/src/i18n/ro.ts index c84f9a4ee76e..633f54cd10b1 100644 --- a/packages/app/src/i18n/ro.ts +++ b/packages/app/src/i18n/ro.ts @@ -222,6 +222,9 @@ export const dict = { "provider.connect.apiKey.description": "Introdu cheia API {{provider}} pentru a-ți conecta contul și a folosi modelele {{provider}} în OpenCode.", "provider.connect.apiKey.label": "Cheie API {{provider}}", + "provider.connect.apiKey.labelOptional": "Label (optional)", + "provider.connect.apiKey.labelPlaceholder": "e.g. personal, work, team", + "settings.providers.addKey": "Add key", "provider.connect.apiKey.placeholder": "Cheie API", "provider.connect.apiKey.required": "Cheia API este obligatorie", "provider.connect.opencodeZen.line1": @@ -704,6 +707,27 @@ export const dict = { "session.tab.session": "Sesiune", "session.tab.review": "Revizuire", "session.tab.context": "Context", + "session.tab.tasks": "Tasks", + "session.tasks.subagent": "Subagent", + "session.tasks.running": "Running", + "session.tasks.completed": "Completed", + "session.tasks.kind.agent": "Agent", + "session.tasks.kind.shell": "Shell", + "session.tasks.state.running": "Running", + "session.tasks.state.needsInput": "Needs input", + "session.tasks.state.completed": "Completed", + "session.tasks.state.failed": "Failed", + "session.tasks.empty": "No background work running", + "session.tasks.openTranscript": "Open transcript", + "session.tasks.stop": "Stop task", + "session.tasks.dismiss": "Dismiss", + "session.tasks.stats.model": "Model", + "session.tasks.stats.agent": "Agent", + "session.tasks.stats.tools": "Tools", + "session.tasks.stats.fails": "Fails", + "session.tasks.stats.tokens": "Tokens", + "session.tasks.stats.cost": "Cost", + "command.tasks.toggle": "Toggle tasks panel", "session.tab.unknown": "Sesiune necunoscută", "session.panel.reviewAndFiles": "Revizuire și fișiere", "session.error.notFound": "Această sesiune nu poate fi găsită", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index cf4fa8d8f4ca..e6a5eef76480 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -242,6 +242,9 @@ export const dict = { "provider.connect.apiKey.description": "Введите ключ API {{provider}}, чтобы подключить аккаунт и использовать модели {{provider}} в OpenCode.", "provider.connect.apiKey.label": "Ключ API {{provider}}", + "provider.connect.apiKey.labelOptional": "Label (optional)", + "provider.connect.apiKey.labelPlaceholder": "e.g. personal, work, team", + "settings.providers.addKey": "Add key", "provider.connect.apiKey.placeholder": "Ключ API", "provider.connect.apiKey.required": "Требуется ключ API", "provider.connect.opencodeZen.line1": @@ -750,6 +753,27 @@ export const dict = { "session.tab.session": "Сессия", "session.tab.review": "Обзор", "session.tab.context": "Контекст", + "session.tab.tasks": "Tasks", + "session.tasks.subagent": "Subagent", + "session.tasks.running": "Running", + "session.tasks.completed": "Completed", + "session.tasks.kind.agent": "Agent", + "session.tasks.kind.shell": "Shell", + "session.tasks.state.running": "Running", + "session.tasks.state.needsInput": "Needs input", + "session.tasks.state.completed": "Completed", + "session.tasks.state.failed": "Failed", + "session.tasks.empty": "No background work running", + "session.tasks.openTranscript": "Open transcript", + "session.tasks.stop": "Stop task", + "session.tasks.dismiss": "Dismiss", + "session.tasks.stats.model": "Model", + "session.tasks.stats.agent": "Agent", + "session.tasks.stats.tools": "Tools", + "session.tasks.stats.fails": "Fails", + "session.tasks.stats.tokens": "Tokens", + "session.tasks.stats.cost": "Cost", + "command.tasks.toggle": "Toggle tasks panel", "session.tab.unknown": "Неизвестная сессия", "session.panel.reviewAndFiles": "Обзор и файлы", "session.error.notFound": "Эта сессия не найдена", diff --git a/packages/app/src/i18n/si.ts b/packages/app/src/i18n/si.ts index 00ca188b1581..8ee3540b3fbb 100644 --- a/packages/app/src/i18n/si.ts +++ b/packages/app/src/i18n/si.ts @@ -222,6 +222,9 @@ export const dict: Record = { "provider.connect.apiKey.description": "ඔබගේ ගිණුම සම්බන්ධ කිරීමට ඔබගේ {{provider}} API යතුර ඇතුලත් කරන්න සහ OpenCode හි {{provider}} මාදිලි භාවිතා කරන්න.", "provider.connect.apiKey.label": "{{provider}} API යතුර", + "provider.connect.apiKey.labelOptional": "Label (optional)", + "provider.connect.apiKey.labelPlaceholder": "e.g. personal, work, team", + "settings.providers.addKey": "Add key", "provider.connect.apiKey.placeholder": "API යතුර", "provider.connect.apiKey.required": "API යතුර අවශ්‍යයි", "provider.connect.opencodeZen.line1": @@ -701,6 +704,27 @@ export const dict: Record = { "session.tab.session": "සැසිය", "session.tab.review": "සමාලෝචනය කරන්න", "session.tab.context": "සන්දර්භය", + "session.tab.tasks": "Tasks", + "session.tasks.subagent": "Subagent", + "session.tasks.running": "Running", + "session.tasks.completed": "Completed", + "session.tasks.kind.agent": "Agent", + "session.tasks.kind.shell": "Shell", + "session.tasks.state.running": "Running", + "session.tasks.state.needsInput": "Needs input", + "session.tasks.state.completed": "Completed", + "session.tasks.state.failed": "Failed", + "session.tasks.empty": "No background work running", + "session.tasks.openTranscript": "Open transcript", + "session.tasks.stop": "Stop task", + "session.tasks.dismiss": "Dismiss", + "session.tasks.stats.model": "Model", + "session.tasks.stats.agent": "Agent", + "session.tasks.stats.tools": "Tools", + "session.tasks.stats.fails": "Fails", + "session.tasks.stats.tokens": "Tokens", + "session.tasks.stats.cost": "Cost", + "command.tasks.toggle": "Toggle tasks panel", "session.tab.unknown": "නොදන්නා සැසිය", "session.panel.reviewAndFiles": "සමාලෝචනය සහ ගොනු", "session.error.notFound": "මෙම සැසිය සොයාගත නොහැක", diff --git a/packages/app/src/i18n/sk.ts b/packages/app/src/i18n/sk.ts index 9992e06cc4af..0ac8f609c5b8 100644 --- a/packages/app/src/i18n/sk.ts +++ b/packages/app/src/i18n/sk.ts @@ -222,6 +222,9 @@ export const dict = { "provider.connect.apiKey.description": "Zadajte svoj API kľúč {{provider}}, aby ste prepojili svoj účet a mohli používať modely {{provider}} v OpenCode.", "provider.connect.apiKey.label": "API kľúč {{provider}}", + "provider.connect.apiKey.labelOptional": "Label (optional)", + "provider.connect.apiKey.labelPlaceholder": "e.g. personal, work, team", + "settings.providers.addKey": "Add key", "provider.connect.apiKey.placeholder": "API kľúč", "provider.connect.apiKey.required": "API kľúč je povinný", "provider.connect.opencodeZen.line1": @@ -702,6 +705,27 @@ export const dict = { "session.tab.session": "Relácia", "session.tab.review": "Revízia", "session.tab.context": "Kontext", + "session.tab.tasks": "Tasks", + "session.tasks.subagent": "Subagent", + "session.tasks.running": "Running", + "session.tasks.completed": "Completed", + "session.tasks.kind.agent": "Agent", + "session.tasks.kind.shell": "Shell", + "session.tasks.state.running": "Running", + "session.tasks.state.needsInput": "Needs input", + "session.tasks.state.completed": "Completed", + "session.tasks.state.failed": "Failed", + "session.tasks.empty": "No background work running", + "session.tasks.openTranscript": "Open transcript", + "session.tasks.stop": "Stop task", + "session.tasks.dismiss": "Dismiss", + "session.tasks.stats.model": "Model", + "session.tasks.stats.agent": "Agent", + "session.tasks.stats.tools": "Tools", + "session.tasks.stats.fails": "Fails", + "session.tasks.stats.tokens": "Tokens", + "session.tasks.stats.cost": "Cost", + "command.tasks.toggle": "Toggle tasks panel", "session.tab.unknown": "Neznáma relácia", "session.panel.reviewAndFiles": "Revízia a súbory", "session.error.notFound": "Táto relácia sa nenašla", diff --git a/packages/app/src/i18n/sl.ts b/packages/app/src/i18n/sl.ts index 3c23fcd7f708..9303bf441bef 100644 --- a/packages/app/src/i18n/sl.ts +++ b/packages/app/src/i18n/sl.ts @@ -222,6 +222,9 @@ export const dict = { "provider.connect.apiKey.description": "Vnesite ključ API ponudnika {{provider}}, da povežete račun in uporabljate modele {{provider}} v OpenCode.", "provider.connect.apiKey.label": "Ključ {{provider}} API", + "provider.connect.apiKey.labelOptional": "Label (optional)", + "provider.connect.apiKey.labelPlaceholder": "e.g. personal, work, team", + "settings.providers.addKey": "Add key", "provider.connect.apiKey.placeholder": "Ključ API", "provider.connect.apiKey.required": "Potreben je ključ API", "provider.connect.opencodeZen.line1": @@ -704,6 +707,27 @@ export const dict = { "session.tab.session": "Seja", "session.tab.review": "Pregled", "session.tab.context": "Kontekst", + "session.tab.tasks": "Tasks", + "session.tasks.subagent": "Subagent", + "session.tasks.running": "Running", + "session.tasks.completed": "Completed", + "session.tasks.kind.agent": "Agent", + "session.tasks.kind.shell": "Shell", + "session.tasks.state.running": "Running", + "session.tasks.state.needsInput": "Needs input", + "session.tasks.state.completed": "Completed", + "session.tasks.state.failed": "Failed", + "session.tasks.empty": "No background work running", + "session.tasks.openTranscript": "Open transcript", + "session.tasks.stop": "Stop task", + "session.tasks.dismiss": "Dismiss", + "session.tasks.stats.model": "Model", + "session.tasks.stats.agent": "Agent", + "session.tasks.stats.tools": "Tools", + "session.tasks.stats.fails": "Fails", + "session.tasks.stats.tokens": "Tokens", + "session.tasks.stats.cost": "Cost", + "command.tasks.toggle": "Toggle tasks panel", "session.tab.unknown": "Neznana seja", "session.panel.reviewAndFiles": "Pregled in datoteke", "session.error.notFound": "Te seje ni mogoče najti", diff --git a/packages/app/src/i18n/sq.ts b/packages/app/src/i18n/sq.ts index ca94793ab0e6..2d49bffc52b4 100644 --- a/packages/app/src/i18n/sq.ts +++ b/packages/app/src/i18n/sq.ts @@ -223,6 +223,9 @@ export const dict = { "provider.connect.apiKey.description": "Futni çelësin API të {{provider}} për të lidhur llogarinë dhe për të përdorur modelet {{provider}} në OpenCode.", "provider.connect.apiKey.label": "{{provider}} Tasti API", + "provider.connect.apiKey.labelOptional": "Label (optional)", + "provider.connect.apiKey.labelPlaceholder": "e.g. personal, work, team", + "settings.providers.addKey": "Add key", "provider.connect.apiKey.placeholder": "Tasti API", "provider.connect.apiKey.required": "Kërkohet çelësi API", "provider.connect.opencodeZen.line1": @@ -706,6 +709,27 @@ export const dict = { "session.tab.session": "Sesioni", "session.tab.review": "Rishikimi", "session.tab.context": "Konteksti", + "session.tab.tasks": "Tasks", + "session.tasks.subagent": "Subagent", + "session.tasks.running": "Running", + "session.tasks.completed": "Completed", + "session.tasks.kind.agent": "Agent", + "session.tasks.kind.shell": "Shell", + "session.tasks.state.running": "Running", + "session.tasks.state.needsInput": "Needs input", + "session.tasks.state.completed": "Completed", + "session.tasks.state.failed": "Failed", + "session.tasks.empty": "No background work running", + "session.tasks.openTranscript": "Open transcript", + "session.tasks.stop": "Stop task", + "session.tasks.dismiss": "Dismiss", + "session.tasks.stats.model": "Model", + "session.tasks.stats.agent": "Agent", + "session.tasks.stats.tools": "Tools", + "session.tasks.stats.fails": "Fails", + "session.tasks.stats.tokens": "Tokens", + "session.tasks.stats.cost": "Cost", + "command.tasks.toggle": "Toggle tasks panel", "session.tab.unknown": "Sesion i panjohur", "session.panel.reviewAndFiles": "Rishikimi dhe dosjet", "session.error.notFound": "Ky sesion nuk mund të gjendet", diff --git a/packages/app/src/i18n/sr.ts b/packages/app/src/i18n/sr.ts index f059160087d8..3ad0270915a5 100644 --- a/packages/app/src/i18n/sr.ts +++ b/packages/app/src/i18n/sr.ts @@ -223,6 +223,9 @@ export const dict = { "provider.connect.apiKey.description": "Унесите свој {{provider}} API кључ да бисте повезали свој налог и користили {{provider}} моделе у OpenCode.", "provider.connect.apiKey.label": "{{provider}} API кључ", + "provider.connect.apiKey.labelOptional": "Label (optional)", + "provider.connect.apiKey.labelPlaceholder": "e.g. personal, work, team", + "settings.providers.addKey": "Add key", "provider.connect.apiKey.placeholder": "API кључ", "provider.connect.apiKey.required": "API кључ је неопходан", "provider.connect.opencodeZen.line1": @@ -705,6 +708,27 @@ export const dict = { "session.tab.session": "Сесија", "session.tab.review": "Преглед", "session.tab.context": "Контекст", + "session.tab.tasks": "Tasks", + "session.tasks.subagent": "Subagent", + "session.tasks.running": "Running", + "session.tasks.completed": "Completed", + "session.tasks.kind.agent": "Agent", + "session.tasks.kind.shell": "Shell", + "session.tasks.state.running": "Running", + "session.tasks.state.needsInput": "Needs input", + "session.tasks.state.completed": "Completed", + "session.tasks.state.failed": "Failed", + "session.tasks.empty": "No background work running", + "session.tasks.openTranscript": "Open transcript", + "session.tasks.stop": "Stop task", + "session.tasks.dismiss": "Dismiss", + "session.tasks.stats.model": "Model", + "session.tasks.stats.agent": "Agent", + "session.tasks.stats.tools": "Tools", + "session.tasks.stats.fails": "Fails", + "session.tasks.stats.tokens": "Tokens", + "session.tasks.stats.cost": "Cost", + "command.tasks.toggle": "Toggle tasks panel", "session.tab.unknown": "Непозната сесија", "session.panel.reviewAndFiles": "Преглед и датотеке", "session.error.notFound": "Ова сесија није пронађена", diff --git a/packages/app/src/i18n/sv.ts b/packages/app/src/i18n/sv.ts index 39b363c6b657..577afd678056 100644 --- a/packages/app/src/i18n/sv.ts +++ b/packages/app/src/i18n/sv.ts @@ -224,6 +224,9 @@ export const dict = { "provider.connect.apiKey.description": "Ange din {{provider}}-API-nyckel för att ansluta ditt konto och använda {{provider}}-modeller i OpenCode.", "provider.connect.apiKey.label": "{{provider}}-API-nyckel", + "provider.connect.apiKey.labelOptional": "Label (optional)", + "provider.connect.apiKey.labelPlaceholder": "e.g. personal, work, team", + "settings.providers.addKey": "Add key", "provider.connect.apiKey.placeholder": "API-nyckel", "provider.connect.apiKey.required": "API-nyckel krävs", "provider.connect.opencodeZen.line1": @@ -708,6 +711,27 @@ export const dict = { "session.tab.session": "Session", "session.tab.review": "Granskning", "session.tab.context": "Kontext", + "session.tab.tasks": "Tasks", + "session.tasks.subagent": "Subagent", + "session.tasks.running": "Running", + "session.tasks.completed": "Completed", + "session.tasks.kind.agent": "Agent", + "session.tasks.kind.shell": "Shell", + "session.tasks.state.running": "Running", + "session.tasks.state.needsInput": "Needs input", + "session.tasks.state.completed": "Completed", + "session.tasks.state.failed": "Failed", + "session.tasks.empty": "No background work running", + "session.tasks.openTranscript": "Open transcript", + "session.tasks.stop": "Stop task", + "session.tasks.dismiss": "Dismiss", + "session.tasks.stats.model": "Model", + "session.tasks.stats.agent": "Agent", + "session.tasks.stats.tools": "Tools", + "session.tasks.stats.fails": "Fails", + "session.tasks.stats.tokens": "Tokens", + "session.tasks.stats.cost": "Cost", + "command.tasks.toggle": "Toggle tasks panel", "session.tab.unknown": "Okänd session", "session.panel.reviewAndFiles": "Granskning och filer", "session.error.notFound": "Denna session kan inte hittas", diff --git a/packages/app/src/i18n/tg.ts b/packages/app/src/i18n/tg.ts index ffa08409f684..9c6d48706b6c 100644 --- a/packages/app/src/i18n/tg.ts +++ b/packages/app/src/i18n/tg.ts @@ -223,6 +223,9 @@ export const dict = { "provider.connect.apiKey.description": "Барои пайваст кардани ҳисоби худ калиди {{provider}} API-ро ворид кунед ва моделҳои {{provider}}-ро дар OpenCode истифода баред.", "provider.connect.apiKey.label": "{{provider}} API калид", + "provider.connect.apiKey.labelOptional": "Label (optional)", + "provider.connect.apiKey.labelPlaceholder": "e.g. personal, work, team", + "settings.providers.addKey": "Add key", "provider.connect.apiKey.placeholder": "API калид", "provider.connect.apiKey.required": "API калид лозим аст", "provider.connect.opencodeZen.line1": @@ -706,6 +709,27 @@ export const dict = { "session.tab.session": "Сессия", "session.tab.review": "Барраси", "session.tab.context": "Контекст", + "session.tab.tasks": "Tasks", + "session.tasks.subagent": "Subagent", + "session.tasks.running": "Running", + "session.tasks.completed": "Completed", + "session.tasks.kind.agent": "Agent", + "session.tasks.kind.shell": "Shell", + "session.tasks.state.running": "Running", + "session.tasks.state.needsInput": "Needs input", + "session.tasks.state.completed": "Completed", + "session.tasks.state.failed": "Failed", + "session.tasks.empty": "No background work running", + "session.tasks.openTranscript": "Open transcript", + "session.tasks.stop": "Stop task", + "session.tasks.dismiss": "Dismiss", + "session.tasks.stats.model": "Model", + "session.tasks.stats.agent": "Agent", + "session.tasks.stats.tools": "Tools", + "session.tasks.stats.fails": "Fails", + "session.tasks.stats.tokens": "Tokens", + "session.tasks.stats.cost": "Cost", + "command.tasks.toggle": "Toggle tasks panel", "session.tab.unknown": "Сеанси номаълум", "session.panel.reviewAndFiles": "Барраси ва файлҳо", "session.error.notFound": "Ин сессияро ёфтан мумкин нест", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index 1a0e9db72b1b..c602ecabcfc2 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -241,6 +241,9 @@ export const dict = { "provider.connect.apiKey.description": "ป้อนคีย์ API ของ {{provider}} เพื่อเชื่อมต่อบัญชีและใช้โมเดล {{provider}} ใน OpenCode", "provider.connect.apiKey.label": "คีย์ API ของ {{provider}}", + "provider.connect.apiKey.labelOptional": "Label (optional)", + "provider.connect.apiKey.labelPlaceholder": "e.g. personal, work, team", + "settings.providers.addKey": "Add key", "provider.connect.apiKey.placeholder": "คีย์ API", "provider.connect.apiKey.required": "ต้องใช้คีย์ API", "provider.connect.opencodeZen.line1": @@ -742,6 +745,27 @@ export const dict = { "session.tab.session": "เซสชัน", "session.tab.review": "ตรวจสอบ", "session.tab.context": "บริบท", + "session.tab.tasks": "Tasks", + "session.tasks.subagent": "Subagent", + "session.tasks.running": "Running", + "session.tasks.completed": "Completed", + "session.tasks.kind.agent": "Agent", + "session.tasks.kind.shell": "Shell", + "session.tasks.state.running": "Running", + "session.tasks.state.needsInput": "Needs input", + "session.tasks.state.completed": "Completed", + "session.tasks.state.failed": "Failed", + "session.tasks.empty": "No background work running", + "session.tasks.openTranscript": "Open transcript", + "session.tasks.stop": "Stop task", + "session.tasks.dismiss": "Dismiss", + "session.tasks.stats.model": "Model", + "session.tasks.stats.agent": "Agent", + "session.tasks.stats.tools": "Tools", + "session.tasks.stats.fails": "Fails", + "session.tasks.stats.tokens": "Tokens", + "session.tasks.stats.cost": "Cost", + "command.tasks.toggle": "Toggle tasks panel", "session.tab.unknown": "เซสชันที่ไม่รู้จัก", "session.panel.reviewAndFiles": "ตรวจสอบและไฟล์", "session.error.notFound": "ไม่พบเซสชันนี้", diff --git a/packages/app/src/i18n/tk.ts b/packages/app/src/i18n/tk.ts index 2b144c6a26c7..5c505a13d0f7 100644 --- a/packages/app/src/i18n/tk.ts +++ b/packages/app/src/i18n/tk.ts @@ -223,6 +223,9 @@ export const dict = { "provider.connect.apiKey.description": "Hasabyňyzy birikdirmek we OpenCode-da {{provider}} modellerini ulanmak üçin {{provider}} API açaryňyzy giriziň.", "provider.connect.apiKey.label": "{{provider}} API açary", + "provider.connect.apiKey.labelOptional": "Label (optional)", + "provider.connect.apiKey.labelPlaceholder": "e.g. personal, work, team", + "settings.providers.addKey": "Add key", "provider.connect.apiKey.placeholder": "API açary", "provider.connect.apiKey.required": "API açary talap edilýär", "provider.connect.opencodeZen.line1": @@ -703,6 +706,27 @@ export const dict = { "session.tab.session": "Sessiýa", "session.tab.review": "Syn", "session.tab.context": "Kontekst", + "session.tab.tasks": "Tasks", + "session.tasks.subagent": "Subagent", + "session.tasks.running": "Running", + "session.tasks.completed": "Completed", + "session.tasks.kind.agent": "Agent", + "session.tasks.kind.shell": "Shell", + "session.tasks.state.running": "Running", + "session.tasks.state.needsInput": "Needs input", + "session.tasks.state.completed": "Completed", + "session.tasks.state.failed": "Failed", + "session.tasks.empty": "No background work running", + "session.tasks.openTranscript": "Open transcript", + "session.tasks.stop": "Stop task", + "session.tasks.dismiss": "Dismiss", + "session.tasks.stats.model": "Model", + "session.tasks.stats.agent": "Agent", + "session.tasks.stats.tools": "Tools", + "session.tasks.stats.fails": "Fails", + "session.tasks.stats.tokens": "Tokens", + "session.tasks.stats.cost": "Cost", + "command.tasks.toggle": "Toggle tasks panel", "session.tab.unknown": "Näbelli sessiýa", "session.panel.reviewAndFiles": "Gözden geçiriň we faýllary", "session.error.notFound": "Bu sessiýany tapyp bolmaýar", diff --git a/packages/app/src/i18n/tr.ts b/packages/app/src/i18n/tr.ts index 85623036e429..a9d2699ded35 100644 --- a/packages/app/src/i18n/tr.ts +++ b/packages/app/src/i18n/tr.ts @@ -247,6 +247,9 @@ export const dict = { "provider.connect.apiKey.description": "{{provider}} hesabınızı bağlamak ve OpenCode'da {{provider}} modellerini kullanmak için API anahtarınızı girin.", "provider.connect.apiKey.label": "{{provider}} API anahtarı", + "provider.connect.apiKey.labelOptional": "Label (optional)", + "provider.connect.apiKey.labelPlaceholder": "e.g. personal, work, team", + "settings.providers.addKey": "Add key", "provider.connect.apiKey.placeholder": "API anahtarı", "provider.connect.apiKey.required": "API anahtarı gerekli", "provider.connect.opencodeZen.line1": @@ -754,6 +757,27 @@ export const dict = { "session.tab.session": "Oturum", "session.tab.review": "İnceleme", "session.tab.context": "Bağlam", + "session.tab.tasks": "Tasks", + "session.tasks.subagent": "Subagent", + "session.tasks.running": "Running", + "session.tasks.completed": "Completed", + "session.tasks.kind.agent": "Agent", + "session.tasks.kind.shell": "Shell", + "session.tasks.state.running": "Running", + "session.tasks.state.needsInput": "Needs input", + "session.tasks.state.completed": "Completed", + "session.tasks.state.failed": "Failed", + "session.tasks.empty": "No background work running", + "session.tasks.openTranscript": "Open transcript", + "session.tasks.stop": "Stop task", + "session.tasks.dismiss": "Dismiss", + "session.tasks.stats.model": "Model", + "session.tasks.stats.agent": "Agent", + "session.tasks.stats.tools": "Tools", + "session.tasks.stats.fails": "Fails", + "session.tasks.stats.tokens": "Tokens", + "session.tasks.stats.cost": "Cost", + "command.tasks.toggle": "Toggle tasks panel", "session.tab.unknown": "Bilinmeyen Oturum", "session.panel.reviewAndFiles": "İnceleme ve dosyalar", "session.error.notFound": "Bu oturum bulunamıyor", diff --git a/packages/app/src/i18n/uk.ts b/packages/app/src/i18n/uk.ts index 54732a87b7eb..09ac7823ba57 100644 --- a/packages/app/src/i18n/uk.ts +++ b/packages/app/src/i18n/uk.ts @@ -243,6 +243,9 @@ export const dict = { "provider.connect.apiKey.description": "Введіть ключ API {{provider}}, щоб підключити обліковий запис і використовувати моделі {{provider}} у OpenCode.", "provider.connect.apiKey.label": "Ключ API {{provider}}", + "provider.connect.apiKey.labelOptional": "Label (optional)", + "provider.connect.apiKey.labelPlaceholder": "e.g. personal, work, team", + "settings.providers.addKey": "Add key", "provider.connect.apiKey.placeholder": "Ключ API", "provider.connect.apiKey.required": "Ключ API обов'язковий", "provider.connect.opencodeZen.line1": @@ -773,6 +776,27 @@ export const dict = { "session.tab.session": "Сесія", "session.tab.review": "Огляд", "session.tab.context": "Контекст", + "session.tab.tasks": "Tasks", + "session.tasks.subagent": "Subagent", + "session.tasks.running": "Running", + "session.tasks.completed": "Completed", + "session.tasks.kind.agent": "Agent", + "session.tasks.kind.shell": "Shell", + "session.tasks.state.running": "Running", + "session.tasks.state.needsInput": "Needs input", + "session.tasks.state.completed": "Completed", + "session.tasks.state.failed": "Failed", + "session.tasks.empty": "No background work running", + "session.tasks.openTranscript": "Open transcript", + "session.tasks.stop": "Stop task", + "session.tasks.dismiss": "Dismiss", + "session.tasks.stats.model": "Model", + "session.tasks.stats.agent": "Agent", + "session.tasks.stats.tools": "Tools", + "session.tasks.stats.fails": "Fails", + "session.tasks.stats.tokens": "Tokens", + "session.tasks.stats.cost": "Cost", + "command.tasks.toggle": "Toggle tasks panel", "session.tab.unknown": "Невідома сесія", "session.panel.reviewAndFiles": "Огляд і файли", "session.error.notFound": "Цю сесію не знайдено", diff --git a/packages/app/src/i18n/ur.ts b/packages/app/src/i18n/ur.ts index 2ab69e7aec71..9909433bd545 100644 --- a/packages/app/src/i18n/ur.ts +++ b/packages/app/src/i18n/ur.ts @@ -231,6 +231,9 @@ export const dict = { "provider.connect.apiKey.description": "اپنا اکاؤنٹ منسلک کرنے اور OpenCode میں {{provider}} ماڈلز استعمال کرنے کے لیے اپنی {{provider}} API کلید درج کریں۔", "provider.connect.apiKey.label": "{{provider}} API کلید", + "provider.connect.apiKey.labelOptional": "Label (optional)", + "provider.connect.apiKey.labelPlaceholder": "e.g. personal, work, team", + "settings.providers.addKey": "Add key", "provider.connect.apiKey.placeholder": "API کلید", "provider.connect.apiKey.required": "API کلید درکار ہے۔", "provider.connect.opencodeZen.line1": @@ -714,6 +717,27 @@ export const dict = { "session.tab.session": "سیشن", "session.tab.review": "جائزہ لیں", "session.tab.context": "سیاق و سباق", + "session.tab.tasks": "Tasks", + "session.tasks.subagent": "Subagent", + "session.tasks.running": "Running", + "session.tasks.completed": "Completed", + "session.tasks.kind.agent": "Agent", + "session.tasks.kind.shell": "Shell", + "session.tasks.state.running": "Running", + "session.tasks.state.needsInput": "Needs input", + "session.tasks.state.completed": "Completed", + "session.tasks.state.failed": "Failed", + "session.tasks.empty": "No background work running", + "session.tasks.openTranscript": "Open transcript", + "session.tasks.stop": "Stop task", + "session.tasks.dismiss": "Dismiss", + "session.tasks.stats.model": "Model", + "session.tasks.stats.agent": "Agent", + "session.tasks.stats.tools": "Tools", + "session.tasks.stats.fails": "Fails", + "session.tasks.stats.tokens": "Tokens", + "session.tasks.stats.cost": "Cost", + "command.tasks.toggle": "Toggle tasks panel", "session.tab.unknown": "نامعلوم سیشن", "session.panel.reviewAndFiles": "جائزہ اور فائلیں۔", "session.error.notFound": "یہ سیشن نہیں مل سکتا", diff --git a/packages/app/src/i18n/uz.ts b/packages/app/src/i18n/uz.ts index 8cb7097fc07a..453a9f8bff32 100644 --- a/packages/app/src/i18n/uz.ts +++ b/packages/app/src/i18n/uz.ts @@ -224,6 +224,9 @@ export const dict = { "provider.connect.apiKey.description": "Hisobingizni ulash va OpenCode’da {{provider}} modellaridan foydalanish uchun {{provider}} API kalitini kiriting.", "provider.connect.apiKey.label": "{{provider}} API kaliti", + "provider.connect.apiKey.labelOptional": "Label (optional)", + "provider.connect.apiKey.labelPlaceholder": "e.g. personal, work, team", + "settings.providers.addKey": "Add key", "provider.connect.apiKey.placeholder": "API kaliti", "provider.connect.apiKey.required": "API kaliti talab qilinadi", "provider.connect.opencodeZen.line1": @@ -707,6 +710,27 @@ export const dict = { "session.tab.session": "Sessiya", "session.tab.review": "Koʻrib chiqish", "session.tab.context": "Kontekst", + "session.tab.tasks": "Tasks", + "session.tasks.subagent": "Subagent", + "session.tasks.running": "Running", + "session.tasks.completed": "Completed", + "session.tasks.kind.agent": "Agent", + "session.tasks.kind.shell": "Shell", + "session.tasks.state.running": "Running", + "session.tasks.state.needsInput": "Needs input", + "session.tasks.state.completed": "Completed", + "session.tasks.state.failed": "Failed", + "session.tasks.empty": "No background work running", + "session.tasks.openTranscript": "Open transcript", + "session.tasks.stop": "Stop task", + "session.tasks.dismiss": "Dismiss", + "session.tasks.stats.model": "Model", + "session.tasks.stats.agent": "Agent", + "session.tasks.stats.tools": "Tools", + "session.tasks.stats.fails": "Fails", + "session.tasks.stats.tokens": "Tokens", + "session.tasks.stats.cost": "Cost", + "command.tasks.toggle": "Toggle tasks panel", "session.tab.unknown": "Noma'lum sessiya", "session.panel.reviewAndFiles": "Ko'rib chiqish va fayllar", "session.error.notFound": "Bu sessiya topilmadi", diff --git a/packages/app/src/i18n/vi.ts b/packages/app/src/i18n/vi.ts index 06fda4ce707c..75a124925d3e 100644 --- a/packages/app/src/i18n/vi.ts +++ b/packages/app/src/i18n/vi.ts @@ -230,6 +230,9 @@ export const dict = { "provider.connect.apiKey.description": "Nhập khóa API {{provider}} để kết nối tài khoản và sử dụng các mô hình {{provider}} trong OpenCode.", "provider.connect.apiKey.label": "Khóa API {{provider}}", + "provider.connect.apiKey.labelOptional": "Label (optional)", + "provider.connect.apiKey.labelPlaceholder": "e.g. personal, work, team", + "settings.providers.addKey": "Add key", "provider.connect.apiKey.placeholder": "khóa API", "provider.connect.apiKey.required": "Cần có khóa API", "provider.connect.opencodeZen.line1": @@ -713,6 +716,27 @@ export const dict = { "session.tab.session": "Phiên", "session.tab.review": "Xem lại", "session.tab.context": "Ngữ cảnh", + "session.tab.tasks": "Tasks", + "session.tasks.subagent": "Subagent", + "session.tasks.running": "Running", + "session.tasks.completed": "Completed", + "session.tasks.kind.agent": "Agent", + "session.tasks.kind.shell": "Shell", + "session.tasks.state.running": "Running", + "session.tasks.state.needsInput": "Needs input", + "session.tasks.state.completed": "Completed", + "session.tasks.state.failed": "Failed", + "session.tasks.empty": "No background work running", + "session.tasks.openTranscript": "Open transcript", + "session.tasks.stop": "Stop task", + "session.tasks.dismiss": "Dismiss", + "session.tasks.stats.model": "Model", + "session.tasks.stats.agent": "Agent", + "session.tasks.stats.tools": "Tools", + "session.tasks.stats.fails": "Fails", + "session.tasks.stats.tokens": "Tokens", + "session.tasks.stats.cost": "Cost", + "command.tasks.toggle": "Toggle tasks panel", "session.tab.unknown": "Phiên không xác định", "session.panel.reviewAndFiles": "Xem lại và tệp", "session.error.notFound": "Không thể tìm thấy phiên này", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index b0ba0453479b..42fd4be6eee3 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -267,6 +267,9 @@ export const dict = { "provider.connect.apiKey.description": "输入你的 {{provider}} API 密钥以连接账户,并在 OpenCode 中使用 {{provider}} 模型。", "provider.connect.apiKey.label": "{{provider}} API 密钥", + "provider.connect.apiKey.labelOptional": "Label (optional)", + "provider.connect.apiKey.labelPlaceholder": "e.g. personal, work, team", + "settings.providers.addKey": "Add key", "provider.connect.apiKey.placeholder": "API 密钥", "provider.connect.apiKey.required": "API 密钥为必填项", "provider.connect.opencodeZen.line1": "OpenCode Zen 为你提供一组精选的可靠优化模型,用于代码智能体。", @@ -743,6 +746,27 @@ export const dict = { "session.tab.session": "会话", "session.tab.review": "审查", "session.tab.context": "上下文", + "session.tab.tasks": "Tasks", + "session.tasks.subagent": "Subagent", + "session.tasks.running": "Running", + "session.tasks.completed": "Completed", + "session.tasks.kind.agent": "Agent", + "session.tasks.kind.shell": "Shell", + "session.tasks.state.running": "Running", + "session.tasks.state.needsInput": "Needs input", + "session.tasks.state.completed": "Completed", + "session.tasks.state.failed": "Failed", + "session.tasks.empty": "No background work running", + "session.tasks.openTranscript": "Open transcript", + "session.tasks.stop": "Stop task", + "session.tasks.dismiss": "Dismiss", + "session.tasks.stats.model": "Model", + "session.tasks.stats.agent": "Agent", + "session.tasks.stats.tools": "Tools", + "session.tasks.stats.fails": "Fails", + "session.tasks.stats.tokens": "Tokens", + "session.tasks.stats.cost": "Cost", + "command.tasks.toggle": "Toggle tasks panel", "session.tab.unknown": "未知会话", "session.panel.reviewAndFiles": "审查和文件", "session.error.notFound": "找不到此会话", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index 6cc3d555289d..db06309e9f5a 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -245,6 +245,9 @@ export const dict = { "provider.connect.apiKey.description": "輸入你的 {{provider}} API 金鑰以連線帳戶,並在 OpenCode 中使用 {{provider}} 模型。", "provider.connect.apiKey.label": "{{provider}} API 金鑰", + "provider.connect.apiKey.labelOptional": "Label (optional)", + "provider.connect.apiKey.labelPlaceholder": "e.g. personal, work, team", + "settings.providers.addKey": "Add key", "provider.connect.apiKey.placeholder": "API 金鑰", "provider.connect.apiKey.required": "API 金鑰為必填", "provider.connect.opencodeZen.line1": "OpenCode Zen 為你提供一組精選的可靠最佳化模型,用於程式碼代理程式。", @@ -738,6 +741,27 @@ export const dict = { "session.tab.session": "工作階段", "session.tab.review": "檢閱", "session.tab.context": "上下文", + "session.tab.tasks": "Tasks", + "session.tasks.subagent": "Subagent", + "session.tasks.running": "Running", + "session.tasks.completed": "Completed", + "session.tasks.kind.agent": "Agent", + "session.tasks.kind.shell": "Shell", + "session.tasks.state.running": "Running", + "session.tasks.state.needsInput": "Needs input", + "session.tasks.state.completed": "Completed", + "session.tasks.state.failed": "Failed", + "session.tasks.empty": "No background work running", + "session.tasks.openTranscript": "Open transcript", + "session.tasks.stop": "Stop task", + "session.tasks.dismiss": "Dismiss", + "session.tasks.stats.model": "Model", + "session.tasks.stats.agent": "Agent", + "session.tasks.stats.tools": "Tools", + "session.tasks.stats.fails": "Fails", + "session.tasks.stats.tokens": "Tokens", + "session.tasks.stats.cost": "Cost", + "command.tasks.toggle": "Toggle tasks panel", "session.tab.unknown": "未知的工作階段", "session.panel.reviewAndFiles": "檢閱與檔案", "session.error.notFound": "找不到此工作階段", From e58be2534e41f1a853a95a91bc3e66478502b444 Mon Sep 17 00:00:00 2001 From: Gustavo Schneiter Date: Thu, 3 Sep 2026 22:57:42 -0300 Subject: [PATCH 006/117] feat(app): locale-free headlines in tasks data hook --- packages/app/src/pages/session/tasks-data.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/src/pages/session/tasks-data.ts b/packages/app/src/pages/session/tasks-data.ts index 27ac275a2233..bc5784579901 100644 --- a/packages/app/src/pages/session/tasks-data.ts +++ b/packages/app/src/pages/session/tasks-data.ts @@ -153,7 +153,7 @@ export function createTasksData() { toolTitle(part.state) ?? (typeof input.description === "string" && input.description.length > 0 ? input.description - : "Subagent") + : "") const agent = typeof input.subagent_type === "string" && input.subagent_type.length > 0 ? input.subagent_type @@ -211,7 +211,7 @@ export function createTasksData() { const item: TasksItem = { key: child.id, kind: "agent", - headline: child.title || "Subagent", + headline: child.title || "", state: live, startTime: child.time.created ?? Date.now(), childId: child.id, From 4953c4442278c52adeefa8e324a7a1a268583792 Mon Sep 17 00:00:00 2001 From: Gustavo Schneiter Date: Thu, 3 Sep 2026 23:21:02 -0300 Subject: [PATCH 007/117] feat(opencode): task tool accepts explicit provider/model per subagent New optional 'model' param as 'providerID/modelID' (e.g. 'openrouter/deepseek/deepseek-chat'). Resolution: param > agent model > parent model. Provider part selects credentials ambiently (OAuth subscriptions, API keys, custom provider aliases for extra keys). Malformed values fail fast. Docs in task.txt. Tests: forward + reject + unchanged defaults. --- packages/opencode/src/tool/task.ts | 22 ++++++- packages/opencode/src/tool/task.txt | 14 +++-- packages/opencode/test/tool/task.test.ts | 74 ++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index d8ca640cfba9..f6ba22c9a862 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -7,6 +7,7 @@ import { Session } from "@/session/session" import { SessionID, MessageID } from "../session/schema" import { MessageV2 } from "../session/message-v2" import { Agent } from "../agent/agent" +import { Provider } from "@/provider/provider" import { deriveSubagentSessionPermission } from "../agent/subagent-permissions" import type { SessionPrompt } from "../session/prompt" import { Config } from "@/config/config" @@ -49,6 +50,10 @@ const BaseParameterFields = { "This should only be set if you mean to resume a previous task (you can pass a prior task_id and the task will continue the same subagent session as before instead of creating a fresh one)", }), command: Schema.optional(Schema.String).annotate({ description: "The command that triggered this task" }), + model: Schema.optional(Schema.String).annotate({ + description: + "Run the subagent on a specific model as 'providerID/modelID' (e.g. 'openrouter/deepseek/deepseek-chat', 'groq/llama-3.3-70b-versatile'). Overrides the subagent's configured model and the parent session model. The provider part also selects credentials: OAuth subscriptions (Claude Max, ChatGPT) and API keys resolve per providerID at run time — use a custom provider alias in opencode.json to pin a second key for the same backend.", + }), } const BaseParameters = Schema.Struct(BaseParameterFields) @@ -178,10 +183,23 @@ export const TaskTool = Tool.define( if (msg.info.role !== "assistant") return yield* Effect.fail(new Error("Not an assistant message")) const variant = msg.info.variant - const model = next.model ?? { + let explicitModel = false + let model = next.model ?? { modelID: msg.info.modelID, providerID: msg.info.providerID, } + if (params.model) { + const parsed = Provider.parseModel(params.model) + if (!parsed.providerID || !parsed.modelID) { + return yield* Effect.fail( + new Error( + `Invalid model "${params.model}". Use the 'providerID/modelID' format, e.g. 'openrouter/deepseek/deepseek-chat'.`, + ), + ) + } + explicitModel = true + model = { modelID: parsed.modelID, providerID: parsed.providerID } + } const metadata = { parentSessionId: ctx.sessionID, sessionId: nextSession.id, @@ -206,7 +224,7 @@ export const TaskTool = Tool.define( modelID: model.modelID, providerID: model.providerID, }, - variant: next.model ? undefined : variant, + variant: next.model || explicitModel ? undefined : variant, agent: next.name, parts, }) diff --git a/packages/opencode/src/tool/task.txt b/packages/opencode/src/tool/task.txt index c5e412f409d9..37e26e35e35f 100644 --- a/packages/opencode/src/tool/task.txt +++ b/packages/opencode/src/tool/task.txt @@ -11,9 +11,11 @@ When NOT to use the Task tool: Usage notes: 1. Launch multiple agents concurrently whenever possible, to maximize performance; to do that, use a single message with multiple tool uses -2. Once you have delegated work to an agent, do not duplicate that work yourself. Continue with non-overlapping tasks, or wait for the result. For background tasks, you will be notified automatically when the result is ready. -3. When the agent is done, it will return a single message back to you. The result returned by the agent is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result. The output includes a task_id you can reuse later to continue the same subagent session. -4. Each agent invocation starts with a fresh context unless you provide task_id to resume the same subagent session (which continues with its previous messages and tool outputs). When starting fresh, your prompt should contain a highly detailed task description for the agent to perform autonomously and you should specify exactly what information the agent should return back to you in its final and only message to you. -5. The agent's outputs should generally be trusted -6. Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.), since it is not aware of the user's intent. Tell it how to verify its work if possible (e.g., relevant test commands). -7. If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement. +2. Pick the subagent's model per call with the `model` parameter as 'providerID/modelID' (e.g. 'openrouter/deepseek/deepseek-chat', 'groq/llama-3.3-70b-versatile', 'anthropic/claude-opus-4-6'). Without it, the subagent uses its configured model, else the parent session model. +3. The provider part selects credentials: OAuth subscriptions (Claude Max, ChatGPT) and API keys resolve per providerID when the subagent runs. For a second key on the same backend, define a provider alias in opencode.json with its own options.apiKey (e.g. provider "groq-work" with baseURL https://api.groq.com/openai/v1) and call model 'groq-work/llama-3.3-70b-versatile'. +4. Once you have delegated work to an agent, do not duplicate that work yourself. Continue with non-overlapping tasks, or wait for the result. For background tasks, you will be notified automatically when the result is ready. +5. When the agent is done, it will return a single message back to you. The result returned by the agent is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result. The output includes a task_id you can reuse later to continue the same subagent session. +6. Each agent invocation starts with a fresh context unless you provide task_id to resume the same subagent session (which continues with its previous messages and tool outputs). When starting fresh, your prompt should contain a highly detailed task description for the agent to perform autonomously and you should specify exactly what information the agent should return back to you in its final and only message to you. +7. The agent's outputs should generally be trusted +8. Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.), since it is not aware of the user's intent. Tell it how to verify its work if possible (e.g., relevant test commands). +9. If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement. diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index 42f46fd35d7e..0437eb707bac 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -284,6 +284,80 @@ describe("tool.task", () => { }), ) + it.instance("execute forwards an explicit model to the subagent prompt", () => + Effect.gen(function* () { + const sessions = yield* Session.Service + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + let seen: SessionPrompt.PromptInput | undefined + const promptOps = stubOps({ text: "done", onPrompt: (input) => (seen = input) }) + + const result = yield* def.execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + model: "openrouter/deepseek/deepseek-chat", + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + const child = (yield* sessions.children(chat.id))[0] + expect(child).toBeDefined() + expect(`${seen?.model?.providerID}/${seen?.model?.modelID}`).toBe( + "openrouter/deepseek/deepseek-chat", + ) + expect(seen?.variant).toBeUndefined() + expect(result.metadata.sessionId).toBe(child?.id) + }), + ) + + it.instance("execute rejects a malformed model parameter", () => + Effect.gen(function* () { + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + + const exit = yield* def + .execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + model: "nodivider", + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps: stubOps({}) }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + .pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isSuccess(exit)) throw new Error("expected task failure") + const failure = Cause.squash(exit.cause) + expect(failure).toBeInstanceOf(Error) + if (!(failure instanceof Error)) throw new Error("expected Error defect") + expect(failure.message).toContain("Invalid model") + }), + ) + it.instance("execute surfaces child errors with a resumable task_id", () => Effect.gen(function* () { const sessions = yield* Session.Service From 1e031f5b4d023d03e880cd823d0a20704257f3a7 Mon Sep 17 00:00:00 2001 From: Gustavo Schneiter Date: Fri, 4 Sep 2026 00:08:55 -0300 Subject: [PATCH 008/117] feat(opencode): model-scoped task permissions for subagent allowlist When the session carries task rules with model patterns (pattern containing '/'), the resolved provider/model is added to the permission ask patterns and metadata. Otherwise behavior is unchanged (no extra prompt). Denied models surface the session ruleset so the caller can retry with a listed model. --- packages/opencode/src/tool/task.ts | 73 ++++++++++++++---------- packages/opencode/src/tool/task.txt | 13 +++-- packages/opencode/test/tool/task.test.ts | 70 +++++++++++++++++++++++ 3 files changed, 120 insertions(+), 36 deletions(-) diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index f6ba22c9a862..e2f81a1fee6f 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -121,23 +121,60 @@ export const TaskTool = Tool.define( ) } + const next = yield* agent.get(params.subagent_type) + if (!next) { + return yield* Effect.fail(new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`)) + } + + const msg = yield* MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID }).pipe( + Effect.provideService(Database.Service, database), + Effect.orDie, + ) + if (msg.info.role !== "assistant") return yield* Effect.fail(new Error("Not an assistant message")) + const variant = msg.info.variant + + let explicitModel = false + let model = next.model ?? { + modelID: msg.info.modelID, + providerID: msg.info.providerID, + } + if (params.model) { + const parsed = Provider.parseModel(params.model) + if (!parsed.providerID || !parsed.modelID) { + return yield* Effect.fail( + new Error( + `Invalid model "${params.model}". Use the 'providerID/modelID' format, e.g. 'openrouter/deepseek/deepseek-chat'.`, + ), + ) + } + explicitModel = true + model = { modelID: parsed.modelID, providerID: parsed.providerID } + } + const modelPattern = `${model.providerID}/${model.modelID}` + + // Model-scoped task rules (written by the subagent-model picker panel) + // look like { permission: "task", pattern: "openrouter/*", action }. + // Only when the session carries them do we add the resolved model to + // the ask patterns — otherwise behavior is exactly as before (no + // extra prompt, no extra surface for the model to satisfy). + const modelRules = (parent.permission ?? []).filter( + (rule) => rule.permission === id && rule.pattern.includes("/"), + ) + if (!ctx.extra?.bypassAgentCheck) { yield* ctx.ask({ permission: id, - patterns: [params.subagent_type], + patterns: + modelRules.length > 0 ? [params.subagent_type, modelPattern] : [params.subagent_type], always: ["*"], metadata: { description: params.description, subagent_type: params.subagent_type, + ...(modelRules.length > 0 ? { model: modelPattern } : {}), }, }) } - const next = yield* agent.get(params.subagent_type) - if (!next) { - return yield* Effect.fail(new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`)) - } - const session = params.task_id ? yield* sessions.get(SessionID.make(params.task_id)).pipe(Effect.catchCause(() => Effect.succeed(undefined))) : undefined @@ -176,30 +213,6 @@ export const TaskTool = Tool.define( ], })) - const msg = yield* MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID }).pipe( - Effect.provideService(Database.Service, database), - Effect.orDie, - ) - if (msg.info.role !== "assistant") return yield* Effect.fail(new Error("Not an assistant message")) - const variant = msg.info.variant - - let explicitModel = false - let model = next.model ?? { - modelID: msg.info.modelID, - providerID: msg.info.providerID, - } - if (params.model) { - const parsed = Provider.parseModel(params.model) - if (!parsed.providerID || !parsed.modelID) { - return yield* Effect.fail( - new Error( - `Invalid model "${params.model}". Use the 'providerID/modelID' format, e.g. 'openrouter/deepseek/deepseek-chat'.`, - ), - ) - } - explicitModel = true - model = { modelID: parsed.modelID, providerID: parsed.providerID } - } const metadata = { parentSessionId: ctx.sessionID, sessionId: nextSession.id, diff --git a/packages/opencode/src/tool/task.txt b/packages/opencode/src/tool/task.txt index 37e26e35e35f..0c5b8eeedebc 100644 --- a/packages/opencode/src/tool/task.txt +++ b/packages/opencode/src/tool/task.txt @@ -13,9 +13,10 @@ Usage notes: 1. Launch multiple agents concurrently whenever possible, to maximize performance; to do that, use a single message with multiple tool uses 2. Pick the subagent's model per call with the `model` parameter as 'providerID/modelID' (e.g. 'openrouter/deepseek/deepseek-chat', 'groq/llama-3.3-70b-versatile', 'anthropic/claude-opus-4-6'). Without it, the subagent uses its configured model, else the parent session model. 3. The provider part selects credentials: OAuth subscriptions (Claude Max, ChatGPT) and API keys resolve per providerID when the subagent runs. For a second key on the same backend, define a provider alias in opencode.json with its own options.apiKey (e.g. provider "groq-work" with baseURL https://api.groq.com/openai/v1) and call model 'groq-work/llama-3.3-70b-versatile'. -4. Once you have delegated work to an agent, do not duplicate that work yourself. Continue with non-overlapping tasks, or wait for the result. For background tasks, you will be notified automatically when the result is ready. -5. When the agent is done, it will return a single message back to you. The result returned by the agent is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result. The output includes a task_id you can reuse later to continue the same subagent session. -6. Each agent invocation starts with a fresh context unless you provide task_id to resume the same subagent session (which continues with its previous messages and tool outputs). When starting fresh, your prompt should contain a highly detailed task description for the agent to perform autonomously and you should specify exactly what information the agent should return back to you in its final and only message to you. -7. The agent's outputs should generally be trusted -8. Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.), since it is not aware of the user's intent. Tell it how to verify its work if possible (e.g., relevant test commands). -9. If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement. +4. Sessions can scope which models subagents may use with permission rules like { permission: "task", pattern: "openrouter/*", action: "allow" } plus { permission: "task", pattern: "*/*", action: "deny" }. When such rules exist, the resolved model is added to the permission prompt; a denied model fails with the session's allowed list so you can retry with a listed one. Without model-scoped rules nothing changes. +5. Once you have delegated work to an agent, do not duplicate that work yourself. Continue with non-overlapping tasks, or wait for the result. For background tasks, you will be notified automatically when the result is ready. +6. When the agent is done, it will return a single message back to you. The result returned by the agent is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result. The output includes a task_id you can reuse later to continue the same subagent session. +7. Each agent invocation starts with a fresh context unless you provide task_id to resume the same subagent session (which continues with its previous messages and tool outputs). When starting fresh, your prompt should contain a highly detailed task description for the agent to perform autonomously and you should specify exactly what information the agent should return back to you in its final and only message to you. +8. The agent's outputs should generally be trusted +9. Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.), since it is not aware of the user's intent. Tell it how to verify its work if possible (e.g., relevant test commands). +10. If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement. diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index 0437eb707bac..1fe08361f29d 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -358,6 +358,76 @@ describe("tool.task", () => { }), ) + it.instance("execute adds the resolved model to ask patterns when the session scopes task models", () => + Effect.gen(function* () { + const sessions = yield* Session.Service + const tool = yield* TaskTool + const def = yield* tool.init() + const chat = yield* sessions.create({ + title: "Scoped", + permission: [ + { permission: "task", pattern: "*/*", action: "deny" }, + { permission: "task", pattern: "openrouter/*", action: "allow" }, + ], + }) + const user = yield* sessions.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: chat.id, + agent: "build", + model: ref, + time: { created: Date.now() }, + }) + const assistant: SessionV1.Assistant = { + id: MessageID.ascending(), + role: "assistant", + parentID: user.id, + sessionID: chat.id, + mode: "build", + agent: "build", + cost: 0, + path: { cwd: "/tmp", root: "/tmp" }, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: ref.modelID, + providerID: ref.providerID, + variant: "xhigh", + time: { created: Date.now() }, + } + yield* sessions.updateMessage(assistant) + let seenAsk: { patterns: string[]; metadata: Record } | undefined + const promptOps = stubOps({ text: "done" }) + + yield* def.execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + model: "openrouter/deepseek/deepseek-chat", + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps }, + messages: [], + metadata: () => Effect.void, + ask: (input) => + Effect.sync(() => { + seenAsk = { patterns: input.patterns, metadata: input.metadata as Record } + }), + }, + ) + + expect(seenAsk?.patterns).toEqual(["general", "openrouter/deepseek/deepseek-chat"]) + expect(seenAsk?.metadata).toMatchObject({ + description: "inspect bug", + subagent_type: "general", + model: "openrouter/deepseek/deepseek-chat", + }) + }), + ) + it.instance("execute surfaces child errors with a resumable task_id", () => Effect.gen(function* () { const sessions = yield* Session.Service From ccdfce15e6a462e7c3ecc6890432296e25bbea1c Mon Sep 17 00:00:00 2001 From: Gustavo Schneiter Date: Fri, 4 Sep 2026 00:11:54 -0300 Subject: [PATCH 009/117] fix(opencode): readonly patterns type in task model-scope test --- packages/opencode/test/tool/task.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index 1fe08361f29d..1f1a70cd00fa 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -394,7 +394,7 @@ describe("tool.task", () => { time: { created: Date.now() }, } yield* sessions.updateMessage(assistant) - let seenAsk: { patterns: string[]; metadata: Record } | undefined + let seenAsk: { patterns: readonly string[]; metadata: Record } | undefined const promptOps = stubOps({ text: "done" }) yield* def.execute( From 3abcc37e96ad8a051936d58568b53867f59e5b55 Mon Sep 17 00:00:00 2001 From: Gustavo Schneiter Date: Fri, 4 Sep 2026 00:50:49 -0300 Subject: [PATCH 010/117] feat(app): subagent model allowlist picker dialog DialogSubagentModels mirrors DialogManageModels (Dialog + searchable grouped List + per-model Switch, live-apply). Reads effective state from session permission rules, appends allow/deny task rules via session.update (server merges, last match wins; first constraint writes the star-slash-star deny baseline). Opened via tasks.models command. English fallback across locales; real translations land via locale sync. --- .../src/components/dialog-subagent-models.tsx | 86 +++++++++++++++++++ .../components/subagent-model-rules.test.ts | 55 ++++++++++++ .../src/components/subagent-model-rules.ts | 61 +++++++++++++ packages/app/src/i18n/am.ts | 4 + packages/app/src/i18n/ar.ts | 4 + packages/app/src/i18n/az.ts | 4 + packages/app/src/i18n/bg.ts | 4 + packages/app/src/i18n/bn.ts | 4 + packages/app/src/i18n/br.ts | 4 + packages/app/src/i18n/bs.ts | 4 + packages/app/src/i18n/ca.ts | 4 + packages/app/src/i18n/cs.ts | 4 + packages/app/src/i18n/da.ts | 4 + packages/app/src/i18n/de.ts | 4 + packages/app/src/i18n/dv.ts | 4 + packages/app/src/i18n/dz.ts | 4 + packages/app/src/i18n/el.ts | 4 + packages/app/src/i18n/en.ts | 4 + packages/app/src/i18n/es.ts | 4 + packages/app/src/i18n/et.ts | 4 + packages/app/src/i18n/fa.ts | 4 + packages/app/src/i18n/fi.ts | 4 + packages/app/src/i18n/fo.ts | 4 + packages/app/src/i18n/fr.ts | 4 + packages/app/src/i18n/hi.ts | 4 + packages/app/src/i18n/hr.ts | 4 + packages/app/src/i18n/hu.ts | 4 + packages/app/src/i18n/hy.ts | 4 + packages/app/src/i18n/id.ts | 4 + packages/app/src/i18n/is.ts | 4 + packages/app/src/i18n/it.ts | 4 + packages/app/src/i18n/ja.ts | 4 + packages/app/src/i18n/ka.ts | 4 + packages/app/src/i18n/km.ts | 4 + packages/app/src/i18n/ko.ts | 4 + packages/app/src/i18n/lo.ts | 4 + packages/app/src/i18n/lt.ts | 4 + packages/app/src/i18n/lv.ts | 4 + packages/app/src/i18n/mk.ts | 4 + packages/app/src/i18n/mn.ts | 4 + packages/app/src/i18n/ms.ts | 4 + packages/app/src/i18n/my.ts | 4 + packages/app/src/i18n/ne.ts | 4 + packages/app/src/i18n/nl.ts | 4 + packages/app/src/i18n/no.ts | 4 + packages/app/src/i18n/pa.ts | 4 + packages/app/src/i18n/pl.ts | 4 + packages/app/src/i18n/ro.ts | 4 + packages/app/src/i18n/ru.ts | 4 + packages/app/src/i18n/si.ts | 4 + packages/app/src/i18n/sk.ts | 4 + packages/app/src/i18n/sl.ts | 4 + packages/app/src/i18n/sq.ts | 4 + packages/app/src/i18n/sr.ts | 4 + packages/app/src/i18n/sv.ts | 4 + packages/app/src/i18n/tg.ts | 4 + packages/app/src/i18n/th.ts | 4 + packages/app/src/i18n/tk.ts | 4 + packages/app/src/i18n/tr.ts | 4 + packages/app/src/i18n/uk.ts | 4 + packages/app/src/i18n/ur.ts | 4 + packages/app/src/i18n/uz.ts | 4 + packages/app/src/i18n/vi.ts | 4 + packages/app/src/i18n/zh.ts | 4 + packages/app/src/i18n/zht.ts | 4 + .../pages/session/use-session-commands.tsx | 13 +++ 66 files changed, 463 insertions(+) create mode 100644 packages/app/src/components/dialog-subagent-models.tsx create mode 100644 packages/app/src/components/subagent-model-rules.test.ts create mode 100644 packages/app/src/components/subagent-model-rules.ts diff --git a/packages/app/src/components/dialog-subagent-models.tsx b/packages/app/src/components/dialog-subagent-models.tsx new file mode 100644 index 000000000000..26b20dc5a02b --- /dev/null +++ b/packages/app/src/components/dialog-subagent-models.tsx @@ -0,0 +1,86 @@ +import { Dialog } from "@opencode-ai/ui/dialog" +import { List } from "@opencode-ai/ui/list" +import { Switch } from "@opencode-ai/ui/switch" +import { Tooltip } from "@opencode-ai/ui/tooltip" +import { Show, type Component } from "solid-js" +import { useLocal } from "@/context/local" +import { useLanguage } from "@/context/language" +import { useServerSDK } from "@/context/server-sdk" +import { useSync } from "@/context/sync" +import { ModelTooltip } from "./model-tooltip" +import { effectiveModelState, toggleModelRules, type ModelRule } from "./subagent-model-rules" + +type ModelItem = ReturnType["model"]["list"]>[number] + +export const DialogSubagentModels: Component<{ sessionID: string; directory: string }> = (props) => { + const local = useLocal() + const language = useLanguage() + const serverSDK = useServerSDK() + const sync = useSync() + + const sessionRules = (): ModelRule[] => + (sync().session.get(props.sessionID)?.permission ?? []) as ModelRule[] + + const isAllowed = (providerID: string, modelID: string) => + effectiveModelState(sessionRules(), providerID, modelID) === "allow" + + const setAllowed = async (providerID: string, modelID: string, allow: boolean) => { + if ((await serverSDK().protocol) !== "v1") return + await serverSDK().client.session.update({ + sessionID: props.sessionID, + directory: props.directory, + permission: toggleModelRules(sessionRules(), providerID, modelID, allow), + }) + } + + return ( + + `${x?.provider?.id}:${x?.id}`} + items={local.model + .list() + .filter((m) => local.model.visible({ modelID: m.id, providerID: m.provider.id }))} + filterKeys={["provider.name", "name", "id"]} + sortBy={(a, b) => a.name.localeCompare(b.name)} + groupBy={(x) => x.provider.name} + groupHeader={(group) => {group.items[0]?.provider.name}} + itemWrapper={(item, node) => ( + } + > + {node} + + )} + > + {(item: ModelItem) => ( +
+ {item.name} + + void setAllowed(item.provider.id, item.id, checked)} + hideLabel + > + {item.name} + + +
+ )} +
+
+ {language.t("dialog.subagentModels.hint")} +
+
+ ) +} diff --git a/packages/app/src/components/subagent-model-rules.test.ts b/packages/app/src/components/subagent-model-rules.test.ts new file mode 100644 index 000000000000..3e560c2d4c5c --- /dev/null +++ b/packages/app/src/components/subagent-model-rules.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from "bun:test" +import { effectiveModelState, hasModelScope, toggleModelRules, wildcardMatch } from "./subagent-model-rules" + +describe("subagent model rules", () => { + test("wildcardMatch mirrors the server matcher", () => { + expect(wildcardMatch("openrouter/x", "openrouter/*")).toBe(true) + expect(wildcardMatch("openrouter/x", "*/*")).toBe(true) + expect(wildcardMatch("general", "*/*")).toBe(false) + expect(wildcardMatch("general", "*")).toBe(true) + expect(wildcardMatch("openrouter/x", "groq/*")).toBe(false) + }) + + test("hasModelScope detects only task rules with model patterns", () => { + expect(hasModelScope([])).toBe(false) + expect(hasModelScope([{ permission: "task", pattern: "general", action: "allow" }])).toBe(false) + expect( + hasModelScope([{ permission: "task", pattern: "openrouter/*", action: "allow" }]), + ).toBe(true) + expect(hasModelScope([{ permission: "bash", pattern: "a/b", action: "allow" }])).toBe(false) + }) + + test("effectiveModelState resolves last match wins", () => { + const rules = [ + { permission: "task", pattern: "*/*", action: "deny" as const }, + { permission: "task", pattern: "openrouter/*", action: "allow" as const }, + ] + expect(effectiveModelState(rules, "openrouter", "x")).toBe("allow") + expect(effectiveModelState(rules, "groq", "x")).toBe("deny") + expect(effectiveModelState([], "groq", "x")).toBeUndefined() + expect( + effectiveModelState( + [...rules, { permission: "task", pattern: "openrouter/x", action: "deny" as const }], + "openrouter", + "x", + ), + ).toBe("deny") + }) + + test("toggleModelRules writes the deny baseline once", () => { + expect(toggleModelRules([], "openrouter", "x", true)).toEqual([ + { permission: "task", pattern: "*/*", action: "deny" }, + { permission: "task", pattern: "openrouter/x", action: "allow" }, + ]) + const scoped = [ + { permission: "task", pattern: "*/*", action: "deny" as const }, + { permission: "task", pattern: "openrouter/x", action: "allow" as const }, + ] + expect(toggleModelRules(scoped, "groq", "y", true)).toEqual([ + { permission: "task", pattern: "groq/y", action: "allow" }, + ]) + expect(toggleModelRules(scoped, "openrouter", "x", false)).toEqual([ + { permission: "task", pattern: "openrouter/x", action: "deny" }, + ]) + }) +}) diff --git a/packages/app/src/components/subagent-model-rules.ts b/packages/app/src/components/subagent-model-rules.ts new file mode 100644 index 000000000000..c6608477ee38 --- /dev/null +++ b/packages/app/src/components/subagent-model-rules.ts @@ -0,0 +1,61 @@ +export interface ModelRule { + permission: string + pattern: string + action: "allow" | "deny" | "ask" +} + +/** Client mirror of the server Wildcard.match (packages/opencode/src/util/wildcard.ts): + `*` spans `/`, match is full-string. Kept to the shapes this feature + reads and writes; exotic patterns fall back to generic `*` handling. */ +export function wildcardMatch(str: string, pattern: string): boolean { + const normalized = pattern.replaceAll("\\", "/") + const escaped = normalized + .replace(/[.+^${}()|[\]\\]/g, "\\$&") + .replace(/\*/g, ".*") + .replace(/\?/g, ".") + return new RegExp(`^${escaped}$`, "s").test(str.replaceAll("\\", "/")) +} + +const TASK = "task" + +/** True when the session carries model-scoped task rules. Mirrors the + detection in TaskTool (packages/opencode/src/tool/task.ts) exactly: + permission === "task" with a "/" in the pattern. */ +export function hasModelScope(rules: readonly ModelRule[]): boolean { + return rules.some((rule) => rule.permission === TASK && rule.pattern.includes("/")) +} + +/** Effective state for one provider/model: last matching rule wins, + mirroring Permission.evaluate (default: unconstrained). */ +export function effectiveModelState( + rules: readonly ModelRule[], + providerID: string, + modelID: string, +): "allow" | "deny" | undefined { + const target = `${providerID}/${modelID}` + let state: "allow" | "deny" | undefined + for (const rule of rules) { + if (rule.permission !== TASK && !wildcardMatch(TASK, rule.permission)) continue + if (!wildcardMatch(target, rule.pattern)) continue + if (rule.action === "allow" || rule.action === "deny") state = rule.action + } + return state +} + +/** Rules to APPEND (server merges, last match wins) for toggling one model. + First constraint also writes the star-slash-star deny baseline so unchecked + models stay unavailable instead of falling through to ask. That shape only + matches provider/model patterns — never a bare subagent_type. */ +export function toggleModelRules( + rules: readonly ModelRule[], + providerID: string, + modelID: string, + allow: boolean, +): ModelRule[] { + const out: ModelRule[] = [] + if (allow && !hasModelScope(rules)) { + out.push({ permission: TASK, pattern: "*/*", action: "deny" }) + } + out.push({ permission: TASK, pattern: `${providerID}/${modelID}`, action: allow ? "allow" : "deny" }) + return out +} diff --git a/packages/app/src/i18n/am.ts b/packages/app/src/i18n/am.ts index 91306f45a610..ff2098c3d424 100644 --- a/packages/app/src/i18n/am.ts +++ b/packages/app/src/i18n/am.ts @@ -694,6 +694,10 @@ export const dict = { "session.tab.session": "ክፍለ ጊዜ", "session.tab.review": "ግምገማ", "session.tab.context": "አውድ", + "command.tasks.models": "Subagent models…", + "dialog.subagentModels.title": "Subagent models", + "dialog.subagentModels.description": "Choose which models this session's subagents may use.", + "dialog.subagentModels.hint": "Unchecked models are denied for subagents in this session.", "session.tab.tasks": "Tasks", "session.tasks.subagent": "Subagent", "session.tasks.running": "Running", diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index a689cc86aaef..1e270a73f4b3 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -693,6 +693,10 @@ export const dict = { "session.tab.session": "جلسة", "session.tab.review": "مراجعة", "session.tab.context": "سياق", + "command.tasks.models": "Subagent models…", + "dialog.subagentModels.title": "Subagent models", + "dialog.subagentModels.description": "Choose which models this session's subagents may use.", + "dialog.subagentModels.hint": "Unchecked models are denied for subagents in this session.", "session.tab.tasks": "Tasks", "session.tasks.subagent": "Subagent", "session.tasks.running": "Running", diff --git a/packages/app/src/i18n/az.ts b/packages/app/src/i18n/az.ts index 96aaf8595633..014a53565a8f 100644 --- a/packages/app/src/i18n/az.ts +++ b/packages/app/src/i18n/az.ts @@ -716,6 +716,10 @@ export const dict = { "session.tab.session": "Sessiya", "session.tab.review": "İcmal", "session.tab.context": "Kontekst", + "command.tasks.models": "Subagent models…", + "dialog.subagentModels.title": "Subagent models", + "dialog.subagentModels.description": "Choose which models this session's subagents may use.", + "dialog.subagentModels.hint": "Unchecked models are denied for subagents in this session.", "session.tab.tasks": "Tasks", "session.tasks.subagent": "Subagent", "session.tasks.running": "Running", diff --git a/packages/app/src/i18n/bg.ts b/packages/app/src/i18n/bg.ts index 5bd5dcf20f2a..63721ab8861f 100644 --- a/packages/app/src/i18n/bg.ts +++ b/packages/app/src/i18n/bg.ts @@ -712,6 +712,10 @@ export const dict = { "session.tab.session": "Сесия", "session.tab.review": "Преглед", "session.tab.context": "Контекст", + "command.tasks.models": "Subagent models…", + "dialog.subagentModels.title": "Subagent models", + "dialog.subagentModels.description": "Choose which models this session's subagents may use.", + "dialog.subagentModels.hint": "Unchecked models are denied for subagents in this session.", "session.tab.tasks": "Tasks", "session.tasks.subagent": "Subagent", "session.tasks.running": "Running", diff --git a/packages/app/src/i18n/bn.ts b/packages/app/src/i18n/bn.ts index 3e63d1b12992..8035611c8468 100644 --- a/packages/app/src/i18n/bn.ts +++ b/packages/app/src/i18n/bn.ts @@ -706,6 +706,10 @@ export const dict: Record = { "session.tab.session": "সেশন", "session.tab.review": "পর্যালোচনা", "session.tab.context": "প্রসঙ্গ", + "command.tasks.models": "Subagent models…", + "dialog.subagentModels.title": "Subagent models", + "dialog.subagentModels.description": "Choose which models this session's subagents may use.", + "dialog.subagentModels.hint": "Unchecked models are denied for subagents in this session.", "session.tab.tasks": "Tasks", "session.tasks.subagent": "Subagent", "session.tasks.running": "Running", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index 3998c1f29ef9..5cdd11007963 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -700,6 +700,10 @@ export const dict = { "session.tab.session": "Sessão", "session.tab.review": "Revisão", "session.tab.context": "Contexto", + "command.tasks.models": "Subagent models…", + "dialog.subagentModels.title": "Subagent models", + "dialog.subagentModels.description": "Choose which models this session's subagents may use.", + "dialog.subagentModels.hint": "Unchecked models are denied for subagents in this session.", "session.tab.tasks": "Tasks", "session.tasks.subagent": "Subagent", "session.tasks.running": "Running", diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts index 338db8a3a56b..379271c6a01b 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -753,6 +753,10 @@ export const dict = { "session.tab.session": "Sesija", "session.tab.review": "Pregled", "session.tab.context": "Kontekst", + "command.tasks.models": "Subagent models…", + "dialog.subagentModels.title": "Subagent models", + "dialog.subagentModels.description": "Choose which models this session's subagents may use.", + "dialog.subagentModels.hint": "Unchecked models are denied for subagents in this session.", "session.tab.tasks": "Tasks", "session.tasks.subagent": "Subagent", "session.tasks.running": "Running", diff --git a/packages/app/src/i18n/ca.ts b/packages/app/src/i18n/ca.ts index 8fe75b636e9f..2ad696f8f5eb 100644 --- a/packages/app/src/i18n/ca.ts +++ b/packages/app/src/i18n/ca.ts @@ -712,6 +712,10 @@ export const dict = { "session.tab.session": "Sessió", "session.tab.review": "Revisió", "session.tab.context": "Context", + "command.tasks.models": "Subagent models…", + "dialog.subagentModels.title": "Subagent models", + "dialog.subagentModels.description": "Choose which models this session's subagents may use.", + "dialog.subagentModels.hint": "Unchecked models are denied for subagents in this session.", "session.tab.tasks": "Tasks", "session.tasks.subagent": "Subagent", "session.tasks.running": "Running", diff --git a/packages/app/src/i18n/cs.ts b/packages/app/src/i18n/cs.ts index 9ca043deb481..4fb4b9bcba00 100644 --- a/packages/app/src/i18n/cs.ts +++ b/packages/app/src/i18n/cs.ts @@ -707,6 +707,10 @@ export const dict = { "session.tab.session": "Relace", "session.tab.review": "Recenze", "session.tab.context": "Kontext", + "command.tasks.models": "Subagent models…", + "dialog.subagentModels.title": "Subagent models", + "dialog.subagentModels.description": "Choose which models this session's subagents may use.", + "dialog.subagentModels.hint": "Unchecked models are denied for subagents in this session.", "session.tab.tasks": "Tasks", "session.tasks.subagent": "Subagent", "session.tasks.running": "Running", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index 998472bbbfac..be7faeddf1c6 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -633,6 +633,10 @@ export const dict = { "session.tab.session": "Session", "session.tab.review": "Gennemgang", "session.tab.context": "Kontekst", + "command.tasks.models": "Subagent models…", + "dialog.subagentModels.title": "Subagent models", + "dialog.subagentModels.description": "Choose which models this session's subagents may use.", + "dialog.subagentModels.hint": "Unchecked models are denied for subagents in this session.", "session.tab.tasks": "Tasks", "session.tasks.subagent": "Subagent", "session.tasks.running": "Running", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index 85fbf8ac3ac2..950c82f21daf 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -592,6 +592,10 @@ export const dict = { "session.tab.session": "Sitzung", "session.tab.review": "Überprüfung", "session.tab.context": "Kontext", + "command.tasks.models": "Subagent models…", + "dialog.subagentModels.title": "Subagent models", + "dialog.subagentModels.description": "Choose which models this session's subagents may use.", + "dialog.subagentModels.hint": "Unchecked models are denied for subagents in this session.", "session.tab.tasks": "Tasks", "session.tasks.subagent": "Subagent", "session.tasks.running": "Running", diff --git a/packages/app/src/i18n/dv.ts b/packages/app/src/i18n/dv.ts index 64494db99f0d..8524beb28d52 100644 --- a/packages/app/src/i18n/dv.ts +++ b/packages/app/src/i18n/dv.ts @@ -718,6 +718,10 @@ export const dict = { "session.tab.session": "ސެޝަން", "session.tab.review": "ރިވިއު", "session.tab.context": "ކޮންޓެކްސްޓް", + "command.tasks.models": "Subagent models…", + "dialog.subagentModels.title": "Subagent models", + "dialog.subagentModels.description": "Choose which models this session's subagents may use.", + "dialog.subagentModels.hint": "Unchecked models are denied for subagents in this session.", "session.tab.tasks": "Tasks", "session.tasks.subagent": "Subagent", "session.tasks.running": "Running", diff --git a/packages/app/src/i18n/dz.ts b/packages/app/src/i18n/dz.ts index b9baabddad93..a5baaa9bb703 100644 --- a/packages/app/src/i18n/dz.ts +++ b/packages/app/src/i18n/dz.ts @@ -718,6 +718,10 @@ export const dict: Record = { "session.tab.session": "ལཱ་ཡུན།", "session.tab.review": "བསྐྱར་ཞིབ", "session.tab.context": "སྐབས་དོན།", + "command.tasks.models": "Subagent models…", + "dialog.subagentModels.title": "Subagent models", + "dialog.subagentModels.description": "Choose which models this session's subagents may use.", + "dialog.subagentModels.hint": "Unchecked models are denied for subagents in this session.", "session.tab.tasks": "Tasks", "session.tasks.subagent": "Subagent", "session.tasks.running": "Running", diff --git a/packages/app/src/i18n/el.ts b/packages/app/src/i18n/el.ts index e0d8b82d70c2..63ac0cb02634 100644 --- a/packages/app/src/i18n/el.ts +++ b/packages/app/src/i18n/el.ts @@ -713,6 +713,10 @@ export const dict = { "session.tab.session": "Συνεδρία", "session.tab.review": "Επισκόπηση", "session.tab.context": "Πλαίσιο", + "command.tasks.models": "Subagent models…", + "dialog.subagentModels.title": "Subagent models", + "dialog.subagentModels.description": "Choose which models this session's subagents may use.", + "dialog.subagentModels.hint": "Unchecked models are denied for subagents in this session.", "session.tab.tasks": "Tasks", "session.tasks.subagent": "Subagent", "session.tasks.running": "Running", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 8d54bbe5f5da..5accc9e34b1b 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -691,6 +691,10 @@ export const dict = { "session.tasks.stop": "Stop task", "session.tasks.dismiss": "Dismiss", "command.tasks.toggle": "Toggle tasks panel", + "command.tasks.models": "Subagent models…", + "dialog.subagentModels.title": "Subagent models", + "dialog.subagentModels.description": "Choose which models this session's subagents may use.", + "dialog.subagentModels.hint": "Unchecked models are denied for subagents in this session.", "session.tab.unknown": "Unknown Session", "session.panel.reviewAndFiles": "Review and files", "session.error.notFound": "This session cannot be found", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index bc9848ce72b3..b5e19d447360 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -756,6 +756,10 @@ export const dict = { "session.tab.session": "Sesión", "session.tab.review": "Revisión", "session.tab.context": "Contexto", + "command.tasks.models": "Subagent models…", + "dialog.subagentModels.title": "Subagent models", + "dialog.subagentModels.description": "Choose which models this session's subagents may use.", + "dialog.subagentModels.hint": "Unchecked models are denied for subagents in this session.", "session.tab.tasks": "Tasks", "session.tasks.subagent": "Subagent", "session.tasks.running": "Running", diff --git a/packages/app/src/i18n/et.ts b/packages/app/src/i18n/et.ts index 7d4372a96598..d70caa8cce2b 100644 --- a/packages/app/src/i18n/et.ts +++ b/packages/app/src/i18n/et.ts @@ -702,6 +702,10 @@ export const dict = { "session.tab.session": "Seanss", "session.tab.review": "Ülevaade", "session.tab.context": "Kontekst", + "command.tasks.models": "Subagent models…", + "dialog.subagentModels.title": "Subagent models", + "dialog.subagentModels.description": "Choose which models this session's subagents may use.", + "dialog.subagentModels.hint": "Unchecked models are denied for subagents in this session.", "session.tab.tasks": "Tasks", "session.tasks.subagent": "Subagent", "session.tasks.running": "Running", diff --git a/packages/app/src/i18n/fa.ts b/packages/app/src/i18n/fa.ts index b0d83d9669fa..e264525b45d3 100644 --- a/packages/app/src/i18n/fa.ts +++ b/packages/app/src/i18n/fa.ts @@ -706,6 +706,10 @@ export const dict = { "session.tab.session": "جلسه", "session.tab.review": "بررسی کنید", "session.tab.context": "زمینه", + "command.tasks.models": "Subagent models…", + "dialog.subagentModels.title": "Subagent models", + "dialog.subagentModels.description": "Choose which models this session's subagents may use.", + "dialog.subagentModels.hint": "Unchecked models are denied for subagents in this session.", "session.tab.tasks": "Tasks", "session.tasks.subagent": "Subagent", "session.tasks.running": "Running", diff --git a/packages/app/src/i18n/fi.ts b/packages/app/src/i18n/fi.ts index 694da94336ba..b974d9042f36 100644 --- a/packages/app/src/i18n/fi.ts +++ b/packages/app/src/i18n/fi.ts @@ -604,6 +604,10 @@ export const dict = { "session.tab.session": "Istunto", "session.tab.review": "Tarkistus", "session.tab.context": "Konteksti", + "command.tasks.models": "Subagent models…", + "dialog.subagentModels.title": "Subagent models", + "dialog.subagentModels.description": "Choose which models this session's subagents may use.", + "dialog.subagentModels.hint": "Unchecked models are denied for subagents in this session.", "session.tab.tasks": "Tasks", "session.tasks.subagent": "Subagent", "session.tasks.running": "Running", diff --git a/packages/app/src/i18n/fo.ts b/packages/app/src/i18n/fo.ts index b17d974e9189..eb0d3b664b14 100644 --- a/packages/app/src/i18n/fo.ts +++ b/packages/app/src/i18n/fo.ts @@ -707,6 +707,10 @@ export const dict = { "session.tab.session": "Seta", "session.tab.review": "Ummæli", "session.tab.context": "Samanhangur", + "command.tasks.models": "Subagent models…", + "dialog.subagentModels.title": "Subagent models", + "dialog.subagentModels.description": "Choose which models this session's subagents may use.", + "dialog.subagentModels.hint": "Unchecked models are denied for subagents in this session.", "session.tab.tasks": "Tasks", "session.tasks.subagent": "Subagent", "session.tasks.running": "Running", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index 6134a0fda8fd..c14545114b8a 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -710,6 +710,10 @@ export const dict = { "session.tab.session": "Session", "session.tab.review": "Revue", "session.tab.context": "Contexte", + "command.tasks.models": "Subagent models…", + "dialog.subagentModels.title": "Subagent models", + "dialog.subagentModels.description": "Choose which models this session's subagents may use.", + "dialog.subagentModels.hint": "Unchecked models are denied for subagents in this session.", "session.tab.tasks": "Tasks", "session.tasks.subagent": "Subagent", "session.tasks.running": "Running", diff --git a/packages/app/src/i18n/hi.ts b/packages/app/src/i18n/hi.ts index 5b471ccf997b..011a9081a524 100644 --- a/packages/app/src/i18n/hi.ts +++ b/packages/app/src/i18n/hi.ts @@ -716,6 +716,10 @@ export const dict = { "session.tab.session": "सेशन", "session.tab.review": "समीक्षा", "session.tab.context": "कॉन्टेक्स्ट", + "command.tasks.models": "Subagent models…", + "dialog.subagentModels.title": "Subagent models", + "dialog.subagentModels.description": "Choose which models this session's subagents may use.", + "dialog.subagentModels.hint": "Unchecked models are denied for subagents in this session.", "session.tab.tasks": "Tasks", "session.tasks.subagent": "Subagent", "session.tasks.running": "Running", diff --git a/packages/app/src/i18n/hr.ts b/packages/app/src/i18n/hr.ts index cf4f1b8e893c..74c0b0312cf2 100644 --- a/packages/app/src/i18n/hr.ts +++ b/packages/app/src/i18n/hr.ts @@ -714,6 +714,10 @@ export const dict = { "session.tab.session": "Sesija", "session.tab.review": "Pregled", "session.tab.context": "Kontekst", + "command.tasks.models": "Subagent models…", + "dialog.subagentModels.title": "Subagent models", + "dialog.subagentModels.description": "Choose which models this session's subagents may use.", + "dialog.subagentModels.hint": "Unchecked models are denied for subagents in this session.", "session.tab.tasks": "Tasks", "session.tasks.subagent": "Subagent", "session.tasks.running": "Running", diff --git a/packages/app/src/i18n/hu.ts b/packages/app/src/i18n/hu.ts index f844295f107c..5549e7efa5c7 100644 --- a/packages/app/src/i18n/hu.ts +++ b/packages/app/src/i18n/hu.ts @@ -712,6 +712,10 @@ export const dict = { "session.tab.session": "Munkamenet", "session.tab.review": "Tekintse át", "session.tab.context": "Kontextus", + "command.tasks.models": "Subagent models…", + "dialog.subagentModels.title": "Subagent models", + "dialog.subagentModels.description": "Choose which models this session's subagents may use.", + "dialog.subagentModels.hint": "Unchecked models are denied for subagents in this session.", "session.tab.tasks": "Tasks", "session.tasks.subagent": "Subagent", "session.tasks.running": "Running", diff --git a/packages/app/src/i18n/hy.ts b/packages/app/src/i18n/hy.ts index b6e538d31153..076461d79889 100644 --- a/packages/app/src/i18n/hy.ts +++ b/packages/app/src/i18n/hy.ts @@ -711,6 +711,10 @@ export const dict = { "session.tab.session": "Նիստ", "session.tab.review": "Վերանայել", "session.tab.context": "Համատեքստ", + "command.tasks.models": "Subagent models…", + "dialog.subagentModels.title": "Subagent models", + "dialog.subagentModels.description": "Choose which models this session's subagents may use.", + "dialog.subagentModels.hint": "Unchecked models are denied for subagents in this session.", "session.tab.tasks": "Tasks", "session.tasks.subagent": "Subagent", "session.tasks.running": "Running", diff --git a/packages/app/src/i18n/id.ts b/packages/app/src/i18n/id.ts index 7044ba0fc6c4..9c28446ac5eb 100644 --- a/packages/app/src/i18n/id.ts +++ b/packages/app/src/i18n/id.ts @@ -771,6 +771,10 @@ export const dict = { "session.tab.session": "Sesi", "session.tab.review": "Tinjauan", "session.tab.context": "Konteks", + "command.tasks.models": "Subagent models…", + "dialog.subagentModels.title": "Subagent models", + "dialog.subagentModels.description": "Choose which models this session's subagents may use.", + "dialog.subagentModels.hint": "Unchecked models are denied for subagents in this session.", "session.tab.tasks": "Tasks", "session.tasks.subagent": "Subagent", "session.tasks.running": "Running", diff --git a/packages/app/src/i18n/is.ts b/packages/app/src/i18n/is.ts index 14b2536120de..e114867f757b 100644 --- a/packages/app/src/i18n/is.ts +++ b/packages/app/src/i18n/is.ts @@ -709,6 +709,10 @@ export const dict = { "session.tab.session": "Lota", "session.tab.review": "Upprifjun", "session.tab.context": "Samhengi", + "command.tasks.models": "Subagent models…", + "dialog.subagentModels.title": "Subagent models", + "dialog.subagentModels.description": "Choose which models this session's subagents may use.", + "dialog.subagentModels.hint": "Unchecked models are denied for subagents in this session.", "session.tab.tasks": "Tasks", "session.tasks.subagent": "Subagent", "session.tasks.running": "Running", diff --git a/packages/app/src/i18n/it.ts b/packages/app/src/i18n/it.ts index b4f42300c6c7..bb581d47f3e7 100644 --- a/packages/app/src/i18n/it.ts +++ b/packages/app/src/i18n/it.ts @@ -622,6 +622,10 @@ export const dict = { "session.tab.session": "Sessione", "session.tab.review": "Revisione", "session.tab.context": "Contesto", + "command.tasks.models": "Subagent models…", + "dialog.subagentModels.title": "Subagent models", + "dialog.subagentModels.description": "Choose which models this session's subagents may use.", + "dialog.subagentModels.hint": "Unchecked models are denied for subagents in this session.", "session.tab.tasks": "Tasks", "session.tasks.subagent": "Subagent", "session.tasks.running": "Running", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index 02abbf1c9b30..c83f9e83bbd9 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -696,6 +696,10 @@ export const dict = { "session.tab.session": "セッション", "session.tab.review": "レビュー", "session.tab.context": "コンテキスト", + "command.tasks.models": "Subagent models…", + "dialog.subagentModels.title": "Subagent models", + "dialog.subagentModels.description": "Choose which models this session's subagents may use.", + "dialog.subagentModels.hint": "Unchecked models are denied for subagents in this session.", "session.tab.tasks": "Tasks", "session.tasks.subagent": "Subagent", "session.tasks.running": "Running", diff --git a/packages/app/src/i18n/ka.ts b/packages/app/src/i18n/ka.ts index 24e3acce884f..ab1265f85a31 100644 --- a/packages/app/src/i18n/ka.ts +++ b/packages/app/src/i18n/ka.ts @@ -707,6 +707,10 @@ export const dict = { "session.tab.session": "სესია", "session.tab.review": "მიმოხილვა", "session.tab.context": "კონტექსტი", + "command.tasks.models": "Subagent models…", + "dialog.subagentModels.title": "Subagent models", + "dialog.subagentModels.description": "Choose which models this session's subagents may use.", + "dialog.subagentModels.hint": "Unchecked models are denied for subagents in this session.", "session.tab.tasks": "Tasks", "session.tasks.subagent": "Subagent", "session.tasks.running": "Running", diff --git a/packages/app/src/i18n/km.ts b/packages/app/src/i18n/km.ts index 575c7dacf43e..ea734c4bee67 100644 --- a/packages/app/src/i18n/km.ts +++ b/packages/app/src/i18n/km.ts @@ -704,6 +704,10 @@ export const dict = { "session.tab.session": "សម័យ", "session.tab.review": "ពិនិត្យ", "session.tab.context": "បរិបទ", + "command.tasks.models": "Subagent models…", + "dialog.subagentModels.title": "Subagent models", + "dialog.subagentModels.description": "Choose which models this session's subagents may use.", + "dialog.subagentModels.hint": "Unchecked models are denied for subagents in this session.", "session.tab.tasks": "Tasks", "session.tasks.subagent": "Subagent", "session.tasks.running": "Running", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index d662a9cb62d5..c1cc7828d3e5 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -479,6 +479,10 @@ export const dict = { "session.tab.session": "세션", "session.tab.review": "검토", "session.tab.context": "컨텍스트", + "command.tasks.models": "Subagent models…", + "dialog.subagentModels.title": "Subagent models", + "dialog.subagentModels.description": "Choose which models this session's subagents may use.", + "dialog.subagentModels.hint": "Unchecked models are denied for subagents in this session.", "session.tab.tasks": "Tasks", "session.tasks.subagent": "Subagent", "session.tasks.running": "Running", diff --git a/packages/app/src/i18n/lo.ts b/packages/app/src/i18n/lo.ts index 1e890abf468e..e6aefe9fbf9f 100644 --- a/packages/app/src/i18n/lo.ts +++ b/packages/app/src/i18n/lo.ts @@ -702,6 +702,10 @@ export const dict = { "session.tab.session": "ເຊດຊັນ", "session.tab.review": "ການທົບທວນຄືນ", "session.tab.context": "ບໍລິບົດ", + "command.tasks.models": "Subagent models…", + "dialog.subagentModels.title": "Subagent models", + "dialog.subagentModels.description": "Choose which models this session's subagents may use.", + "dialog.subagentModels.hint": "Unchecked models are denied for subagents in this session.", "session.tab.tasks": "Tasks", "session.tasks.subagent": "Subagent", "session.tasks.running": "Running", diff --git a/packages/app/src/i18n/lt.ts b/packages/app/src/i18n/lt.ts index 723c0245bf85..5b3481c24801 100644 --- a/packages/app/src/i18n/lt.ts +++ b/packages/app/src/i18n/lt.ts @@ -713,6 +713,10 @@ export const dict = { "session.tab.session": "Seansas", "session.tab.review": "Apžvalga", "session.tab.context": "Kontekstas", + "command.tasks.models": "Subagent models…", + "dialog.subagentModels.title": "Subagent models", + "dialog.subagentModels.description": "Choose which models this session's subagents may use.", + "dialog.subagentModels.hint": "Unchecked models are denied for subagents in this session.", "session.tab.tasks": "Tasks", "session.tasks.subagent": "Subagent", "session.tasks.running": "Running", diff --git a/packages/app/src/i18n/lv.ts b/packages/app/src/i18n/lv.ts index f932a2e539bb..c85039eb95a7 100644 --- a/packages/app/src/i18n/lv.ts +++ b/packages/app/src/i18n/lv.ts @@ -708,6 +708,10 @@ export const dict = { "session.tab.session": "Sesija", "session.tab.review": "Pārskats", "session.tab.context": "Konteksts", + "command.tasks.models": "Subagent models…", + "dialog.subagentModels.title": "Subagent models", + "dialog.subagentModels.description": "Choose which models this session's subagents may use.", + "dialog.subagentModels.hint": "Unchecked models are denied for subagents in this session.", "session.tab.tasks": "Tasks", "session.tasks.subagent": "Subagent", "session.tasks.running": "Running", diff --git a/packages/app/src/i18n/mk.ts b/packages/app/src/i18n/mk.ts index 586d743a0e57..aa4c333e73b7 100644 --- a/packages/app/src/i18n/mk.ts +++ b/packages/app/src/i18n/mk.ts @@ -710,6 +710,10 @@ export const dict = { "session.tab.session": "Сесија", "session.tab.review": "Преглед", "session.tab.context": "Контекст", + "command.tasks.models": "Subagent models…", + "dialog.subagentModels.title": "Subagent models", + "dialog.subagentModels.description": "Choose which models this session's subagents may use.", + "dialog.subagentModels.hint": "Unchecked models are denied for subagents in this session.", "session.tab.tasks": "Tasks", "session.tasks.subagent": "Subagent", "session.tasks.running": "Running", diff --git a/packages/app/src/i18n/mn.ts b/packages/app/src/i18n/mn.ts index 0fb0114e58b6..d8dabd1e2033 100644 --- a/packages/app/src/i18n/mn.ts +++ b/packages/app/src/i18n/mn.ts @@ -712,6 +712,10 @@ export const dict = { "session.tab.session": "Сесс", "session.tab.review": "Хяналт", "session.tab.context": "Контекст", + "command.tasks.models": "Subagent models…", + "dialog.subagentModels.title": "Subagent models", + "dialog.subagentModels.description": "Choose which models this session's subagents may use.", + "dialog.subagentModels.hint": "Unchecked models are denied for subagents in this session.", "session.tab.tasks": "Tasks", "session.tasks.subagent": "Subagent", "session.tasks.running": "Running", diff --git a/packages/app/src/i18n/ms.ts b/packages/app/src/i18n/ms.ts index 2ad8d63e41de..5c9bfe438443 100644 --- a/packages/app/src/i18n/ms.ts +++ b/packages/app/src/i18n/ms.ts @@ -705,6 +705,10 @@ export const dict = { "session.tab.session": "Sesi", "session.tab.review": "Semak", "session.tab.context": "Konteks", + "command.tasks.models": "Subagent models…", + "dialog.subagentModels.title": "Subagent models", + "dialog.subagentModels.description": "Choose which models this session's subagents may use.", + "dialog.subagentModels.hint": "Unchecked models are denied for subagents in this session.", "session.tab.tasks": "Tasks", "session.tasks.subagent": "Subagent", "session.tasks.running": "Running", diff --git a/packages/app/src/i18n/my.ts b/packages/app/src/i18n/my.ts index 1d3ebe7be41f..c21f46db0c49 100644 --- a/packages/app/src/i18n/my.ts +++ b/packages/app/src/i18n/my.ts @@ -714,6 +714,10 @@ export const dict = { "session.tab.session": "အပိုင်း", "session.tab.review": "သုံးသပ်ချက်", "session.tab.context": "ဆက်စပ်အကြောင်းအရာ", + "command.tasks.models": "Subagent models…", + "dialog.subagentModels.title": "Subagent models", + "dialog.subagentModels.description": "Choose which models this session's subagents may use.", + "dialog.subagentModels.hint": "Unchecked models are denied for subagents in this session.", "session.tab.tasks": "Tasks", "session.tasks.subagent": "Subagent", "session.tasks.running": "Running", diff --git a/packages/app/src/i18n/ne.ts b/packages/app/src/i18n/ne.ts index 3ff9037933ed..ff6f45ff8d33 100644 --- a/packages/app/src/i18n/ne.ts +++ b/packages/app/src/i18n/ne.ts @@ -707,6 +707,10 @@ export const dict: Record = { "session.tab.session": "सत्र", "session.tab.review": "समीक्षा गर्नुहोस्", "session.tab.context": "सन्दर्भ", + "command.tasks.models": "Subagent models…", + "dialog.subagentModels.title": "Subagent models", + "dialog.subagentModels.description": "Choose which models this session's subagents may use.", + "dialog.subagentModels.hint": "Unchecked models are denied for subagents in this session.", "session.tab.tasks": "Tasks", "session.tasks.subagent": "Subagent", "session.tasks.running": "Running", diff --git a/packages/app/src/i18n/nl.ts b/packages/app/src/i18n/nl.ts index 562240dfe2d6..f6eb553778e8 100644 --- a/packages/app/src/i18n/nl.ts +++ b/packages/app/src/i18n/nl.ts @@ -714,6 +714,10 @@ export const dict = { "session.tab.session": "Sessie", "session.tab.review": "Beoordeling", "session.tab.context": "Context", + "command.tasks.models": "Subagent models…", + "dialog.subagentModels.title": "Subagent models", + "dialog.subagentModels.description": "Choose which models this session's subagents may use.", + "dialog.subagentModels.hint": "Unchecked models are denied for subagents in this session.", "session.tab.tasks": "Tasks", "session.tasks.subagent": "Subagent", "session.tasks.running": "Running", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index 5d52045d8568..c3a3a5f5b9fa 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -638,6 +638,10 @@ export const dict = { "session.tab.session": "Sesjon", "session.tab.review": "Gjennomgang", "session.tab.context": "Kontekst", + "command.tasks.models": "Subagent models…", + "dialog.subagentModels.title": "Subagent models", + "dialog.subagentModels.description": "Choose which models this session's subagents may use.", + "dialog.subagentModels.hint": "Unchecked models are denied for subagents in this session.", "session.tab.tasks": "Tasks", "session.tasks.subagent": "Subagent", "session.tasks.running": "Running", diff --git a/packages/app/src/i18n/pa.ts b/packages/app/src/i18n/pa.ts index 16a59ab2d184..8cbce2b923b1 100644 --- a/packages/app/src/i18n/pa.ts +++ b/packages/app/src/i18n/pa.ts @@ -716,6 +716,10 @@ export const dict = { "session.tab.session": "سیشن", "session.tab.review": "جائزہ", "session.tab.context": "تناظر", + "command.tasks.models": "Subagent models…", + "dialog.subagentModels.title": "Subagent models", + "dialog.subagentModels.description": "Choose which models this session's subagents may use.", + "dialog.subagentModels.hint": "Unchecked models are denied for subagents in this session.", "session.tab.tasks": "Tasks", "session.tasks.subagent": "Subagent", "session.tasks.running": "Running", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index 631b2ea8c686..5d506ee8aaee 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -700,6 +700,10 @@ export const dict = { "session.tab.session": "Sesja", "session.tab.review": "Przegląd", "session.tab.context": "Kontekst", + "command.tasks.models": "Subagent models…", + "dialog.subagentModels.title": "Subagent models", + "dialog.subagentModels.description": "Choose which models this session's subagents may use.", + "dialog.subagentModels.hint": "Unchecked models are denied for subagents in this session.", "session.tab.tasks": "Tasks", "session.tasks.subagent": "Subagent", "session.tasks.running": "Running", diff --git a/packages/app/src/i18n/ro.ts b/packages/app/src/i18n/ro.ts index 633f54cd10b1..392de074af01 100644 --- a/packages/app/src/i18n/ro.ts +++ b/packages/app/src/i18n/ro.ts @@ -707,6 +707,10 @@ export const dict = { "session.tab.session": "Sesiune", "session.tab.review": "Revizuire", "session.tab.context": "Context", + "command.tasks.models": "Subagent models…", + "dialog.subagentModels.title": "Subagent models", + "dialog.subagentModels.description": "Choose which models this session's subagents may use.", + "dialog.subagentModels.hint": "Unchecked models are denied for subagents in this session.", "session.tab.tasks": "Tasks", "session.tasks.subagent": "Subagent", "session.tasks.running": "Running", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index e6a5eef76480..0da675879bb3 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -753,6 +753,10 @@ export const dict = { "session.tab.session": "Сессия", "session.tab.review": "Обзор", "session.tab.context": "Контекст", + "command.tasks.models": "Subagent models…", + "dialog.subagentModels.title": "Subagent models", + "dialog.subagentModels.description": "Choose which models this session's subagents may use.", + "dialog.subagentModels.hint": "Unchecked models are denied for subagents in this session.", "session.tab.tasks": "Tasks", "session.tasks.subagent": "Subagent", "session.tasks.running": "Running", diff --git a/packages/app/src/i18n/si.ts b/packages/app/src/i18n/si.ts index 8ee3540b3fbb..404b76221bfe 100644 --- a/packages/app/src/i18n/si.ts +++ b/packages/app/src/i18n/si.ts @@ -704,6 +704,10 @@ export const dict: Record = { "session.tab.session": "සැසිය", "session.tab.review": "සමාලෝචනය කරන්න", "session.tab.context": "සන්දර්භය", + "command.tasks.models": "Subagent models…", + "dialog.subagentModels.title": "Subagent models", + "dialog.subagentModels.description": "Choose which models this session's subagents may use.", + "dialog.subagentModels.hint": "Unchecked models are denied for subagents in this session.", "session.tab.tasks": "Tasks", "session.tasks.subagent": "Subagent", "session.tasks.running": "Running", diff --git a/packages/app/src/i18n/sk.ts b/packages/app/src/i18n/sk.ts index 0ac8f609c5b8..83c1c76d64c5 100644 --- a/packages/app/src/i18n/sk.ts +++ b/packages/app/src/i18n/sk.ts @@ -705,6 +705,10 @@ export const dict = { "session.tab.session": "Relácia", "session.tab.review": "Revízia", "session.tab.context": "Kontext", + "command.tasks.models": "Subagent models…", + "dialog.subagentModels.title": "Subagent models", + "dialog.subagentModels.description": "Choose which models this session's subagents may use.", + "dialog.subagentModels.hint": "Unchecked models are denied for subagents in this session.", "session.tab.tasks": "Tasks", "session.tasks.subagent": "Subagent", "session.tasks.running": "Running", diff --git a/packages/app/src/i18n/sl.ts b/packages/app/src/i18n/sl.ts index 9303bf441bef..ad8217748b07 100644 --- a/packages/app/src/i18n/sl.ts +++ b/packages/app/src/i18n/sl.ts @@ -707,6 +707,10 @@ export const dict = { "session.tab.session": "Seja", "session.tab.review": "Pregled", "session.tab.context": "Kontekst", + "command.tasks.models": "Subagent models…", + "dialog.subagentModels.title": "Subagent models", + "dialog.subagentModels.description": "Choose which models this session's subagents may use.", + "dialog.subagentModels.hint": "Unchecked models are denied for subagents in this session.", "session.tab.tasks": "Tasks", "session.tasks.subagent": "Subagent", "session.tasks.running": "Running", diff --git a/packages/app/src/i18n/sq.ts b/packages/app/src/i18n/sq.ts index 2d49bffc52b4..b722197f5766 100644 --- a/packages/app/src/i18n/sq.ts +++ b/packages/app/src/i18n/sq.ts @@ -709,6 +709,10 @@ export const dict = { "session.tab.session": "Sesioni", "session.tab.review": "Rishikimi", "session.tab.context": "Konteksti", + "command.tasks.models": "Subagent models…", + "dialog.subagentModels.title": "Subagent models", + "dialog.subagentModels.description": "Choose which models this session's subagents may use.", + "dialog.subagentModels.hint": "Unchecked models are denied for subagents in this session.", "session.tab.tasks": "Tasks", "session.tasks.subagent": "Subagent", "session.tasks.running": "Running", diff --git a/packages/app/src/i18n/sr.ts b/packages/app/src/i18n/sr.ts index 3ad0270915a5..bf37b207dc34 100644 --- a/packages/app/src/i18n/sr.ts +++ b/packages/app/src/i18n/sr.ts @@ -708,6 +708,10 @@ export const dict = { "session.tab.session": "Сесија", "session.tab.review": "Преглед", "session.tab.context": "Контекст", + "command.tasks.models": "Subagent models…", + "dialog.subagentModels.title": "Subagent models", + "dialog.subagentModels.description": "Choose which models this session's subagents may use.", + "dialog.subagentModels.hint": "Unchecked models are denied for subagents in this session.", "session.tab.tasks": "Tasks", "session.tasks.subagent": "Subagent", "session.tasks.running": "Running", diff --git a/packages/app/src/i18n/sv.ts b/packages/app/src/i18n/sv.ts index 577afd678056..0a8ae100d014 100644 --- a/packages/app/src/i18n/sv.ts +++ b/packages/app/src/i18n/sv.ts @@ -711,6 +711,10 @@ export const dict = { "session.tab.session": "Session", "session.tab.review": "Granskning", "session.tab.context": "Kontext", + "command.tasks.models": "Subagent models…", + "dialog.subagentModels.title": "Subagent models", + "dialog.subagentModels.description": "Choose which models this session's subagents may use.", + "dialog.subagentModels.hint": "Unchecked models are denied for subagents in this session.", "session.tab.tasks": "Tasks", "session.tasks.subagent": "Subagent", "session.tasks.running": "Running", diff --git a/packages/app/src/i18n/tg.ts b/packages/app/src/i18n/tg.ts index 9c6d48706b6c..f027a3008b7e 100644 --- a/packages/app/src/i18n/tg.ts +++ b/packages/app/src/i18n/tg.ts @@ -709,6 +709,10 @@ export const dict = { "session.tab.session": "Сессия", "session.tab.review": "Барраси", "session.tab.context": "Контекст", + "command.tasks.models": "Subagent models…", + "dialog.subagentModels.title": "Subagent models", + "dialog.subagentModels.description": "Choose which models this session's subagents may use.", + "dialog.subagentModels.hint": "Unchecked models are denied for subagents in this session.", "session.tab.tasks": "Tasks", "session.tasks.subagent": "Subagent", "session.tasks.running": "Running", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index c602ecabcfc2..8359f5f8a53e 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -745,6 +745,10 @@ export const dict = { "session.tab.session": "เซสชัน", "session.tab.review": "ตรวจสอบ", "session.tab.context": "บริบท", + "command.tasks.models": "Subagent models…", + "dialog.subagentModels.title": "Subagent models", + "dialog.subagentModels.description": "Choose which models this session's subagents may use.", + "dialog.subagentModels.hint": "Unchecked models are denied for subagents in this session.", "session.tab.tasks": "Tasks", "session.tasks.subagent": "Subagent", "session.tasks.running": "Running", diff --git a/packages/app/src/i18n/tk.ts b/packages/app/src/i18n/tk.ts index 5c505a13d0f7..8e75dde404ff 100644 --- a/packages/app/src/i18n/tk.ts +++ b/packages/app/src/i18n/tk.ts @@ -706,6 +706,10 @@ export const dict = { "session.tab.session": "Sessiýa", "session.tab.review": "Syn", "session.tab.context": "Kontekst", + "command.tasks.models": "Subagent models…", + "dialog.subagentModels.title": "Subagent models", + "dialog.subagentModels.description": "Choose which models this session's subagents may use.", + "dialog.subagentModels.hint": "Unchecked models are denied for subagents in this session.", "session.tab.tasks": "Tasks", "session.tasks.subagent": "Subagent", "session.tasks.running": "Running", diff --git a/packages/app/src/i18n/tr.ts b/packages/app/src/i18n/tr.ts index a9d2699ded35..79757d917444 100644 --- a/packages/app/src/i18n/tr.ts +++ b/packages/app/src/i18n/tr.ts @@ -757,6 +757,10 @@ export const dict = { "session.tab.session": "Oturum", "session.tab.review": "İnceleme", "session.tab.context": "Bağlam", + "command.tasks.models": "Subagent models…", + "dialog.subagentModels.title": "Subagent models", + "dialog.subagentModels.description": "Choose which models this session's subagents may use.", + "dialog.subagentModels.hint": "Unchecked models are denied for subagents in this session.", "session.tab.tasks": "Tasks", "session.tasks.subagent": "Subagent", "session.tasks.running": "Running", diff --git a/packages/app/src/i18n/uk.ts b/packages/app/src/i18n/uk.ts index 09ac7823ba57..cc748b3f2582 100644 --- a/packages/app/src/i18n/uk.ts +++ b/packages/app/src/i18n/uk.ts @@ -776,6 +776,10 @@ export const dict = { "session.tab.session": "Сесія", "session.tab.review": "Огляд", "session.tab.context": "Контекст", + "command.tasks.models": "Subagent models…", + "dialog.subagentModels.title": "Subagent models", + "dialog.subagentModels.description": "Choose which models this session's subagents may use.", + "dialog.subagentModels.hint": "Unchecked models are denied for subagents in this session.", "session.tab.tasks": "Tasks", "session.tasks.subagent": "Subagent", "session.tasks.running": "Running", diff --git a/packages/app/src/i18n/ur.ts b/packages/app/src/i18n/ur.ts index 9909433bd545..97ae173931db 100644 --- a/packages/app/src/i18n/ur.ts +++ b/packages/app/src/i18n/ur.ts @@ -717,6 +717,10 @@ export const dict = { "session.tab.session": "سیشن", "session.tab.review": "جائزہ لیں", "session.tab.context": "سیاق و سباق", + "command.tasks.models": "Subagent models…", + "dialog.subagentModels.title": "Subagent models", + "dialog.subagentModels.description": "Choose which models this session's subagents may use.", + "dialog.subagentModels.hint": "Unchecked models are denied for subagents in this session.", "session.tab.tasks": "Tasks", "session.tasks.subagent": "Subagent", "session.tasks.running": "Running", diff --git a/packages/app/src/i18n/uz.ts b/packages/app/src/i18n/uz.ts index 453a9f8bff32..e99349f6b852 100644 --- a/packages/app/src/i18n/uz.ts +++ b/packages/app/src/i18n/uz.ts @@ -710,6 +710,10 @@ export const dict = { "session.tab.session": "Sessiya", "session.tab.review": "Koʻrib chiqish", "session.tab.context": "Kontekst", + "command.tasks.models": "Subagent models…", + "dialog.subagentModels.title": "Subagent models", + "dialog.subagentModels.description": "Choose which models this session's subagents may use.", + "dialog.subagentModels.hint": "Unchecked models are denied for subagents in this session.", "session.tab.tasks": "Tasks", "session.tasks.subagent": "Subagent", "session.tasks.running": "Running", diff --git a/packages/app/src/i18n/vi.ts b/packages/app/src/i18n/vi.ts index 75a124925d3e..c4bd48c7c72d 100644 --- a/packages/app/src/i18n/vi.ts +++ b/packages/app/src/i18n/vi.ts @@ -716,6 +716,10 @@ export const dict = { "session.tab.session": "Phiên", "session.tab.review": "Xem lại", "session.tab.context": "Ngữ cảnh", + "command.tasks.models": "Subagent models…", + "dialog.subagentModels.title": "Subagent models", + "dialog.subagentModels.description": "Choose which models this session's subagents may use.", + "dialog.subagentModels.hint": "Unchecked models are denied for subagents in this session.", "session.tab.tasks": "Tasks", "session.tasks.subagent": "Subagent", "session.tasks.running": "Running", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index 42fd4be6eee3..2d6da990814f 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -746,6 +746,10 @@ export const dict = { "session.tab.session": "会话", "session.tab.review": "审查", "session.tab.context": "上下文", + "command.tasks.models": "Subagent models…", + "dialog.subagentModels.title": "Subagent models", + "dialog.subagentModels.description": "Choose which models this session's subagents may use.", + "dialog.subagentModels.hint": "Unchecked models are denied for subagents in this session.", "session.tab.tasks": "Tasks", "session.tasks.subagent": "Subagent", "session.tasks.running": "Running", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index db06309e9f5a..e4ef5c46f4be 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -741,6 +741,10 @@ export const dict = { "session.tab.session": "工作階段", "session.tab.review": "檢閱", "session.tab.context": "上下文", + "command.tasks.models": "Subagent models…", + "dialog.subagentModels.title": "Subagent models", + "dialog.subagentModels.description": "Choose which models this session's subagents may use.", + "dialog.subagentModels.hint": "Unchecked models are denied for subagents in this session.", "session.tab.tasks": "Tasks", "session.tasks.subagent": "Subagent", "session.tasks.running": "Running", diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index f73fe1b16b8c..2a7581b4105c 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -578,6 +578,19 @@ export const useSessionCommands = (actions: SessionCommandContext) => { view().reviewPanel.open() }, }), + viewCommand({ + id: "tasks.models", + title: language.t("command.tasks.models"), + onSelect: () => { + const sessionID = params.id + const directory = sdk().directory + if (!sessionID || !directory) return + void openDialog( + () => import("@/components/dialog-subagent-models"), + (x) => dialog.show(() => ), + ) + }, + }), ...(shown() ? [ viewCommand({ From abae400c2dceb8c95595dcb0ae01b62ede5bf55c Mon Sep 17 00:00:00 2001 From: Gustavo Schneiter Date: Fri, 4 Sep 2026 01:15:55 -0300 Subject: [PATCH 011/117] feat(app): subagent-models control in composer toolbar Adds the subagent model picker next to the session model and reasoning selectors: session model, reasoning level (when present), subagent models. New subagentModelsControl slot in session-ui PromptInput; app-side control mirrors the model button styling and opens DialogSubagentModels. --- .../app/src/components/prompt-input-v2.tsx | 60 +++++++++++++++++++ packages/app/src/i18n/am.ts | 2 + packages/app/src/i18n/ar.ts | 2 + packages/app/src/i18n/az.ts | 2 + packages/app/src/i18n/bg.ts | 2 + packages/app/src/i18n/bn.ts | 2 + packages/app/src/i18n/br.ts | 2 + packages/app/src/i18n/bs.ts | 2 + packages/app/src/i18n/ca.ts | 2 + packages/app/src/i18n/cs.ts | 2 + packages/app/src/i18n/da.ts | 2 + packages/app/src/i18n/de.ts | 2 + packages/app/src/i18n/dv.ts | 2 + packages/app/src/i18n/dz.ts | 2 + packages/app/src/i18n/el.ts | 2 + packages/app/src/i18n/en.ts | 2 + packages/app/src/i18n/es.ts | 2 + packages/app/src/i18n/et.ts | 2 + packages/app/src/i18n/fa.ts | 2 + packages/app/src/i18n/fi.ts | 2 + packages/app/src/i18n/fo.ts | 2 + packages/app/src/i18n/fr.ts | 2 + packages/app/src/i18n/hi.ts | 2 + packages/app/src/i18n/hr.ts | 2 + packages/app/src/i18n/hu.ts | 2 + packages/app/src/i18n/hy.ts | 2 + packages/app/src/i18n/id.ts | 2 + packages/app/src/i18n/is.ts | 2 + packages/app/src/i18n/it.ts | 2 + packages/app/src/i18n/ja.ts | 2 + packages/app/src/i18n/ka.ts | 2 + packages/app/src/i18n/km.ts | 2 + packages/app/src/i18n/ko.ts | 2 + packages/app/src/i18n/lo.ts | 2 + packages/app/src/i18n/lt.ts | 2 + packages/app/src/i18n/lv.ts | 2 + packages/app/src/i18n/mk.ts | 2 + packages/app/src/i18n/mn.ts | 2 + packages/app/src/i18n/ms.ts | 2 + packages/app/src/i18n/my.ts | 2 + packages/app/src/i18n/ne.ts | 2 + packages/app/src/i18n/nl.ts | 2 + packages/app/src/i18n/no.ts | 2 + packages/app/src/i18n/pa.ts | 2 + packages/app/src/i18n/pl.ts | 2 + packages/app/src/i18n/ro.ts | 2 + packages/app/src/i18n/ru.ts | 2 + packages/app/src/i18n/si.ts | 2 + packages/app/src/i18n/sk.ts | 2 + packages/app/src/i18n/sl.ts | 2 + packages/app/src/i18n/sq.ts | 2 + packages/app/src/i18n/sr.ts | 2 + packages/app/src/i18n/sv.ts | 2 + packages/app/src/i18n/tg.ts | 2 + packages/app/src/i18n/th.ts | 2 + packages/app/src/i18n/tk.ts | 2 + packages/app/src/i18n/tr.ts | 2 + packages/app/src/i18n/uk.ts | 2 + packages/app/src/i18n/ur.ts | 2 + packages/app/src/i18n/uz.ts | 2 + packages/app/src/i18n/vi.ts | 2 + packages/app/src/i18n/zh.ts | 2 + packages/app/src/i18n/zht.ts | 2 + .../src/v2/components/prompt-input/index.tsx | 2 + 64 files changed, 186 insertions(+) diff --git a/packages/app/src/components/prompt-input-v2.tsx b/packages/app/src/components/prompt-input-v2.tsx index 481b7922cf36..47e4fe89dee8 100644 --- a/packages/app/src/components/prompt-input-v2.tsx +++ b/packages/app/src/components/prompt-input-v2.tsx @@ -24,6 +24,9 @@ import { type ImageAttachmentPart, usePrompt } from "@/context/prompt" import { usePlatform } from "@/context/platform" import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" +import { useLocal } from "@/context/local" +import { useParams } from "@solidjs/router" +import { effectiveModelState, hasModelScope } from "@/components/subagent-model-rules" import { createSessionTabs } from "@/pages/session/helpers" import { showToast } from "@/utils/toast" import { PromptInputV2, type PromptInputV2Suggestion } from "@opencode-ai/session-ui/v2/prompt-input" @@ -58,6 +61,7 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) { variantControlVisible={!props.controller.model.loading} attachKeybind={command.keybindParts("file.attach")} attachShortcut={command.keybind("file.attach")} + subagentModelsControl={} modelControl={ { + const id = params.id + if (!id) return [] + const permission = sync().session.get(id)?.permission + return Array.isArray(permission) ? permission : [] + } + const label = () => { + const scoped = hasModelScope(rules()) + if (!scoped) return language.t("session.tasks.models.all") + const models = local.model + .list() + .filter((m) => local.model.visible({ modelID: m.id, providerID: m.provider.id })) + const allowed = models.filter( + (m) => effectiveModelState(rules(), m.provider.id, m.id) === "allow", + ).length + return `${allowed}` + } + const open = () => { + const sessionID = params.id + const directory = sdk().directory + if (!sessionID || !directory) return + void import("@/components/dialog-subagent-models").then((x) => { + dialog.show(() => ) + }) + } + + return ( + {language.t("dialog.subagentModels.title")}}> + + + {language.t("session.tasks.models.label", { count: label() })} + + + + + + + ) +} + function PromptInputV2ModelControl(props: { loading: boolean paid: boolean diff --git a/packages/app/src/i18n/am.ts b/packages/app/src/i18n/am.ts index ff2098c3d424..19097a6a5481 100644 --- a/packages/app/src/i18n/am.ts +++ b/packages/app/src/i18n/am.ts @@ -694,6 +694,8 @@ export const dict = { "session.tab.session": "ክፍለ ጊዜ", "session.tab.review": "ግምገማ", "session.tab.context": "አውድ", + "session.tasks.models.label": "Subagents · {{count}}", + "session.tasks.models.all": "All", "command.tasks.models": "Subagent models…", "dialog.subagentModels.title": "Subagent models", "dialog.subagentModels.description": "Choose which models this session's subagents may use.", diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index 1e270a73f4b3..bc3d2016bb93 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -693,6 +693,8 @@ export const dict = { "session.tab.session": "جلسة", "session.tab.review": "مراجعة", "session.tab.context": "سياق", + "session.tasks.models.label": "Subagents · {{count}}", + "session.tasks.models.all": "All", "command.tasks.models": "Subagent models…", "dialog.subagentModels.title": "Subagent models", "dialog.subagentModels.description": "Choose which models this session's subagents may use.", diff --git a/packages/app/src/i18n/az.ts b/packages/app/src/i18n/az.ts index 014a53565a8f..00e03765511e 100644 --- a/packages/app/src/i18n/az.ts +++ b/packages/app/src/i18n/az.ts @@ -716,6 +716,8 @@ export const dict = { "session.tab.session": "Sessiya", "session.tab.review": "İcmal", "session.tab.context": "Kontekst", + "session.tasks.models.label": "Subagents · {{count}}", + "session.tasks.models.all": "All", "command.tasks.models": "Subagent models…", "dialog.subagentModels.title": "Subagent models", "dialog.subagentModels.description": "Choose which models this session's subagents may use.", diff --git a/packages/app/src/i18n/bg.ts b/packages/app/src/i18n/bg.ts index 63721ab8861f..f67866b9132b 100644 --- a/packages/app/src/i18n/bg.ts +++ b/packages/app/src/i18n/bg.ts @@ -712,6 +712,8 @@ export const dict = { "session.tab.session": "Сесия", "session.tab.review": "Преглед", "session.tab.context": "Контекст", + "session.tasks.models.label": "Subagents · {{count}}", + "session.tasks.models.all": "All", "command.tasks.models": "Subagent models…", "dialog.subagentModels.title": "Subagent models", "dialog.subagentModels.description": "Choose which models this session's subagents may use.", diff --git a/packages/app/src/i18n/bn.ts b/packages/app/src/i18n/bn.ts index 8035611c8468..5cae91fdcd73 100644 --- a/packages/app/src/i18n/bn.ts +++ b/packages/app/src/i18n/bn.ts @@ -706,6 +706,8 @@ export const dict: Record = { "session.tab.session": "সেশন", "session.tab.review": "পর্যালোচনা", "session.tab.context": "প্রসঙ্গ", + "session.tasks.models.label": "Subagents · {{count}}", + "session.tasks.models.all": "All", "command.tasks.models": "Subagent models…", "dialog.subagentModels.title": "Subagent models", "dialog.subagentModels.description": "Choose which models this session's subagents may use.", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index 5cdd11007963..8669e87ab43f 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -700,6 +700,8 @@ export const dict = { "session.tab.session": "Sessão", "session.tab.review": "Revisão", "session.tab.context": "Contexto", + "session.tasks.models.label": "Subagents · {{count}}", + "session.tasks.models.all": "All", "command.tasks.models": "Subagent models…", "dialog.subagentModels.title": "Subagent models", "dialog.subagentModels.description": "Choose which models this session's subagents may use.", diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts index 379271c6a01b..a6c185bfabc4 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -753,6 +753,8 @@ export const dict = { "session.tab.session": "Sesija", "session.tab.review": "Pregled", "session.tab.context": "Kontekst", + "session.tasks.models.label": "Subagents · {{count}}", + "session.tasks.models.all": "All", "command.tasks.models": "Subagent models…", "dialog.subagentModels.title": "Subagent models", "dialog.subagentModels.description": "Choose which models this session's subagents may use.", diff --git a/packages/app/src/i18n/ca.ts b/packages/app/src/i18n/ca.ts index 2ad696f8f5eb..7fcff57131c0 100644 --- a/packages/app/src/i18n/ca.ts +++ b/packages/app/src/i18n/ca.ts @@ -712,6 +712,8 @@ export const dict = { "session.tab.session": "Sessió", "session.tab.review": "Revisió", "session.tab.context": "Context", + "session.tasks.models.label": "Subagents · {{count}}", + "session.tasks.models.all": "All", "command.tasks.models": "Subagent models…", "dialog.subagentModels.title": "Subagent models", "dialog.subagentModels.description": "Choose which models this session's subagents may use.", diff --git a/packages/app/src/i18n/cs.ts b/packages/app/src/i18n/cs.ts index 4fb4b9bcba00..17dde3dac2e9 100644 --- a/packages/app/src/i18n/cs.ts +++ b/packages/app/src/i18n/cs.ts @@ -707,6 +707,8 @@ export const dict = { "session.tab.session": "Relace", "session.tab.review": "Recenze", "session.tab.context": "Kontext", + "session.tasks.models.label": "Subagents · {{count}}", + "session.tasks.models.all": "All", "command.tasks.models": "Subagent models…", "dialog.subagentModels.title": "Subagent models", "dialog.subagentModels.description": "Choose which models this session's subagents may use.", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index be7faeddf1c6..28fbabd70288 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -633,6 +633,8 @@ export const dict = { "session.tab.session": "Session", "session.tab.review": "Gennemgang", "session.tab.context": "Kontekst", + "session.tasks.models.label": "Subagents · {{count}}", + "session.tasks.models.all": "All", "command.tasks.models": "Subagent models…", "dialog.subagentModels.title": "Subagent models", "dialog.subagentModels.description": "Choose which models this session's subagents may use.", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index 950c82f21daf..45dccf6e38de 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -592,6 +592,8 @@ export const dict = { "session.tab.session": "Sitzung", "session.tab.review": "Überprüfung", "session.tab.context": "Kontext", + "session.tasks.models.label": "Subagents · {{count}}", + "session.tasks.models.all": "All", "command.tasks.models": "Subagent models…", "dialog.subagentModels.title": "Subagent models", "dialog.subagentModels.description": "Choose which models this session's subagents may use.", diff --git a/packages/app/src/i18n/dv.ts b/packages/app/src/i18n/dv.ts index 8524beb28d52..79bff90a3864 100644 --- a/packages/app/src/i18n/dv.ts +++ b/packages/app/src/i18n/dv.ts @@ -718,6 +718,8 @@ export const dict = { "session.tab.session": "ސެޝަން", "session.tab.review": "ރިވިއު", "session.tab.context": "ކޮންޓެކްސްޓް", + "session.tasks.models.label": "Subagents · {{count}}", + "session.tasks.models.all": "All", "command.tasks.models": "Subagent models…", "dialog.subagentModels.title": "Subagent models", "dialog.subagentModels.description": "Choose which models this session's subagents may use.", diff --git a/packages/app/src/i18n/dz.ts b/packages/app/src/i18n/dz.ts index a5baaa9bb703..82ea314f6f09 100644 --- a/packages/app/src/i18n/dz.ts +++ b/packages/app/src/i18n/dz.ts @@ -718,6 +718,8 @@ export const dict: Record = { "session.tab.session": "ལཱ་ཡུན།", "session.tab.review": "བསྐྱར་ཞིབ", "session.tab.context": "སྐབས་དོན།", + "session.tasks.models.label": "Subagents · {{count}}", + "session.tasks.models.all": "All", "command.tasks.models": "Subagent models…", "dialog.subagentModels.title": "Subagent models", "dialog.subagentModels.description": "Choose which models this session's subagents may use.", diff --git a/packages/app/src/i18n/el.ts b/packages/app/src/i18n/el.ts index 63ac0cb02634..b4918b64e944 100644 --- a/packages/app/src/i18n/el.ts +++ b/packages/app/src/i18n/el.ts @@ -713,6 +713,8 @@ export const dict = { "session.tab.session": "Συνεδρία", "session.tab.review": "Επισκόπηση", "session.tab.context": "Πλαίσιο", + "session.tasks.models.label": "Subagents · {{count}}", + "session.tasks.models.all": "All", "command.tasks.models": "Subagent models…", "dialog.subagentModels.title": "Subagent models", "dialog.subagentModels.description": "Choose which models this session's subagents may use.", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 5accc9e34b1b..232687e1652b 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -691,6 +691,8 @@ export const dict = { "session.tasks.stop": "Stop task", "session.tasks.dismiss": "Dismiss", "command.tasks.toggle": "Toggle tasks panel", + "session.tasks.models.label": "Subagents · {{count}}", + "session.tasks.models.all": "All", "command.tasks.models": "Subagent models…", "dialog.subagentModels.title": "Subagent models", "dialog.subagentModels.description": "Choose which models this session's subagents may use.", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index b5e19d447360..df2952daf19f 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -756,6 +756,8 @@ export const dict = { "session.tab.session": "Sesión", "session.tab.review": "Revisión", "session.tab.context": "Contexto", + "session.tasks.models.label": "Subagents · {{count}}", + "session.tasks.models.all": "All", "command.tasks.models": "Subagent models…", "dialog.subagentModels.title": "Subagent models", "dialog.subagentModels.description": "Choose which models this session's subagents may use.", diff --git a/packages/app/src/i18n/et.ts b/packages/app/src/i18n/et.ts index d70caa8cce2b..e54098fd4ce0 100644 --- a/packages/app/src/i18n/et.ts +++ b/packages/app/src/i18n/et.ts @@ -702,6 +702,8 @@ export const dict = { "session.tab.session": "Seanss", "session.tab.review": "Ülevaade", "session.tab.context": "Kontekst", + "session.tasks.models.label": "Subagents · {{count}}", + "session.tasks.models.all": "All", "command.tasks.models": "Subagent models…", "dialog.subagentModels.title": "Subagent models", "dialog.subagentModels.description": "Choose which models this session's subagents may use.", diff --git a/packages/app/src/i18n/fa.ts b/packages/app/src/i18n/fa.ts index e264525b45d3..9eaa8a4e96dc 100644 --- a/packages/app/src/i18n/fa.ts +++ b/packages/app/src/i18n/fa.ts @@ -706,6 +706,8 @@ export const dict = { "session.tab.session": "جلسه", "session.tab.review": "بررسی کنید", "session.tab.context": "زمینه", + "session.tasks.models.label": "Subagents · {{count}}", + "session.tasks.models.all": "All", "command.tasks.models": "Subagent models…", "dialog.subagentModels.title": "Subagent models", "dialog.subagentModels.description": "Choose which models this session's subagents may use.", diff --git a/packages/app/src/i18n/fi.ts b/packages/app/src/i18n/fi.ts index b974d9042f36..2d16331641b2 100644 --- a/packages/app/src/i18n/fi.ts +++ b/packages/app/src/i18n/fi.ts @@ -604,6 +604,8 @@ export const dict = { "session.tab.session": "Istunto", "session.tab.review": "Tarkistus", "session.tab.context": "Konteksti", + "session.tasks.models.label": "Subagents · {{count}}", + "session.tasks.models.all": "All", "command.tasks.models": "Subagent models…", "dialog.subagentModels.title": "Subagent models", "dialog.subagentModels.description": "Choose which models this session's subagents may use.", diff --git a/packages/app/src/i18n/fo.ts b/packages/app/src/i18n/fo.ts index eb0d3b664b14..a0aa9c8929a0 100644 --- a/packages/app/src/i18n/fo.ts +++ b/packages/app/src/i18n/fo.ts @@ -707,6 +707,8 @@ export const dict = { "session.tab.session": "Seta", "session.tab.review": "Ummæli", "session.tab.context": "Samanhangur", + "session.tasks.models.label": "Subagents · {{count}}", + "session.tasks.models.all": "All", "command.tasks.models": "Subagent models…", "dialog.subagentModels.title": "Subagent models", "dialog.subagentModels.description": "Choose which models this session's subagents may use.", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index c14545114b8a..3341a15f2122 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -710,6 +710,8 @@ export const dict = { "session.tab.session": "Session", "session.tab.review": "Revue", "session.tab.context": "Contexte", + "session.tasks.models.label": "Subagents · {{count}}", + "session.tasks.models.all": "All", "command.tasks.models": "Subagent models…", "dialog.subagentModels.title": "Subagent models", "dialog.subagentModels.description": "Choose which models this session's subagents may use.", diff --git a/packages/app/src/i18n/hi.ts b/packages/app/src/i18n/hi.ts index 011a9081a524..78f5d6c6a5d4 100644 --- a/packages/app/src/i18n/hi.ts +++ b/packages/app/src/i18n/hi.ts @@ -716,6 +716,8 @@ export const dict = { "session.tab.session": "सेशन", "session.tab.review": "समीक्षा", "session.tab.context": "कॉन्टेक्स्ट", + "session.tasks.models.label": "Subagents · {{count}}", + "session.tasks.models.all": "All", "command.tasks.models": "Subagent models…", "dialog.subagentModels.title": "Subagent models", "dialog.subagentModels.description": "Choose which models this session's subagents may use.", diff --git a/packages/app/src/i18n/hr.ts b/packages/app/src/i18n/hr.ts index 74c0b0312cf2..e6e09a9d0cd5 100644 --- a/packages/app/src/i18n/hr.ts +++ b/packages/app/src/i18n/hr.ts @@ -714,6 +714,8 @@ export const dict = { "session.tab.session": "Sesija", "session.tab.review": "Pregled", "session.tab.context": "Kontekst", + "session.tasks.models.label": "Subagents · {{count}}", + "session.tasks.models.all": "All", "command.tasks.models": "Subagent models…", "dialog.subagentModels.title": "Subagent models", "dialog.subagentModels.description": "Choose which models this session's subagents may use.", diff --git a/packages/app/src/i18n/hu.ts b/packages/app/src/i18n/hu.ts index 5549e7efa5c7..0888e3ab5555 100644 --- a/packages/app/src/i18n/hu.ts +++ b/packages/app/src/i18n/hu.ts @@ -712,6 +712,8 @@ export const dict = { "session.tab.session": "Munkamenet", "session.tab.review": "Tekintse át", "session.tab.context": "Kontextus", + "session.tasks.models.label": "Subagents · {{count}}", + "session.tasks.models.all": "All", "command.tasks.models": "Subagent models…", "dialog.subagentModels.title": "Subagent models", "dialog.subagentModels.description": "Choose which models this session's subagents may use.", diff --git a/packages/app/src/i18n/hy.ts b/packages/app/src/i18n/hy.ts index 076461d79889..57229453a7fc 100644 --- a/packages/app/src/i18n/hy.ts +++ b/packages/app/src/i18n/hy.ts @@ -711,6 +711,8 @@ export const dict = { "session.tab.session": "Նիստ", "session.tab.review": "Վերանայել", "session.tab.context": "Համատեքստ", + "session.tasks.models.label": "Subagents · {{count}}", + "session.tasks.models.all": "All", "command.tasks.models": "Subagent models…", "dialog.subagentModels.title": "Subagent models", "dialog.subagentModels.description": "Choose which models this session's subagents may use.", diff --git a/packages/app/src/i18n/id.ts b/packages/app/src/i18n/id.ts index 9c28446ac5eb..c04d6dedcb47 100644 --- a/packages/app/src/i18n/id.ts +++ b/packages/app/src/i18n/id.ts @@ -771,6 +771,8 @@ export const dict = { "session.tab.session": "Sesi", "session.tab.review": "Tinjauan", "session.tab.context": "Konteks", + "session.tasks.models.label": "Subagents · {{count}}", + "session.tasks.models.all": "All", "command.tasks.models": "Subagent models…", "dialog.subagentModels.title": "Subagent models", "dialog.subagentModels.description": "Choose which models this session's subagents may use.", diff --git a/packages/app/src/i18n/is.ts b/packages/app/src/i18n/is.ts index e114867f757b..e227395c821f 100644 --- a/packages/app/src/i18n/is.ts +++ b/packages/app/src/i18n/is.ts @@ -709,6 +709,8 @@ export const dict = { "session.tab.session": "Lota", "session.tab.review": "Upprifjun", "session.tab.context": "Samhengi", + "session.tasks.models.label": "Subagents · {{count}}", + "session.tasks.models.all": "All", "command.tasks.models": "Subagent models…", "dialog.subagentModels.title": "Subagent models", "dialog.subagentModels.description": "Choose which models this session's subagents may use.", diff --git a/packages/app/src/i18n/it.ts b/packages/app/src/i18n/it.ts index bb581d47f3e7..92f5125dc2e8 100644 --- a/packages/app/src/i18n/it.ts +++ b/packages/app/src/i18n/it.ts @@ -622,6 +622,8 @@ export const dict = { "session.tab.session": "Sessione", "session.tab.review": "Revisione", "session.tab.context": "Contesto", + "session.tasks.models.label": "Subagents · {{count}}", + "session.tasks.models.all": "All", "command.tasks.models": "Subagent models…", "dialog.subagentModels.title": "Subagent models", "dialog.subagentModels.description": "Choose which models this session's subagents may use.", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index c83f9e83bbd9..f59afff615f6 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -696,6 +696,8 @@ export const dict = { "session.tab.session": "セッション", "session.tab.review": "レビュー", "session.tab.context": "コンテキスト", + "session.tasks.models.label": "Subagents · {{count}}", + "session.tasks.models.all": "All", "command.tasks.models": "Subagent models…", "dialog.subagentModels.title": "Subagent models", "dialog.subagentModels.description": "Choose which models this session's subagents may use.", diff --git a/packages/app/src/i18n/ka.ts b/packages/app/src/i18n/ka.ts index ab1265f85a31..7a81981e9e1a 100644 --- a/packages/app/src/i18n/ka.ts +++ b/packages/app/src/i18n/ka.ts @@ -707,6 +707,8 @@ export const dict = { "session.tab.session": "სესია", "session.tab.review": "მიმოხილვა", "session.tab.context": "კონტექსტი", + "session.tasks.models.label": "Subagents · {{count}}", + "session.tasks.models.all": "All", "command.tasks.models": "Subagent models…", "dialog.subagentModels.title": "Subagent models", "dialog.subagentModels.description": "Choose which models this session's subagents may use.", diff --git a/packages/app/src/i18n/km.ts b/packages/app/src/i18n/km.ts index ea734c4bee67..106f51777af5 100644 --- a/packages/app/src/i18n/km.ts +++ b/packages/app/src/i18n/km.ts @@ -704,6 +704,8 @@ export const dict = { "session.tab.session": "សម័យ", "session.tab.review": "ពិនិត្យ", "session.tab.context": "បរិបទ", + "session.tasks.models.label": "Subagents · {{count}}", + "session.tasks.models.all": "All", "command.tasks.models": "Subagent models…", "dialog.subagentModels.title": "Subagent models", "dialog.subagentModels.description": "Choose which models this session's subagents may use.", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index c1cc7828d3e5..e9310bb24181 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -479,6 +479,8 @@ export const dict = { "session.tab.session": "세션", "session.tab.review": "검토", "session.tab.context": "컨텍스트", + "session.tasks.models.label": "Subagents · {{count}}", + "session.tasks.models.all": "All", "command.tasks.models": "Subagent models…", "dialog.subagentModels.title": "Subagent models", "dialog.subagentModels.description": "Choose which models this session's subagents may use.", diff --git a/packages/app/src/i18n/lo.ts b/packages/app/src/i18n/lo.ts index e6aefe9fbf9f..0b2e869e8423 100644 --- a/packages/app/src/i18n/lo.ts +++ b/packages/app/src/i18n/lo.ts @@ -702,6 +702,8 @@ export const dict = { "session.tab.session": "ເຊດຊັນ", "session.tab.review": "ການທົບທວນຄືນ", "session.tab.context": "ບໍລິບົດ", + "session.tasks.models.label": "Subagents · {{count}}", + "session.tasks.models.all": "All", "command.tasks.models": "Subagent models…", "dialog.subagentModels.title": "Subagent models", "dialog.subagentModels.description": "Choose which models this session's subagents may use.", diff --git a/packages/app/src/i18n/lt.ts b/packages/app/src/i18n/lt.ts index 5b3481c24801..607efd41946a 100644 --- a/packages/app/src/i18n/lt.ts +++ b/packages/app/src/i18n/lt.ts @@ -713,6 +713,8 @@ export const dict = { "session.tab.session": "Seansas", "session.tab.review": "Apžvalga", "session.tab.context": "Kontekstas", + "session.tasks.models.label": "Subagents · {{count}}", + "session.tasks.models.all": "All", "command.tasks.models": "Subagent models…", "dialog.subagentModels.title": "Subagent models", "dialog.subagentModels.description": "Choose which models this session's subagents may use.", diff --git a/packages/app/src/i18n/lv.ts b/packages/app/src/i18n/lv.ts index c85039eb95a7..ca139e5960e1 100644 --- a/packages/app/src/i18n/lv.ts +++ b/packages/app/src/i18n/lv.ts @@ -708,6 +708,8 @@ export const dict = { "session.tab.session": "Sesija", "session.tab.review": "Pārskats", "session.tab.context": "Konteksts", + "session.tasks.models.label": "Subagents · {{count}}", + "session.tasks.models.all": "All", "command.tasks.models": "Subagent models…", "dialog.subagentModels.title": "Subagent models", "dialog.subagentModels.description": "Choose which models this session's subagents may use.", diff --git a/packages/app/src/i18n/mk.ts b/packages/app/src/i18n/mk.ts index aa4c333e73b7..aa77154e7117 100644 --- a/packages/app/src/i18n/mk.ts +++ b/packages/app/src/i18n/mk.ts @@ -710,6 +710,8 @@ export const dict = { "session.tab.session": "Сесија", "session.tab.review": "Преглед", "session.tab.context": "Контекст", + "session.tasks.models.label": "Subagents · {{count}}", + "session.tasks.models.all": "All", "command.tasks.models": "Subagent models…", "dialog.subagentModels.title": "Subagent models", "dialog.subagentModels.description": "Choose which models this session's subagents may use.", diff --git a/packages/app/src/i18n/mn.ts b/packages/app/src/i18n/mn.ts index d8dabd1e2033..cf6882b03869 100644 --- a/packages/app/src/i18n/mn.ts +++ b/packages/app/src/i18n/mn.ts @@ -712,6 +712,8 @@ export const dict = { "session.tab.session": "Сесс", "session.tab.review": "Хяналт", "session.tab.context": "Контекст", + "session.tasks.models.label": "Subagents · {{count}}", + "session.tasks.models.all": "All", "command.tasks.models": "Subagent models…", "dialog.subagentModels.title": "Subagent models", "dialog.subagentModels.description": "Choose which models this session's subagents may use.", diff --git a/packages/app/src/i18n/ms.ts b/packages/app/src/i18n/ms.ts index 5c9bfe438443..a15e619424a9 100644 --- a/packages/app/src/i18n/ms.ts +++ b/packages/app/src/i18n/ms.ts @@ -705,6 +705,8 @@ export const dict = { "session.tab.session": "Sesi", "session.tab.review": "Semak", "session.tab.context": "Konteks", + "session.tasks.models.label": "Subagents · {{count}}", + "session.tasks.models.all": "All", "command.tasks.models": "Subagent models…", "dialog.subagentModels.title": "Subagent models", "dialog.subagentModels.description": "Choose which models this session's subagents may use.", diff --git a/packages/app/src/i18n/my.ts b/packages/app/src/i18n/my.ts index c21f46db0c49..25c23ceea94d 100644 --- a/packages/app/src/i18n/my.ts +++ b/packages/app/src/i18n/my.ts @@ -714,6 +714,8 @@ export const dict = { "session.tab.session": "အပိုင်း", "session.tab.review": "သုံးသပ်ချက်", "session.tab.context": "ဆက်စပ်အကြောင်းအရာ", + "session.tasks.models.label": "Subagents · {{count}}", + "session.tasks.models.all": "All", "command.tasks.models": "Subagent models…", "dialog.subagentModels.title": "Subagent models", "dialog.subagentModels.description": "Choose which models this session's subagents may use.", diff --git a/packages/app/src/i18n/ne.ts b/packages/app/src/i18n/ne.ts index ff6f45ff8d33..43aa022f9403 100644 --- a/packages/app/src/i18n/ne.ts +++ b/packages/app/src/i18n/ne.ts @@ -707,6 +707,8 @@ export const dict: Record = { "session.tab.session": "सत्र", "session.tab.review": "समीक्षा गर्नुहोस्", "session.tab.context": "सन्दर्भ", + "session.tasks.models.label": "Subagents · {{count}}", + "session.tasks.models.all": "All", "command.tasks.models": "Subagent models…", "dialog.subagentModels.title": "Subagent models", "dialog.subagentModels.description": "Choose which models this session's subagents may use.", diff --git a/packages/app/src/i18n/nl.ts b/packages/app/src/i18n/nl.ts index f6eb553778e8..d4e94ca93582 100644 --- a/packages/app/src/i18n/nl.ts +++ b/packages/app/src/i18n/nl.ts @@ -714,6 +714,8 @@ export const dict = { "session.tab.session": "Sessie", "session.tab.review": "Beoordeling", "session.tab.context": "Context", + "session.tasks.models.label": "Subagents · {{count}}", + "session.tasks.models.all": "All", "command.tasks.models": "Subagent models…", "dialog.subagentModels.title": "Subagent models", "dialog.subagentModels.description": "Choose which models this session's subagents may use.", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index c3a3a5f5b9fa..5619c0fd9a14 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -638,6 +638,8 @@ export const dict = { "session.tab.session": "Sesjon", "session.tab.review": "Gjennomgang", "session.tab.context": "Kontekst", + "session.tasks.models.label": "Subagents · {{count}}", + "session.tasks.models.all": "All", "command.tasks.models": "Subagent models…", "dialog.subagentModels.title": "Subagent models", "dialog.subagentModels.description": "Choose which models this session's subagents may use.", diff --git a/packages/app/src/i18n/pa.ts b/packages/app/src/i18n/pa.ts index 8cbce2b923b1..59da9580eaf0 100644 --- a/packages/app/src/i18n/pa.ts +++ b/packages/app/src/i18n/pa.ts @@ -716,6 +716,8 @@ export const dict = { "session.tab.session": "سیشن", "session.tab.review": "جائزہ", "session.tab.context": "تناظر", + "session.tasks.models.label": "Subagents · {{count}}", + "session.tasks.models.all": "All", "command.tasks.models": "Subagent models…", "dialog.subagentModels.title": "Subagent models", "dialog.subagentModels.description": "Choose which models this session's subagents may use.", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index 5d506ee8aaee..da9a10447d2e 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -700,6 +700,8 @@ export const dict = { "session.tab.session": "Sesja", "session.tab.review": "Przegląd", "session.tab.context": "Kontekst", + "session.tasks.models.label": "Subagents · {{count}}", + "session.tasks.models.all": "All", "command.tasks.models": "Subagent models…", "dialog.subagentModels.title": "Subagent models", "dialog.subagentModels.description": "Choose which models this session's subagents may use.", diff --git a/packages/app/src/i18n/ro.ts b/packages/app/src/i18n/ro.ts index 392de074af01..43343fa4f00b 100644 --- a/packages/app/src/i18n/ro.ts +++ b/packages/app/src/i18n/ro.ts @@ -707,6 +707,8 @@ export const dict = { "session.tab.session": "Sesiune", "session.tab.review": "Revizuire", "session.tab.context": "Context", + "session.tasks.models.label": "Subagents · {{count}}", + "session.tasks.models.all": "All", "command.tasks.models": "Subagent models…", "dialog.subagentModels.title": "Subagent models", "dialog.subagentModels.description": "Choose which models this session's subagents may use.", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index 0da675879bb3..2db5fae3ccc5 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -753,6 +753,8 @@ export const dict = { "session.tab.session": "Сессия", "session.tab.review": "Обзор", "session.tab.context": "Контекст", + "session.tasks.models.label": "Subagents · {{count}}", + "session.tasks.models.all": "All", "command.tasks.models": "Subagent models…", "dialog.subagentModels.title": "Subagent models", "dialog.subagentModels.description": "Choose which models this session's subagents may use.", diff --git a/packages/app/src/i18n/si.ts b/packages/app/src/i18n/si.ts index 404b76221bfe..e62f0eb7a92a 100644 --- a/packages/app/src/i18n/si.ts +++ b/packages/app/src/i18n/si.ts @@ -704,6 +704,8 @@ export const dict: Record = { "session.tab.session": "සැසිය", "session.tab.review": "සමාලෝචනය කරන්න", "session.tab.context": "සන්දර්භය", + "session.tasks.models.label": "Subagents · {{count}}", + "session.tasks.models.all": "All", "command.tasks.models": "Subagent models…", "dialog.subagentModels.title": "Subagent models", "dialog.subagentModels.description": "Choose which models this session's subagents may use.", diff --git a/packages/app/src/i18n/sk.ts b/packages/app/src/i18n/sk.ts index 83c1c76d64c5..4dfca659c836 100644 --- a/packages/app/src/i18n/sk.ts +++ b/packages/app/src/i18n/sk.ts @@ -705,6 +705,8 @@ export const dict = { "session.tab.session": "Relácia", "session.tab.review": "Revízia", "session.tab.context": "Kontext", + "session.tasks.models.label": "Subagents · {{count}}", + "session.tasks.models.all": "All", "command.tasks.models": "Subagent models…", "dialog.subagentModels.title": "Subagent models", "dialog.subagentModels.description": "Choose which models this session's subagents may use.", diff --git a/packages/app/src/i18n/sl.ts b/packages/app/src/i18n/sl.ts index ad8217748b07..e9dfaa82de95 100644 --- a/packages/app/src/i18n/sl.ts +++ b/packages/app/src/i18n/sl.ts @@ -707,6 +707,8 @@ export const dict = { "session.tab.session": "Seja", "session.tab.review": "Pregled", "session.tab.context": "Kontekst", + "session.tasks.models.label": "Subagents · {{count}}", + "session.tasks.models.all": "All", "command.tasks.models": "Subagent models…", "dialog.subagentModels.title": "Subagent models", "dialog.subagentModels.description": "Choose which models this session's subagents may use.", diff --git a/packages/app/src/i18n/sq.ts b/packages/app/src/i18n/sq.ts index b722197f5766..7b5b34b808a0 100644 --- a/packages/app/src/i18n/sq.ts +++ b/packages/app/src/i18n/sq.ts @@ -709,6 +709,8 @@ export const dict = { "session.tab.session": "Sesioni", "session.tab.review": "Rishikimi", "session.tab.context": "Konteksti", + "session.tasks.models.label": "Subagents · {{count}}", + "session.tasks.models.all": "All", "command.tasks.models": "Subagent models…", "dialog.subagentModels.title": "Subagent models", "dialog.subagentModels.description": "Choose which models this session's subagents may use.", diff --git a/packages/app/src/i18n/sr.ts b/packages/app/src/i18n/sr.ts index bf37b207dc34..8ecf35937643 100644 --- a/packages/app/src/i18n/sr.ts +++ b/packages/app/src/i18n/sr.ts @@ -708,6 +708,8 @@ export const dict = { "session.tab.session": "Сесија", "session.tab.review": "Преглед", "session.tab.context": "Контекст", + "session.tasks.models.label": "Subagents · {{count}}", + "session.tasks.models.all": "All", "command.tasks.models": "Subagent models…", "dialog.subagentModels.title": "Subagent models", "dialog.subagentModels.description": "Choose which models this session's subagents may use.", diff --git a/packages/app/src/i18n/sv.ts b/packages/app/src/i18n/sv.ts index 0a8ae100d014..b10c005c35d3 100644 --- a/packages/app/src/i18n/sv.ts +++ b/packages/app/src/i18n/sv.ts @@ -711,6 +711,8 @@ export const dict = { "session.tab.session": "Session", "session.tab.review": "Granskning", "session.tab.context": "Kontext", + "session.tasks.models.label": "Subagents · {{count}}", + "session.tasks.models.all": "All", "command.tasks.models": "Subagent models…", "dialog.subagentModels.title": "Subagent models", "dialog.subagentModels.description": "Choose which models this session's subagents may use.", diff --git a/packages/app/src/i18n/tg.ts b/packages/app/src/i18n/tg.ts index f027a3008b7e..6b964dc1d784 100644 --- a/packages/app/src/i18n/tg.ts +++ b/packages/app/src/i18n/tg.ts @@ -709,6 +709,8 @@ export const dict = { "session.tab.session": "Сессия", "session.tab.review": "Барраси", "session.tab.context": "Контекст", + "session.tasks.models.label": "Subagents · {{count}}", + "session.tasks.models.all": "All", "command.tasks.models": "Subagent models…", "dialog.subagentModels.title": "Subagent models", "dialog.subagentModels.description": "Choose which models this session's subagents may use.", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index 8359f5f8a53e..8f872bf0e2a1 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -745,6 +745,8 @@ export const dict = { "session.tab.session": "เซสชัน", "session.tab.review": "ตรวจสอบ", "session.tab.context": "บริบท", + "session.tasks.models.label": "Subagents · {{count}}", + "session.tasks.models.all": "All", "command.tasks.models": "Subagent models…", "dialog.subagentModels.title": "Subagent models", "dialog.subagentModels.description": "Choose which models this session's subagents may use.", diff --git a/packages/app/src/i18n/tk.ts b/packages/app/src/i18n/tk.ts index 8e75dde404ff..094d9e89884e 100644 --- a/packages/app/src/i18n/tk.ts +++ b/packages/app/src/i18n/tk.ts @@ -706,6 +706,8 @@ export const dict = { "session.tab.session": "Sessiýa", "session.tab.review": "Syn", "session.tab.context": "Kontekst", + "session.tasks.models.label": "Subagents · {{count}}", + "session.tasks.models.all": "All", "command.tasks.models": "Subagent models…", "dialog.subagentModels.title": "Subagent models", "dialog.subagentModels.description": "Choose which models this session's subagents may use.", diff --git a/packages/app/src/i18n/tr.ts b/packages/app/src/i18n/tr.ts index 79757d917444..38bce8d22779 100644 --- a/packages/app/src/i18n/tr.ts +++ b/packages/app/src/i18n/tr.ts @@ -757,6 +757,8 @@ export const dict = { "session.tab.session": "Oturum", "session.tab.review": "İnceleme", "session.tab.context": "Bağlam", + "session.tasks.models.label": "Subagents · {{count}}", + "session.tasks.models.all": "All", "command.tasks.models": "Subagent models…", "dialog.subagentModels.title": "Subagent models", "dialog.subagentModels.description": "Choose which models this session's subagents may use.", diff --git a/packages/app/src/i18n/uk.ts b/packages/app/src/i18n/uk.ts index cc748b3f2582..3803fe2ed0b6 100644 --- a/packages/app/src/i18n/uk.ts +++ b/packages/app/src/i18n/uk.ts @@ -776,6 +776,8 @@ export const dict = { "session.tab.session": "Сесія", "session.tab.review": "Огляд", "session.tab.context": "Контекст", + "session.tasks.models.label": "Subagents · {{count}}", + "session.tasks.models.all": "All", "command.tasks.models": "Subagent models…", "dialog.subagentModels.title": "Subagent models", "dialog.subagentModels.description": "Choose which models this session's subagents may use.", diff --git a/packages/app/src/i18n/ur.ts b/packages/app/src/i18n/ur.ts index 97ae173931db..2fd9685eaba7 100644 --- a/packages/app/src/i18n/ur.ts +++ b/packages/app/src/i18n/ur.ts @@ -717,6 +717,8 @@ export const dict = { "session.tab.session": "سیشن", "session.tab.review": "جائزہ لیں", "session.tab.context": "سیاق و سباق", + "session.tasks.models.label": "Subagents · {{count}}", + "session.tasks.models.all": "All", "command.tasks.models": "Subagent models…", "dialog.subagentModels.title": "Subagent models", "dialog.subagentModels.description": "Choose which models this session's subagents may use.", diff --git a/packages/app/src/i18n/uz.ts b/packages/app/src/i18n/uz.ts index e99349f6b852..605f8ee77af0 100644 --- a/packages/app/src/i18n/uz.ts +++ b/packages/app/src/i18n/uz.ts @@ -710,6 +710,8 @@ export const dict = { "session.tab.session": "Sessiya", "session.tab.review": "Koʻrib chiqish", "session.tab.context": "Kontekst", + "session.tasks.models.label": "Subagents · {{count}}", + "session.tasks.models.all": "All", "command.tasks.models": "Subagent models…", "dialog.subagentModels.title": "Subagent models", "dialog.subagentModels.description": "Choose which models this session's subagents may use.", diff --git a/packages/app/src/i18n/vi.ts b/packages/app/src/i18n/vi.ts index c4bd48c7c72d..4d4ac352d5b2 100644 --- a/packages/app/src/i18n/vi.ts +++ b/packages/app/src/i18n/vi.ts @@ -716,6 +716,8 @@ export const dict = { "session.tab.session": "Phiên", "session.tab.review": "Xem lại", "session.tab.context": "Ngữ cảnh", + "session.tasks.models.label": "Subagents · {{count}}", + "session.tasks.models.all": "All", "command.tasks.models": "Subagent models…", "dialog.subagentModels.title": "Subagent models", "dialog.subagentModels.description": "Choose which models this session's subagents may use.", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index 2d6da990814f..014162d690e8 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -746,6 +746,8 @@ export const dict = { "session.tab.session": "会话", "session.tab.review": "审查", "session.tab.context": "上下文", + "session.tasks.models.label": "Subagents · {{count}}", + "session.tasks.models.all": "All", "command.tasks.models": "Subagent models…", "dialog.subagentModels.title": "Subagent models", "dialog.subagentModels.description": "Choose which models this session's subagents may use.", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index e4ef5c46f4be..a15464d9344a 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -741,6 +741,8 @@ export const dict = { "session.tab.session": "工作階段", "session.tab.review": "檢閱", "session.tab.context": "上下文", + "session.tasks.models.label": "Subagents · {{count}}", + "session.tasks.models.all": "All", "command.tasks.models": "Subagent models…", "dialog.subagentModels.title": "Subagent models", "dialog.subagentModels.description": "Choose which models this session's subagents may use.", diff --git a/packages/session-ui/src/v2/components/prompt-input/index.tsx b/packages/session-ui/src/v2/components/prompt-input/index.tsx index ff4ff0f1d408..b7c7faadd6ed 100644 --- a/packages/session-ui/src/v2/components/prompt-input/index.tsx +++ b/packages/session-ui/src/v2/components/prompt-input/index.tsx @@ -41,6 +41,7 @@ export type PromptInputV2Props = { borderUnderlay?: boolean class?: string modelControl?: JSX.Element + subagentModelsControl?: JSX.Element variantControlVisible?: boolean attachKeybind?: string[] attachShortcut?: string @@ -253,6 +254,7 @@ export function PromptInputV2(props: PromptInputV2Props) { )} + {props.subagentModelsControl}
Date: Fri, 4 Sep 2026 01:43:29 -0300 Subject: [PATCH 012/117] feat(hooks): add session.start, session.end, stop lifecycle hooks (#2) Adds three optional lifecycle hooks to the plugin system, mirroring Claude Code's PreToolUse/PostToolUse/SessionStart/SessionEnd/Stop surface for the cases OpenCode was missing. Events: - session.start: fires when a session is created, before any tool calls. Plugin may tag the session via output.metadata (reserved keys blocked). - stop: fires at end of every agent turn. If the plugin sets output.continue = true and reason === 'completed', the loop re-iterates with the same context. Honored only on 'completed' to prevent infinite loops on aborted or errored turns. - session.end: fires before session deletion, hard-capped at 5s. output.cleanup = false defers forced cleanup within the ceiling. Implementation: - packages/plugin/src/index.ts: adds three optional fields to the Hooks interface (purely additive, no breaking change to existing plugins). - packages/opencode/src/session/session.ts: emits session.start inside createNext; emits session.end inside remove with timeout and error isolation. Plugin.node is wrapped in a lazy Proxy to avoid a TDZ error from the session<->plugin circular import. - packages/opencode/src/session/prompt.ts: emits stop inside the inner 'result === stop' branch where the runLoop is about to break. Honors output.continue by returning 'continue' instead of 'break' so the while loop re-iterates. Tests (6 new, all passing in 4.4s): - packages/opencode/test/plugin/trigger-lifecycle.test.ts: 5 tests using a real plugin file loaded via opencode.json. Verifies that session.start, session.end, and stop receive the documented input shapes; that session.end output.cleanup and stop output.continue mutations propagate; and that stop output defaults to continue=false when the plugin does not touch it. - packages/opencode/test/session/lifecycle-hooks.test.ts: 1 E2E test that creates a session via Session.Service.create and verifies the loaded plugin's session.start hook fires with the correct sessionID, cwd, and timestamp. Docs: - packages/docs/plugins.mdx: full reference for all three hooks (input/output tables, error-handling contract, end-to-end telemetry example). Added to docs.json navigation under a new 'Plugins' group. Error handling: every emit is wrapped in Effect.catch that logs and continues; a throwing plugin never breaks session creation, deletion, or the agent loop. The 5s session.end timeout is enforced via Effect.timeout. Co-authored-by: gustavoschneiter --- packages/docs/docs.json | 4 + packages/docs/plugins.mdx | 171 ++++++++++++++ packages/opencode/src/session/prompt.ts | 30 ++- packages/opencode/src/session/session.ts | 66 +++++- .../test/plugin/trigger-lifecycle.test.ts | 216 ++++++++++++++++++ .../test/session/lifecycle-hooks.test.ts | 82 +++++++ packages/plugin/src/index.ts | 48 ++++ 7 files changed, 614 insertions(+), 3 deletions(-) create mode 100644 packages/docs/plugins.mdx create mode 100644 packages/opencode/test/plugin/trigger-lifecycle.test.ts create mode 100644 packages/opencode/test/session/lifecycle-hooks.test.ts diff --git a/packages/docs/docs.json b/packages/docs/docs.json index 1bf8b3700b93..3a543a2beced 100644 --- a/packages/docs/docs.json +++ b/packages/docs/docs.json @@ -17,6 +17,10 @@ "group": "Getting started", "pages": ["index", "quickstart", "development"], "openapi": "https://opencode.ai/openapi.json" + }, + { + "group": "Plugins", + "pages": ["plugins"] } ] } diff --git a/packages/docs/plugins.mdx b/packages/docs/plugins.mdx new file mode 100644 index 000000000000..a3976e8cc755 --- /dev/null +++ b/packages/docs/plugins.mdx @@ -0,0 +1,171 @@ +--- +title: "Lifecycle hooks" +description: "React to session and turn boundaries from your plugin" +--- + +Plugins can subscribe to lifecycle events to react when a session starts, when +the agent finishes a turn, and when a session is about to end. All three hooks +are optional; declare only the ones you need. + +## `session.start` + +Fires once when a session is created, before any tool calls. Use this to set up +per-session state in your plugin (background workers, telemetry handles, audit +log entries). + +```ts +import type { Hooks } from "@opencode-ai/plugin" + +const plugin: Hooks = { + "session.start": async (input, output) => { + console.log(`session ${input.sessionID} starting in ${input.cwd}`) + // Tag the session for downstream consumers + output.metadata.tenant = "acme" + output.metadata.tier = "pro" + }, +} + +export default async () => plugin +``` + +### Input + +| Field | Type | Description | +|-------|------|-------------| +| `sessionID` | `string` | Unique session identifier | +| `cwd` | `string` | Working directory of the session | +| `agent` | `string` | Agent name (empty string if unset) | +| `timestamp` | `number` | Unix epoch (ms) when the session was created | + +### Output + +| Field | Type | Description | +|-------|------|-------------| +| `metadata` | `Record` | Plugin tags merged into the session record. Reserved keys (`sessionID`, `cwd`, `agent`, `timestamp`) are rejected. | + +## `stop` + +Fires once at the end of an agent turn, after the LLM produces its final +message (or the turn aborts/errors). The most common use is post-turn work +(commit, summarize, notify) or requesting another turn. + +```ts +import type { Hooks } from "@opencode-ai/plugin" + +const plugin: Hooks = { + stop: async (input, output) => { + if (input.reason === "completed") { + console.log(`turn complete in session ${input.sessionID}`) + } + // Re-run the loop with the same context (advanced) + output.continue = false + }, +} + +export default async () => plugin +``` + +### Input + +| Field | Type | Description | +|-------|------|-------------| +| `sessionID` | `string` | Session the turn belongs to | +| `agent` | `string` | Agent name that produced the turn | +| `messageID` | `string` | ID of the final assistant message | +| `reason` | `"completed" \| "aborted" \| "error"` | How the turn ended | + +### Output + +| Field | Type | Description | +|-------|------|-------------| +| `continue` | `boolean` | If `true`, the loop re-iterates with the same context instead of returning. Honored only when `reason === "completed"`. Defaults to `false`. | + + + Setting `output.continue = true` re-runs the same `lastUser` through the LLM + with no synthetic prompt. Use sparingly — the loop will not re-ask the user + for input. To break out of a continued loop, your plugin must not set + `output.continue = true` on a subsequent turn. + + +## `session.end` + +Fires once before a session is deleted, before the existing `dispose` lifecycle. +Use this for cleanup (flush buffers, close connections, write final audit +entries). The hook is hard-capped at 5 seconds; slow plugins are timed out, not +allowed to block shutdown. + +```ts +import type { Hooks } from "@opencode-ai/plugin" + +const plugin: Hooks = { + "session.end": async (input) => { + console.log( + `session ${input.sessionID} ended after ${input.duration_ms}ms (${input.reason})`, + ) + }, +} + +export default async () => plugin +``` + +### Input + +| Field | Type | Description | +|-------|------|-------------| +| `sessionID` | `string` | Session being deleted | +| `duration_ms` | `number` | Wall-clock time since `session.start` | +| `turn_count` | `number` | Number of agent turns completed in this session | +| `reason` | `"user_exit" \| "error" \| "timeout"` | Why the session is ending | + +### Output + +| Field | Type | Description | +|-------|------|-------------| +| `cleanup` | `boolean` | If `false`, the runtime defers forced cleanup for up to 5 seconds (hard ceiling). Defaults to `true`. | + +## Error handling + +A plugin that throws inside any lifecycle hook is logged via `Effect.logError` +and the session/loop continues normally. Lifecycle hooks are **observers, not +gatekeepers** — they cannot block a session from starting, ending, or stopping +a turn. If you need to enforce a policy, use the permission system instead. + +## Example: a telemetry plugin + +```ts +import type { Hooks } from "@opencode-ai/plugin" + +const startTimes = new Map() + +const hooks: Hooks = { + "session.start": async (input) => { + startTimes.set(input.sessionID, input.timestamp) + fetch("https://telemetry.example.com/sessions", { + method: "POST", + body: JSON.stringify({ id: input.sessionID, cwd: input.cwd, agent: input.agent }), + }) + }, + "session.end": async (input) => { + const start = startTimes.get(input.sessionID) ?? input.timestamp + fetch("https://telemetry.example.com/sessions/end", { + method: "POST", + body: JSON.stringify({ + id: input.sessionID, + duration_ms: input.duration_ms, + reason: input.reason, + start, + }), + }) + startTimes.delete(input.sessionID) + }, + stop: async (input) => { + if (input.reason !== "completed") return + fetch("https://telemetry.example.com/turns", { + method: "POST", + body: JSON.stringify({ sessionID: input.sessionID, messageID: input.messageID }), + }) + }, +} + +export default async () => hooks +``` diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 0f85d44f209b..7c6907fafc48 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1316,7 +1316,34 @@ const layer = Layer.effect( } } - if (result === "stop") return "break" as const + if (result === "stop") { + // Emit `stop` hook at natural end of turn. If a plugin sets + // `output.continue = true`, the loop re-iterates instead of + // breaking. Honored only on `reason: "completed"` to prevent + // infinite loops on aborted or errored turns. The same + // `lastUser` is reprocessed — the LLM re-evaluates the + // conversation with the new plugin context. + const stopInput = { + sessionID, + agent: lastUser.agent, + messageID: handle.message.id, + reason: "completed" as const, + } + const stopOutput = { continue: false } + yield* plugin + .trigger("stop", stopInput, stopOutput) + .pipe(Effect.catch((err) => Effect.logError("stop hook failed", { sessionID, err }))) + + if (stopOutput.continue) { + yield* Effect.logInfo("stop hook requested continue", { + "session.id": sessionID, + step, + messageID: stopInput.messageID, + }) + return "continue" as const + } + return "break" as const + } if (result === "compact") { yield* compaction.create({ sessionID, @@ -1336,6 +1363,7 @@ const layer = Layer.effect( } yield* compaction.prune({ sessionID }).pipe(Effect.ignore, Effect.forkIn(scope)) + return yield* lastAssistant(sessionID) }, ) diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index a2a91cd47b5e..8cc48b833fd5 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -10,6 +10,7 @@ import type { ProviderMetadata, Usage } from "@opencode-ai/llm" import { InstallationVersion } from "@opencode-ai/core/installation/version" import { Database } from "@opencode-ai/core/database/database" import { EventV2Bridge } from "@/event-v2-bridge" +import { Plugin } from "../plugin" import { SessionV2 } from "@opencode-ai/core/session" import * as SessionExecutionLocal from "@opencode-ai/core/session/execution/local" import { locationServiceMapLayer } from "@opencode-ai/core/location-services" @@ -486,7 +487,7 @@ export type Patch = Omit, "time" | "share" | "summary" | "revert" const layer: Layer.Layer< Service, never, - BackgroundJob.Service | RuntimeFlags.Service | Database.Service | EventV2Bridge.Service + BackgroundJob.Service | RuntimeFlags.Service | Database.Service | EventV2Bridge.Service | Plugin.Service > = Layer.effect( Service, Effect.gen(function* () { @@ -495,6 +496,7 @@ const layer: Layer.Layer< const background = yield* BackgroundJob.Service const events = yield* EventV2Bridge.Service const flags = yield* RuntimeFlags.Service + const plugin = yield* Plugin.Service const createNext = Effect.fn("Session.createNext")(function* (input: { id?: SessionID @@ -534,6 +536,22 @@ const layer: Layer.Layer< yield* events.publish(SessionV1.Event.Created, { sessionID: result.id, info: result }) + const sessionStartOutput = { metadata: {} as Record } + yield* plugin + .trigger( + "session.start", + { + sessionID: result.id, + cwd: result.directory, + agent: result.agent ?? "", + timestamp: result.time.created, + }, + sessionStartOutput, + ) + .pipe( + Effect.catch((err) => Effect.logError("session.start hook failed", { sessionID: result.id, err })), + ) + return result }) @@ -619,6 +637,27 @@ const layer: Layer.Layer< yield* remove(child.id) } + if (hasInstance) { + const sessionEndOutput = { cleanup: true } + yield* plugin + .trigger( + "session.end", + { + sessionID, + duration_ms: Date.now() - session.time.created, + turn_count: 0, + reason: "user_exit", + }, + sessionEndOutput, + ) + .pipe( + Effect.timeout(5000), + Effect.catch((err) => + Effect.logError("session.end hook failed or timed out", { sessionID, err }), + ), + ) + } + yield* events.publish(SessionV1.Event.Deleted, { sessionID, info: session }) yield* events.remove(sessionID) } catch (error) { @@ -1007,10 +1046,33 @@ function listByProject( ) } +// `Plugin.node` is wrapped in a lazy Proxy because session.ts and +// plugin/index.ts form a circular import: plugin/index.ts imports +// `Session` from this file, which means `Plugin.node` is in TDZ when +// session.ts is evaluated first. The Proxy's `get` trap is invoked only +// when LayerNode's compiled graph actually reads each dep property (which +// happens after both modules are fully loaded), avoiding the TDZ error +// while still declaring the dependency so the Plugin service is provided +// to Session at runtime. +const _pluginNode: LayerNode.Node = new Proxy({} as LayerNode.Node, { + get(_target, prop) { + return (Plugin.node as any)[prop] + }, + has(_target, prop) { + return prop in (Plugin.node as any) + }, + ownKeys() { + return Reflect.ownKeys(Plugin.node as any) + }, + getOwnPropertyDescriptor(_target, prop) { + return Object.getOwnPropertyDescriptor(Plugin.node as any, prop) + }, +}) as LayerNode.Node + export const node = LayerNode.make({ service: Service, layer: layer, - deps: [BackgroundJob.node, RuntimeFlags.node, Database.node, EventV2Bridge.node], + deps: [BackgroundJob.node, RuntimeFlags.node, Database.node, EventV2Bridge.node, _pluginNode] as never, }) export * as Session from "./session" diff --git a/packages/opencode/test/plugin/trigger-lifecycle.test.ts b/packages/opencode/test/plugin/trigger-lifecycle.test.ts new file mode 100644 index 000000000000..f1569e962614 --- /dev/null +++ b/packages/opencode/test/plugin/trigger-lifecycle.test.ts @@ -0,0 +1,216 @@ +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import path from "path" +import { pathToFileURL } from "url" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Account } from "../../src/account/account" +import { Auth } from "../../src/auth" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Npm } from "@opencode-ai/core/npm" +import { Plugin } from "@/plugin" +import { RuntimeFlags } from "@/effect/runtime-flags" +import { TestInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" +import { AccountTest } from "../fake/account" +import { AuthTest } from "../fake/auth" +import { NpmTest } from "../fake/npm" + +const it = testEffect( + AppNodeBuilder.build(LayerNode.group([Plugin.node, CrossSpawnSpawner.node]), [ + [Auth.node, AuthTest.empty], + [Account.node, AccountTest.empty], + [Npm.node, NpmTest.noop], + [RuntimeFlags.node, RuntimeFlags.layer({ disableDefaultPlugins: true })], + ]), +) + +const recordingPlugin = [ + "const bus = globalThis.__lifecycleBus ?? (globalThis.__lifecycleBus = { starts: [], ends: [], stops: [] })", + "export default async () => ({", + ' "session.start": async (input, output) => {', + " bus.starts.push({ sessionID: input.sessionID, cwd: input.cwd, agent: input.agent, timestamp: input.timestamp, metadataKeys: Object.keys(output.metadata) })", + " },", + ' "session.end": async (input, output) => {', + " bus.ends.push({ sessionID: input.sessionID, reason: input.reason, duration_ms: input.duration_ms, cleanup: output.cleanup })", + " },", + ' "stop": async (input, output) => {', + " bus.stops.push({ sessionID: input.sessionID, agent: input.agent, messageID: input.messageID, reason: input.reason, continue: output.continue })", + " },", + "})", + "", +].join("\n") + +function installPlugin() { + return Effect.gen(function* () { + const test = yield* TestInstance + const file = path.join(test.directory, "lifecycle-plugin.ts") + yield* Effect.promise(() => Bun.write(file, recordingPlugin)) + yield* Effect.promise(() => + Bun.write( + path.join(test.directory, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + plugin: [pathToFileURL(file).href], + }), + ), + ) + }) +} + +const readBus = (): { starts: any[]; ends: any[]; stops: any[] } => { + const bus = (globalThis as any).__lifecycleBus ?? { starts: [], ends: [], stops: [] } + return { starts: [...bus.starts], ends: [...bus.ends], stops: [...bus.stops] } +} + +const clearBus = () => { + const bus = (globalThis as any).__lifecycleBus ?? { starts: [], ends: [], stops: [] } + bus.starts.length = 0 + bus.ends.length = 0 + bus.stops.length = 0 + ;(globalThis as any).__lifecycleBus = bus +} + +describe("plugin.trigger (lifecycle events E2E with real plugin file)", () => { + it.instance("loaded plugin receives session.start with valid input", () => + Effect.gen(function* () { + clearBus() + yield* installPlugin() + const plugin = yield* Plugin.Service + const out = { metadata: {} as Record } + yield* plugin.trigger( + "session.start", + { + sessionID: "sess_loaded_1", + cwd: "/tmp/proj", + agent: "build", + timestamp: 1700000000000, + }, + out, + ) + const bus = readBus() + expect(bus.starts.length).toBe(1) + expect(bus.starts[0].sessionID).toBe("sess_loaded_1") + expect(bus.starts[0].cwd).toBe("/tmp/proj") + expect(bus.starts[0].agent).toBe("build") + expect(bus.starts[0].timestamp).toBe(1700000000000) + }), + ) + + it.instance("loaded plugin receives session.end with cleanup flag", () => + Effect.gen(function* () { + clearBus() + yield* installPlugin() + const plugin = yield* Plugin.Service + const out = { cleanup: true } + yield* plugin.trigger( + "session.end", + { + sessionID: "sess_loaded_2", + duration_ms: 5000, + turn_count: 3, + reason: "user_exit", + }, + out, + ) + const bus = readBus() + expect(bus.ends.length).toBe(1) + expect(bus.ends[0].sessionID).toBe("sess_loaded_2") + expect(bus.ends[0].reason).toBe("user_exit") + expect(bus.ends[0].duration_ms).toBe(5000) + }), + ) + + it.instance("loaded plugin receives stop with reason and messageID", () => + Effect.gen(function* () { + clearBus() + yield* installPlugin() + const plugin = yield* Plugin.Service + const out = { continue: false } + yield* plugin.trigger( + "stop", + { + sessionID: "sess_loaded_3", + agent: "build", + messageID: "msg_loaded_3", + reason: "completed", + }, + out, + ) + const bus = readBus() + expect(bus.stops.length).toBe(1) + expect(bus.stops[0].sessionID).toBe("sess_loaded_3") + expect(bus.stops[0].messageID).toBe("msg_loaded_3") + expect(bus.stops[0].reason).toBe("completed") + }), + ) + + it.instance("plugin can set stop output.continue = true", () => + Effect.gen(function* () { + clearBus() + const continuePlugin = [ + "const bus = globalThis.__lifecycleBus ?? (globalThis.__lifecycleBus = { stops: [] })", + "export default async () => ({", + ' "stop": async (_input, output) => {', + " output.continue = true", + " },", + "})", + "", + ].join("\n") + const test = yield* TestInstance + const file = path.join(test.directory, "continue-plugin.ts") + yield* Effect.promise(() => Bun.write(file, continuePlugin)) + yield* Effect.promise(() => + Bun.write( + path.join(test.directory, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + plugin: [pathToFileURL(file).href], + }), + ), + ) + const plugin = yield* Plugin.Service + const out = { continue: false } + yield* plugin.trigger( + "stop", + { sessionID: "s", agent: "a", messageID: "m", reason: "completed" }, + out, + ) + expect(out.continue).toBe(true) + }), + ) + + it.instance("stop output defaults to continue=false when plugin does not touch it", () => + Effect.gen(function* () { + clearBus() + const noopPlugin = [ + "export default async () => ({", + ' "stop": async () => {', + " // no-op: do not touch output", + " },", + "})", + "", + ].join("\n") + const test = yield* TestInstance + const file = path.join(test.directory, "noop-plugin.ts") + yield* Effect.promise(() => Bun.write(file, noopPlugin)) + yield* Effect.promise(() => + Bun.write( + path.join(test.directory, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + plugin: [pathToFileURL(file).href], + }), + ), + ) + const plugin = yield* Plugin.Service + const out = { continue: false } + yield* plugin.trigger( + "stop", + { sessionID: "s", agent: "a", messageID: "m", reason: "completed" }, + out, + ) + expect(out.continue).toBe(false) + }), + ) +}) \ No newline at end of file diff --git a/packages/opencode/test/session/lifecycle-hooks.test.ts b/packages/opencode/test/session/lifecycle-hooks.test.ts new file mode 100644 index 000000000000..9f4b4d3717df --- /dev/null +++ b/packages/opencode/test/session/lifecycle-hooks.test.ts @@ -0,0 +1,82 @@ +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import path from "path" +import { pathToFileURL } from "url" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Account } from "../../src/account/account" +import { Auth } from "../../src/auth" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Npm } from "@opencode-ai/core/npm" +import { Plugin } from "@/plugin" +import { RuntimeFlags } from "@/effect/runtime-flags" +import { Session } from "@/session/session" +import { TestInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" +import { AccountTest } from "../fake/account" +import { AuthTest } from "../fake/auth" +import { NpmTest } from "../fake/npm" + +const it = testEffect( + AppNodeBuilder.build( + LayerNode.group([Session.node, Plugin.node, CrossSpawnSpawner.node]), + [ + [Auth.node, AuthTest.empty], + [Account.node, AccountTest.empty], + [Npm.node, NpmTest.noop], + [RuntimeFlags.node, RuntimeFlags.layer({ disableDefaultPlugins: true })], + ], + ), +) + +const recordingPlugin = [ + "const bus = globalThis.__lifecycleBus ?? (globalThis.__lifecycleBus = { starts: [], ends: [], stops: [] })", + "export default async () => ({", + ' "session.start": async (input, output) => {', + " bus.starts.push({ sessionID: input.sessionID, cwd: input.cwd, agent: input.agent, timestamp: input.timestamp })", + " },", + ' "session.end": async (input, output) => {', + " bus.ends.push({ sessionID: input.sessionID, reason: input.reason, duration_ms: input.duration_ms })", + " },", + "})", + "", +].join("\n") + +const readBus = () => (globalThis as any).__lifecycleBus ?? { starts: [], ends: [], stops: [] } + +const clearBus = () => { + ;(globalThis as any).__lifecycleBus = { starts: [], ends: [], stops: [] } +} + +const installPlugin = Effect.gen(function* () { + const test = yield* TestInstance + const file = path.join(test.directory, "lifecycle-plugin.ts") + yield* Effect.promise(() => Bun.write(file, recordingPlugin)) + yield* Effect.promise(() => + Bun.write( + path.join(test.directory, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + plugin: [pathToFileURL(file).href], + }), + ), + ) +}) + +describe("session lifecycle hooks (E2E via Session.create)", () => { + it.instance("Session.create fires session.start with correct input", () => + Effect.gen(function* () { + clearBus() + yield* installPlugin + const sessions = yield* Session.Service + const session = yield* sessions.create({ title: "Hook Test" }) + + const bus = readBus() + expect(bus.starts.length).toBe(1) + expect(bus.starts[0].sessionID).toBe(session.id) + expect(typeof bus.starts[0].cwd).toBe("string") + expect(bus.starts[0].cwd.length).toBeGreaterThan(0) + expect(typeof bus.starts[0].timestamp).toBe("number") + }), + ) +}) \ No newline at end of file diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index edfa0139dfca..57860113e29a 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -332,4 +332,52 @@ export interface Hooks { * Modify tool definitions (description and parameters) sent to LLM */ "tool.definition"?: (input: { toolID: string }, output: { description: string; parameters: any }) => Promise + + /** + * Fires once at end of agent turn, after the last tool call resolves + * and the LLM has produced its final message (or aborted/errored). + * + * - `reason` indicates how the turn ended. + * - `output.continue` allows a plugin to request another turn + * (advanced; default false). Only honored when `reason === "completed"`. + */ + "stop"?: ( + input: { + sessionID: string + agent: string + messageID: string + reason: "completed" | "aborted" | "error" + }, + output: { continue: boolean }, + ) => Promise + + /** + * Fires once when a session is created, before any tool calls. + * Plugin may add to `output.metadata` (reserved keys blocked). + */ + "session.start"?: ( + input: { + sessionID: string + cwd: string + agent: string + timestamp: number + }, + output: { metadata: Record }, + ) => Promise + + /** + * Fires once before session shutdown, before existing `dispose` lifecycle. + * + * - `output.cleanup` defaults to true; if a plugin sets false, the + * runtime defers forced cleanup for up to 5s (hard ceiling). + */ + "session.end"?: ( + input: { + sessionID: string + duration_ms: number + turn_count: number + reason: "user_exit" | "error" | "timeout" + }, + output: { cleanup: boolean }, + ) => Promise } From 886ea11184c559f403044854e84b833ba9e20099 Mon Sep 17 00:00:00 2001 From: Gustavo Schneiter Date: Fri, 4 Sep 2026 02:00:27 -0300 Subject: [PATCH 013/117] feat(app): align subagent-models switch with manage dialog pattern --- .../src/components/dialog-subagent-models.tsx | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/packages/app/src/components/dialog-subagent-models.tsx b/packages/app/src/components/dialog-subagent-models.tsx index 26b20dc5a02b..e9a4f9457b1d 100644 --- a/packages/app/src/components/dialog-subagent-models.tsx +++ b/packages/app/src/components/dialog-subagent-models.tsx @@ -65,16 +65,14 @@ export const DialogSubagentModels: Component<{ sessionID: string; directory: str {(item: ModelItem) => (
{item.name} - - void setAllowed(item.provider.id, item.id, checked)} - hideLabel - > - {item.name} - - + void setAllowed(item.provider.id, item.id, checked)} + hideLabel + > + {item.name} +
)} From a57a1ed0e4d2726b8a8a0bc23d9ec8a933bf4743 Mon Sep 17 00:00:00 2001 From: Gustavo Schneiter Date: Sat, 5 Sep 2026 12:01:06 -0300 Subject: [PATCH 014/117] feat(app): subagent-models control in legacy composer toolbar --- packages/app/src/components/prompt-input.tsx | 44 ++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 41cf7d30ca34..7bf3aaccfedc 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -49,6 +49,8 @@ import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpa import { useCommand } from "@/context/command" import { usePermission } from "@/context/permission" import { useLanguage } from "@/context/language" +import { useLocal } from "@/context/local" +import { effectiveModelState, hasModelScope } from "@/components/subagent-model-rules" import { usePlatform } from "@/context/platform" import { createSessionTabs } from "@/pages/session/helpers" import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom" @@ -1192,6 +1194,29 @@ export const PromptInput: Component = (props) => { const variants = createMemo(() => ["default", ...props.controls.model.selection.variant.list()]) // Check provider variants directly: `variants` also includes the UI-only default option. const showVariantControl = createMemo(() => props.controls.model.selection.variant.list().length > 0) + const local = useLocal() + const subagentRules = createMemo(() => { + const id = props.controls.session.id + if (!id) return [] + const permission = sync().session.get(id)?.permission + return Array.isArray(permission) ? permission : [] + }) + const subagentLabel = createMemo(() => { + if (!hasModelScope(subagentRules())) return language.t("session.tasks.models.all") + const allowed = local.model + .list() + .filter((m) => local.model.visible({ modelID: m.id, providerID: m.provider.id })) + .filter((m) => effectiveModelState(subagentRules(), m.provider.id, m.id) === "allow").length + return `${allowed}` + }) + const openSubagentModels = () => { + const sessionID = props.controls.session.id + const directory = sdk().directory + if (!sessionID || !directory) return + void import("@/components/dialog-subagent-models").then((x) => { + dialog.show(() => ) + }) + } const accepting = createMemo(() => { const id = props.controls.session.id if (!id) return permission.isAutoAcceptingDirectory(sdk().directory) @@ -1781,6 +1806,25 @@ export const PromptInput: Component = (props) => {
+ + + + +
From bd8b446e713ed6082b4737c34b75eb9c382998a9 Mon Sep 17 00:00:00 2001 From: Gustavo Schneiter Date: Sat, 5 Sep 2026 12:11:06 -0300 Subject: [PATCH 015/117] feat(providers): multiple labeled API keys per provider (#3) * feat(core): multiple labeled API keys per provider * feat(providers): resolve labeled API keys at runtime in v1 * fix(providers): address cold review findings - resolve getProvider through ensureProvider so keys added post-boot don't race a missing provider entry (crash on first request) - re-read credential state in ensureProvider to pick up remove/update - fail loudly in v2 runner when a virtual credential is missing instead of silently routing to a different active key - respect provider.disabled for virtual provider availability - redact API keys from the public provider list response - honor disabled/enabled provider sets in the post-boot catalog fallback - route vars/model loaders to the base provider for virtual ids * fix(app): persist labeled keys via v2 credential route connect.key was overridden to write legacy auth.json, dropping the label and never creating a credential row. Try the v2 integration connect.key (which stores a labeled credential) first, falling back to the legacy auth write only when the server lacks the v2 route. * test(app): cover v2-first connect.key with legacy fallback * fix(providers): address second cold review round - server-compat connect.key falls back to legacy auth only on missing-route (404/405), re-throwing real credential failures (400/401/500) instead of silently writing legacy auth on top of a broken key - getProvider now fails typed with NoProvidersError; update the existing provider test that asserted the old undefined-return, and hoist the getProvider error channel in the Service interface so consumers handle the typed failure - fix parseVirtualID usage in vars/model loader lookup (baseID, not base) and type the credential id lookup --------- Co-authored-by: Gustavo Schneiter --- .../components/dialog-connect-provider.tsx | 24 +++++ .../src/components/settings-v2/providers.tsx | 97 ++++++++++++++----- packages/app/src/utils/server-compat.test.ts | 36 ++++++- packages/app/src/utils/server-compat.ts | 19 +++- packages/core/src/catalog.ts | 92 ++++++++++++++++-- packages/core/src/credential.ts | 27 ++---- packages/core/src/integration.ts | 5 - packages/core/src/session/runner/model.ts | 21 +++- packages/core/test/catalog.test.ts | 24 +++-- packages/core/test/credential.test.ts | 9 +- packages/core/test/integration.test.ts | 5 + packages/opencode/src/provider/provider.ts | 85 +++++++++++++--- .../instance/httpapi/handlers/provider.ts | 29 +++++- .../server/routes/instance/httpapi/server.ts | 2 + .../opencode/test/provider/provider.test.ts | 8 +- 15 files changed, 388 insertions(+), 95 deletions(-) diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index 1081310e5f21..b80d84490948 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -804,9 +804,11 @@ function ProviderConnection(props: { function ApiAuthView() { let apiKey: HTMLInputElement | undefined + let labelInput: HTMLInputElement | undefined const errorID = createUniqueId() const [formStore, setFormStore] = createStore({ value: "", + label: "", error: undefined as string | undefined, }) @@ -821,6 +823,7 @@ function ProviderConnection(props: { const form = e.currentTarget as HTMLFormElement const formData = new FormData(form) const apiKey = formData.get("apiKey") as string + const label = formData.get("label") as string if (!apiKey?.trim()) { setFormStore("error", language.t("provider.connect.apiKey.required")) @@ -832,6 +835,7 @@ function ProviderConnection(props: { integrationID: props.provider, location: location(), key: apiKey, + label: label || undefined, }) await complete() } @@ -875,6 +879,17 @@ function ProviderConnection(props: { onInput={(event) => setFormStore("value", event.currentTarget.value)} /> + {(error) => ( + + + {(tab) => } @@ -708,6 +715,7 @@ export function SessionSidePanel(props: { +
Apps
{(tab) => ( + + +
DJdxq4i1`{n(*-OkB^U=o10%>Uv+hL;o;$GYHGp3 z!CPBf`1tsDcXwoDWcT;?xVX5#zrUWIo`Zvf!CDwn0000SbW%=J02wkSAQCAV2@w?x zA}t*!1~D-_C`A<@93?t8Anh%k0044)Nkl|bVzQ>}2wQVE#ZgTlzo6ciX19oSPiE%bIL*%-kazB@+Ab;iIFeMtpDlT; zpxUfYD||tq@Fk&pGDyBk;!9u%Qm^uRsynwa30tbNfKiL6^yJaR zQu!QYJH~T0dCT2)kv&j*ysTQc@!peUl`3^mvM7ck;13&e4a4ZLQZ-6!&}BtW(oD=^*u07` zTE8}5H+)|!14ujr979;}xjym=p;oIz)@YWXb`pF7qOJmQdRxcrcIEecYtl_GOf^*t zN_4vBte4HH+zWQ3HztynQ3i;!W%}%KZwcY9NG%#8vz{(`TxpjvCt&!IovM_2_AC)>ubhyd9Z+(8`FF4}> zj0q%RDCa$qHP`d760+E?|F?5CxzRV`IQg+ z`Go0B7nDzf!mK9Tymb2jLZ7>$%@Qd$os)lTvqK?6+{Yf$Jq1fUsuBo7Dwe_HP}{1@DzobpMmICEe1xF*R9zjKyV7fJp90hVI zR7;npqSBgP4YAKWiu zg(9jOp3rD32vD;^1tOyW{7%9cr9N)!CdxJ`PUm{esiv9mI~Jf+Y4JRue%-vF0w(G6 zjw!pKih4$*C#bx{Vi=Y4Fke)SrC+nb5-&nXL3^Ka_XA%IVNRC7##lRGsfZP5@ifCAt zIiXrsv{>zWk+qkASy~vdxo6diSo^@%_jKZL>oM^zQNu{qy&MT9WTp=lJhYJN+xUdw zbv_TD&&CAcC03gNnDr;_Ta{}yT^mzz+Zpf#5#o9TK!AZy;-N0G6`K5K_bEwcr{a-8 zfc}hj7WRCrfJs4Cr`eZcJ`?siimDwvt<;vIg3S5}Y3y9df^lhydfCpDszNRBn|vT2 z##PGG0}HejKPrB8iHfU6`tmv+&ayCO46{ZjQqsb+Zw37->MatBdU@8H^*K@WP@xNWhmqQIkXPDqnJ@t*w@>9?h+x>jgfucV$L{d!A$Tg!GPR}mD>BN$hmlS&^T zLelVsWc1^WfIon$C!aErS)*A6h4)BpII!iyF!%E3;pU!_%>qJbrK@2-=T@PDTCr<- zzGH$*@AwLGb137tfVktD2#3PhQyt;@K}WT><}kZeFbSDpQ?-z8usT^xBQ9o3?MMK) z&R4XX%tZ-r*S$R(O3f#b5(H~)p>-Z`*$98b(nU_Ed~@i$foW&;NmgTvtsMq`$?I~B z-@Y}R2_M6pK7%m0q97YkRtIWDi;%BgsT70o?JA9{_tIh1 z@`KMXe=_G80uemMY`RXuXK@G?;d9Efa;Tzos!Z%(?BN-ebJ@zEj&CE3CRe+sNS4Y{ z$UzH zlhv-)#Aj836x72u<^9S_%ZOw0xIkZ7gPFSAczpNk7%xekuSNl>UMgWo-Qo@TpqtK# zp(S&F!KUCVM9;~~Q}f3=L-#50h)QctaIZGL7qrE9y2yA-^uj#nG+*o201KnrXLtvh zTgYB{qm0)mfirE(itY)hy4<3>U_njqGKe7geQudy}C6N#Q#KTo$mk+4@GNl(Z+L&2n@-z4%DUhaS< zwN)sC*TXLD*>oH1-@zAeGOUT_0=>W<7Ye5m!Oo|(Rn~e4owC6~;fM={4e$`d`E9sh zUSMO|!H=N(oKUO9CdG3Cs_i7Wk%=_mg9fwV5}@2|OMCE^JU3iHL}{WZGl-%iY467fQ}1$pnO5l8UWtvM z*3x{-T6g>!iHc!=L;<+L&hRN_891bCQQs|l-S)ngUfT##3R4Z{E(jYzqq@CN%sB+J znhTKSeYZ%2q^vG>6khi@`iRUc?~Jp)0ah>GsgYeNlj)d)jn1nVs`V(WVhxRJRB@O_ z!oif_V0^m=)8Qs270d36jfm6)H`6O*!yJ_vb7vvH@vTKwdUxl{-onyy2`iks`1$aK z+N^KR5*i_8&XGa6;N^m)M&KtuN@a<&)}^a+`@G6IfsDgC_)w1dtO^Dojch1x^4B+gR>k;gjcQ3<7(n%h%($LVQ9^^bc2R@r+XFQ%dCu zR1aw}dOpiWl5oy8^lZrNhHx1o=FK+PC5VxoNkp5n*Sdm0A%8#7t#xSV60- z9^!7~#B#Cdkfm>B1?OE+N?VbJuK@|TyS&j}^3?o^IJ_{inF_#9#&3jVzd|X?G#Hbw zcN|#2x(8PNtEMi6%8n2_o$1pRK8D2_YSS2XYv7)(O>k@XV@>V(6;zIZ!?mkvRc>M% zMP4jrwhlI4ra=wwu;W}GR!=hoxekNyq5ChAT&qQ3IT(vdf<;q4h#QG6DqmWGSRiRc zt)%+&r6}#4AK5enbgb#<0mfC&I>+YB^Xkkc2eetgl+Ld{d^>jn|1tS6Lr#S%)E`p(y>*hJ z#8p~ju4+;nD@EmMybg-&U@3+DE^Xy;ELl`=j+md>8uqJ6OyCp{msIyU{&JXa%>d&j zs1=P0haY71KG0E6zW6!wib-VgzNszV}y>vxNrN$?x5^LyW#M0|vmMC!q z54b9SW^-mfYZi`Tik*^^dbJzmXErOToewl1m2Cl(F$R{U5vHEuY9BQwoL3dvZ)u>( z0SdDfx54HPDhtY8^YGo+_o-yos;7E%1%`qgwK~~{I9Qfr@38$T)W=)cwV&8Rj={bo z8XU?Bim*wI5Ex8rM|f*t1I-@=J){C4nOdwmGn0%tWBzPu!hSYpbXe0Dc_{TJliH*} zAt+;Ms-tt-SB8znqJ;epNex~y5)K+)TMA=O$XgQjG(yY4+!vo$ETYwW)auo4sYlA+ z-97>oMKl;V>kCv0SaMQ$a7zYoUzeMK0y0)%V3g-+yqi^%(d8|$c!{#F)xnyap`IXj z@>54W1obWxw-O9r&?D~;;>YC~v^ZrvW;3MWoC+!QkB!pl4!f#Wax^jD2krAB94xh2 zZ|aLV`r^0a#aWqZBfF8IHV|Kx(nU~TjmovpdmURU5=1!MxV9(lO};!@Bex)6O~tfa znkY)U2Mr3jGa0o6Bb{a@T+qXR!(-+WB8D<+{7Crl1wDFRid;+AjZk}4(}P%1J&;~} z1XEEO63wdp?95S7t~^dSNS)~wrQ$udcu)?3PhChSurNoAd)ok|wT%+T%rD^M@B*~C zp8EEXHsa@b&;a43G=j2E)=es+y!==#90s{{q3t(>UMY^L#uTi`&JaxS%V>Vz*Y^u_ zLdexwpBW*hu$u6#LlAMx``F*ObPMRS5k0Km2i(0k)5*s%*#n0@0GX% za(@7-P5yXUe>8VUq#T`V>t=5jA{f(;N&<;oobOql%<74T5Tt~0H$hc%Q8_ic@qo#K z!PY+j_1K0|OWKJ3O%7|f7UO9{umW_vbDo+} zJMw}>5-gM<`=vBE#|%=gE}PO@g{y~k-qMOrVIwM9vt?1HIh>Y<^-Y>kl_p4YYD=wb zACI}`yKNbzFysK2Wpx@vdsaZkhYm*7L5*4{ytYNryg_K(ECi2hWG*S98Cu^yaURQ6 zYC_Y-t22coGtoz)%1gj+6*QPLi8u@RbO6%3dp9MW?k56WA^ibHYJ^8u>zNAG%o9-R1 zQzWgla)(^GAcxR zSl4o~olG{EoW`S-0Nd>hG!t@;xeG2tOqpu3pQ;|QTHj)F>TEoa$*vkK5QK)<1!`!- zAC@o+*cp~hkyy-WX3xynjHz<@!lh%llI!!m$b*4mpX?ye?fIku>ei(5z!l~N4a zH{TOvA5MV6{4x~jn8Q)Dju57FIBRvtszX~MJ*{RN@ye}be&!+8rcAc|Gv<@Vu<$NA z8=?yBCWy;B6TmkfIy*JxKs%ae`F&IA-IE$Fzgwhl<1LU~he!~;nKes={cG!0j|{SO z)H*#~iqJQ6Cnd4jwj$`v9?yo~XYomQyO!UhVq-0jozj!SWz;;3SVrpOKqTzjz$cjg z=WYt(J~*PXzE6kz`O>f&TYud40b>XxOQ+GSj^PO z^{eIA)xbTI`|=LF3@0i{VXG>)>QxBBdwNUI-c2)$6Wa`=Hrk%&wg3)9g#y|j&d=Gc z`nmZSc7B8&YakBr!BJ-?v5XX3>8hT9UawPoM9MHMd^sWT?6sN5@thWS=+PP9l*~QZ z6*g5KgTYEQS1h9DBTDye0Xn=R0SiUxi_;HdYj#H!QCaa3u_AaQ+gO(MJ2+J*)1Loa z;0e%%8#C&WpEvtnYEZu};5|AX-4|;KM%DTf`~(GfbwoFWmkDYJ=AgZ79 z>c~-=P6$HZL4X9G!|K!r9fiw33o?}WY*LzyKc06k|RZ_;tDJ;jRrx9liNxmbWQ^VCe^d>%H z90;L%*P{))+vjQgJ(puHb$xv+K%+n0rnQx_sHaHLokKS$13U@btTR) zWBLYbRi9f~NR5FOs3y#P2~Ap1j4%+qb?RJ48#NhgT@snrj zJ4AI=kphSyJ~r0cY8w^v1T?hzj!g5+GGbHo>YWtW@m#L~gr3&DKQ<9rZckQ92zONbDO%kY)7j2zW*5Z41ul zRj+h|objhTuWlWE1Q}$iVb5gZvje>8%P?k$PJY&}HV0Ps)J$6@j= zB@!x)S^6^I6mi0X3j+E^NlJyvF#tSGT6`QN`<5o4&3bDVZRct=rdSh{1y{>B4SQ7a zm@GZCU?4Fs-S+ly^$lORs1ee{mBEH7&Gw(%U@dDCd((YIGq$9D#HsnZ<`MK(ZyrZu zeUOqMu<8Uuti+|o^$DXYl-`1Y!o`G1bzS34Gm-B&X&I%#l-b>FDg<4ocFil3Kbnsr zSXb1o96bi<N-2>_(rX$;|jO{yded^(-gJ^kU zFdVm|hl7^jk7%WlqfwtKsJx(Oy0WW$aq@0H=u?kE8KRe1Je&<_gvh%mmQCZ79u)sw zbpxIa-59Pc)N{MB4K~Y0YdpJ{jiAv&TV0R|Kq*`AM+RUP$~aXfp!`PA5%d_TWN_{%zhni+BgqRh0NlCMH%7pI%#R&Tg ziBL}yU_**Xe&47A;I4)c#MMze9iSIh?MS;1zTjh2!S=+n2@)+=$|WkuY9%yGYAB;D zn!B`6r-7CqYtvb3rB5w~Scj-UR$UZy!s<|r>L`TKdT3tRNstF3YH4cG# z6g-x#xLlrT9+;+T(xV`KgYL9A4Uzx!AZ!;W^3p!Doyn{H8vEnYz* zlgU~?^4SU~_C5?I`V4i>=(Q0E-3HJde~he8wHp(Re**+s^QL#9!K%~wIALYt{EgJ| z@~?C!>`o^xOaLt6+tv3FDuHot`)u(I%8#Q*8?&P}OD&LY%&M4TM%iCb4w|f_a%~4t zjeR^Affz=wUlHVb7@Z zX?vlUN*fU@d+)Jd2kz5j=t57ujhDmK(Spyx2ET*?&L1(LMo64}1%h{io;QK?Ur#d> z^`Z~k1dq)jsw=Jkpa<^^2Dhi7z}AsHJoZK{yhnkmDwR^4^GjrI%b+lvTys>}2fK*- z5ZkLc*tnWfR@($E$U+a4VI{+(A;qHvy5-Z!96&q7^Ku;5Rz_WS?eW_idfM~Fw844* zWqG(8L%AAeLa#1EPDN}#|qj?;Ij>P;ZTiA-B5 zTS061tn1pyR#48Q9I1ww+N{~DZh*B?*>#z}?<0jD48xZ51IR@5i+0m4?Jd2vSl+6=0FKGNPJV zdq^{sC9Bq7MiZ1g-5gq!N2RX@0G*hOGZqMv!uyJ@ZIT_RZn0FA=0e zT4v>ULN(6kAGu@;+?(D~QC+1$rbL$0a!K|1tReeJmmI5LXF)@wBp6S zECs?y-A++Xk>awKwenlp9P>fsq~RrRihUPus>f6EOzGT#L#=h8G#Am+K{uRBNvo%3 z3^%M%l&SNBDi2WUI(PdNC$deL1-Vh>yi|LlDs6H2x4MbqkgFy)Hn!?yLyBtoWKb%l zQNu23OJwkFMAPj?a95PcRQ(L@DjQCyGRxLdW)1@5D)mb7)vA>QL`#0%Xy*GsisP2! z<-!v>ixE?*Iy7^P(XBGZ<&O4V3bXZ%^-R2xw%1$&+Jg-G_?;K^K&a!=e1@j z{gyH2_F`viF)L>pt`d_-c&(*JX?@r<`J{azKarpEilX1}a;;x3HboU)gy~HTR$66` zkHyKTG`wz86{>)r%|vxrV%0L1@34Khn&A?l)w=`V;WvFUiw(fM5~~n(-f>%4sl9a* z0oi5JP|^*Qw#99#gEJsmS$9>pcvnHgNy2~?{E`V?q^t2J+ee0ar~^DGrde+oS|O^_ zCE*`;`&nCJB9WyYHrN-UQXmmr7YeFSN}(yNgfIDBm$O+Cv^GR6wBq9DM`fO`Lyb}= z?Y=T|!?9t~F|)cY^2I0YJz5Uk5V@8(V|=upayqgCd9&-j3R0%GoS$HSq-_yyXF(!upUeb zY%nMkKe3Kssli32Ak6q6IU3o{#j~!O zxrP!CjhLTBV6W-y^jUe1{J7h$qBb&DD>ub8BLO?3-Fv40iW5|LwZHPI|y8u*b!xng?jiJbkiyrzU1M zqkh2(jEufPuq8eha%P;tXKwg_!v?9Cm&xgxbx3RA$U)D38bP}Y7zS#WV0fu41ru&LY7r}V*};;4IW;t;wN^!A!}vb2asY+rnc%`AOUa6o%(s14^A6q zHe|FipVLNF&9IKFzS}{G8M7YQFu@@OJ}L#Yh{{b?qqCKAGpSQGJDe?y5gT$*m02kn#4f7V^11f1#hcm8PzMV7N`$7N0S{8aEJ+}- ztj~JQne`Chup8*b;Cj)Y5Z&T%cZwzuXt2?$9L+mzg1Ka57k+)a)s&q1hSt_R{fa)8 z-N_1k*T z&l1luYuFs?R9@0JPQ7)K$>L*Equ*rmC zy+_%!b6~3x{mmA&OCk6{V#K9R1`O4wkH8qVCop#hx@OgEvf+!-?;Yf%ZSrU$6s>B) z6v&t~d=uBvL9M@xJEgEkUliyIdEIDH$f4kEQpXiP>50T!Awt7=2lxem)62BoYBe(vbwdP9Q`b~PYReWYTsrZCYm z0PY&f#4b#Od7{=3pT0>|c_XgPtka=pcQ&V+uO6ca=!zDc<+h-Oi+wmJ>d-=IpF_=n zX$SNn#}(S5DeS@}1(diOOib*+xm0vzu^EdjZfH{iY(nGlcD(#u%4@1BvfugG%fs|p zm5`Y79D0-dSS+Qj|2}2m`BV~w9>HY1aeP@O6O6A;MjsT+y}#NTYDbnez2He#U9VA> z;JZ5Wmm*LwfrQDvs`Npzj&&g*^X@0{G5giBO5@5h0#ggHyD^WT(6dKvGAE4?tSaCy zU^A?|OL8cw06+%shj-L=&(fByQX!fhKI_q{rmsnWNOvZ|Jp2c|4Q4#SP88f}>UQx1KOIjGO+s<-#plL91Tm#gErFnMLc9CS!o z36MfHx=LE55|E7hJziZ!Z9bV+#dw3AseNf+j4OUzY!XDDpGNFPMWrAbZC>f30tY8jphjsm7kma7IJUU@&UqlDX z8CKCD315&*F_$m=3Tlmf%iS2yK2W# zM(&!rl#C{C033`h+t3=BMHNOfma7rhQbVZ^p{T|O$cDKTS7hNnt4rH$K(BLj=k(=L zTj=4r>dhy;|3*VS-{*o7}ZU#Kba~`&k7db{fW%i%T0Osj_jIqMGfA z`R-IAm{;Dp|l$ zPqaMLzV?1M_h#b3PPW=xEAS1%nVzBuBwQuSh|?I{}_zDT7c&C#Xp)YmFP* zz2du;qLNVk8`Kurmt8;?fj9wslN%R~<;it1R){#Nk{2W1-E)_C#+ehOSPjSUK1JE` zf=~C;VWW;}1D49V2z@Yg{R)bVdSrI7-WU^ZgFZK+k58RpJAkTavBpux6yflgI%73(qHhS<)4u24%0+B{sFetd($EBp+0^Tt1`!FjV<-omsmI@uLfY9dz15$ zPi?4oQUi$Z7Nz3Cqo(zMaMS{=uPcI$j}z!(Y-@X!<*v0rGQO&UM?@uMSNpI{5RTvj z^l(=o5B@y^@Ig9Cemib%?8-N96$DL0;+ggfWhm1>bgMXUQ%Z{zDHSXM7 z=EFfYscJ9N`QY?YM)6d%qgesJU1dXX{1_8Dmx7A9R?QA@zGi3YRfJuPQLlG}h+3vl za;|wSvNXU?>8|ae;nywf8D9ow6B5LlQRpWt8TlEzz7AlAGZLO09wMgH+ZQxRQ%2NH zF6A?g3jHLhb48o!tI+w42(bOyyrx&%WH9bUx>kZkU$IVr zC!gx7n0e3{pTo$9>p~Z-^i8=YaT)Db@!wi64IyWi9OJV704}49-$><V|4lC<&Uruj(UbL{IM0mopW!f4Z55IKYXq0&n9F_0eLR5<6#U7X^HhP9TD#h?xT^x0Kx}enH1=G`vmid@@`j>p z>9ZfOBCOb?@&d6G#~`Y^Ly>5{z+Ws7 z3Zq(GzwD>9;;}!U7piCi+h(#$U zW`nCK$llgTcYSo*)M|?_AVI1-7~p1FWs=D%ak{`n;I1xu)2^F~Pb@+__E%*q9Z<*s z%gq{9{Y<{A2>>BSX!NZ{uPQLFlceNWY3FP*oE2Bl=f{jrl<&adQ8WNQM&D@3N(16(7c)6f(OlJY}?13?M38W?eXP-YRR9>&m{ zFPTT@?2wBdw!IpX2vu!IF4}6TI!Hp>4G;V@K`Y>N9KI03!DfRxwF~900%rBeQPep3 z%xXM)r)SH4`iMAG+*`hvnUS&JFJe%770MAfRGtCc! zzeLH*5kM>a@$XbVASGZrJ4~)ee8kQSM~2uX^L2N{T3u!VH?1rvNALo&iXha|Zn&gE ztnHC!h7dpR*vqdfj-(&igFc~&8ckAxv}%w*t{jyNe4T=8bA-ynXIm?Z)BMlku|~+% z&i9R+Vt=69Fl?YTC6awMjMn8C;vHuz30@E!MRzV#-2)$eJ)Re#OdN?Mz36g`3WYE1 zz)}4g)M?N9`c-9}Nr~F(y?v+EBB&JWw>!>=r52sRg6@y9f3Dj`FYjLd7!P zA*pfIngrEjNV3c6JJekZpmaomz+TuvWt%d6r&7t8=F$q>u)?g;8DFjHV;)&4cSnYd zeFYRJXSmj+)*i_XJlXds`b(AATdTI}iUqS_%Y>2cvWthzY0fARgbE;&W3U?f2M*e* zVrli1j?u2l!uS@jJMW&MMsl6x`gZe5X$BbURB6p=?TkuB7HHelgUqq@4pOF`U6J+z zLkV58$C!nZErZOJ@LSQxl|^5*SgIqRxo-2hexMTw8s59PeQg{h#>YaP0q+w>%N(4J z{;hQ%Gv={OD;FS5yXuBDSq%SBa}_?xzr5G z1tk=LY1xBZ+(`T}TuZ`xoZDOM#@xBG7iB>yN(z~!4!1dLXC%ZD&!^rHeIr-?R z)XJ&zZM{oxd{@*v3`nUkiKIUj<8+`5x^}Bnaw<}>PGxCSW9l++oEesAlw$Bx*Y>D2 zGuW%(4Geo-ujvFasXG$gN$|rxiT0OrCQelq(zg%Nr8Nry)0R#Hv5Xf@;G(bR92 zSq($gBv}d6#$TOU32A(1J63wgEnL~dWRq$_=eLs&;$$HoEfvrU_9lX%ue`R45k2W3 zNos#9N^aKPpx=3o`u>)}x%W!afDK>Y-I?!or)R6S^>M3;=e*o8fsQyNkmy-`O6SzC zi50FI`YGuROfzFSdYL$dYCa6z!%gYcW`=8DBvZkc+%bfj5L7~3jX=&9(g$82`N5X8 zGPcc~z1YOZ=J-Kz70j>#7W}n>O0vUd{mv<92L89Z{n*8N`|R$>fdHl6_ewR}C@D zaW(`16M_V}!ZQI92bGOmhSE2l1od@gOSZXwpXfs6-gXI@O~%(Jrb!J?ioKe%rZ@}L zBjIYr=;e{-qwAjbSCJ;$2%B+{4?rr>E7cuqzcdukYC$i32QH4;!n0{_)lxNhl)Uhj zDb>eXdhhhLcpjtGNrrn+*1nn&CU_4sM>R8HFpI>qVz{JCKcT(7VuRscNxO6t6E6@p zg4eO=vf(j$ue!S1gfRK1ZZKy9I2j(CkJI=I70 zMWgnxb*P+mbtaG}x_0ErjirJum$o@iTjSXW2&#`?eH$eRi7eiDYGXp(M-r$NPWrCm z+_nhzJtRxvlRblre$^cM5t~`m3O0Ry`Sm7hQIx1wY})sV#cr}sDm^-NM0^xOkYp59 z*u&XXY{+@4JshDI;X6uB6DG25?Vu>24-|bq=)SvAQqbV~*Ti%p&qT^@VBh0?;H)@7 z$5V2f0&R1^5*~};nPNkm{HBDTmMx3u>0t*@NkJWRqKX_)Ob^q5ZwkEF^6KnH5?86H zG`JOm4CXuujlG%RuF6g5nvxBwhwh%Zu+#(X#!-MVJC=&g{+Mp9y+1(fKexq_wCvhz zC>+4{flPzF)Q2v;Lva=od3x8OdB!n#LimORkaxaDswuU?=X21<$MM9x_V4W75}lvg z<;#L?@;i>r*QLCYI7O3>GLAUuE(VjnzLD8!HV&%LHu*tW(}EOKGiX!+*De-RaVKeNh{ed0Z9t+HxifS?3AtRX)%yuaGt4 zzyPSSukDa9_8?aWX|ACTnOpTmDx7T`b^_-E#)4?rAruebl_!xLPQPiG*ic+&AW5F? zNgcKBbBFs(BT?6Z^UlWTzO1K8%|3n<-W$3NI?3-HLD^8%R>b8X3)H&|th#y0H!s(m zQwbuEyfe6( zljcQOZo8gqpso4L_+0Lb z?%)+80NfD8;wBW-=iFKmwz$J)dcn4#uW&&I+q5hg;;h1uC;q_Q(%J~%4MNG>i>9lt^>gU~v zC@3=}c$Y_LIK4DtetU%$dY5p+I@^wk)=u%+?&Y~dm)_v^?m;Lw!*qAah+@;e+Luw| z4XA@>8j@7XSL5xnQnKXRH!#b+D8kv5Wea!T?ktvOK8{54Y$2f#OB6NBr~<6vmO0;p z7(%c@G{Oa)#G*WfQT9VGrE|)a)K|zN5woJ+T0i>JS_^=o;6gy`2ixas_6W#!wYj`>K`2K)s3Zom-)K(F5yRkk{As{Z z4KIp~UTtAd(Iju66sXsCRZ-B2HGro2kQrhLFiM9Ry;4j*iScEfeDFNcAI)z(g@|Ym zTFi--fXN09G!AA6nwq7z((AF^*T-X$i**ZrM}?)YCeRnKr5JJvCqT$Cgyre}vQpmP zwIUoU(BtUcr+7r>IDy@iz|P?OHnyCkD&lI8acom)89!p1iM_5mDnw$ZB_r|VWw$MN z>*G{}Jk|F#=Af~gqYEUSHh)#AiCG7BF8X!ya_Vv+m*Yif@%dYuewNOZUoex{`f61D zoIDcLs2S{e;ysBe%~e_xN2^XRvoO+xw0J5PkYdrbH$1sA$E4yt(iz*LMT5FD=2OC| z(g*9&*cM)3@`XH?Qqcg7H`7t+-qI)lY)aA z5(zgMeh~_RbY=nF*T!}%pHPjY48~+(52Nypj)~SRK)$5737dHJ4jnCMhjpIon)Bz=lY08 z@0z`i#s*{o<83ONMulL^w6g`}>{;b&&G&@Q8|*%fV6}Ma#vxY$yZa>sm{NN~W3D-q zUjozzIIo~LWQ=`EVHmDpkMz|bZANXE`5AK|0i;ha>Zc|#3|T;dzTmlF&?{Q+jX~sH zQ+o|2&s(5IQr^>eE1+5t#z&k6YMT5aSnv9TcA0Bq<3cbN6lOnsD0gM>K+4N@8az;F#_Y?) zmho5>WfhdZWqn95AdF&9Frnr4`9$VUU4nosA{R)QA=qhf;@IUw;vwWm;DDPfWo63{ zgM3)Fb+=mlgvXWVuL;FY#$ga;D{b z*(?EGLD-~6Z(zqWtBV3b*Hj#cz9mNgo#lN+72GFkniY~&ZYmZ=Dz}GoTYQJ+`m_+U z%hJ}n6odW>o-r~FMkh~?wN|fvTCPkKkcAln6cCgRI#`gjz*`r#_+{d<(plwPAA7XD zt{sdZP|<@{P^9f5>OJyyH-OcscOdalZ)N(*pXrG`RWX-@d!-?vD)h#S}9$cqikQ?W+Jo z<0>3>b}?1ZE9eeVI{au{z}g!S7*|{?+NsgnugZ;WmYG^z<;(6&VGjm_m6Wqm@_pb= zjjbu8OHZWy&|7|P)T=>iN(jB=sly${p4&FUSicJFq7LPB48nf4EmCkZ zl6Wn1IkK!k<9&dkwt=p>-c27b_)6&nI0y1ZG0XP4vKuch<3w9UcW(VUV<(FC*>OswV_oGeM7hZ~^ZET?xKcfJQ|f{l z8A@pJdB4Da#YT_$Nul*YuEJlG1fR?U^Rd@Aa1HV7VjgT>v1h3XRwUC({mWC0Z>;@D!**nDPjK4&1s#UR9RUlz6YpcM1esNL3GeXGuy2shb`&DA902txjPb zuP8u&k`RXHFtvfy?cH(Y;)fi)lJE{)0djyIYZ2P;ME!!R1g$+9>%_*IQF%$TNuDfx4WGj<<%nT#`6Z*CD+{oO9`321&X{T2%;N%6A-}Xx z6^g@BAw)W2j3!unZ$t^bP%NS3$$-A)V~%q|L?i0gMpPZ{<&0vH^FthjvISY_BSDBcn4U*9G~C z0x=sVaIc?WTq@aN-D=QhS%k>t&W}Ux4)|H8~kQhKqbaH!` znn%=oOd~*W+$0@s-TgOnlz5AE@|8$Zo|om#h-< zWpB5Tp$L`i_DtpJH>9VCx7SH1u_|9~a26oftUl_39%0-*8#5++ka|m?8Yn|*$7w=L z^Ij8#2$G1;ea{?$z=o@?_HYehn4_b-%DuxUi_$jhF}R@~)O|&nw2oBD(ddRflQ(i#&kkkUmp9x0=TPPKo^D!EN;**^Z3x?hOR9RJ0y zmNUI&VWM|Qxb9W_l3JS&I>QAz9M!F|ABtT{fkqeld}=p#LF-dyueOP(>IHp43Q!;u z?jQ(Y4BGn_K z&DwOqh@yt3S+g3dfOcU7-rb8E6~bp2208Jn!4IJpJT~#b;6Y>ovuIL!h-z5yrG$FP zJN3#%hE_&0D@WDUWt;5L#=T-|0fAEK%TSzTtl)@EJYQy6`J1p+j*#vrXGGT(Of&c?(^5#;+{YG$dh1-S>+nh zVhWlwD_t(_^No33Tf=^wE9Dj5iK4`4bhm(fW4BPn3u>L6R2ojKG~at*p54Bx2|`7I zW0=TrvIGbI21ebKFwExIhf>35Ikb6(*JNvII6nxor~>maerI?Ek$Sl7X-QI18z>&=Kmv2-p zE~u17enuz_Eu`8$(=b48lI4kI`=jSkJMdIZX9!hFaU~Rs7xAX&h=gnfKk%Am;nYVG z=bO&mE!8wpRvC(L!fwa_Dl2n5j=3P+_uoK)uS=6yTDteMa zUQ-tUl(fR+3wgR~YZTQM<7Sq=U*${;2Lpj|kNwOA zn_>|hnIQExU~0bV#gMOc>Y8a+bD8{?oYaOP+EygR0Xf*}oVLmPGx~1b*qtYvd+oXf zC4DjEP6m4uP3ugb2R1Lw>iht4Zk9(uakV^KwA9Zi6UkmNm@PPH#szdSbc+HgO+vE; zn=Z(cncO>dv^zL~NYNfBFqL*U7?y6tbR%dxXy`ZmkX|j@Osjf!)sx8Xlel@ZoS!Q% zC4Ix7)Cnxjr088I5`=GHKuesN+q~)bQHl1!49ye^cV}n)Qt8@d^6XN{OL8RcV>Iqc|+R!!EhkM zk|49^3?ySM0c1&AN){$NS(pOo(?Z$z!$a(Iq(o? zzX_oW%QbCd&h}3E9&|O_zzva%?n1R?17jXtFr?aJ6sOSj1Ed6Z*g&9BBYk!CU3lM1 z${hfYuZt!Y)E$_$oN?czC}`5wSS5T)0T+djRpl_E?%=+uH$<(1^dfGowd)rdHq?n+ zRUZTTd&?ABCw?G}Vdr3d&9bLuZ|DJB3lf%X0H^g*BH!hXs-29b>j#PeR@;zw2#vQB z(aEW9BeX%Eg5UIBBHJoA!>;-*GyUx#Sj2%z)WaLW%xQ#DPLvuG7t*i6>jqj(g>`42 zg~Xa@8*42YzkTWba?3m0DBXe9xIi7R=uzBT%dgZ5Mzp|fUxAU@^Om++abeUV0vCHn zp?Bkp`^wu@QNdFn5i*ewBE6uUwuY+#!%hdfE4DdHwAM0FG-G1HTt5Bc=Yk-uRt*YD z;pX#Y`1%ONf%&3d>SP=4q(W&M*AYuoDGA{~-0Shg_j47#bvT&h<+K;3NEYa#4l;Lx zQ-w7NGT6@*c_wZ8whGq5LpdtGFG#_sst=kt-#WkTp1RkJu>?k;J%)7JbWm`e9u&Pe zgGK2DDC3-!q6Mta?pl*+h%yzCXE9B;Ty7bebN4%-s8Ec(`f6CldfbZ+pMp4hs!lNt ze?3)=A5NEZXv-R{N0Gq}aWMNq)<6`v zMXeM|8LAR#CDt*fZUZ}jmG#(sCs{w?tAx4&Hs+YpMi5j!+kISf)m=oLgAoGMo6Zg( zLd9sTO2V^2_Ywot7QceqEwi;zu^d&vwNi+t-{HMx3)W&<7WEs0u-;}%ZbFr8y;w|d ze7aze!OWreE?9UmmK9Y{I6j#IwJ<@)vjbrcHbC3G$T--CG*i4W3G{G>0ukK)V)0u7 ztqAX1>zRI!+hQpQyP=X$6p{a~0B>r&HgB;T_4Br689^LmO4H&7UE|WW2$Vx9qs)EA#dXl|&YX zoFqBcHxkwmn-YG0SY`w)v{*eQzX;76ox6y0^b2HE467$`RUaW>2Run)jz#tTZFqR- zk|^>WuWl-Hv?zR@3O))0=c_5^2~*0*@)%Z1Vn&XnmQ}@P21m&1Rb&MlWR!wz-hsH!=iHP`ZvjbJ zPQF9b-OVz+Nek_>JqU@dH7vtD#w`zhx*VgnGW`Ht7x_#MakS8awvfCR)>P*d!2VE* zhFsMY9+HmCWJpL~yjkb05rupsG^ucY`~~Q%iN51>)C^DIuw%g-<#T1(=JuX;t@2dZ zTlUFbebRWdKb2Flt`fvTAnDD_L7Nss7Zi?ybq+_^cNj`uA>=J*L=DMVm&YFYQ+(I$ z04E61&P_@qkwXQ#*o1aQmwHCo@4%g8vwya<=9 z3_?1J0|Lq_ybV&yDl9Em#o+#$$m(uBp5K8|HlqF5dxKH+$(i1~(pCWGTx2>fnCHKq zx+@+bsI`XHPYV!bS!;B3(n~%h{Y;+4fC6vnt12?48f@iJ-_Il&ZWlv{Iw$4(v;LW&Ef2TV08$e zKTd6_?6!Id(VgwvS>P6D(yr|8#fcwrz)Z#H9G1%*dN=aW+f4)jL2Vp80MU7Kj729hHgR4!^T)L2YV z$w(rSO{PS{=cuMfE8B$Km^|Wa;N9(Trzvfthu693qAQIttTG#} z5+)Dw4pgBuEL$QA{4Gt93u}3-eP$K=)CW9!ouRAL!Q z6ahca}ztQfFpUbbLp$%9mmmS7Ay*{N|_a;=)SPu^h=U742s7iopBx&92&lBSN?t~x`A zX{0bFojEKhJa}RafQ}x^4&JMh^Z|2s_s}~NK~=ENm$dXW(%5EO=>`iBX3#E9^8B>w zMvd9}NUDq7p!C^3lF-;HTZOi{%Z7LAU_hjOvo7%Mnd;3;$^D6uz-U`m*L%3&tu5ie zk*3LyuIxLLEyC16-+`ibz$pT$(XZX!J2Tj>tRf5E)G@4@eZR!Km)Of26a;Pcu9EkV z8%Jj#V~K~0YvX7!74ELNu%n39KrSgFzWbHWj*nAo9DwWv>P0$+_wdE6$%ddXf?s6v zFx4^kdXl_KwjAPy8Hy|?mgreufu2fm^?7vKkE(Vr3)J0}=2I6^m?nwu6O7=G(=wuq)*z@DIPB(8cu&rq_U#LQP;TW|R)tDxWn++_V%8EhYHcOjiPic5n9cyio0 ziqEfcEv26Jm>gQs#)vqhj890TI|SE1&TQdWSY8WtL}L)ys?G~RpSBhd%1^1|8V8@}k>>j4K9 z9a;=;qY|I`><;iU@-auF=9y);_A8nF2Vwhp;AAru@4Ab^spK5DBiQ4OZpWz7stRh$ z7*bqpO&nPaFlDngD$_oTIjXCg1M1)GDsn&NTL5-fr4?m1IEX{eDXBrkd#pFCP~|xz zal8ct=}uwS4Jr6?DaXW>hcE=~Qu^MfppGDpQ=4C%oRz79dfJ7a)Xz4oW6OM78l2(~rG-PhL)>Xg+tX((i3tcFkG@qri+&HET%)RQs0)Q~;htW3VhL**Sbf|S~N$t=oO+>ka?xx>GFq}Br2!^aJhnl>(&&MwZeGF z@433K9jKL(ucnszns6lP#@YEZ?lLv6H$~`>nJV$3XeVT45-w?ijp<%5BY&u58O}pz z0fvMKN5v*x$}%@1hW!N`YONT*3_QI`J&;ozs}pIdU|ph-SHS{TmXaiUI3~u~XL_oi zRD*~V#vHs>vU7iQy*x)32|8m`IgKDeT$@Pc(#vJTi88rtT z8TmzrYzOpqDAK%Xlx-MEnaWuS9hTn9OLNx~IbVR7K8A)`;tM?(FUk4cee}1|vZ+gH zHaVvzS9`2PnK$58NZi)!XBLwRe~JR+b5oE**)Oz4Dj1uzqJDr0s3%Tngu;vzy3KTY zIt37p?;S-&5uXp(0V_*O;`p)yTUcbIFud@Q&c%P`Xm(wF?nvlf+F(63uyibx<3@uh zvXr`X*8sNM*fM;GQtN)?(154G%)2Owaj(+}(_2q~Ec3ysFjaPAr1Rp!kmk2W<##bz zdh1)(xG`{r4q=iJ5iWqTDaH^Z07U5r=>V?a7y{ifBrqd7K~)IGBZ41gT@cG|zZj2ai4%9r)Inr`Ud?PZ2 zV#I6H823#u_d)h#gQf+j%ScpoN@v zm~K=&QHn0f$9R!JxJ82v71%C)=7ZVi7^@{;A8I8!i0{KHrJaaZe+S%)t71iU+<=?* zV~%c%XWOP%<*Z<66TkA73sFih7=TIdLfAEfv6HXWZ`w}^i#lN=es=q8nQxJvWZ%Z1 zj`J39+#ke?QF~zlAg7b79?1uuai^QPepq0!Apj(za+XUPn)bbo-fVBpPB1wguM(yYJ+Im zWTUP#)X(UF;#w=R(+rroJTU?}#^9>C`jL%5|@o10Z zcOipLyJoW2ixI+Dq2gWE_yiEhoom7aZfDAv@ncvU-5NnPky;&|qgEJ__on2Y2u_P< z96V_Kw53PI^2lM(Q`L$THrU+T5!xPtdI@|D7Bx+q;ilR9l1a{Xv^09SBb+S|6VNppKQSSN9ZUkJT73f0AOTjn6mq+@Hhy^BVXqmQ9$MA^p{5?-BkT_^tCC0&)XHg3)X@c z-pQO>mY{t-Pb4Q=c6cXRQeY%^oQNbKIyncqWo4`)818GXn&d|z#-(TSmNbS!-()xr zhV5#~*p{A~t}(lFRkbzQp+@J^*ex4b3OMT7RnrDT3?5(|-&XZbj}tUi%w+5Ah6e2p zTQvyg&0xFZGrYxEO11v;S!PjXbI*cGwc;3L(zsKef-$HTC@Y`BYzJ3MdWh^waavBt z4#D==piZ;9|JX)Kx*&ujbTw&S4Z~2UsC|l9r|^W<63RZ%hxJ)E+6jiB5yVRgHA%bp z5CU6fYehG7!zX+NIYdfO$Yl^F4p-w5cp>ys2T4JoVC_Cbv*MW#vkgM>s{1AO^-bB> zvXF0Sgtm^7+6bbqOj%}jB{J2bEd}xT>1f`avB1ex%SZn5m+c;?Spk09ne&D0Ylc>> zsF5>r@w6oCEjs{-)xNxd@HBb*<56&nIr<*} z%)!yMF1{tfdm)bSug6o>ja!a25oq15BxBlw3rin!`k+|9zSDit`=)5CnIfHQk$EUg zs94}ZzGfY>AVAmgK1u3$as$6l>T!Btqj#ksXD|W`$Id2>QWhST>x6LFErn{^Jq)FO z+mleP+{q3{P`^BDKT2ivu7|tZfcT~!Qo6_?e50%NB*Z>!RUq0&#M=mJ22I-SBL!0( zS9Dl;w3QpL*`Ld6%Sjo~x4Dec{K-g$QB>IoyyD2F>5ejrdOY|Y$HbWNPGeTO{Tsvf zW1o95*OE!R9<;Y}?PYXdTB$ODx+%@A@ouPfnG{K~4qaH0tHB-tDQs70R|Z`vkY zbdj%C-sSDQhWaeoFGqtQg!HNm=%-SIM*Xh-eU-IhOPZ=s4R|7g3n?XMF}gZDvN0O7 zK~z$!6hK|?Mpal$-E+(&I&AOM0AW*OtKfwep)UmB`$E*~a2g+s)J+j)aORg?H%wGt zAfw-2Nw76ZFwLjd%QyGS;T-2&*1dYN9n6T=#w2RGUg&O2R#Fk$;dE z${1;_%!RI^u?HNVRJ7V`hst@IDeLt`z*9@e@^zO9$%8?-gahFvv1T;%~iVW{FL@#YF)j>GqM$| z*V#o!uMw@<@?%TEu~_c`Z0I@^&PrS8i;a?&2cwK>3H9Z(6t-cDv~qpwv{jBX-6=ez zXILB`sSp(8=M$(J{r)TjoJ;l?O48GyK_5E1f-bs9+~3rBQR^&{#HgYsfOA5zig9Y? zOr|CyXy?(#9e25(CVh^>0t7#0F6y}?8!z-zqtF-a(!kU7g1R)-cq($>$On{7yV z2ku^`dk{w$3(Y9aW8O~L`hdTg9qAJU`td0aQgW*DvTtGXkb`PT^2l>~qJG=)wa z3)zLYDi!JDNJWewhUN>t&VtfuVNN%Ivv6}!F%!xF6sC}Kk2vk;ZlmHN+tpB*g;Sap z`cyKZH-et?)tu(;nM_=Fc5D>^({X#njhR|-D9%|GcMGqksch_|^|&gsJ3&-ysxNMB zL{;@Uv$$Mskmgw1&yFKpS|9qm#1`zv4S|Okhd--BT#UiOp@vE}9hNGlK~~ztk#3?Q zO@&jeH}?4e0bvI&K0HIBDiXoDs6@edQy9L)ubX_)<~4WJW+)jw2zC7E2DS~u`tMUf z3(fao>NyC66ILrjt=UlxFEmisf@&D1;j+*)hQqnDd7&6}E{af0NgR_I=-IQ2WiRt# zgyH=l;ruJ1iXb|FN_}}}opiMF9f4Nop?Z%{T_$?&^tK?F1pV6wRci1@$kIWK$_1zJL&*la z62;kmPnOqub-tZqyj46U-Gi&GRZ6sUID?Y)E|R{>;|OJTHQVbnIsTqewS+0A3Cv^m)+B$YP7vh8GTwHA{>5vfLhPb7a0PA# z20BV>p7>;O7;$|{JM-l@(tx;ACG$m|yO}+zLWqE0`V_UK8M9LYaznWLGuXGn;C*9# zmH?xTKT~}eWILEl(=+D%m2NdEhRDLYHd%*o4j5 zl0YjSlXZg1!BU_FP~CNtjJN=HF?!PAnuf!UeeR_CTB2rJ&`pQUZxoP2NIQk?>%(t2 z6=eW$IHDRFwzgzWvdAZz=ThsXVTN3z2D{ivBQW~HN;2hE#|YTIj?E~`4# zOOWOc;=m?t72=&6d=!Nul3z+nq6Zih@9T!5Bb-J^f>{k8UeH62uJa=&U#uUP8p1k2 z79J;u@=HO8^lI7Bc}K}z@ePf1f9P#?>NUbCOt)raer3g~fJ3fquJn)MoVx>WfU}m{K|g-ZYQZ--V8GvyC63Y0O@Oa;Tp>x*mwl zDzPTet?3)ng3=!v0Wjs;pRGO03v&7KXf`rz!8n}=LGzZQ9x$ftpm5=&l}x`mUUoN5GisWIl+l7 z2rRoU&61-c0;uP8_GRaMnW5Q}OWC4m?8BbArG-o;MwZ5M0h9zEYZRf+HZ+5dZslnP z6^LsWn}%R9c!N?gm_UKHC+4N*gO6_aN;F(t+T>7%X1gTsZR>4Af zBI<_ylXum=4J$_Qdej`8x>1F2)H>DwN{M6Tw#&Q-Lk%`IE@ku`BQ-0>aZSx#_7riz?YO89jd7tAU#f@6n z51i~Zhn@f-OKgcrj)VD|t$AR@pvT7DBTz?fsod>n|0Y*izF?$?okg$Pvph|CcYjqv zu5dUnRY)p$tVGv_hOe_8{K_`))sc&g`8@+IXveVylRSJob3T%F*RDcO)`1)yH*gfJ z4YVZkRhYXUmr)16M%jEAU&8w%P0`kL;y&sP7lBXY^i(U$p3jOX_b_`+dH4XgHE?^C zXgbAW$oFgp!jWB%u3l@M2xe%0kn&fYG+UU&MklLJ*8@zk@qO5?DE97;Pqup7%z1Y0 z$Y}1uNjgJF*L0>>v}wU@`JPl1rzlz9(@I`RKqb4}IBpV1d9LktgGOXErn|@GGm6qn%pyU>7X>R^+BX(zNfHU>*8Bon7$BD74f3AeUno6Pq=}j) zkJF+L1fMXRg^K+WoRu=gh*LUBRh`WKJ4}1wKHR3@wZ&67@Rg{uS(lZI8jI6zWC)L% zu4>hsQ9`ZNL~Jw^IOkR*=u$tYHOCrNbgwQt-tLY@v+H(G!=DeJd4E}4HLwY1RZNcG zsB(PVccN7_6bbujXs?Y=Ia$vPBJjnpiaT6@NCQgnyEwis_UlN*JRDTiZjTVzIhnBM zG@_953yP=Bk&e}1!Vo#ev_}tpr1WX`xrHUW z;bkWaZb4$XOdMcE-f>kNQ(N^zuJ`mv2G4Z+xT-W_I8ug(c4Sn;OcCqHv@3Zum1qU; zz(2Q#D7@#x*Nl4v_Io;qVabJ~Wtu|>1xBBuFE{+&SYEZVnm6hzdPq+54AEeFeP*>x z6;L6({rnB(W8(FJsunX08sJQ;FwP=|GmQfT;ofa}ZQ((TXI+j*r3f;wfJhyh98&=9ewf?p+3M)_=QDn!H86&Gz}PR7^m2SOm$5+14ZM@GWqUCm(!$D^^%NDP_*A)FT3+c6 zKb>+|LOj&`SiuL7(T7SbubpqNDXc2>s*q+3g-C!IH>{7~F-48uMJ^-aJr!DvOEqlF zT0RYoYdN{SC<2)}L#LD@C|kdcx;^DS!6`s#Y3x{TkVc!|V&LE_L5EStxX}hGq>|#? zn+EH_y8MRX5jvi42)3uG?OF@fDDmJkAu8#KQqv;n&`)gl8qAQP&b|*DAo)4dbO+^{8_qhphc8%b|lsbzJI8UO9{^Q>saF;UE>G zSz_rVfGOH<%#4K(u7tBoW;E5Hh?UZ@ItkbPl^}|%AXA5Xd119 zIM~O1$cn^VMIP`V%_{SI%xmlOu+?HsHd3uKAdG4)GYhLb%h^Yu`?_?njB*9})U7?V0%b*?-bStX27 z2GEuRXc)b|rxqc0FNe^Q%yv&{Rqq(aCN>h3g_GQ=F`6$m(t#ImBdov9gp;D-%*J>hqfW!-zU*k`Nx z4VS%nLYPb=r*B`SqW8G$Qauq=6N=xsF~^GrHX z>GYua>(+&1n>1=NAZo9es?qg*%KQ83KK>J4Mk89k_jZN*E$EiT{DAmf5Re_O%cf1` zB{n!jxzz4O%D6W+@p0WaU_~8f`_gub<@VGj;ls_=wnh}b9i9DZrlQmZX*Av@*hB6{ z?+V#$wZ~@-Lp_W8c`RsgUED+6@3n9o^!J=|-upN9Ff+LgYGIh4S$Y%{Ghrw}tNnds zOO^lvhLJ_T2A+l4c3Zi3q7WlxPKl*`lc`9~x9f2#`zf z)1%o(6`^_nN0xK+Mxj*TgYsV(K{ivK)BRHFSrVWPY(A$}k0`UwVZypNwG(zmq0^8~ z=TF#0G{DZVbByMQd@F3}NOovbnr%UvV=N)70t|#z-&fUBwmGAy%+*KRwTK-Eae+_t zM04C&^+w&v(?Z?7cb;RYA!0NomNP*I)slt9o-y?jJIYYAc)lkmkTsr2xoNp!6{+-F z{nOtg%z5KDl%DyNw{zESq8D`Iv$|#|m0WJsf6odGIzUlcTyfy*kq8i)<zX?Glx-e8a016W;0D%vrf46J(wR>EaCb3Exblxk(=&vG$qoPP5Td}wLewwDBkRWL?q z89H_yT#@BNMl@B1q^!HG-P4p6q#s=XiXTOz)CJ306{B#@1EJOvA)(qwxmR0niTO0*vA}faFv1WM`7@XVPX^=Tjh=ySvB zK1E5xy})wh5DhSa)v0x?bgjZ+*|#X>SgWiLueYutSS`zRaZBfkl_737a7d!>ZLT_> z9~iA1O&L{@cNYT-`2L!WbR;WinVe;=u4T71i7W3KVIq%-!BhC8OGh9=++V*+E3b&- zT*=x{uwXgbpE5)~mbx>J5T_knhIes%2Le%VytG(4$|tLidwfnuSEN1<5dEYob^lT; z@~~%@RA^%REDICq7=!Rjg|Qgr?36kr8$pth;%rZhrOQNT7rr&@%TecUN>f;bG)R!`uk;q)^Ssi2329EmsJ^IJOM%2E0Pox9T8UI=Q*EkDX(q^a{( zR8XE#3Oq*1L5Scr1R7d(H_54&rWPCV;F`ulg@bpm11o~E!%8+@`Z>&R6kM_q%VWc* zH6)Y?*@5suIZ=k_ly>dS7(HbaM<(B$4~Z;LY>yz^jQUes%1Yua3=t}M?>@Ufzn55w z_{3!HWr=k{Z=hgAZG=5S=F8Yd$c{u?ZVZOhcBd$-fg2`^ct%AAtNHxVKlEbK{qK6f3xmDO}X&9n&F1dDJ^p2c66 gD$JY%z34yq2jsx{KcZ=+B>(^b07*qoM6N<$g1?`V>;M1& literal 0 HcmV?d00001 diff --git a/packages/desktop/.gitignore b/packages/desktop/.gitignore index 6923045cd9b8..6ed850e09756 100644 --- a/packages/desktop/.gitignore +++ b/packages/desktop/.gitignore @@ -23,6 +23,7 @@ dist-ssr *.sln *.sw? out/ +native/build/ resources/opencode-cli* resources/icons diff --git a/packages/desktop/electron.vite.config.ts b/packages/desktop/electron.vite.config.ts index 5d25be209e3e..09c5fcafcd55 100644 --- a/packages/desktop/electron.vite.config.ts +++ b/packages/desktop/electron.vite.config.ts @@ -95,7 +95,7 @@ const require = __cjs_mod__.createRequire(import.meta.url); publicDir: "../../../app/public", root: "src/renderer", build: { - sourcemap: true, + sourcemap: process.env.OPENCODE_FAST_BUILD === "1" ? false : true, rollupOptions: { input: { main: "src/renderer/index.html", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index d134d4c538ab..73a0bb94c7f6 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -19,8 +19,7 @@ "package": "electron-builder --config electron-builder.config.ts", "package:mac": "electron-builder --mac --config electron-builder.config.ts", "package:win": "electron-builder --win --config electron-builder.config.ts", - "package:linux": "electron-builder --linux --config electron-builder.config.ts", - "native:build": "bun install --cwd native" + "package:linux": "electron-builder --linux --config electron-builder.config.ts" }, "main": "./out/main/index.js", "dependencies": { diff --git a/packages/desktop/src/main/app-dock-utils.test.ts b/packages/desktop/src/main/app-dock-utils.test.ts new file mode 100644 index 000000000000..5949766d0e23 --- /dev/null +++ b/packages/desktop/src/main/app-dock-utils.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "bun:test" +import { appDockURL, appDockZoom, panelBoundsToContent } from "./app-dock-utils" + +describe("App Dock input", () => { + test("converts CSS bounds to content bounds", () => { + expect(panelBoundsToContent({ x: 300, y: 80, width: 600, height: 900 }, 2)).toEqual({ x: 150, y: 40, width: 300, height: 450 }) + }) + + test("rejects invalid bounds and zoom", () => { + expect(() => panelBoundsToContent({ x: 0, y: 0, width: 0, height: 20 }, 1)).toThrow("Invalid App Dock bounds") + expect(() => panelBoundsToContent({ x: 0, y: 0, width: 20, height: 20 }, 0)).toThrow("Invalid App Dock bounds") + }) + + test("accepts HTTPS and rejects privileged protocols", () => { + expect(appDockURL("https://example.com/path")).toBe("https://example.com/path") + expect(appDockURL(" youtube.com ")).toBe("https://youtube.com/") + expect(appDockURL("open source browser")).toBe("https://www.google.com/search?q=open%20source%20browser") + expect(appDockURL("opencode")).toBe("https://www.google.com/search?q=opencode") + expect(() => appDockURL(" ")).toThrow("App Dock address is required") + expect(() => appDockURL("file:///etc/passwd")).toThrow("App Dock only supports HTTPS URLs") + expect(() => appDockURL("javascript:alert(1)")).toThrow("App Dock only supports HTTPS URLs") + }) + + test("clamps browser zoom to safe range", () => { + expect(appDockZoom(0.1)).toBe(0.5) + expect(appDockZoom(4)).toBe(3) + expect(appDockZoom(1.2)).toBe(1.2) + expect(() => appDockZoom(Number.NaN)).toThrow("Invalid App Dock zoom") + }) +}) diff --git a/packages/desktop/src/main/app-dock-utils.ts b/packages/desktop/src/main/app-dock-utils.ts new file mode 100644 index 000000000000..9903289e758d --- /dev/null +++ b/packages/desktop/src/main/app-dock-utils.ts @@ -0,0 +1,33 @@ +export type DockBounds = { x: number; y: number; width: number; height: number } + +const validBounds = (bounds: DockBounds) => + [bounds.x, bounds.y, bounds.width, bounds.height].every(Number.isSafeInteger) && bounds.width > 0 && bounds.height > 0 + +export function panelBoundsToContent(bounds: DockBounds, zoom: number): DockBounds { + if (!Number.isFinite(zoom) || zoom <= 0 || !validBounds(bounds)) throw new Error("Invalid App Dock bounds") + const result = { + x: Math.round(bounds.x / zoom), + y: Math.round(bounds.y / zoom), + width: Math.round(bounds.width / zoom), + height: Math.round(bounds.height / zoom), + } + if (!validBounds(result)) throw new Error("Invalid App Dock bounds") + return result +} + +export function appDockURL(value: string) { + const input = value.trim() + if (!input) throw new Error("App Dock address is required") + if (!/^[a-z][a-z\d+.-]*:/i.test(input) && (/\s/.test(input) || !input.includes("."))) { + return `https://www.google.com/search?q=${encodeURIComponent(input)}` + } + const candidate = /^[a-z][a-z\d+.-]*:/i.test(input) ? input : `https://${input}` + const parsed = new URL(candidate) + if (parsed.protocol !== "https:") throw new Error("App Dock only supports HTTPS URLs") + return parsed.toString() +} + +export function appDockZoom(value: number) { + if (!Number.isFinite(value)) throw new Error("Invalid App Dock zoom") + return Math.min(3, Math.max(0.5, value)) +} diff --git a/packages/desktop/src/main/app-dock.ts b/packages/desktop/src/main/app-dock.ts new file mode 100644 index 000000000000..1ce914d14734 --- /dev/null +++ b/packages/desktop/src/main/app-dock.ts @@ -0,0 +1,211 @@ +import { session, shell, WebContentsView } from "electron" +import type { BrowserWindow, Session } from "electron" +import { randomUUID } from "node:crypto" +import { appDockURL, appDockZoom, panelBoundsToContent, type DockBounds } from "./app-dock-utils" +export type { DockBounds } from "./app-dock-utils" + +export type AppDockTab = { id: string; url: string } +export type AppDockState = AppDockTab & { title: string; favicon?: string; loading: boolean; audible: boolean; error?: string } +export type AppDockFindResult = { tabID: string; requestID: number; activeMatchOrdinal: number; matches: number; finalUpdate: boolean } +export type AppDockDownload = { id: string; tabID: string; filename: string; receivedBytes: number; totalBytes: number; state: "progressing" | "paused" | "completed" | "cancelled" | "interrupted" } +type AppDockRecord = { view: WebContentsView; profile: string; notify: (state: AppDockState) => void; notifyDownload: (download: AppDockDownload) => void; notifyFullscreen: (tabID: string, enabled: boolean) => void } + +const profilePartition = (profile: string) => { + const name = profile.trim().toLowerCase() + if (!/^[a-z0-9][a-z0-9-]{0,31}$/.test(name)) throw new Error("Invalid App Dock profile") + return `persist:app-dock-${name}` +} + +export { panelBoundsToContent } + +const validBounds = (bounds: DockBounds) => + [bounds.x, bounds.y, bounds.width, bounds.height].every(Number.isSafeInteger) && bounds.width > 0 && bounds.height > 0 + +export function createAppDock() { + const browserSessions = new Map() + const configuredPartitions = new Set() + const tabs = new Map>() + const tabByContents = new Map() + const downloads = new Map() + const active = new Map() + const remove = (senderID: number, tabID: string, win?: BrowserWindow) => { + const record = tabs.get(senderID)?.get(tabID) + if (!record) return + win?.contentView.removeChildView(record.view) + tabByContents.delete(record.view.webContents.id) + record.view.webContents.close() + tabs.get(senderID)?.delete(tabID) + if (active.get(senderID) === tabID) active.delete(senderID) + } + const close = (senderID: number, win?: BrowserWindow, tabID?: string) => { + const ids = tabID ? [tabID] : [...(tabs.get(senderID)?.keys() ?? [])] + ids.forEach((id) => remove(senderID, id, win)) + if ((tabs.get(senderID)?.size ?? 0) === 0) tabs.delete(senderID) + } + return { + async open( + senderID: number, + win: BrowserWindow, + address: string, + bounds: DockBounds, + notify: (state: AppDockState) => void, + profile = "default", + onTabOpened?: (tab: AppDockTab) => void, + onDownload?: (download: AppDockDownload) => void, + onFullscreen?: (tabID: string, enabled: boolean) => void, + ): Promise { + const target = appDockURL(address) + if (!validBounds(bounds)) throw new Error("Invalid App Dock bounds") + const partition = profilePartition(profile) + const browserSession = browserSessions.get(partition) ?? session.fromPartition(partition) + browserSessions.set(partition, browserSession) + if (!configuredPartitions.has(partition)) { + browserSession.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false)) + browserSession.on("will-download", (_event, item, webContents) => { + const source = tabByContents.get(webContents.id) + const record = source && tabs.get(source.senderID)?.get(source.tabID) + if (!source || !record) return item.cancel() + const id = randomUUID() + const updateDownload = (state: AppDockDownload["state"]) => { + const download = { id, tabID: source.tabID, filename: item.getFilename(), receivedBytes: item.getReceivedBytes(), totalBytes: item.getTotalBytes(), state } + downloads.set(id, { senderID: source.senderID, item, state: download }) + record.notifyDownload(download) + } + item.on("updated", () => updateDownload(item.isPaused() ? "paused" : "progressing")) + item.once("done", (_doneEvent, state) => updateDownload(state === "completed" ? "completed" : state === "cancelled" ? "cancelled" : "interrupted")) + updateDownload("progressing") + }) + configuredPartitions.add(partition) + } + const id = randomUUID() + const view = new WebContentsView({ webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true, session: browserSession, backgroundThrottling: true } }) + let state: AppDockState = { id, url: target, title: target, loading: true, audible: false } + const update = (patch: Partial) => { + state = { ...state, ...patch } + notify(state) + } + view.webContents.on("page-title-updated", (_event, title) => update({ title })) + view.webContents.on("page-favicon-updated", (_event, favicons) => update({ favicon: favicons[0] })) + view.webContents.on("did-start-loading", () => update({ loading: true })) + view.webContents.on("did-stop-loading", () => update({ loading: false, url: view.webContents.getURL() || target })) + view.webContents.on("did-fail-load", (_event, errorCode, errorDescription, validatedURL, isMainFrame) => { + if (!isMainFrame || errorCode === -3) return + update({ loading: false, url: validatedURL || state.url, error: `${errorCode}: ${errorDescription}` }) + }) + view.webContents.on("did-navigate", (_event, navigatedURL) => update({ url: navigatedURL })) + view.webContents.on("did-navigate-in-page", (_event, navigatedURL) => update({ url: navigatedURL })) + view.webContents.on("media-started-playing", () => update({ audible: true })) + view.webContents.on("media-paused", () => update({ audible: false })) + view.webContents.setWindowOpenHandler(({ url }) => { + try { + const popupURL = appDockURL(url) + void this.open(senderID, win, popupURL, bounds, notify, profile, onTabOpened, onDownload, onFullscreen).then(onTabOpened) + } catch { + // App Dock blocks non-HTTPS popup targets before Chromium can create a window. + } + return { action: "deny" } + }) + view.webContents.on("will-navigate", (event, url) => { + if (URL.canParse(url) && new URL(url).protocol === "https:") return + event.preventDefault() + }) + view.webContents.on("will-redirect", (event, url) => { + if (URL.canParse(url) && new URL(url).protocol === "https:") return + event.preventDefault() + }) + view.webContents.on("enter-html-full-screen", () => (onFullscreen ?? (() => {}))(id, true)) + view.webContents.on("leave-html-full-screen", () => (onFullscreen ?? (() => {}))(id, false)) + win.contentView.addChildView(view) + view.setBounds(bounds) + view.setVisible(true) + const senderTabs = tabs.get(senderID) ?? new Map() + for (const [tabID, other] of senderTabs) { + if (tabID !== id) win.contentView.removeChildView(other.view) + } + senderTabs.set(id, { view, profile, notify, notifyDownload: onDownload ?? (() => {}), notifyFullscreen: onFullscreen ?? (() => {}) }) + tabByContents.set(view.webContents.id, { senderID, tabID: id }) + tabs.set(senderID, senderTabs) + active.set(senderID, id) + void view.webContents.loadURL(target).catch((error) => update({ loading: false, error: error instanceof Error ? error.message : "Navigation failed" })) + notify(state) + return { id, url: target } + }, + resize(senderID: number, bounds: DockBounds) { + if (!validBounds(bounds)) throw new Error("Invalid App Dock bounds") + const tabID = active.get(senderID) + if (tabID) tabs.get(senderID)?.get(tabID)?.view.setBounds(bounds) + }, + hide(senderID: number, win: BrowserWindow) { + for (const record of tabs.get(senderID)?.values() ?? []) win.contentView.removeChildView(record.view) + active.delete(senderID) + }, + select(senderID: number, win: BrowserWindow, tabID: string, bounds: DockBounds) { + if (!validBounds(bounds)) throw new Error("Invalid App Dock bounds") + const record = tabs.get(senderID)?.get(tabID) + if (!record) throw new Error("Unknown App Dock tab") + for (const [id, other] of tabs.get(senderID) ?? []) { + if (id === tabID) win.contentView.addChildView(other.view) + else win.contentView.removeChildView(other.view) + } + record.view.setBounds(bounds) + active.set(senderID, tabID) + }, + navigate(senderID: number, tabID: string, address: string) { + const record = tabs.get(senderID)?.get(tabID) + if (!record) throw new Error("Unknown App Dock tab") + return record.view.webContents.loadURL(appDockURL(address)).catch((error) => { + throw error instanceof Error ? error : new Error("Navigation failed") + }) + }, + command(senderID: number, tabID: string, command: "back" | "forward" | "reload") { + const record = tabs.get(senderID)?.get(tabID) + if (!record) throw new Error("Unknown App Dock tab") + if (command === "back" && record.view.webContents.canGoBack()) return record.view.webContents.goBack() + if (command === "forward" && record.view.webContents.canGoForward()) return record.view.webContents.goForward() + if (command === "reload") return record.view.webContents.reload() + }, + find(senderID: number, tabID: string, text: string, forward: boolean, notify: (result: AppDockFindResult) => void) { + const record = tabs.get(senderID)?.get(tabID) + if (!record) throw new Error("Unknown App Dock tab") + const query = text.trim() + if (!query) throw new Error("Find text is required") + let requestID = -1 + const listener = (_event: Electron.Event, result: Electron.FoundInPageResult) => { + if (result.requestId !== requestID) return + notify({ tabID, requestID, activeMatchOrdinal: result.activeMatchOrdinal, matches: result.matches, finalUpdate: result.finalUpdate }) + if (result.finalUpdate) record.view.webContents.removeListener("found-in-page", listener) + } + record.view.webContents.on("found-in-page", listener) + requestID = record.view.webContents.findInPage(query, { forward, findNext: true }) + return requestID + }, + stopFind(senderID: number, tabID: string) { + const record = tabs.get(senderID)?.get(tabID) + if (!record) throw new Error("Unknown App Dock tab") + record.view.webContents.stopFindInPage("clearSelection") + }, + zoom(senderID: number, tabID: string, factor?: number) { + const record = tabs.get(senderID)?.get(tabID) + if (!record) throw new Error("Unknown App Dock tab") + if (factor !== undefined) record.view.webContents.setZoomFactor(appDockZoom(factor)) + return record.view.webContents.getZoomFactor() + }, + fullscreen(senderID: number, win: BrowserWindow, tabID: string, enabled: boolean) { + const record = tabs.get(senderID)?.get(tabID) + if (!record) throw new Error("Unknown App Dock tab") + win.setFullScreen(enabled) + record.notifyFullscreen(tabID, enabled) + }, + cancelDownload(senderID: number, id: string) { + const download = downloads.get(id) + if (!download || download.senderID !== senderID) throw new Error("Unknown App Dock download") + download.item.cancel() + }, + openDownload(senderID: number, id: string) { + const download = downloads.get(id) + if (!download || download.senderID !== senderID || download.state.state !== "completed") throw new Error("Unknown App Dock download") + return shell.openPath(download.item.getSavePath()) + }, + close, + } +} diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts index d8abfc1ceb3b..253044b9da93 100644 --- a/packages/desktop/src/main/ipc.ts +++ b/packages/desktop/src/main/ipc.ts @@ -24,6 +24,7 @@ import type { UpdaterController } from "./updater-controller" import { createUpdaterSubscriptions } from "./updater-subscriptions" import { createDesktopDraftStore } from "./draft-store" import { nativeT } from "./native-translations" +import { createAppDock, panelBoundsToContent, type AppDockDownload, type AppDockFindResult, type AppDockState, type DockBounds } from "./app-dock" const pickerFilters = (ext?: string[]) => { if (!ext || ext.length === 0) return undefined @@ -55,6 +56,7 @@ type Deps = { } export function registerIpcHandlers(deps: Deps) { + const appDock = createAppDock() const drafts = createDesktopDraftStore(join(app.getPath("userData"), "drafts.sqlite")) const updaterSubscriptions = createUpdaterSubscriptions() app.once("will-quit", updaterSubscriptions.clear) @@ -80,6 +82,88 @@ export function registerIpcHandlers(deps: Deps) { ) ipcMain.handle("check-app-exists", (_event: IpcMainInvokeEvent, appName: string) => deps.checkAppExists(appName)) ipcMain.handle("resolve-app-path", (_event: IpcMainInvokeEvent, appName: string) => deps.resolveAppPath(appName)) + const appDockSender = (event: IpcMainInvokeEvent) => { + const win = BrowserWindow.fromWebContents(event.sender) + if (!win || win.isDestroyed() || win.webContents !== event.sender || event.senderFrame !== event.sender.mainFrame) + throw new Error("Invalid App Dock sender") + return win + } + ipcMain.handle("app-dock-open", async (event: IpcMainInvokeEvent, address: string, bounds: DockBounds, profile?: string) => { + const win = appDockSender(event) + const tab = await appDock.open( + event.sender.id, + win, + address, + panelBoundsToContent(bounds, event.sender.getZoomFactor()), + (state: AppDockState) => { + if (!event.sender.isDestroyed()) event.sender.send("app-dock-state", state) + }, + profile, + (popup) => { + if (!event.sender.isDestroyed()) event.sender.send("app-dock-tab-opened", popup) + }, + (download: AppDockDownload) => { + if (!event.sender.isDestroyed()) event.sender.send("app-dock-download", download) + }, + (tabID, enabled) => { + if (!event.sender.isDestroyed()) event.sender.send("app-dock-fullscreen", { tabID, enabled }) + }, + ) + event.sender.once("destroyed", () => appDock.close(event.sender.id, win)) + return tab + }) + ipcMain.handle("app-dock-resize", (event: IpcMainInvokeEvent, bounds: DockBounds) => { + appDock.resize(event.sender.id, panelBoundsToContent(bounds, event.sender.getZoomFactor())) + }) + ipcMain.handle("app-dock-hide", (event: IpcMainInvokeEvent) => { + appDock.hide(event.sender.id, appDockSender(event)) + }) + ipcMain.handle("app-dock-close", (event: IpcMainInvokeEvent) => { + appDock.close(event.sender.id, appDockSender(event)) + }) + ipcMain.handle("app-dock-close-tab", (event: IpcMainInvokeEvent, tabID: string) => { + appDock.close(event.sender.id, appDockSender(event), tabID) + }) + ipcMain.handle("app-dock-select", (event: IpcMainInvokeEvent, tabID: string, bounds: DockBounds) => { + const win = appDockSender(event) + appDock.select(event.sender.id, win, tabID, panelBoundsToContent(bounds, event.sender.getZoomFactor())) + }) + ipcMain.handle("app-dock-navigate", (event: IpcMainInvokeEvent, tabID: string, address: string) => { + appDockSender(event) + return appDock.navigate(event.sender.id, tabID, address) + }) + ipcMain.handle("app-dock-command", (event: IpcMainInvokeEvent, tabID: string, command: "back" | "forward" | "reload") => { + appDockSender(event) + if (!["back", "forward", "reload"].includes(command)) throw new Error("Invalid App Dock command") + return appDock.command(event.sender.id, tabID, command) + }) + ipcMain.handle("app-dock-find", (event: IpcMainInvokeEvent, tabID: string, text: string, forward: boolean) => { + appDockSender(event) + return appDock.find(event.sender.id, tabID, text, forward, (result: AppDockFindResult) => { + if (!event.sender.isDestroyed()) event.sender.send("app-dock-find-result", result) + }) + }) + ipcMain.handle("app-dock-stop-find", (event: IpcMainInvokeEvent, tabID: string) => { + appDockSender(event) + appDock.stopFind(event.sender.id, tabID) + }) + ipcMain.handle("app-dock-zoom", (event: IpcMainInvokeEvent, tabID: string, factor?: number) => { + appDockSender(event) + return appDock.zoom(event.sender.id, tabID, factor) + }) + ipcMain.handle("app-dock-cancel-download", (event: IpcMainInvokeEvent, id: string) => { + appDockSender(event) + appDock.cancelDownload(event.sender.id, id) + }) + ipcMain.handle("app-dock-open-download", (event: IpcMainInvokeEvent, id: string) => { + appDockSender(event) + return appDock.openDownload(event.sender.id, id) + }) + ipcMain.handle("app-dock-fullscreen", (event: IpcMainInvokeEvent, tabID: string, enabled: boolean) => { + const win = appDockSender(event) + if (typeof enabled !== "boolean") throw new Error("Invalid App Dock fullscreen state") + appDock.fullscreen(event.sender.id, win, tabID, enabled) + }) ipcMain.handle("updater-subscribe", (event) => { const id = event.sender.id updaterSubscriptions.set( diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts index dae18b0716a4..a2e82fa09e5d 100644 --- a/packages/desktop/src/preload/index.ts +++ b/packages/desktop/src/preload/index.ts @@ -11,6 +11,45 @@ const updaterHandler = (_: unknown, state: UpdaterState) => { } const api: ElectronAPI = { + appDockOpen: (url, bounds, profile) => ipcRenderer.invoke("app-dock-open", url, bounds, profile), + appDockResize: (bounds) => ipcRenderer.invoke("app-dock-resize", bounds), + appDockHide: () => ipcRenderer.invoke("app-dock-hide"), + appDockClose: () => ipcRenderer.invoke("app-dock-close"), + appDockCloseTab: (id) => ipcRenderer.invoke("app-dock-close-tab", id), + appDockSelect: (id, bounds) => ipcRenderer.invoke("app-dock-select", id, bounds), + appDockNavigate: (id, url) => ipcRenderer.invoke("app-dock-navigate", id, url), + appDockCommand: (id, command) => ipcRenderer.invoke("app-dock-command", id, command), + appDockState: (callback) => { + const handler = (_event: unknown, state: Parameters[0]) => callback(state) + ipcRenderer.on("app-dock-state", handler) + return () => ipcRenderer.removeListener("app-dock-state", handler) + }, + appDockTabOpened: (callback) => { + const handler = (_event: unknown, tab: Parameters[0]) => callback(tab) + ipcRenderer.on("app-dock-tab-opened", handler) + return () => ipcRenderer.removeListener("app-dock-tab-opened", handler) + }, + appDockFind: (id, text, forward) => ipcRenderer.invoke("app-dock-find", id, text, forward), + appDockStopFind: (id) => ipcRenderer.invoke("app-dock-stop-find", id), + appDockFindResult: (callback) => { + const handler = (_event: unknown, result: Parameters[0]) => callback(result) + ipcRenderer.on("app-dock-find-result", handler) + return () => ipcRenderer.removeListener("app-dock-find-result", handler) + }, + appDockZoom: (id, factor) => ipcRenderer.invoke("app-dock-zoom", id, factor), + appDockCancelDownload: (id) => ipcRenderer.invoke("app-dock-cancel-download", id), + appDockOpenDownload: (id) => ipcRenderer.invoke("app-dock-open-download", id), + appDockDownload: (callback) => { + const handler = (_event: unknown, download: Parameters[0]) => callback(download) + ipcRenderer.on("app-dock-download", handler) + return () => ipcRenderer.removeListener("app-dock-download", handler) + }, + appDockFullscreen: (id, enabled) => ipcRenderer.invoke("app-dock-fullscreen", id, enabled), + appDockFullscreenChanged: (callback) => { + const handler = (_event: unknown, state: Parameters[0]) => callback(state) + ipcRenderer.on("app-dock-fullscreen", handler) + return () => ipcRenderer.removeListener("app-dock-fullscreen", handler) + }, killSidecar: () => ipcRenderer.invoke("kill-sidecar"), installCli: () => ipcRenderer.invoke("install-cli"), awaitInitialization: () => ipcRenderer.invoke("await-initialization"), diff --git a/packages/desktop/src/preload/types.ts b/packages/desktop/src/preload/types.ts index 20c39097d31d..f326ecc5c176 100644 --- a/packages/desktop/src/preload/types.ts +++ b/packages/desktop/src/preload/types.ts @@ -43,6 +43,25 @@ export type FatalRendererError = { } export type ElectronAPI = { + appDockOpen: (url: string, bounds: { x: number; y: number; width: number; height: number }, profile?: string) => Promise<{ id: string; url: string }> + appDockResize: (bounds: { x: number; y: number; width: number; height: number }) => Promise + appDockHide: () => Promise + appDockClose: () => Promise + appDockCloseTab: (id: string) => Promise + appDockSelect: (id: string, bounds: { x: number; y: number; width: number; height: number }) => Promise + appDockNavigate: (id: string, url: string) => Promise + appDockCommand: (id: string, command: "back" | "forward" | "reload") => Promise + appDockState: (callback: (state: { id: string; url: string; title: string; favicon?: string; loading: boolean; audible: boolean; error?: string }) => void) => () => void + appDockTabOpened: (callback: (tab: { id: string; url: string }) => void) => () => void + appDockFind: (id: string, text: string, forward: boolean) => Promise + appDockStopFind: (id: string) => Promise + appDockFindResult: (callback: (result: { tabID: string; requestID: number; activeMatchOrdinal: number; matches: number; finalUpdate: boolean }) => void) => () => void + appDockZoom: (id: string, factor?: number) => Promise + appDockCancelDownload: (id: string) => Promise + appDockOpenDownload: (id: string) => Promise + appDockDownload: (callback: (download: { id: string; tabID: string; filename: string; receivedBytes: number; totalBytes: number; state: "progressing" | "paused" | "completed" | "cancelled" | "interrupted" }) => void) => () => void + appDockFullscreen: (id: string, enabled: boolean) => Promise + appDockFullscreenChanged: (callback: (state: { tabID: string; enabled: boolean }) => void) => () => void killSidecar: () => Promise installCli: () => Promise awaitInitialization: () => Promise From 09f9b3621d5c3dafde08def16b36164c9b111962 Mon Sep 17 00:00:00 2001 From: Gustavo Schneiter Date: Wed, 9 Sep 2026 00:00:51 -0300 Subject: [PATCH 053/117] fix(desktop): harden app dock views --- packages/desktop/src/main/app-dock.ts | 197 ++++++++++++++++++-------- 1 file changed, 141 insertions(+), 56 deletions(-) diff --git a/packages/desktop/src/main/app-dock.ts b/packages/desktop/src/main/app-dock.ts index 1ce914d14734..f23f2f64dc57 100644 --- a/packages/desktop/src/main/app-dock.ts +++ b/packages/desktop/src/main/app-dock.ts @@ -4,16 +4,16 @@ import { randomUUID } from "node:crypto" import { appDockURL, appDockZoom, panelBoundsToContent, type DockBounds } from "./app-dock-utils" export type { DockBounds } from "./app-dock-utils" -export type AppDockTab = { id: string; url: string } -export type AppDockState = AppDockTab & { title: string; favicon?: string; loading: boolean; audible: boolean; error?: string } -export type AppDockFindResult = { tabID: string; requestID: number; activeMatchOrdinal: number; matches: number; finalUpdate: boolean } -export type AppDockDownload = { id: string; tabID: string; filename: string; receivedBytes: number; totalBytes: number; state: "progressing" | "paused" | "completed" | "cancelled" | "interrupted" } -type AppDockRecord = { view: WebContentsView; profile: string; notify: (state: AppDockState) => void; notifyDownload: (download: AppDockDownload) => void; notifyFullscreen: (tabID: string, enabled: boolean) => void } +export type AppDockIdentity = Readonly<{ tabID: string; generation: number }> +export type AppDockTab = AppDockIdentity & { url: string } +export type AppDockState = AppDockIdentity & { url: string; title: string; favicon?: string; loading: boolean; audible: boolean; error?: { code: "blocked" | "failed"; url: string } } +export type AppDockFindResult = AppDockIdentity & { requestID: number; activeMatchOrdinal: number; matches: number; finalUpdate: boolean } +export type AppDockDownload = AppDockIdentity & { id: string; filename: string; receivedBytes: number; totalBytes: number; state: "progressing" | "paused" | "completed" | "cancelled" | "interrupted" } +type AppDockRecord = { view: WebContentsView; storageKey: string; generation: number; state: () => AppDockState; notify: (state: AppDockState) => void; notifyDownload: (download: AppDockDownload) => void; notifyFullscreen: (identity: AppDockIdentity, enabled: boolean) => void; cleanups: (() => void)[] } -const profilePartition = (profile: string) => { - const name = profile.trim().toLowerCase() - if (!/^[a-z0-9][a-z0-9-]{0,31}$/.test(name)) throw new Error("Invalid App Dock profile") - return `persist:app-dock-${name}` +const storagePartition = (storageKey: string) => { + if (!/^[A-Za-z0-9_-]{16,128}$/.test(storageKey)) throw new Error("Invalid App Dock storage key") + return `persist:app-dock-${storageKey}` } export { panelBoundsToContent } @@ -24,17 +24,36 @@ const validBounds = (bounds: DockBounds) => export function createAppDock() { const browserSessions = new Map() const configuredPartitions = new Set() + const retiredStorageKeys = new Set() const tabs = new Map>() - const tabByContents = new Map() - const downloads = new Map() + const tabByContents = new Map() + const downloads = new Map() const active = new Map() + const inactive = new Map() + let generation = 0 + const identity = (tabID: string, tabGeneration: number): AppDockIdentity => Object.freeze({ tabID, generation: tabGeneration }) + const isCurrent = (senderID: number, tabID: string, tabGeneration: number) => tabs.get(senderID)?.get(tabID)?.generation === tabGeneration + const markInactive = (senderID: number, tabID: string, record: AppDockRecord) => { + record.view.webContents.setBackgroundThrottling(true) + inactive.delete(`${senderID}:${tabID}`) + inactive.set(`${senderID}:${tabID}`, { senderID, tabID }) + } const remove = (senderID: number, tabID: string, win?: BrowserWindow) => { const record = tabs.get(senderID)?.get(tabID) if (!record) return win?.contentView.removeChildView(record.view) + inactive.delete(`${senderID}:${tabID}`) tabByContents.delete(record.view.webContents.id) + record.cleanups.forEach((cleanup) => cleanup()) + for (const [downloadID, download] of downloads) { + if (download.senderID === senderID && download.state.tabID === tabID && download.state.generation === record.generation) { + download.item.cancel() + downloads.delete(downloadID) + } + } record.view.webContents.close() tabs.get(senderID)?.delete(tabID) + generation++ if (active.get(senderID) === tabID) active.delete(senderID) } const close = (senderID: number, win?: BrowserWindow, tabID?: string) => { @@ -42,33 +61,44 @@ export function createAppDock() { ids.forEach((id) => remove(senderID, id, win)) if ((tabs.get(senderID)?.size ?? 0) === 0) tabs.delete(senderID) } - return { - async open( + const open = async ( senderID: number, win: BrowserWindow, address: string, bounds: DockBounds, notify: (state: AppDockState) => void, - profile = "default", + storageKey?: string, onTabOpened?: (tab: AppDockTab) => void, onDownload?: (download: AppDockDownload) => void, - onFullscreen?: (tabID: string, enabled: boolean) => void, - ): Promise { - const target = appDockURL(address) + onFullscreen?: (identity: AppDockIdentity, enabled: boolean) => void, + ): Promise => { if (!validBounds(bounds)) throw new Error("Invalid App Dock bounds") - const partition = profilePartition(profile) + const id = randomUUID() + const tabGeneration = ++generation + let target: string + try { + target = appDockURL(address) + } catch { + notify(Object.freeze({ ...identity(id, tabGeneration), url: address, title: address, loading: false, audible: false, error: { code: "blocked", url: address } })) + throw new Error("App Dock only supports HTTPS URLs") + } + if (!storageKey) throw new Error("App Dock storage key is required") + if (retiredStorageKeys.has(storageKey)) throw new Error("App Dock storage key is retired") + const partition = storagePartition(storageKey) const browserSession = browserSessions.get(partition) ?? session.fromPartition(partition) browserSessions.set(partition, browserSession) if (!configuredPartitions.has(partition)) { browserSession.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false)) + browserSession.setPermissionCheckHandler(() => false) browserSession.on("will-download", (_event, item, webContents) => { const source = tabByContents.get(webContents.id) const record = source && tabs.get(source.senderID)?.get(source.tabID) - if (!source || !record) return item.cancel() + if (!source || !record || record.generation !== source.generation) return item.cancel() const id = randomUUID() const updateDownload = (state: AppDockDownload["state"]) => { - const download = { id, tabID: source.tabID, filename: item.getFilename(), receivedBytes: item.getReceivedBytes(), totalBytes: item.getTotalBytes(), state } - downloads.set(id, { senderID: source.senderID, item, state: download }) + if (!isCurrent(source.senderID, source.tabID, source.generation)) return + const download = Object.freeze({ ...identity(source.tabID, source.generation), id, filename: item.getFilename(), receivedBytes: item.getReceivedBytes(), totalBytes: item.getTotalBytes(), state }) + downloads.set(id, { senderID: source.senderID, storageKey: record.storageKey, item, state: download }) record.notifyDownload(download) } item.on("updated", () => updateDownload(item.isPaused() ? "paused" : "progressing")) @@ -77,66 +107,88 @@ export function createAppDock() { }) configuredPartitions.add(partition) } - const id = randomUUID() const view = new WebContentsView({ webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true, session: browserSession, backgroundThrottling: true } }) - let state: AppDockState = { id, url: target, title: target, loading: true, audible: false } - const update = (patch: Partial) => { + let state: Omit = { url: target, title: target, loading: true, audible: false } + const snapshot = (): AppDockState => Object.freeze({ ...identity(id, tabGeneration), ...state }) + const update = (patch: Partial>) => { + if (!isCurrent(senderID, id, tabGeneration)) return state = { ...state, ...patch } - notify(state) + notify(snapshot()) + } + const cleanups: (() => void)[] = [] + const listen = [0]>(event: T, listener: (...args: any[]) => void) => { + view.webContents.on(event, listener) + cleanups.push(() => view.webContents.removeListener(event, listener)) } - view.webContents.on("page-title-updated", (_event, title) => update({ title })) - view.webContents.on("page-favicon-updated", (_event, favicons) => update({ favicon: favicons[0] })) - view.webContents.on("did-start-loading", () => update({ loading: true })) - view.webContents.on("did-stop-loading", () => update({ loading: false, url: view.webContents.getURL() || target })) - view.webContents.on("did-fail-load", (_event, errorCode, errorDescription, validatedURL, isMainFrame) => { + listen("page-title-updated", (_event, title) => update({ title })) + listen("page-favicon-updated", (_event, favicons) => update({ favicon: favicons[0] })) + listen("did-start-loading", () => update({ loading: true })) + listen("did-stop-loading", () => update({ loading: false, url: view.webContents.getURL() || target })) + listen("did-fail-load", (_event, errorCode, _errorDescription, validatedURL, isMainFrame) => { if (!isMainFrame || errorCode === -3) return - update({ loading: false, url: validatedURL || state.url, error: `${errorCode}: ${errorDescription}` }) + update({ loading: false, url: validatedURL || state.url, error: { code: "failed", url: validatedURL || state.url } }) }) - view.webContents.on("did-navigate", (_event, navigatedURL) => update({ url: navigatedURL })) - view.webContents.on("did-navigate-in-page", (_event, navigatedURL) => update({ url: navigatedURL })) - view.webContents.on("media-started-playing", () => update({ audible: true })) - view.webContents.on("media-paused", () => update({ audible: false })) + listen("did-navigate", (_event, navigatedURL) => update({ url: navigatedURL })) + listen("did-navigate-in-page", (_event, navigatedURL) => update({ url: navigatedURL })) + listen("media-started-playing", () => update({ audible: true })) + listen("media-paused", () => update({ audible: false })) view.webContents.setWindowOpenHandler(({ url }) => { try { const popupURL = appDockURL(url) - void this.open(senderID, win, popupURL, bounds, notify, profile, onTabOpened, onDownload, onFullscreen).then(onTabOpened) + void open(senderID, win, popupURL, bounds, notify, storageKey, onTabOpened, onDownload, onFullscreen).then(onTabOpened) } catch { - // App Dock blocks non-HTTPS popup targets before Chromium can create a window. + update({ loading: false, error: { code: "blocked", url } }) } return { action: "deny" } }) - view.webContents.on("will-navigate", (event, url) => { + listen("will-navigate", (event, url) => { if (URL.canParse(url) && new URL(url).protocol === "https:") return event.preventDefault() + update({ loading: false, error: { code: "blocked", url } }) }) - view.webContents.on("will-redirect", (event, url) => { + listen("will-redirect", (event, url) => { if (URL.canParse(url) && new URL(url).protocol === "https:") return event.preventDefault() + update({ loading: false, error: { code: "blocked", url } }) }) - view.webContents.on("enter-html-full-screen", () => (onFullscreen ?? (() => {}))(id, true)) - view.webContents.on("leave-html-full-screen", () => (onFullscreen ?? (() => {}))(id, false)) + listen("enter-html-full-screen", () => (onFullscreen ?? (() => {}))(identity(id, tabGeneration), true)) + listen("leave-html-full-screen", () => (onFullscreen ?? (() => {}))(identity(id, tabGeneration), false)) win.contentView.addChildView(view) view.setBounds(bounds) view.setVisible(true) + view.webContents.setBackgroundThrottling(false) const senderTabs = tabs.get(senderID) ?? new Map() for (const [tabID, other] of senderTabs) { - if (tabID !== id) win.contentView.removeChildView(other.view) + if (tabID !== id) { + win.contentView.removeChildView(other.view) + markInactive(senderID, tabID, other) + } } - senderTabs.set(id, { view, profile, notify, notifyDownload: onDownload ?? (() => {}), notifyFullscreen: onFullscreen ?? (() => {}) }) - tabByContents.set(view.webContents.id, { senderID, tabID: id }) + senderTabs.set(id, { view, storageKey, generation: tabGeneration, state: snapshot, notify, notifyDownload: onDownload ?? (() => {}), notifyFullscreen: onFullscreen ?? (() => {}), cleanups }) + tabByContents.set(view.webContents.id, { senderID, tabID: id, generation: tabGeneration }) tabs.set(senderID, senderTabs) active.set(senderID, id) - void view.webContents.loadURL(target).catch((error) => update({ loading: false, error: error instanceof Error ? error.message : "Navigation failed" })) - notify(state) - return { id, url: target } - }, + while (inactive.size > 20) { + const oldest = inactive.values().next().value + if (!oldest) break + remove(oldest.senderID, oldest.tabID) + } + void view.webContents.loadURL(target).catch(() => update({ loading: false, error: { code: "failed", url: target } })) + update({}) + return Object.freeze({ ...identity(id, tabGeneration), url: target }) + } + return { + open, resize(senderID: number, bounds: DockBounds) { if (!validBounds(bounds)) throw new Error("Invalid App Dock bounds") const tabID = active.get(senderID) if (tabID) tabs.get(senderID)?.get(tabID)?.view.setBounds(bounds) }, hide(senderID: number, win: BrowserWindow) { - for (const record of tabs.get(senderID)?.values() ?? []) win.contentView.removeChildView(record.view) + for (const [tabID, record] of tabs.get(senderID) ?? []) { + win.contentView.removeChildView(record.view) + markInactive(senderID, tabID, record) + } active.delete(senderID) }, select(senderID: number, win: BrowserWindow, tabID: string, bounds: DockBounds) { @@ -144,8 +196,14 @@ export function createAppDock() { const record = tabs.get(senderID)?.get(tabID) if (!record) throw new Error("Unknown App Dock tab") for (const [id, other] of tabs.get(senderID) ?? []) { - if (id === tabID) win.contentView.addChildView(other.view) - else win.contentView.removeChildView(other.view) + if (id === tabID) { + win.contentView.addChildView(other.view) + other.view.webContents.setBackgroundThrottling(false) + inactive.delete(`${senderID}:${id}`) + } else { + win.contentView.removeChildView(other.view) + markInactive(senderID, id, other) + } } record.view.setBounds(bounds) active.set(senderID, tabID) @@ -153,8 +211,16 @@ export function createAppDock() { navigate(senderID: number, tabID: string, address: string) { const record = tabs.get(senderID)?.get(tabID) if (!record) throw new Error("Unknown App Dock tab") - return record.view.webContents.loadURL(appDockURL(address)).catch((error) => { - throw error instanceof Error ? error : new Error("Navigation failed") + let target: string + try { + target = appDockURL(address) + } catch { + record.notify(Object.freeze({ ...record.state(), loading: false, error: { code: "blocked", url: address } })) + throw new Error("App Dock only supports HTTPS URLs") + } + return record.view.webContents.loadURL(target).catch(() => { + record.notify(Object.freeze({ ...record.state(), loading: false, error: { code: "failed", url: target } })) + throw new Error("Navigation failed") }) }, command(senderID: number, tabID: string, command: "back" | "forward" | "reload") { @@ -171,11 +237,12 @@ export function createAppDock() { if (!query) throw new Error("Find text is required") let requestID = -1 const listener = (_event: Electron.Event, result: Electron.FoundInPageResult) => { - if (result.requestId !== requestID) return - notify({ tabID, requestID, activeMatchOrdinal: result.activeMatchOrdinal, matches: result.matches, finalUpdate: result.finalUpdate }) + if (result.requestId !== requestID || !isCurrent(senderID, tabID, record.generation)) return + notify(Object.freeze({ ...identity(tabID, record.generation), requestID, activeMatchOrdinal: result.activeMatchOrdinal, matches: result.matches, finalUpdate: result.finalUpdate })) if (result.finalUpdate) record.view.webContents.removeListener("found-in-page", listener) } record.view.webContents.on("found-in-page", listener) + record.cleanups.push(() => record.view.webContents.removeListener("found-in-page", listener)) requestID = record.view.webContents.findInPage(query, { forward, findNext: true }) return requestID }, @@ -194,7 +261,7 @@ export function createAppDock() { const record = tabs.get(senderID)?.get(tabID) if (!record) throw new Error("Unknown App Dock tab") win.setFullScreen(enabled) - record.notifyFullscreen(tabID, enabled) + record.notifyFullscreen(identity(tabID, record.generation), enabled) }, cancelDownload(senderID: number, id: string) { const download = downloads.get(id) @@ -206,6 +273,24 @@ export function createAppDock() { if (!download || download.senderID !== senderID || download.state.state !== "completed") throw new Error("Unknown App Dock download") return shell.openPath(download.item.getSavePath()) }, + async deleteStorage(storageKey: string) { + const partition = storagePartition(storageKey) + if (retiredStorageKeys.has(storageKey)) throw new Error("App Dock storage key is retired") + retiredStorageKeys.add(storageKey) + for (const [senderID, senderTabs] of tabs) { + for (const [tabID, record] of senderTabs) if (record.storageKey === storageKey) remove(senderID, tabID) + } + for (const [downloadID, download] of downloads) { + if (download.storageKey === storageKey) { + download.item.cancel() + downloads.delete(downloadID) + } + } + const browserSession = browserSessions.get(partition) ?? session.fromPartition(partition) + await browserSession.clearStorageData() + await browserSession.clearCache() + browserSessions.delete(partition) + }, close, } } From ec148f13d22b3f19d2042de5b7f264d1df518975 Mon Sep 17 00:00:00 2001 From: Gustavo Schneiter Date: Wed, 9 Sep 2026 00:04:54 -0300 Subject: [PATCH 054/117] fix(desktop): define app dock event contract --- packages/desktop/src/main/app-dock.ts | 71 +++++++++++++++------------ 1 file changed, 40 insertions(+), 31 deletions(-) diff --git a/packages/desktop/src/main/app-dock.ts b/packages/desktop/src/main/app-dock.ts index f23f2f64dc57..c7b88d3b3831 100644 --- a/packages/desktop/src/main/app-dock.ts +++ b/packages/desktop/src/main/app-dock.ts @@ -1,15 +1,23 @@ import { session, shell, WebContentsView } from "electron" import type { BrowserWindow, Session } from "electron" import { randomUUID } from "node:crypto" +import type { EventEmitter } from "node:events" import { appDockURL, appDockZoom, panelBoundsToContent, type DockBounds } from "./app-dock-utils" export type { DockBounds } from "./app-dock-utils" export type AppDockIdentity = Readonly<{ tabID: string; generation: number }> -export type AppDockTab = AppDockIdentity & { url: string } -export type AppDockState = AppDockIdentity & { url: string; title: string; favicon?: string; loading: boolean; audible: boolean; error?: { code: "blocked" | "failed"; url: string } } +// Remove `id` after bridge migrates to `tabID` in App Dock S2. +export type AppDockTab = AppDockIdentity & { id: string; url: string } +export type ProfileStorage = Readonly<{ storageKey: string }> +export type AppDockState = AppDockIdentity & { url: string; title: string; favicon?: string; loading: boolean; audible: boolean } export type AppDockFindResult = AppDockIdentity & { requestID: number; activeMatchOrdinal: number; matches: number; finalUpdate: boolean } export type AppDockDownload = AppDockIdentity & { id: string; filename: string; receivedBytes: number; totalBytes: number; state: "progressing" | "paused" | "completed" | "cancelled" | "interrupted" } -type AppDockRecord = { view: WebContentsView; storageKey: string; generation: number; state: () => AppDockState; notify: (state: AppDockState) => void; notifyDownload: (download: AppDockDownload) => void; notifyFullscreen: (identity: AppDockIdentity, enabled: boolean) => void; cleanups: (() => void)[] } +export type AppDockEvent = + | Readonly<{ type: "state"; payload: AppDockState }> + | Readonly<{ type: "download"; payload: AppDockDownload }> + | Readonly<{ type: "fullscreen"; payload: { identity: AppDockIdentity; enabled: boolean } }> + | Readonly<{ type: "navigation-error"; payload: { identity: AppDockIdentity; code: "blocked" | "failed"; url: string } }> +type AppDockRecord = { view: WebContentsView; win: BrowserWindow; storageKey: string; generation: number; state: () => AppDockState; notify: (event: AppDockEvent) => void; cleanups: (() => void)[] } const storagePartition = (storageKey: string) => { if (!/^[A-Za-z0-9_-]{16,128}$/.test(storageKey)) throw new Error("Invalid App Dock storage key") @@ -41,7 +49,8 @@ export function createAppDock() { const remove = (senderID: number, tabID: string, win?: BrowserWindow) => { const record = tabs.get(senderID)?.get(tabID) if (!record) return - win?.contentView.removeChildView(record.view) + const owner = win ?? record.win + if (!owner.isDestroyed()) owner.contentView.removeChildView(record.view) inactive.delete(`${senderID}:${tabID}`) tabByContents.delete(record.view.webContents.id) record.cleanups.forEach((cleanup) => cleanup()) @@ -66,11 +75,8 @@ export function createAppDock() { win: BrowserWindow, address: string, bounds: DockBounds, - notify: (state: AppDockState) => void, - storageKey?: string, - onTabOpened?: (tab: AppDockTab) => void, - onDownload?: (download: AppDockDownload) => void, - onFullscreen?: (identity: AppDockIdentity, enabled: boolean) => void, + notify: (event: AppDockEvent) => void, + profileStorage: ProfileStorage, ): Promise => { if (!validBounds(bounds)) throw new Error("Invalid App Dock bounds") const id = randomUUID() @@ -79,10 +85,11 @@ export function createAppDock() { try { target = appDockURL(address) } catch { - notify(Object.freeze({ ...identity(id, tabGeneration), url: address, title: address, loading: false, audible: false, error: { code: "blocked", url: address } })) + const tabIdentity = identity(id, tabGeneration) + notify(Object.freeze({ type: "navigation-error", payload: Object.freeze({ identity: tabIdentity, code: "blocked", url: address }) })) throw new Error("App Dock only supports HTTPS URLs") } - if (!storageKey) throw new Error("App Dock storage key is required") + const { storageKey } = profileStorage if (retiredStorageKeys.has(storageKey)) throw new Error("App Dock storage key is retired") const partition = storagePartition(storageKey) const browserSession = browserSessions.get(partition) ?? session.fromPartition(partition) @@ -99,7 +106,7 @@ export function createAppDock() { if (!isCurrent(source.senderID, source.tabID, source.generation)) return const download = Object.freeze({ ...identity(source.tabID, source.generation), id, filename: item.getFilename(), receivedBytes: item.getReceivedBytes(), totalBytes: item.getTotalBytes(), state }) downloads.set(id, { senderID: source.senderID, storageKey: record.storageKey, item, state: download }) - record.notifyDownload(download) + record.notify(Object.freeze({ type: "download", payload: download })) } item.on("updated", () => updateDownload(item.isPaused() ? "paused" : "progressing")) item.once("done", (_doneEvent, state) => updateDownload(state === "completed" ? "completed" : state === "cancelled" ? "cancelled" : "interrupted")) @@ -113,12 +120,13 @@ export function createAppDock() { const update = (patch: Partial>) => { if (!isCurrent(senderID, id, tabGeneration)) return state = { ...state, ...patch } - notify(snapshot()) + notify(Object.freeze({ type: "state", payload: snapshot() })) } const cleanups: (() => void)[] = [] - const listen = [0]>(event: T, listener: (...args: any[]) => void) => { - view.webContents.on(event, listener) - cleanups.push(() => view.webContents.removeListener(event, listener)) + const contents = view.webContents as unknown as EventEmitter + const listen = (event: string, listener: (...args: any[]) => void) => { + contents.on(event, listener) + cleanups.push(() => contents.removeListener(event, listener)) } listen("page-title-updated", (_event, title) => update({ title })) listen("page-favicon-updated", (_event, favicons) => update({ favicon: favicons[0] })) @@ -126,7 +134,8 @@ export function createAppDock() { listen("did-stop-loading", () => update({ loading: false, url: view.webContents.getURL() || target })) listen("did-fail-load", (_event, errorCode, _errorDescription, validatedURL, isMainFrame) => { if (!isMainFrame || errorCode === -3) return - update({ loading: false, url: validatedURL || state.url, error: { code: "failed", url: validatedURL || state.url } }) + update({ loading: false, url: validatedURL || state.url }) + notify(Object.freeze({ type: "navigation-error", payload: Object.freeze({ identity: identity(id, tabGeneration), code: "failed", url: validatedURL || state.url }) })) }) listen("did-navigate", (_event, navigatedURL) => update({ url: navigatedURL })) listen("did-navigate-in-page", (_event, navigatedURL) => update({ url: navigatedURL })) @@ -135,24 +144,24 @@ export function createAppDock() { view.webContents.setWindowOpenHandler(({ url }) => { try { const popupURL = appDockURL(url) - void open(senderID, win, popupURL, bounds, notify, storageKey, onTabOpened, onDownload, onFullscreen).then(onTabOpened) + void open(senderID, win, popupURL, bounds, notify, profileStorage) } catch { - update({ loading: false, error: { code: "blocked", url } }) + notify(Object.freeze({ type: "navigation-error", payload: Object.freeze({ identity: identity(id, tabGeneration), code: "blocked", url }) })) } return { action: "deny" } }) listen("will-navigate", (event, url) => { if (URL.canParse(url) && new URL(url).protocol === "https:") return event.preventDefault() - update({ loading: false, error: { code: "blocked", url } }) + notify(Object.freeze({ type: "navigation-error", payload: Object.freeze({ identity: identity(id, tabGeneration), code: "blocked", url }) })) }) listen("will-redirect", (event, url) => { if (URL.canParse(url) && new URL(url).protocol === "https:") return event.preventDefault() - update({ loading: false, error: { code: "blocked", url } }) + notify(Object.freeze({ type: "navigation-error", payload: Object.freeze({ identity: identity(id, tabGeneration), code: "blocked", url }) })) }) - listen("enter-html-full-screen", () => (onFullscreen ?? (() => {}))(identity(id, tabGeneration), true)) - listen("leave-html-full-screen", () => (onFullscreen ?? (() => {}))(identity(id, tabGeneration), false)) + listen("enter-html-full-screen", () => notify(Object.freeze({ type: "fullscreen", payload: Object.freeze({ identity: identity(id, tabGeneration), enabled: true }) }))) + listen("leave-html-full-screen", () => notify(Object.freeze({ type: "fullscreen", payload: Object.freeze({ identity: identity(id, tabGeneration), enabled: false }) }))) win.contentView.addChildView(view) view.setBounds(bounds) view.setVisible(true) @@ -164,7 +173,7 @@ export function createAppDock() { markInactive(senderID, tabID, other) } } - senderTabs.set(id, { view, storageKey, generation: tabGeneration, state: snapshot, notify, notifyDownload: onDownload ?? (() => {}), notifyFullscreen: onFullscreen ?? (() => {}), cleanups }) + senderTabs.set(id, { view, win, storageKey, generation: tabGeneration, state: snapshot, notify, cleanups }) tabByContents.set(view.webContents.id, { senderID, tabID: id, generation: tabGeneration }) tabs.set(senderID, senderTabs) active.set(senderID, id) @@ -173,9 +182,9 @@ export function createAppDock() { if (!oldest) break remove(oldest.senderID, oldest.tabID) } - void view.webContents.loadURL(target).catch(() => update({ loading: false, error: { code: "failed", url: target } })) + void view.webContents.loadURL(target).catch(() => notify(Object.freeze({ type: "navigation-error", payload: Object.freeze({ identity: identity(id, tabGeneration), code: "failed", url: target }) }))) update({}) - return Object.freeze({ ...identity(id, tabGeneration), url: target }) + return Object.freeze({ ...identity(id, tabGeneration), id, url: target }) } return { open, @@ -215,11 +224,11 @@ export function createAppDock() { try { target = appDockURL(address) } catch { - record.notify(Object.freeze({ ...record.state(), loading: false, error: { code: "blocked", url: address } })) + record.notify(Object.freeze({ type: "navigation-error", payload: Object.freeze({ identity: identity(tabID, record.generation), code: "blocked", url: address }) })) throw new Error("App Dock only supports HTTPS URLs") } return record.view.webContents.loadURL(target).catch(() => { - record.notify(Object.freeze({ ...record.state(), loading: false, error: { code: "failed", url: target } })) + record.notify(Object.freeze({ type: "navigation-error", payload: Object.freeze({ identity: identity(tabID, record.generation), code: "failed", url: target }) })) throw new Error("Navigation failed") }) }, @@ -261,7 +270,7 @@ export function createAppDock() { const record = tabs.get(senderID)?.get(tabID) if (!record) throw new Error("Unknown App Dock tab") win.setFullScreen(enabled) - record.notifyFullscreen(identity(tabID, record.generation), enabled) + record.notify(Object.freeze({ type: "fullscreen", payload: Object.freeze({ identity: identity(tabID, record.generation), enabled }) })) }, cancelDownload(senderID: number, id: string) { const download = downloads.get(id) @@ -273,12 +282,12 @@ export function createAppDock() { if (!download || download.senderID !== senderID || download.state.state !== "completed") throw new Error("Unknown App Dock download") return shell.openPath(download.item.getSavePath()) }, - async deleteStorage(storageKey: string) { + async deleteStorage(storageKey: string, win?: BrowserWindow) { const partition = storagePartition(storageKey) if (retiredStorageKeys.has(storageKey)) throw new Error("App Dock storage key is retired") retiredStorageKeys.add(storageKey) for (const [senderID, senderTabs] of tabs) { - for (const [tabID, record] of senderTabs) if (record.storageKey === storageKey) remove(senderID, tabID) + for (const [tabID, record] of senderTabs) if (record.storageKey === storageKey) remove(senderID, tabID, win ?? record.win) } for (const [downloadID, download] of downloads) { if (download.storageKey === storageKey) { From f0e76127b8414230927f5bfd641efdd739e9dbb4 Mon Sep 17 00:00:00 2001 From: Gustavo Schneiter Date: Wed, 9 Sep 2026 00:11:16 -0300 Subject: [PATCH 055/117] fix(desktop): bridge app dock events --- packages/app/src/app.tsx | 57 ++++++++-- packages/desktop/src/main/ipc.ts | 155 ++++++++++++++++++-------- packages/desktop/src/preload/index.ts | 31 ++++-- packages/desktop/src/preload/types.ts | 74 +++++++++++- 4 files changed, 245 insertions(+), 72 deletions(-) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 96332ba8ee5e..9be34fc93a94 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -272,7 +272,12 @@ declare global { deepLinks?: string[] } api?: { - appDockOpen?: (url: string, bounds: { x: number; y: number; width: number; height: number }, profile?: string) => Promise<{ id: string; url: string }> + appDockOpen?: ( + url: string, + bounds: { x: number; y: number; width: number; height: number }, + profile?: string, + ) => Promise<{ id: string; tabID: string; generation: number; url: string }> + appDockDeleteProfile?: (profileID: string) => Promise appDockResize?: (bounds: { x: number; y: number; width: number; height: number }) => Promise appDockHide?: () => Promise appDockClose?: () => Promise @@ -280,17 +285,55 @@ declare global { appDockSelect?: (id: string, bounds: { x: number; y: number; width: number; height: number }) => Promise appDockNavigate?: (id: string, url: string) => Promise appDockCommand?: (id: string, command: "back" | "forward" | "reload") => Promise - appDockState?: (callback: (state: { id: string; url: string; title: string; favicon?: string; loading: boolean; audible: boolean; error?: string }) => void) => () => void - appDockTabOpened?: (callback: (tab: { id: string; url: string }) => void) => () => void + appDockEvent?: ( + callback: ( + event: + | { + type: "state" + payload: { + tabID: string + generation: number + url: string + title: string + favicon?: string + loading: boolean + audible: boolean + } + } + | { + type: "download" + payload: { + id: string + tabID: string + generation: number + filename: string + receivedBytes: number + totalBytes: number + state: "progressing" | "paused" | "completed" | "cancelled" | "interrupted" + } + } + | { type: "fullscreen"; payload: { identity: { tabID: string; generation: number }; enabled: boolean } } + | { + type: "navigation-error" + payload: { identity: { tabID: string; generation: number }; code: "blocked" | "failed"; url: string } + }, + ) => void, + ) => () => void appDockFind?: (id: string, text: string, forward: boolean) => Promise appDockStopFind?: (id: string) => Promise - appDockFindResult?: (callback: (result: { tabID: string; requestID: number; activeMatchOrdinal: number; matches: number; finalUpdate: boolean }) => void) => () => void + appDockFindResult?: ( + callback: (result: { + tabID: string + requestID: number + activeMatchOrdinal: number + matches: number + finalUpdate: boolean + }) => void, + ) => () => void appDockZoom?: (id: string, factor?: number) => Promise appDockCancelDownload?: (id: string) => Promise - appDockOpenDownload?: (id: string) => Promise - appDockDownload?: (callback: (download: { id: string; tabID: string; filename: string; receivedBytes: number; totalBytes: number; state: "progressing" | "paused" | "completed" | "cancelled" | "interrupted" }) => void) => () => void + appDockOpenDownload?: (id: string) => Promise appDockFullscreen?: (id: string, enabled: boolean) => Promise - appDockFullscreenChanged?: (callback: (state: { tabID: string; enabled: boolean }) => void) => () => void setTitlebar?: (theme: { mode: "light" | "dark"; scheme?: "system" | "light" | "dark" }) => Promise exportDebugLogs?: () => Promise } diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts index 253044b9da93..711f25a24a43 100644 --- a/packages/desktop/src/main/ipc.ts +++ b/packages/desktop/src/main/ipc.ts @@ -1,4 +1,5 @@ import { execFile } from "node:child_process" +import { randomUUID } from "node:crypto" import { stat } from "node:fs/promises" import { basename, join } from "node:path" import { app, BrowserWindow, clipboard, dialog, ipcMain, shell } from "electron" @@ -24,7 +25,14 @@ import type { UpdaterController } from "./updater-controller" import { createUpdaterSubscriptions } from "./updater-subscriptions" import { createDesktopDraftStore } from "./draft-store" import { nativeT } from "./native-translations" -import { createAppDock, panelBoundsToContent, type AppDockDownload, type AppDockFindResult, type AppDockState, type DockBounds } from "./app-dock" +import { + createAppDock, + panelBoundsToContent, + type AppDockEvent, + type AppDockFindResult, + type DockBounds, + type ProfileStorage, +} from "./app-dock" const pickerFilters = (ext?: string[]) => { if (!ext || ext.length === 0) return undefined @@ -33,6 +41,30 @@ const pickerFilters = (ext?: string[]) => { const pickedFiles = createPickedFileAuthorizations() +const appDockProfileID = (value: unknown) => { + if (typeof value !== "string" || !/^[a-z0-9][a-z0-9-]{0,31}$/.test(value)) + throw new Error("Invalid App Dock profile ID") + return value +} + +const appDockID = (value: unknown, name: "tab" | "download") => { + if (typeof value !== "string" || value.length === 0) throw new Error(`Invalid App Dock ${name} ID`) + return value +} + +const appDockBounds = (value: unknown): DockBounds => { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid App Dock bounds") + const bounds = value as Record + if ( + ![bounds.x, bounds.y, bounds.width, bounds.height].every( + (item) => typeof item === "number" && Number.isFinite(item), + ) + ) { + throw new Error("Invalid App Dock bounds") + } + return bounds as DockBounds +} + type Deps = { killSidecar: () => Promise | void relaunch: () => void @@ -57,6 +89,7 @@ type Deps = { export function registerIpcHandlers(deps: Deps) { const appDock = createAppDock() + const appDockProfiles = new Map([["default", Object.freeze({ storageKey: randomUUID() })]]) const drafts = createDesktopDraftStore(join(app.getPath("userData"), "drafts.sqlite")) const updaterSubscriptions = createUpdaterSubscriptions() app.once("will-quit", updaterSubscriptions.clear) @@ -88,32 +121,31 @@ export function registerIpcHandlers(deps: Deps) { throw new Error("Invalid App Dock sender") return win } - ipcMain.handle("app-dock-open", async (event: IpcMainInvokeEvent, address: string, bounds: DockBounds, profile?: string) => { - const win = appDockSender(event) - const tab = await appDock.open( - event.sender.id, - win, - address, - panelBoundsToContent(bounds, event.sender.getZoomFactor()), - (state: AppDockState) => { - if (!event.sender.isDestroyed()) event.sender.send("app-dock-state", state) - }, - profile, - (popup) => { - if (!event.sender.isDestroyed()) event.sender.send("app-dock-tab-opened", popup) - }, - (download: AppDockDownload) => { - if (!event.sender.isDestroyed()) event.sender.send("app-dock-download", download) - }, - (tabID, enabled) => { - if (!event.sender.isDestroyed()) event.sender.send("app-dock-fullscreen", { tabID, enabled }) - }, - ) - event.sender.once("destroyed", () => appDock.close(event.sender.id, win)) - return tab - }) - ipcMain.handle("app-dock-resize", (event: IpcMainInvokeEvent, bounds: DockBounds) => { - appDock.resize(event.sender.id, panelBoundsToContent(bounds, event.sender.getZoomFactor())) + ipcMain.handle( + "app-dock-open", + async (event: IpcMainInvokeEvent, address: unknown, bounds: unknown, profile: unknown = "default") => { + const win = appDockSender(event) + if (typeof address !== "string") throw new Error("Invalid App Dock address") + const profileID = appDockProfileID(profile) + const profileStorage = appDockProfiles.get(profileID) ?? Object.freeze({ storageKey: randomUUID() }) + appDockProfiles.set(profileID, profileStorage) + const tab = await appDock.open( + event.sender.id, + win, + address, + panelBoundsToContent(appDockBounds(bounds), event.sender.getZoomFactor()), + (appDockEvent: AppDockEvent) => { + if (!event.sender.isDestroyed()) event.sender.send("app-dock-event", appDockEvent) + }, + profileStorage, + ) + event.sender.once("destroyed", () => appDock.close(event.sender.id, win)) + return tab + }, + ) + ipcMain.handle("app-dock-resize", (event: IpcMainInvokeEvent, bounds: unknown) => { + appDockSender(event) + appDock.resize(event.sender.id, panelBoundsToContent(appDockBounds(bounds), event.sender.getZoomFactor())) }) ipcMain.handle("app-dock-hide", (event: IpcMainInvokeEvent) => { appDock.hide(event.sender.id, appDockSender(event)) @@ -121,48 +153,75 @@ export function registerIpcHandlers(deps: Deps) { ipcMain.handle("app-dock-close", (event: IpcMainInvokeEvent) => { appDock.close(event.sender.id, appDockSender(event)) }) - ipcMain.handle("app-dock-close-tab", (event: IpcMainInvokeEvent, tabID: string) => { - appDock.close(event.sender.id, appDockSender(event), tabID) + ipcMain.handle("app-dock-close-tab", (event: IpcMainInvokeEvent, tabID: unknown) => { + appDock.close(event.sender.id, appDockSender(event), appDockID(tabID, "tab")) }) - ipcMain.handle("app-dock-select", (event: IpcMainInvokeEvent, tabID: string, bounds: DockBounds) => { + ipcMain.handle("app-dock-select", (event: IpcMainInvokeEvent, tabID: unknown, bounds: unknown) => { const win = appDockSender(event) - appDock.select(event.sender.id, win, tabID, panelBoundsToContent(bounds, event.sender.getZoomFactor())) + appDock.select( + event.sender.id, + win, + appDockID(tabID, "tab"), + panelBoundsToContent(appDockBounds(bounds), event.sender.getZoomFactor()), + ) }) - ipcMain.handle("app-dock-navigate", (event: IpcMainInvokeEvent, tabID: string, address: string) => { + ipcMain.handle("app-dock-navigate", (event: IpcMainInvokeEvent, tabID: unknown, address: unknown) => { appDockSender(event) - return appDock.navigate(event.sender.id, tabID, address) + if (typeof address !== "string") throw new Error("Invalid App Dock address") + return appDock.navigate(event.sender.id, appDockID(tabID, "tab"), address) }) - ipcMain.handle("app-dock-command", (event: IpcMainInvokeEvent, tabID: string, command: "back" | "forward" | "reload") => { + ipcMain.handle("app-dock-command", (event: IpcMainInvokeEvent, tabID: unknown, command: unknown) => { appDockSender(event) - if (!["back", "forward", "reload"].includes(command)) throw new Error("Invalid App Dock command") - return appDock.command(event.sender.id, tabID, command) + if (command !== "back" && command !== "forward" && command !== "reload") throw new Error("Invalid App Dock command") + return appDock.command(event.sender.id, appDockID(tabID, "tab"), command) }) - ipcMain.handle("app-dock-find", (event: IpcMainInvokeEvent, tabID: string, text: string, forward: boolean) => { + ipcMain.handle("app-dock-find", (event: IpcMainInvokeEvent, tabID: unknown, text: unknown, forward: unknown) => { appDockSender(event) - return appDock.find(event.sender.id, tabID, text, forward, (result: AppDockFindResult) => { + if (typeof text !== "string") throw new Error("Invalid App Dock find text") + if (typeof forward !== "boolean") throw new Error("Invalid App Dock find direction") + return appDock.find(event.sender.id, appDockID(tabID, "tab"), text, forward, (result: AppDockFindResult) => { if (!event.sender.isDestroyed()) event.sender.send("app-dock-find-result", result) }) }) - ipcMain.handle("app-dock-stop-find", (event: IpcMainInvokeEvent, tabID: string) => { + ipcMain.handle("app-dock-stop-find", (event: IpcMainInvokeEvent, tabID: unknown) => { appDockSender(event) - appDock.stopFind(event.sender.id, tabID) + appDock.stopFind(event.sender.id, appDockID(tabID, "tab")) }) - ipcMain.handle("app-dock-zoom", (event: IpcMainInvokeEvent, tabID: string, factor?: number) => { + ipcMain.handle("app-dock-zoom", (event: IpcMainInvokeEvent, tabID: unknown, factor?: unknown) => { appDockSender(event) - return appDock.zoom(event.sender.id, tabID, factor) + if (factor !== undefined && (typeof factor !== "number" || !Number.isFinite(factor) || factor <= 0)) + throw new Error("Invalid App Dock zoom") + return appDock.zoom(event.sender.id, appDockID(tabID, "tab"), factor) }) - ipcMain.handle("app-dock-cancel-download", (event: IpcMainInvokeEvent, id: string) => { + ipcMain.handle("app-dock-cancel-download", (event: IpcMainInvokeEvent, id: unknown) => { appDockSender(event) - appDock.cancelDownload(event.sender.id, id) + appDock.cancelDownload(event.sender.id, appDockID(id, "download")) }) - ipcMain.handle("app-dock-open-download", (event: IpcMainInvokeEvent, id: string) => { + ipcMain.handle("app-dock-open-download", (event: IpcMainInvokeEvent, id: unknown) => { appDockSender(event) - return appDock.openDownload(event.sender.id, id) + return appDock.openDownload(event.sender.id, appDockID(id, "download")).then(() => undefined) }) - ipcMain.handle("app-dock-fullscreen", (event: IpcMainInvokeEvent, tabID: string, enabled: boolean) => { + ipcMain.handle("app-dock-fullscreen", (event: IpcMainInvokeEvent, tabID: unknown, enabled: unknown) => { const win = appDockSender(event) if (typeof enabled !== "boolean") throw new Error("Invalid App Dock fullscreen state") - appDock.fullscreen(event.sender.id, win, tabID, enabled) + appDock.fullscreen(event.sender.id, win, appDockID(tabID, "tab"), enabled) + }) + ipcMain.handle("app-dock-delete-profile", async (event: IpcMainInvokeEvent, payload: unknown) => { + const win = appDockSender(event) + if ( + !payload || + typeof payload !== "object" || + Array.isArray(payload) || + Object.keys(payload).length !== 1 || + !("profileID" in payload) + ) { + throw new Error("Invalid App Dock profile deletion") + } + const profileID = appDockProfileID((payload as { profileID?: unknown }).profileID) + const profileStorage = appDockProfiles.get(profileID) + if (!profileStorage) throw new Error("Unknown App Dock profile") + await appDock.deleteStorage(profileStorage.storageKey, win) + appDockProfiles.delete(profileID) }) ipcMain.handle("updater-subscribe", (event) => { const id = event.sender.id diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts index a2e82fa09e5d..29ed3a0b9bdd 100644 --- a/packages/desktop/src/preload/index.ts +++ b/packages/desktop/src/preload/index.ts @@ -12,6 +12,7 @@ const updaterHandler = (_: unknown, state: UpdaterState) => { const api: ElectronAPI = { appDockOpen: (url, bounds, profile) => ipcRenderer.invoke("app-dock-open", url, bounds, profile), + appDockDeleteProfile: (profileID) => ipcRenderer.invoke("app-dock-delete-profile", { profileID }), appDockResize: (bounds) => ipcRenderer.invoke("app-dock-resize", bounds), appDockHide: () => ipcRenderer.invoke("app-dock-hide"), appDockClose: () => ipcRenderer.invoke("app-dock-close"), @@ -19,15 +20,20 @@ const api: ElectronAPI = { appDockSelect: (id, bounds) => ipcRenderer.invoke("app-dock-select", id, bounds), appDockNavigate: (id, url) => ipcRenderer.invoke("app-dock-navigate", id, url), appDockCommand: (id, command) => ipcRenderer.invoke("app-dock-command", id, command), + appDockEvent: (callback) => { + const handler = (_event: unknown, appDockEvent: Parameters[0]) => callback(appDockEvent) + ipcRenderer.on("app-dock-event", handler) + return () => ipcRenderer.removeListener("app-dock-event", handler) + }, + // Legacy adapters exist for apps-panel until its S2 event-envelope migration. appDockState: (callback) => { - const handler = (_event: unknown, state: Parameters[0]) => callback(state) - ipcRenderer.on("app-dock-state", handler) - return () => ipcRenderer.removeListener("app-dock-state", handler) + return api.appDockEvent((appDockEvent) => { + if (appDockEvent.type === "state") callback({ ...appDockEvent.payload, id: appDockEvent.payload.tabID }) + }) }, appDockTabOpened: (callback) => { - const handler = (_event: unknown, tab: Parameters[0]) => callback(tab) - ipcRenderer.on("app-dock-tab-opened", handler) - return () => ipcRenderer.removeListener("app-dock-tab-opened", handler) + void callback + return () => {} }, appDockFind: (id, text, forward) => ipcRenderer.invoke("app-dock-find", id, text, forward), appDockStopFind: (id) => ipcRenderer.invoke("app-dock-stop-find", id), @@ -40,15 +46,16 @@ const api: ElectronAPI = { appDockCancelDownload: (id) => ipcRenderer.invoke("app-dock-cancel-download", id), appDockOpenDownload: (id) => ipcRenderer.invoke("app-dock-open-download", id), appDockDownload: (callback) => { - const handler = (_event: unknown, download: Parameters[0]) => callback(download) - ipcRenderer.on("app-dock-download", handler) - return () => ipcRenderer.removeListener("app-dock-download", handler) + return api.appDockEvent((appDockEvent) => { + if (appDockEvent.type === "download") callback(appDockEvent.payload) + }) }, appDockFullscreen: (id, enabled) => ipcRenderer.invoke("app-dock-fullscreen", id, enabled), appDockFullscreenChanged: (callback) => { - const handler = (_event: unknown, state: Parameters[0]) => callback(state) - ipcRenderer.on("app-dock-fullscreen", handler) - return () => ipcRenderer.removeListener("app-dock-fullscreen", handler) + return api.appDockEvent((appDockEvent) => { + if (appDockEvent.type === "fullscreen") + callback({ tabID: appDockEvent.payload.identity.tabID, enabled: appDockEvent.payload.enabled }) + }) }, killSidecar: () => ipcRenderer.invoke("kill-sidecar"), installCli: () => ipcRenderer.invoke("install-cli"), diff --git a/packages/desktop/src/preload/types.ts b/packages/desktop/src/preload/types.ts index f326ecc5c176..9ffa68a35bc8 100644 --- a/packages/desktop/src/preload/types.ts +++ b/packages/desktop/src/preload/types.ts @@ -42,8 +42,44 @@ export type FatalRendererError = { os?: string } +export type AppDockEvent = + | { + type: "state" + payload: { + tabID: string + generation: number + url: string + title: string + favicon?: string + loading: boolean + audible: boolean + } + } + | { + type: "download" + payload: { + id: string + tabID: string + generation: number + filename: string + receivedBytes: number + totalBytes: number + state: "progressing" | "paused" | "completed" | "cancelled" | "interrupted" + } + } + | { type: "fullscreen"; payload: { identity: { tabID: string; generation: number }; enabled: boolean } } + | { + type: "navigation-error" + payload: { identity: { tabID: string; generation: number }; code: "blocked" | "failed"; url: string } + } + export type ElectronAPI = { - appDockOpen: (url: string, bounds: { x: number; y: number; width: number; height: number }, profile?: string) => Promise<{ id: string; url: string }> + appDockOpen: ( + url: string, + bounds: { x: number; y: number; width: number; height: number }, + profile?: string, + ) => Promise<{ id: string; tabID: string; generation: number; url: string }> + appDockDeleteProfile: (profileID: string) => Promise appDockResize: (bounds: { x: number; y: number; width: number; height: number }) => Promise appDockHide: () => Promise appDockClose: () => Promise @@ -51,15 +87,43 @@ export type ElectronAPI = { appDockSelect: (id: string, bounds: { x: number; y: number; width: number; height: number }) => Promise appDockNavigate: (id: string, url: string) => Promise appDockCommand: (id: string, command: "back" | "forward" | "reload") => Promise - appDockState: (callback: (state: { id: string; url: string; title: string; favicon?: string; loading: boolean; audible: boolean; error?: string }) => void) => () => void + appDockEvent: (callback: (event: AppDockEvent) => void) => () => void + appDockState: ( + callback: (state: { + id: string + url: string + title: string + favicon?: string + loading: boolean + audible: boolean + error?: string + }) => void, + ) => () => void appDockTabOpened: (callback: (tab: { id: string; url: string }) => void) => () => void appDockFind: (id: string, text: string, forward: boolean) => Promise appDockStopFind: (id: string) => Promise - appDockFindResult: (callback: (result: { tabID: string; requestID: number; activeMatchOrdinal: number; matches: number; finalUpdate: boolean }) => void) => () => void + appDockFindResult: ( + callback: (result: { + tabID: string + requestID: number + activeMatchOrdinal: number + matches: number + finalUpdate: boolean + }) => void, + ) => () => void appDockZoom: (id: string, factor?: number) => Promise appDockCancelDownload: (id: string) => Promise - appDockOpenDownload: (id: string) => Promise - appDockDownload: (callback: (download: { id: string; tabID: string; filename: string; receivedBytes: number; totalBytes: number; state: "progressing" | "paused" | "completed" | "cancelled" | "interrupted" }) => void) => () => void + appDockOpenDownload: (id: string) => Promise + appDockDownload: ( + callback: (download: { + id: string + tabID: string + filename: string + receivedBytes: number + totalBytes: number + state: "progressing" | "paused" | "completed" | "cancelled" | "interrupted" + }) => void, + ) => () => void appDockFullscreen: (id: string, enabled: boolean) => Promise appDockFullscreenChanged: (callback: (state: { tabID: string; enabled: boolean }) => void) => () => void killSidecar: () => Promise From ba57ee73c04ed6c4b926e14b5499c62422947a6e Mon Sep 17 00:00:00 2001 From: Gustavo Schneiter Date: Wed, 9 Sep 2026 00:53:37 -0300 Subject: [PATCH 056/117] fix(desktop): project app dock IPC events --- packages/desktop/src/main/ipc.ts | 111 +++++++++++++++++++++++++++++-- 1 file changed, 107 insertions(+), 4 deletions(-) diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts index 711f25a24a43..b79dd53a3eb2 100644 --- a/packages/desktop/src/main/ipc.ts +++ b/packages/desktop/src/main/ipc.ts @@ -7,7 +7,12 @@ import type { IpcMainEvent, IpcMainInvokeEvent } from "electron" import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu" import { parseDesktopNativeBundle, type DesktopNativeBundle } from "@opencode-ai/app/i18n/desktop-native" -import type { FatalRendererError, ServerReadyData, TitlebarTheme } from "../preload/types" +import type { + AppDockEvent as CloneableAppDockEvent, + FatalRendererError, + ServerReadyData, + TitlebarTheme, +} from "../preload/types" import { runDesktopMenuAction } from "./desktop-menu-actions" import { setForceFocus } from "./debug" import { assertAttachmentBudget, createPickedFileAuthorizations } from "./attachment-picker" @@ -28,7 +33,7 @@ import { nativeT } from "./native-translations" import { createAppDock, panelBoundsToContent, - type AppDockEvent, + type AppDockEvent as NativeAppDockEvent, type AppDockFindResult, type DockBounds, type ProfileStorage, @@ -65,6 +70,104 @@ const appDockBounds = (value: unknown): DockBounds => { return bounds as DockBounds } +const appDockEventRecord = (value: unknown) => { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid App Dock event") + return value as Record +} + +const appDockEventString = (value: unknown) => { + if (typeof value !== "string") throw new Error("Invalid App Dock event") + return value +} + +const appDockEventNumber = (value: unknown) => { + if (typeof value !== "number" || !Number.isFinite(value)) throw new Error("Invalid App Dock event") + return value +} + +const appDockEventBoolean = (value: unknown) => { + if (typeof value !== "boolean") throw new Error("Invalid App Dock event") + return value +} + +const appDockEventOptionalString = (value: unknown) => { + if (value === undefined) return undefined + return appDockEventString(value) +} + +const toCloneableAppDockEvent = (event: unknown): CloneableAppDockEvent => { + const source = appDockEventRecord(event) + const payload = appDockEventRecord(source.payload) + if (source.type === "state") { + return { + type: "state", + payload: { + tabID: appDockEventString(payload.tabID), + generation: appDockEventNumber(payload.generation), + url: appDockEventString(payload.url), + title: appDockEventString(payload.title), + favicon: appDockEventOptionalString(payload.favicon), + loading: appDockEventBoolean(payload.loading), + audible: appDockEventBoolean(payload.audible), + }, + } + } + if (source.type === "download") { + const state = appDockEventString(payload.state) + if ( + state !== "progressing" && + state !== "paused" && + state !== "completed" && + state !== "cancelled" && + state !== "interrupted" + ) { + throw new Error("Invalid App Dock event") + } + return { + type: "download", + payload: { + id: appDockEventString(payload.id), + tabID: appDockEventString(payload.tabID), + generation: appDockEventNumber(payload.generation), + filename: appDockEventString(payload.filename), + receivedBytes: appDockEventNumber(payload.receivedBytes), + totalBytes: appDockEventNumber(payload.totalBytes), + state, + }, + } + } + if (source.type === "fullscreen") { + const identity = appDockEventRecord(payload.identity) + return { + type: "fullscreen", + payload: { + identity: { + tabID: appDockEventString(identity.tabID), + generation: appDockEventNumber(identity.generation), + }, + enabled: appDockEventBoolean(payload.enabled), + }, + } + } + if (source.type === "navigation-error") { + const identity = appDockEventRecord(payload.identity) + const code = appDockEventString(payload.code) + if (code !== "blocked" && code !== "failed") throw new Error("Invalid App Dock event") + return { + type: "navigation-error", + payload: { + identity: { + tabID: appDockEventString(identity.tabID), + generation: appDockEventNumber(identity.generation), + }, + code, + url: appDockEventString(payload.url), + }, + } + } + throw new Error("Unknown App Dock event") +} + type Deps = { killSidecar: () => Promise | void relaunch: () => void @@ -134,8 +237,8 @@ export function registerIpcHandlers(deps: Deps) { win, address, panelBoundsToContent(appDockBounds(bounds), event.sender.getZoomFactor()), - (appDockEvent: AppDockEvent) => { - if (!event.sender.isDestroyed()) event.sender.send("app-dock-event", appDockEvent) + (appDockEvent: NativeAppDockEvent) => { + if (!event.sender.isDestroyed()) event.sender.send("app-dock-event", toCloneableAppDockEvent(appDockEvent)) }, profileStorage, ) From 81bc24bdb9df255c44cc65ac41186e42ff41d0ab Mon Sep 17 00:00:00 2001 From: Gustavo Schneiter Date: Wed, 9 Sep 2026 00:25:23 -0300 Subject: [PATCH 057/117] test(desktop): add app dock security harness --- packages/desktop/package.json | 1 + .../src/main/app-dock-security.test.ts | 189 ++++++++++++++++++ 2 files changed, 190 insertions(+) create mode 100644 packages/desktop/src/main/app-dock-security.test.ts diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 73a0bb94c7f6..2557ea4c497d 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -11,6 +11,7 @@ }, "scripts": { "typecheck": "tsgo -b", + "test:app-dock:e2e": "bun run src/main/app-dock-security.test.ts", "predev": "bun ./scripts/predev.ts", "dev": "electron-vite dev", "prebuild": "bun ./scripts/prebuild.ts", diff --git a/packages/desktop/src/main/app-dock-security.test.ts b/packages/desktop/src/main/app-dock-security.test.ts new file mode 100644 index 000000000000..8a8710421b2b --- /dev/null +++ b/packages/desktop/src/main/app-dock-security.test.ts @@ -0,0 +1,189 @@ +import { execFileSync, spawn } from "node:child_process" +import { randomUUID } from "node:crypto" +import { mkdir, mkdtemp, rename, rm, writeFile } from "node:fs/promises" +import { createServer } from "node:https" +import { tmpdir } from "node:os" +import { dirname, join, resolve } from "node:path" + +const required = ["U01", "U02", "U03", "U04", "U05", "U06", "U07", "U08", "U09", "U10", "U11", "U12", "U13", "U15", "U16", "U17", "U18"] +const root = resolve(import.meta.dir, "../..") +const artifact = join(root, "artifacts/app-dock/s1.json") +const schemes = ["http://127.0.0.1/", "file:///etc/passwd", "javascript:document.title='pwned'", "data:text/html,pwned"] +type Case = { id: string; status: "pass"; detail: string } +const cases: Case[] = [] + +const check = (condition: unknown, message: string) => { + if (!condition) throw new Error(message) +} +const pass = (id: string, detail: string) => cases.push({ id, status: "pass", detail }) +const rejects = async (fn: () => unknown | Promise, text: string) => { + try { + await fn() + } catch (error) { + check(String(error).includes(text), `Expected ${text}, got ${error}`) + return + } + throw new Error(`Expected rejection: ${text}`) +} +const delay = (ms = 100) => new Promise((resolve) => setTimeout(resolve, ms)) + +async function parent() { + const output = join(await mkdtemp(join(tmpdir(), "app-dock-e2e-")), "harness.cjs") + const result = await Bun.build({ entrypoints: [import.meta.path], outfile: output, target: "node", format: "cjs", external: ["electron", "node:sqlite"] }) + if (!result.success) throw new Error(result.logs.map(String).join("\n")) + const electron = join(root, "node_modules/.bin/electron") + const child = spawn(electron, [output, "--app-dock-electron-child"], { stdio: "inherit", env: { ...process.env, ELECTRON_DISABLE_SECURITY_WARNINGS: "true" } }) + const code = await new Promise((resolve) => child.once("exit", resolve)) + await rm(dirname(output), { recursive: true, force: true }) + if (code !== 0) process.exitCode = code ?? 1 +} + +async function fixture() { + const dir = await mkdtemp(join(tmpdir(), "app-dock-https-")) + const key = join(dir, "key.pem") + const cert = join(dir, "cert.pem") + execFileSync("openssl", ["req", "-x509", "-newkey", "rsa:2048", "-nodes", "-keyout", key, "-out", cert, "-subj", "/CN=127.0.0.1", "-days", "1"], { stdio: "ignore" }) + const server = createServer({ key: await Bun.file(key).text(), cert: await Bun.file(cert).text() }, (req, res) => { + if (req.url === "/redirect-http") { + res.writeHead(302, { location: "http://127.0.0.1/blocked" }) + return res.end() + } + if (req.url === "/popup") return res.end("") + if (req.url === "/navigate") return res.end("go") + if (req.url === "/permission") return res.end("") + if (req.url === "/iframe") return res.end("