diff --git a/packages/amico-run/src/agent_spawn.ts b/packages/amico-run/src/agent_spawn.ts index 85e69e7c..0b6343f2 100644 --- a/packages/amico-run/src/agent_spawn.ts +++ b/packages/amico-run/src/agent_spawn.ts @@ -31,8 +31,12 @@ import type { Finding } from "./lenses.js"; export const DEFAULT_CRITIC_MODEL = "anthropic/claude-opus-5"; export const DEFAULT_CRITIC_VARIANT = "high"; -/** Per-child ceiling (§3.7). A critic that has not answered in two minutes has failed. */ -export const CRITIC_TIMEOUT_MS = 120_000; +/** Per-child ceiling (§3.7). A critic that has not answered in four minutes has failed. + * 240s, not 120s: a frontier-class review of a full spec on a free-tier model takes + * ~3 minutes measured (178s for a 13k-token decomposition pass, 2026-07-31) — the + * two-minute ceiling read legitimate slow answers as failures and silently degraded + * every review to approved-mechanical/degraded. */ +export const CRITIC_TIMEOUT_MS = 240_000; /** Why a lens has no findings, which is NOT the same question as whether it ran. * @@ -99,6 +103,13 @@ export function resolveAgentBin(env: NodeJS.ProcessEnv = process.env): string | * already authenticated against. */ const ENV_ALLOWLIST = ["HOME", "PATH", "TMPDIR", "SHELL", "LANG", "LC_ALL", "TERM"]; const ENV_ALLOW_PREFIXES = ["XDG_", "OPENCODE_"]; +/** Live-session pointers: when amico runs INSIDE a live Amicode session, these + * ride the OPENCODE_ prefix allowance into the child, and the child's headless + * `run` tries to resolve the PARENT's session — failing with "Session not + * found" before the critic ever starts. The child spawns its own runtime; the + * parent's session pointers are never valid for it. Config vars + * (OPENCODE_CONFIG_CONTENT/DIR) stay — those are the legitimate prefix users. */ +const ENV_DENYLIST = new Set(["OPENCODE", "OPENCODE_PID", "OPENCODE_SERVER_PASSWORD"]); export function buildChildEnv( parent: NodeJS.ProcessEnv = process.env, @@ -107,6 +118,7 @@ export function buildChildEnv( const out: NodeJS.ProcessEnv = {}; for (const [k, v] of Object.entries(parent)) { if (v === undefined) continue; + if (ENV_DENYLIST.has(k)) continue; if (ENV_ALLOWLIST.includes(k) || ENV_ALLOW_PREFIXES.some((p) => k.startsWith(p))) out[k] = v; } return { ...out, ...extra }; diff --git a/packages/amico-run/src/plan_compile.ts b/packages/amico-run/src/plan_compile.ts index 8ca17445..fa165365 100644 --- a/packages/amico-run/src/plan_compile.ts +++ b/packages/amico-run/src/plan_compile.ts @@ -469,8 +469,16 @@ function defaultPlanner(specPath: string, env?: NodeJS.ProcessEnv): ((specText: agent: "planner", model: criticModel(e), env: e, + // Plan generation is a bigger output than a critic pass (a full gated + // plan JSON, not findings) — measured beyond the 240s critic ceiling on + // a free-tier model, so the planner gets its own ceiling (2026-07-31). + timeoutMs: PLANNER_TIMEOUT_MS, prompt: "Compile the spec in your working directory into a plan. Reply with the JSON object only.", specText, specFilename: specPath.split("/").pop() ?? "spec.md", }); } + +/** Planner-only spawn ceiling: ~2.7× the measured 178s critic pass at the same + * tier (2026-07-31). Anything longer is a hung child, not a slow answer. */ +export const PLANNER_TIMEOUT_MS = 480_000; diff --git a/packages/amico-run/test/agent_spawn.test.ts b/packages/amico-run/test/agent_spawn.test.ts index 4feafa8c..3f9041b6 100644 --- a/packages/amico-run/test/agent_spawn.test.ts +++ b/packages/amico-run/test/agent_spawn.test.ts @@ -348,4 +348,17 @@ describe("buildChildEnv", () => { it("lets explicit extras through", () => { expect(buildChildEnv({ HOME: "/h" }, { OPENCODE_CONFIG_CONTENT: "{}" }).OPENCODE_CONFIG_CONTENT).toBe("{}"); }); + it("strips live-session pointers even though they carry the OPENCODE_ prefix", () => { + // Regression: spawned from inside a live Amicode session, these vars made + // the child's headless `run` resolve the PARENT's session → "Session not + // found", killing every critic before it started. Config vars must stay. + const env = buildChildEnv({ + HOME: "/h", + OPENCODE: "1", + OPENCODE_PID: "3434", + OPENCODE_SERVER_PASSWORD: "live-pw", + OPENCODE_CONFIG_CONTENT: "{}", + }); + expect(env).toEqual({ HOME: "/h", OPENCODE_CONFIG_CONTENT: "{}" }); + }); }); diff --git a/packages/extension/esbuild.config.mjs b/packages/extension/esbuild.config.mjs index 1451c09d..db9838e3 100644 --- a/packages/extension/esbuild.config.mjs +++ b/packages/extension/esbuild.config.mjs @@ -86,6 +86,18 @@ const targets = [ logLevel: "info", loader: { ".svg": "text" }, }, + // Chat Deck webview bundle — pane-manager shell (src/deck/shell.ts + model) + { + entryPoints: ["src/deck/shell.ts"], + bundle: true, + platform: "browser", + target: "es2022", + format: "iife", + outfile: "dist/deck_shell.js", + sourcemap: true, + minify: false, + logLevel: "info", + }, ]; if (watch) { diff --git a/packages/extension/package.json b/packages/extension/package.json index f6f19ee3..a6b29fc5 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -31,6 +31,8 @@ ], "activationEvents": [ "onCommand:amicode.openChat", + "onCommand:amicode.newChat", + "onCommand:amicode.chatDeck", "onCommand:amicode.openInspector", "onView:amicode.runInspector", "onView:amicode.deviceInspector", @@ -92,6 +94,16 @@ "title": "Amicode: Open Chat", "icon": "$(comment-discussion)" }, + { + "command": "amicode.newChat", + "title": "Amicode: New Chat (Side by Side)", + "icon": "$(add)" + }, + { + "command": "amicode.chatDeck", + "title": "Amicode: Open Chat Deck (Panes in One Tab)", + "icon": "$(layout)" + }, { "command": "amicode.setupVault", "title": "Amicode: Set up a personal vault" diff --git a/packages/extension/src/chat_bridge.ts b/packages/extension/src/chat_bridge.ts new file mode 100644 index 00000000..5ad4d7e0 --- /dev/null +++ b/packages/extension/src/chat_bridge.ts @@ -0,0 +1,138 @@ +import * as vscode from "vscode"; +import * as path from "node:path"; +import * as os from "node:os"; + +// ============================================================================ +// The amicode iframe⇄extension command bridge, shared by ChatPanel (one +// iframe) and DeckPanel (N panes). The framed app renders LLM output, so the +// handler is paranoid by construction: strict command allowlist, https-only +// externals, visibility-gated clipboard reads, bounded payloads. Panes tag +// their messages with an opaque `tab` id; replies ECHO it so the shell can +// route the answer back to the asking pane. Single-iframe panels leave `tab` +// undefined and their relay simply forwards to the one iframe. +// ============================================================================ + +// Commands the in-app palette (opencode "Amico" command group) may trigger via +// the iframe→parent→extension postMessage bridge. STRICT allowlist: the framed +// app renders LLM output, so we never executeCommand anything outside this set. +export const BRIDGE_ALLOWED_COMMANDS: ReadonlySet = new Set([ + "amicode.restartServer", + "amicode.distillNow", + "amicode.stopRun", + "amicode.savePulse", + "amicode.openRunDir", + "amicode.openInspector", + // ⌘⇧P inside the chat iframe lands in the APP's palette, not VS Code's — + // the fork forwards it here so the editor's Command Palette (where every + // Amicode: command lives) opens as users expect. + "workbench.action.showCommands", +]); + +/** Side channels the handler needs from its host panel. */ +export interface BridgeIo { + /** Clipboard reads only answer while the user can see the chat. */ + visible(): boolean; + /** Replies (clipboard text) go back to the host webview; `tab` echoes along. */ + postToWebview(msg: unknown): void; +} + +const isAmicode = (msg: unknown): msg is { source: "amicode"; kind: string; tab?: string } => + !!msg && typeof msg === "object" && (msg as { source?: unknown }).source === "amicode"; + +/** Handle one envelope from a framed app. Returns true when the message was + * consumed (hosts log the rest). */ +export function handleAmicodeBridgeMessage(msg: unknown, io: BridgeIo): boolean { + if (!isAmicode(msg)) return false; + + // target=_blank/window.open are dead inside the framed app — open https + // links via the editor (system browser). https-only; scheme is + // case-insensitive (RFC 3986). + if ( + msg.kind === "open-external" && + typeof (msg as { url?: unknown }).url === "string" && + /^https:\/\//i.test((msg as unknown as { url: string }).url) + ) { + void vscode.env.openExternal(vscode.Uri.parse((msg as unknown as { url: string }).url)); + return true; + } + + // Paste bridge: navigator.clipboard is unavailable to the framed app (the + // webview parent has no clipboard-read to delegate), so the app asks US — + // the extension host reads the OS clipboard and replies. Visibility gate: + // the app renders LLM-driven content, so a hidden panel must not be able to + // sample the clipboard in the background. + if (msg.kind === "clipboard-request") { + if (!io.visible()) return true; + void vscode.env.clipboard.readText().then((text) => + io.postToWebview({ + source: "amicode", + kind: "clipboard", + nonce: (msg as { nonce?: string }).nonce, + text, + tab: msg.tab, + }), + ); + return true; + } + + // Copy bridge (mirror of clipboard-request): the framed app's native copy + // can't reach the OS clipboard, so an in-chat ⌘C posts the text here and we + // write it via vscode.env.clipboard — otherwise the paste bridge above reads + // back stale content. Same visibility gate; payload is untrusted, so bound it. + if (msg.kind === "clipboard-write" && typeof (msg as { text?: unknown }).text === "string") { + if (!io.visible()) return true; + const text = (msg as unknown as { text: string }).text; + if (text.length > 5_000_000) return true; + void vscode.env.clipboard.writeText(text); + return true; + } + + // Save bridge (run-card PNG export): downloads are dead inside the framed + // app — the extension shows a save dialog and writes the file. PNG-only, + // basename-only, bounded size: the payload is untrusted. + if ( + msg.kind === "save-file" && + typeof (msg as { filename?: unknown }).filename === "string" && + typeof (msg as { dataUrl?: unknown }).dataUrl === "string" + ) { + const raw = msg as unknown as { filename: string; dataUrl: string }; + const prefix = "data:image/png;base64,"; + const base64 = raw.dataUrl.startsWith(prefix) ? raw.dataUrl.slice(prefix.length) : undefined; + const name = path.basename(raw.filename).replace(/[^\w.-]+/g, "-"); + if (!base64 || base64.length > 24_000_000 || !name.endsWith(".png")) return true; + void (async () => { + const target = await vscode.window.showSaveDialog({ + defaultUri: vscode.Uri.file(path.join(os.homedir(), "Downloads", name)), + filters: { Images: ["png"] }, + }); + if (!target) return; + await vscode.workspace.fs.writeFile(target, Buffer.from(base64, "base64")); + const pick = await vscode.window.showInformationMessage(`Amicode: saved ${path.basename(target.fsPath)}`, "Reveal"); + if (pick === "Reveal") await vscode.commands.executeCommand("revealFileInOS", target); + })(); + return true; + } + + // The "Amico" palette group — allowlisted commands only. + if (msg.kind === "command") { + const command = (msg as unknown as { command?: unknown }).command; + if (typeof command === "string" && BRIDGE_ALLOWED_COMMANDS.has(command)) { + void vscode.commands.executeCommand(command); + return true; + } + return false; + } + + // Dashboard "Default model" control mirrors its choice into the + // amicode.defaultModel setting, so the config pin (headless / first turn) + // tracks the UI. "provider/model-id" only, bounded — untrusted. + if (msg.kind === "set-default-model" && typeof (msg as { model?: unknown }).model === "string") { + const model = (msg as unknown as { model: string }).model.trim(); + if (model.length > 0 && model.length <= 200 && /^[\w.-]+\/[\w.:-]+$/.test(model)) { + void vscode.workspace.getConfiguration("amicode").update("defaultModel", model, vscode.ConfigurationTarget.Global); + } + return true; + } + + return false; +} diff --git a/packages/extension/src/chat_panel.ts b/packages/extension/src/chat_panel.ts index 1877cf3e..518729ce 100644 --- a/packages/extension/src/chat_panel.ts +++ b/packages/extension/src/chat_panel.ts @@ -1,33 +1,18 @@ import * as vscode from "vscode"; import { randomBytes } from "node:crypto"; -import * as path from "node:path"; -import * as os from "node:os"; +import { handleAmicodeBridgeMessage } from "./chat_bridge"; // ============================================================================ -// ChatPanel — a single WebviewPanel that iframes opencode's SolidJS chat at +// ChatPanel — a WebviewPanel that iframes opencode's SolidJS chat at // http://127.0.0.1:. Adapted directly from the opencode-v2 decompile -// (class `j` at L2499). Stays singleton: `openOrReveal` either pops the -// existing panel forward or creates a fresh one. +// (class `j` at L2499). Multi-instance: `openOrReveal` keeps PRIMARY semantics +// (pops the front door forward or creates it), while `openNew` always spawns +// an additional tab beside the active editor — side-by-side sessions, each +// pinned to its own in-app route (e.g. /new-session), one server underneath. // ============================================================================ -// Commands the in-app palette (opencode "Amico" command group) may trigger via -// the iframe→parent→extension postMessage bridge. STRICT allowlist: the framed -// app renders LLM output, so we never executeCommand anything outside this set. -const BRIDGE_ALLOWED_COMMANDS: ReadonlySet = new Set([ - "amicode.restartServer", - "amicode.distillNow", - "amicode.stopRun", - "amicode.savePulse", - "amicode.openRunDir", - "amicode.openInspector", - // ⌘⇧P inside the chat iframe lands in the APP's palette, not VS Code's — - // the fork forwards it here so the editor's Command Palette (where every - // Amicode: command lives) opens as users expect. - "workbench.action.showCommands", -]); - /** VS Code theme kind → the fork app's ColorScheme. */ -function themeKindToScheme(kind: vscode.ColorThemeKind): "light" | "dark" { +export function themeKindToScheme(kind: vscode.ColorThemeKind): "light" | "dark" { return kind === vscode.ColorThemeKind.Light || kind === vscode.ColorThemeKind.HighContrastLight ? "light" : "dark"; } @@ -44,7 +29,7 @@ function themeKindToScheme(kind: vscode.ColorThemeKind): "light" | "dark" { // theme-appropriate foreground gray. A unit test keeps them in sync with the // source geometry. `light` is shown on light themes (dark mark), `dark` on // dark themes (light mark). -function tabIconPath(ctx: vscode.ExtensionContext): { light: vscode.Uri; dark: vscode.Uri } { +export function tabIconPath(ctx: vscode.ExtensionContext): { light: vscode.Uri; dark: vscode.Uri } { return { light: vscode.Uri.joinPath(ctx.extensionUri, "media", "amico-tab-light.svg"), dark: vscode.Uri.joinPath(ctx.extensionUri, "media", "amico-tab-dark.svg"), @@ -53,15 +38,19 @@ function tabIconPath(ctx: vscode.ExtensionContext): { light: vscode.Uri; dark: v export class ChatPanel { private static current?: ChatPanel; + /** Every live chat tab (primary included) — drives tab-title numbering. */ + private static readonly live = new Set(); private readonly disposables: vscode.Disposable[] = []; private constructor( private readonly panel: vscode.WebviewPanel, + private readonly tabTitle: string, opencodeUrl: URL, authToken?: string, hideProjectDir?: string, ) { this.panel.webview.html = this.renderHtml(opencodeUrl, authToken, hideProjectDir); + ChatPanel.live.add(this); this.panel.onDidDispose(() => this.dispose(), null, this.disposables); // Live theme bridge: editor theme changes flow extension → outer relay → // iframe → the app's setColorScheme (boot theme rides ?colorScheme=). @@ -77,123 +66,14 @@ export class ChatPanel { ); this.panel.webview.onDidReceiveMessage( (msg) => { - // iframe → extension command bridge: the opencode "Amico" palette group - // posts {source:"amicode", kind:"command", command} to window.parent; - // the outer webview relay (renderHtml) forwards it here. We honor ONLY - // allowlisted amicode.* commands so the framed app can't run arbitrary - // vscode commands. - if ( - msg && - typeof msg === "object" && - (msg as { source?: unknown }).source === "amicode" && - (msg as { kind?: unknown }).kind === "open-external" && - typeof (msg as { url?: unknown }).url === "string" && - /^https:\/\//i.test((msg as { url: string }).url) // scheme is case-insensitive (RFC 3986) - ) { - // target=_blank/window.open are dead inside the framed app — open - // https links via the editor (system browser). https-only. - void vscode.env.openExternal(vscode.Uri.parse((msg as { url: string }).url)); - return; - } - if ( - msg && - typeof msg === "object" && - (msg as { source?: unknown }).source === "amicode" && - (msg as { kind?: unknown }).kind === "clipboard-request" - ) { - // Paste bridge: navigator.clipboard is unavailable to the framed app - // (the webview parent has no clipboard-read to delegate), so the app - // asks US — the extension host reads the OS clipboard and replies. - // Visibility gate: the app renders LLM-driven content, so a hidden - // panel must not be able to sample the clipboard in the background — - // reads only answer while the user can see the chat. - if (!this.panel.visible) return; - void vscode.env.clipboard.readText().then((text) => - this.panel.webview.postMessage({ - source: "amicode", - kind: "clipboard", - nonce: (msg as { nonce?: string }).nonce, - text, - }), - ); - return; - } - if ( - msg && - typeof msg === "object" && - (msg as { source?: unknown }).source === "amicode" && - (msg as { kind?: unknown }).kind === "clipboard-write" && - typeof (msg as { text?: unknown }).text === "string" - ) { - // Copy bridge (mirror of clipboard-request): the framed app's native - // copy can't reach the OS clipboard, so an in-chat ⌘C posts the text - // here and we write it via vscode.env.clipboard — otherwise the paste - // bridge above reads back stale content. Same visibility gate as the - // read side, and the payload is untrusted (LLM-rendered), so bound it. - if (!this.panel.visible) return; - const text = (msg as { text: string }).text; - if (text.length > 5_000_000) return; - void vscode.env.clipboard.writeText(text); - return; - } - if ( - msg && - typeof msg === "object" && - (msg as { source?: unknown }).source === "amicode" && - (msg as { kind?: unknown }).kind === "save-file" && - typeof (msg as { filename?: unknown }).filename === "string" && - typeof (msg as { dataUrl?: unknown }).dataUrl === "string" - ) { - // Save bridge (run-card PNG export): downloads are dead inside the - // framed app — the extension shows a save dialog and writes the file. - // PNG-only, basename-only, bounded size: the payload is untrusted. - const raw = msg as { filename: string; dataUrl: string }; - const prefix = "data:image/png;base64,"; - const base64 = raw.dataUrl.startsWith(prefix) ? raw.dataUrl.slice(prefix.length) : undefined; - const name = path.basename(raw.filename).replace(/[^\w.-]+/g, "-"); - if (!base64 || base64.length > 24_000_000 || !name.endsWith(".png")) return; - void (async () => { - const target = await vscode.window.showSaveDialog({ - defaultUri: vscode.Uri.file(path.join(os.homedir(), "Downloads", name)), - filters: { Images: ["png"] }, - }); - if (!target) return; - await vscode.workspace.fs.writeFile(target, Buffer.from(base64, "base64")); - const pick = await vscode.window.showInformationMessage(`Amicode: saved ${path.basename(target.fsPath)}`, "Reveal"); - if (pick === "Reveal") await vscode.commands.executeCommand("revealFileInOS", target); - })(); - return; - } - if ( - msg && - typeof msg === "object" && - (msg as { source?: unknown }).source === "amicode" && - (msg as { kind?: unknown }).kind === "command" && - typeof (msg as { command?: unknown }).command === "string" && - BRIDGE_ALLOWED_COMMANDS.has((msg as { command: string }).command) - ) { - void vscode.commands.executeCommand((msg as { command: string }).command); - return; - } - if ( - msg && - typeof msg === "object" && - (msg as { source?: unknown }).source === "amicode" && - (msg as { kind?: unknown }).kind === "set-default-model" && - typeof (msg as { model?: unknown }).model === "string" - ) { - // Dashboard "Default model" control mirrors its choice into the - // amicode.defaultModel setting, so the config pin (headless / first - // turn) tracks the UI. "provider/model-id" only, bounded — untrusted. - const model = (msg as { model: string }).model.trim(); - if (model.length > 0 && model.length <= 200 && /^[\w.-]+\/[\w.:-]+$/.test(model)) { - void vscode.workspace - .getConfiguration("amicode") - .update("defaultModel", model, vscode.ConfigurationTarget.Global); - } - return; - } - console.log("[amicode/chat] webview msg:", msg); + // iframe → extension bridge: the outer webview relay (renderHtml) + // forwards the framed app's envelopes here; the shared handler owns the + // strict allowlists (chat_bridge.ts, also used by the deck's panes). + const handled = handleAmicodeBridgeMessage(msg, { + visible: () => this.panel.visible, + postToWebview: (m) => void this.panel.webview.postMessage(m), + }); + if (!handled) console.log("[amicode/chat] webview msg:", msg); }, null, this.disposables, @@ -215,17 +95,27 @@ export class ChatPanel { setTimeout(() => void this.panel.webview.postMessage(envelope), 1500); } - static openOrReveal( + /** Lowest free tab label: the lone tab reads "Amicode Chat"; extras take the + * smallest unused "Amicode Chat N" (N ≥ 2). Numbers free up on dispose, so a + * closed tab's number is reused — existing tabs are never retitled. */ + private static nextTitle(): string { + const taken = new Set([...ChatPanel.live].map((p) => p.tabTitle)); + if (!taken.has("Amicode Chat")) return "Amicode Chat"; + for (let n = 2; ; n++) { + const candidate = `Amicode Chat ${n}`; + if (!taken.has(candidate)) return candidate; + } + } + + private static createPanel( ctx: vscode.ExtensionContext, + column: vscode.ViewColumn, opencodeUrl: URL, authToken?: string, hideProjectDir?: string, ): ChatPanel { - if (ChatPanel.current) { - ChatPanel.current.panel.reveal(vscode.ViewColumn.One); - return ChatPanel.current; - } - const panel = vscode.window.createWebviewPanel("amicode.chat", "Amicode Chat", vscode.ViewColumn.One, { + const title = ChatPanel.nextTitle(); + const panel = vscode.window.createWebviewPanel("amicode.chat", title, column, { enableScripts: true, retainContextWhenHidden: true, // The chat lives at localhost; we let the webview reach out via http://127.0.0.1 @@ -234,10 +124,36 @@ export class ChatPanel { localResourceRoots: [vscode.Uri.joinPath(ctx.extensionUri, "media")], }); panel.iconPath = tabIconPath(ctx); - ChatPanel.current = new ChatPanel(panel, opencodeUrl, authToken, hideProjectDir); + return new ChatPanel(panel, title, opencodeUrl, authToken, hideProjectDir); + } + + static openOrReveal( + ctx: vscode.ExtensionContext, + opencodeUrl: URL, + authToken?: string, + hideProjectDir?: string, + ): ChatPanel { + if (ChatPanel.current) { + ChatPanel.current.panel.reveal(vscode.ViewColumn.One); + return ChatPanel.current; + } + ChatPanel.current = ChatPanel.createPanel(ctx, vscode.ViewColumn.One, opencodeUrl, authToken, hideProjectDir); return ChatPanel.current; } + /** Side-by-side sessions: ALWAYS a fresh tab beside the active editor — the + * caller pins the tab's session scope via the URL (e.g. the app's + * /new-session draft route), so each tab owns its conversation while sharing + * the one opencode server underneath. */ + static openNew( + ctx: vscode.ExtensionContext, + opencodeUrl: URL, + authToken?: string, + hideProjectDir?: string, + ): ChatPanel { + return ChatPanel.createPanel(ctx, vscode.ViewColumn.Beside, opencodeUrl, authToken, hideProjectDir); + } + private renderHtml(opencodeUrl: URL, authToken?: string, hideProjectDir?: string): string { // CSP: allow the iframe to load opencode's localhost origin. The frame // itself is isolated, but VS Code's webview CSP needs to explicitly grant @@ -364,6 +280,7 @@ export class ChatPanel { } catch {} } this.disposables.length = 0; + ChatPanel.live.delete(this); if (ChatPanel.current === this) ChatPanel.current = undefined; } } diff --git a/packages/extension/src/deck/model.ts b/packages/extension/src/deck/model.ts new file mode 100644 index 00000000..53e9d126 --- /dev/null +++ b/packages/extension/src/deck/model.ts @@ -0,0 +1,152 @@ +// ============================================================================ +// Deck pane model — the pure state machine under the Chat Deck webview +// (dist/deck_shell.js). No DOM, no vscode: groups of tabs in a horizontal row. +// Tabs drag between strips (moveTab), onto a group's left/right edge +// (splitTab → a new group docks there), and a group that loses its last tab +// collapses (merge-back). Every op returns a FRESH Deck — the shell diff- +// renders from the returned value, and tests assert inputs are never mutated. +// ============================================================================ + +export type DeckTabKind = "home" | "draft" | "session"; + +export interface DeckTab { + id: string; + /** The iframe's current URL — shell-maintained from the route-info bridge. */ + url: string; + label: string; + kind: DeckTabKind; +} + +export interface DeckGroup { + id: string; + tabs: DeckTab[]; + activeTabId?: string; + /** flex-grow share of the row; proportions are relative across groups. */ + flex: number; +} + +export interface Deck { + groups: DeckGroup[]; +} + +export function createDeck(): Deck { + return { groups: [] }; +} + +const cloneTab = (t: DeckTab): DeckTab => ({ ...t }); +const cloneGroup = (g: DeckGroup): DeckGroup => ({ ...g, tabs: g.tabs.map(cloneTab) }); +const cloneDeck = (d: Deck): Deck => ({ groups: d.groups.map(cloneGroup) }); + +/** Locate a tab: its group index, tab index, and the tab itself. */ +function locate(deck: Deck, groupId: string, tabId: string): { gi: number; ti: number; tab: DeckTab } | undefined { + const gi = deck.groups.findIndex((g) => g.id === groupId); + if (gi === -1) return undefined; + const ti = deck.groups[gi].tabs.findIndex((t) => t.id === tabId); + if (ti === -1) return undefined; + return { gi, ti, tab: deck.groups[gi].tabs[ti] }; +} + +/** Remove empty groups — the merge-back physics. */ +function collapseEmpty(groups: DeckGroup[]): DeckGroup[] { + return groups.filter((g) => g.tabs.length > 0); +} + +export function addTab(deck: Deck, groupId: string, tab: DeckTab): Deck { + const next = cloneDeck(deck); + let g = next.groups.find((g) => g.id === groupId); + if (!g) { + g = { id: groupId, tabs: [], flex: 1 }; + next.groups.push(g); + } + g.tabs.push(tab); + g.activeTabId = tab.id; + return next; +} + +export function closeTab(deck: Deck, groupId: string, tabId: string): Deck { + const at = locate(deck, groupId, tabId); + if (!at) return deck; + const next = cloneDeck(deck); + const g = next.groups[at.gi]; + g.tabs.splice(at.ti, 1); + if (g.activeTabId === tabId) { + // Prefer the left neighbor (VS Code's habit), else whatever remains first. + g.activeTabId = (g.tabs[at.ti - 1] ?? g.tabs[0])?.id; + } + next.groups = collapseEmpty(next.groups); + return next; +} + +export function activateTab(deck: Deck, groupId: string, tabId: string): Deck { + const at = locate(deck, groupId, tabId); + if (!at) return deck; + const next = cloneDeck(deck); + next.groups[at.gi].activeTabId = tabId; + return next; +} + +/** Move a tab to `dstIndex` in `dstGroupId` — the index applies to the FINAL + * array (after removal), so reordering [a,b,c] moving a→2 yields [b,c,a]. + * The dragged tab becomes active in the destination; an emptied source group + * collapses. */ +export function moveTab(deck: Deck, srcGroupId: string, tabId: string, dstGroupId: string, dstIndex: number): Deck { + const at = locate(deck, srcGroupId, tabId); + if (!at) return deck; + const next = cloneDeck(deck); + const src = next.groups[at.gi]; + const [moving] = src.tabs.splice(at.ti, 1); + const dst = next.groups.find((g) => g.id === dstGroupId); + if (!dst) return deck; + const clamped = Math.max(0, Math.min(dstIndex, dst.tabs.length)); + dst.tabs.splice(clamped, 0, moving); + dst.activeTabId = moving.id; + if (src !== dst && src.activeTabId === tabId) { + src.activeTabId = src.tabs[0]?.id; + } + next.groups = collapseEmpty(next.groups); + return next; +} + +/** Split physics: dock `tabId` into a NEW group (`newGroupId`) inserted on + * `side` of `targetGroupId`. No-op when the tab already sits alone in the + * target group (dragging a lone tab onto its own edge). */ +export function splitTab( + deck: Deck, + srcGroupId: string, + tabId: string, + targetGroupId: string, + side: "left" | "right", + newGroupId: string, +): Deck { + const at = locate(deck, srcGroupId, tabId); + const targetIndex = deck.groups.findIndex((g) => g.id === targetGroupId); + if (!at || targetIndex === -1) return deck; + if (srcGroupId === targetGroupId && deck.groups[at.gi].tabs.length === 1) return deck; + const next = cloneDeck(deck); + const src = next.groups[at.gi]; + const [moving] = src.tabs.splice(at.ti, 1); + if (src.activeTabId === tabId) src.activeTabId = src.tabs[0]?.id; + const insertAt = targetIndex + (side === "right" ? 1 : 0); + next.groups.splice(insertAt, 0, { id: newGroupId, tabs: [moving], activeTabId: moving.id, flex: 1 }); + next.groups = collapseEmpty(next.groups); + return next; +} + +export function setTabLabel(deck: Deck, tabId: string, label: string): Deck { + const next = cloneDeck(deck); + for (const g of next.groups) { + const t = g.tabs.find((t) => t.id === tabId); + if (t) t.label = label; + } + return next; +} + +/** Sash drag: set two adjacent groups' flexes; everyone else keeps theirs. */ +export function resizeGroups(deck: Deck, leftId: string, rightId: string, leftFlex: number, rightFlex: number): Deck { + const next = cloneDeck(deck); + const l = next.groups.find((g) => g.id === leftId); + const r = next.groups.find((g) => g.id === rightId); + if (l) l.flex = leftFlex; + if (r) r.flex = rightFlex; + return next; +} diff --git a/packages/extension/src/deck/shell.ts b/packages/extension/src/deck/shell.ts new file mode 100644 index 00000000..35e9430e --- /dev/null +++ b/packages/extension/src/deck/shell.ts @@ -0,0 +1,483 @@ +// ============================================================================ +// Chat Deck shell — runs INSIDE the deck webview (dist/deck_shell.js). A tiny +// window manager for chat panes: groups of iframed apps in a horizontal row, +// VS Code-style drag physics (drag a tab onto a strip to move it, onto a +// group's edge to split, and a group that empties merges back), draggable +// sashes, live labels via the fork's route-info bridge. +// +// DOM discipline that makes this work at all: +// - iframes RELOAD when reparented, so nothing ever reparents one: group +// order is set via CSS `order`, inactive tabs are display:none, and only +// the DRAGGED tab pays the one unavoidable reload (its iframe is rebuilt at +// its current route — sessions restore from the server; draft composer +// text survives via the app's persisted draft store keyed by draftId). +// - during drags, iframes take pointer-events:none so dragover/pointermove +// reach shell elements (they'd otherwise be swallowed cross-frame). +// Security: labels render via textContent only; adopted routes must be +// same-origin paths ("/..."), never absolute URLs; the boot credential lives +// only in JS memory and is re-minted onto frame srcs at build time — it is +// NEVER written to webview state (secrets don't go to disk). +// ============================================================================ + +import { + createDeck, + addTab, + closeTab, + activateTab, + moveTab, + splitTab, + setTabLabel, + resizeGroups, + type Deck, + type DeckGroup, + type DeckTab, +} from "./model"; + +interface DeckBoot { + origin: string; + authToken?: string; + colorScheme: "light" | "dark"; + hideProjectDir?: string; +} + +declare const acquireVsCodeApi: () => { postMessage(m: unknown): void; setState(s: unknown): void; getState(): T | undefined }; + +const boot = (window as unknown as { __AMICODE_DECK__: DeckBoot }).__AMICODE_DECK__; +const vscode = acquireVsCodeApi<{ deck: Deck }>(); + +const uid = (): string => + (crypto as { randomUUID?: () => string }).randomUUID?.() ?? `id-${Date.now()}-${Math.random().toString(36).slice(2)}`; + +const draftTab = (): DeckTab => ({ id: uid(), url: `/new-session?draftId=${uid()}`, label: "New session", kind: "draft" }); + +/** path-only tab.url → full frame src with boot params re-minted on. */ +function frameSrc(tab: DeckTab): string { + const u = new URL(tab.url, boot.origin); + u.searchParams.set("colorScheme", boot.colorScheme); + if (boot.authToken) u.searchParams.set("auth_token", boot.authToken); + if (boot.hideProjectDir) u.searchParams.set("amicode_hide_project", boot.hideProjectDir); + return u.href; +} + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +const restored = vscode.getState(); +let deck: Deck = restored?.deck && restored.deck.groups.length > 0 ? restored.deck : addTab(createDeck(), uid(), draftTab()); + +const groupEls = new Map(); // groupId → group element +const frameByTab = new Map(); // tabId → live iframe +let drag: { tabId: string; srcGroupId: string } | null = null; + +const persist = () => vscode.setState({ deck }); + +// --------------------------------------------------------------------------- +// Styles (constructable — not CSP-governed) +// --------------------------------------------------------------------------- + +const css = ` + html, body { margin: 0; height: 100%; overflow: hidden; background: var(--vscode-editor-background); } + body { font: 12px var(--vscode-font-family, sans-serif); } + #deck { display: flex; height: 100vh; width: 100vw; } + .group { display: flex; flex-direction: column; min-width: 140px; min-height: 0; } + .strip { + display: flex; align-items: stretch; height: 34px; flex: none; overflow-x: auto; overflow-y: hidden; + background: var(--vscode-editorGroupHeader-tabsBackground, var(--vscode-editor-background)); + border-bottom: 1px solid var(--vscode-editorGroupHeader-border, var(--vscode-panel-border, transparent)); + scrollbar-width: none; + } + .strip::-webkit-scrollbar { display: none; } + .tab { + display: flex; align-items: center; gap: 6px; padding: 0 6px 0 10px; max-width: 180px; flex: none; + color: var(--vscode-tab-inactiveForeground, #888); cursor: pointer; user-select: none; white-space: nowrap; + border-right: 1px solid var(--vscode-tab-border, transparent); + } + .tab.active { + color: var(--vscode-tab-activeForeground, #fff); + background: var(--vscode-tab-activeBackground, var(--vscode-editor-background)); + box-shadow: inset 0 1px 0 var(--vscode-tab-activeBorderTop, var(--vscode-focusBorder, transparent)); + } + .tab .lbl { overflow: hidden; text-overflow: ellipsis; } + .tab.dragging { opacity: 0.45; } + .tab .x { + visibility: hidden; border: 0; background: none; color: inherit; cursor: pointer; padding: 1px 3px; + border-radius: 4px; font-size: 12px; line-height: 1; + } + .tab:hover .x, .tab.active .x { visibility: visible; } + .tab .x:hover { background: var(--vscode-toolbar-hoverBackground, rgba(128,128,128,.3)); } + .ghostbtn { + border: 0; background: none; cursor: pointer; flex: none; align-self: center; margin: 0 4px; padding: 2px 6px; + color: var(--vscode-tab-inactiveForeground, #888); border-radius: 4px; font-size: 14px; line-height: 1; + } + .ghostbtn:hover { background: var(--vscode-toolbar-hoverBackground, rgba(128,128,128,.3)); } + .frames { position: relative; flex: 1; min-height: 0; } + .frames iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; display: block; } + .frames iframe.hidden { display: none; } + body.dragging .frames iframe, body.sashing .frames iframe { pointer-events: none !important; } + .drop-hl { + position: absolute; top: 0; bottom: 0; z-index: 30; pointer-events: none; display: none; + background: var(--vscode-editorGroup-dropBackground, rgba(0, 122, 204, 0.25)); + } + body.dragging .drop-hl.on { display: block; } + .sash { width: 4px; flex: none; cursor: col-resize; z-index: 40; } + .sash:hover, body.sashing .sash.live { background: var(--vscode-sash-hoverBorder, var(--vscode-focusBorder, #007acc)); } + #empty { + display: flex; flex-direction: column; gap: 10px; align-items: center; justify-content: center; + height: 100vh; width: 100vw; color: var(--vscode-descriptionForeground, #888); + } + #empty button { + border: 1px solid var(--vscode-button-border, transparent); border-radius: 6px; padding: 6px 14px; cursor: pointer; + background: var(--vscode-button-background, #0e639c); color: var(--vscode-button-foreground, #fff); + } +`; +const sheet = new CSSStyleSheet(); +sheet.replaceSync(css); +document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet]; + +// --------------------------------------------------------------------------- +// Render +// --------------------------------------------------------------------------- + +const root = document.createElement("div"); +root.id = "deck"; +document.body.appendChild(root); + +function syncStrip(group: DeckGroup, stripEl: HTMLElement): void { + stripEl.textContent = ""; + for (const t of group.tabs) { + const el = document.createElement("div"); + el.className = "tab" + (t.id === group.activeTabId ? " active" : ""); + el.draggable = true; + el.dataset.tabId = t.id; + el.title = t.label; + if (drag?.tabId === t.id) el.classList.add("dragging"); + const lbl = document.createElement("span"); + lbl.className = "lbl"; + lbl.textContent = t.label; // untrusted (route-info) — textContent only + el.appendChild(lbl); + const x = document.createElement("button"); + x.className = "x"; + x.textContent = "✕"; + x.addEventListener("click", (e) => { + e.stopPropagation(); + apply(closeTab(deck, group.id, t.id)); + }); + el.appendChild(x); + el.addEventListener("click", () => apply(activateTab(deck, group.id, t.id))); + el.addEventListener("dragstart", (e) => { + drag = { tabId: t.id, srcGroupId: group.id }; + e.dataTransfer?.setData("text/plain", t.id); + if (e.dataTransfer) e.dataTransfer.effectAllowed = "move"; + document.body.classList.add("dragging"); + requestAnimationFrame(() => el.classList.add("dragging")); + }); + el.addEventListener("dragend", () => endDrag()); + stripEl.appendChild(el); + } + const plus = document.createElement("button"); + plus.className = "ghostbtn"; + plus.textContent = "+"; + plus.title = "New chat in this pane"; + plus.addEventListener("click", () => apply(addTab(deck, group.id, draftTab()))); + stripEl.appendChild(plus); +} + +function syncFrames(group: DeckGroup, framesEl: HTMLElement): void { + const want = new Set(group.tabs.map((t) => t.id)); + // Drop iframes whose tab left this group (the dragged tab's old frame is + // discarded here; its replacement was built by the destination's sync). + for (const el of Array.from(framesEl.querySelectorAll("iframe"))) { + const id = (el as HTMLIFrameElement).dataset.tabId ?? ""; + if (!want.has(id)) { + frameByTab.delete(id); + el.remove(); + } + } + for (const t of group.tabs) { + let f = frameByTab.get(t.id); + if (!f || f.dataset.tabUrl !== t.url) { + // New tab, or the tab's route advanced while its old frame was gone — + // build at the CURRENT route (the one reload we can't avoid on a move). + f?.remove(); + f = document.createElement("iframe"); + f.dataset.tabId = t.id; + f.dataset.tabUrl = t.url; + f.src = frameSrc(t); + f.setAttribute("allow", "clipboard-read; clipboard-write"); + f.setAttribute("sandbox", "allow-scripts allow-forms allow-same-origin allow-popups allow-downloads"); + frameByTab.set(t.id, f); + framesEl.appendChild(f); + } + f.classList.toggle("hidden", t.id !== group.activeTabId); + } +} + +function render(): void { + const seen = new Set(); + deck.groups.forEach((group, i) => { + seen.add(group.id); + let gEl = groupEls.get(group.id); + if (!gEl) { + gEl = document.createElement("section"); + gEl.className = "group"; + const strip = document.createElement("div"); + strip.className = "strip"; + const frames = document.createElement("div"); + frames.className = "frames"; + for (const side of ["left", "right"] as const) { + const hl = document.createElement("div"); + hl.className = "drop-hl"; + hl.dataset.side = side; + hl.style[side] = "0"; + hl.style.width = "50%"; + gEl.appendChild(hl); + } + gEl.appendChild(strip); + gEl.appendChild(frames); + gEl.dataset.groupId = group.id; + wireDropTargets(gEl, group.id); + groupEls.set(group.id, gEl); + root.appendChild(gEl); // appended once — NEVER reparented (order via CSS) + // sash after this group (except the last) + const sash = document.createElement("div"); + sash.className = "sash"; + sash.dataset.afterGroupId = group.id; + root.appendChild(sash); + wireSash(sash); + } + gEl.style.order = String(i * 2); // groups interleave with sashes + gEl.style.flex = `${group.flex} 1 0`; + syncStrip(group, gEl.querySelector(".strip") as HTMLElement); + syncFrames(group, gEl.querySelector(".frames") as HTMLElement); + }); + // remove vanished groups + retune sashes + for (const [id, el] of Array.from(groupEls)) { + if (!seen.has(id)) { + groupEls.delete(id); + el.remove(); + } + } + let sashIdx = 0; + for (const sash of Array.from(root.querySelectorAll(".sash"))) { + const el = sash as HTMLElement; + if (sashIdx < deck.groups.length - 1) { + el.style.display = ""; + el.style.order = String(sashIdx * 2 + 1); + el.dataset.afterGroupId = deck.groups[sashIdx].id; + sashIdx++; + } else { + el.remove(); + } + } + persist(); +} + +function apply(next: Deck): void { + if (next === deck) return; + // The deck never sits empty: closing the last tab of the last group spawns + // a fresh draft — it's a chat tool, not an editor area. + if (next.groups.length === 0) next = addTab(next, uid(), draftTab()); + deck = next; + render(); +} + +// --------------------------------------------------------------------------- +// Drag & drop physics +// --------------------------------------------------------------------------- + +function endDrag(): void { + drag = null; + document.body.classList.remove("dragging"); + for (const hl of Array.from(document.querySelectorAll(".drop-hl"))) hl.classList.remove("on"); +} + +function wireDropTargets(gEl: HTMLElement, groupId: string): void { + gEl.addEventListener("dragover", (e) => { + if (!drag) return; + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = "move"; + const rect = gEl.getBoundingClientRect(); + const x = e.clientX - rect.left; + const overStrip = (e.target as HTMLElement).closest(".strip") !== null; + for (const hl of Array.from(gEl.querySelectorAll(".drop-hl"))) { + const side = (hl as HTMLElement).dataset.side; + const on = !overStrip && (side === "left" ? x < rect.width * 0.3 : x > rect.width * 0.7); + hl.classList.toggle("on", on); + } + }); + gEl.addEventListener("drop", (e) => { + if (!drag) return; + e.preventDefault(); + const { tabId, srcGroupId } = drag; + const target = e.target as HTMLElement; + const rect = gEl.getBoundingClientRect(); + const x = e.clientX - rect.left; + const stripEl = target.closest(".strip") as HTMLElement | null; + if (stripEl) { + // Insert at the tab midpoint the pointer is over (append at the end). + const tabs = Array.from(stripEl.querySelectorAll(".tab")) as HTMLElement[]; + let idx = tabs.length; + for (let i = 0; i < tabs.length; i++) { + const r = tabs[i].getBoundingClientRect(); + if (e.clientX < r.left + r.width / 2) { + idx = i; + break; + } + } + // Final-array indexing: removing the dragged tab shifts later indices. + const srcGroup = deck.groups.find((g) => g.id === srcGroupId); + if (srcGroupId === groupId && srcGroup) { + const from = srcGroup.tabs.findIndex((t) => t.id === tabId); + if (from !== -1 && from < idx) idx -= 1; + } + apply(moveTab(deck, srcGroupId, tabId, groupId, idx)); + } else if (x < rect.width * 0.3 || x > rect.width * 0.7) { + apply(splitTab(deck, srcGroupId, tabId, groupId, x < rect.width * 0.3 ? "left" : "right", uid())); + } else { + const dst = deck.groups.find((g) => g.id === groupId); + apply(moveTab(deck, srcGroupId, tabId, groupId, dst ? dst.tabs.length : 0)); + } + endDrag(); + }); +} + +// --------------------------------------------------------------------------- +// Sashes +// --------------------------------------------------------------------------- + +function wireSash(sash: HTMLElement): void { + sash.addEventListener("pointerdown", (e) => { + const afterId = sash.dataset.afterGroupId; + const gi = deck.groups.findIndex((g) => g.id === afterId); + if (gi === -1 || gi + 1 >= deck.groups.length) return; + const l = deck.groups[gi]; + const r = deck.groups[gi + 1]; + const lEl = groupEls.get(l.id); + const rEl = groupEls.get(r.id); + if (!lEl || !rEl) return; + sash.setPointerCapture(e.pointerId); + sash.classList.add("live"); + document.body.classList.add("sashing"); + const startX = e.clientX; + const sum = l.flex + r.flex; + const pxSum = lEl.getBoundingClientRect().width + rEl.getBoundingClientRect().width; + const onMove = (ev: PointerEvent) => { + const dx = ev.clientX - startX; + const dFlex = (dx / Math.max(pxSum, 1)) * sum; + const nl = Math.max(0.3, Math.min(sum - 0.3, l.flex + dFlex)); + apply(resizeGroups(deck, l.id, r.id, nl, sum - nl)); + }; + const onUp = () => { + sash.classList.remove("live"); + document.body.classList.remove("sashing"); + sash.removeEventListener("pointermove", onMove); + sash.removeEventListener("pointerup", onUp); + }; + sash.addEventListener("pointermove", onMove); + sash.addEventListener("pointerup", onUp); + }); +} + +// --------------------------------------------------------------------------- +// Bridges: iframe ⇄ shell ⇄ extension +// --------------------------------------------------------------------------- + +const tabBySource = (source: MessageEventSource | null): string | undefined => { + for (const [id, f] of frameByTab) { + if (f.contentWindow === source) return id; + } + return undefined; +}; + +window.addEventListener("message", (e) => { + const d = e.data; + if (!d || d.source !== "amicode") return; + + // Lane 1 — extension → shell (webview-internal origin, never the app origin): + // theme fan-out and clipboard replies (routed to the asking pane by `tab`). + if (e.origin !== boot.origin) { + if (d.kind === "theme" && (d.colorScheme === "light" || d.colorScheme === "dark")) { + boot.colorScheme = d.colorScheme; + for (const f of frameByTab.values()) f.contentWindow?.postMessage({ source: "amicode", kind: "theme", colorScheme: d.colorScheme }, boot.origin); + } + if (d.kind === "clipboard" && typeof d.tab === "string") { + frameByTab.get(d.tab)?.contentWindow?.postMessage(d, boot.origin); + } + return; + } + + // Lane 2 — app iframes → shell (origin-checked against the server origin). + const tabId = tabBySource(e.source); + + // route-info (fork bridge): live label + current route for future rebuilds. + // Adopt paths only — never absolute URLs (an injected message must not be + // able to point a pane at an arbitrary origin). + if (d.kind === "route-info" && typeof d.path === "string" && tabId) { + const safe = d.path.startsWith("/") && !d.path.startsWith("//") ? d.path : undefined; + let changed = false; + if (safe) { + for (const g of deck.groups) { + const t = g.tabs.find((t) => t.id === tabId); + if (t && t.url !== safe) { + t.url = safe; // path only — frameSrc re-mints params; mutation OK here (no re-render needed) + // The iframe navigated ITSELF to this route — mark it in sync so the + // next render doesn't pointlessly rebuild it. + const f = frameByTab.get(tabId); + if (f) f.dataset.tabUrl = safe; + changed = true; + } + } + } + if (typeof d.title === "string" && d.title.length > 0) { + apply(setTabLabel(deck, tabId, d.title.slice(0, 120))); + return; + } + if (changed) persist(); + return; + } + + // clipboard-image-request is answered shell-side (the shell webview has + // clipboard-read; the sandboxed iframe doesn't) — reply to the ASKING pane. + if (d.kind === "clipboard-image-request") { + void (async () => { + const payload = { source: "amicode", kind: "clipboard-image", nonce: d.nonce, dataUrl: null as string | null, mime: null as string | null, filename: null as string | null }; + try { + const items = await navigator.clipboard.read(); + for (const item of items) { + const type = item.types.find((t) => t.startsWith("image/")); + if (!type) continue; + const blob = await item.getType(type); + payload.dataUrl = await new Promise((res) => { + const r = new FileReader(); + r.onload = () => res(typeof r.result === "string" ? r.result : null); + r.onerror = () => res(null); + r.readAsDataURL(blob); + }); + if (payload.dataUrl) { + payload.mime = type; + payload.filename = `pasted-image.${type.split("/")[1] ?? "png"}`; + } + break; + } + } catch { + /* dataUrl:null → app falls back to text paste */ + } + (e.source as Window | null)?.postMessage(payload, boot.origin); + })(); + return; + } + + // Everything else rides up to the extension, tagged with the asking pane so + // replies (clipboard text) route back correctly. + if (d.kind === "command" || d.kind === "clipboard-request" || d.kind === "clipboard-write" || d.kind === "open-external" || d.kind === "save-file" || d.kind === "set-default-model") { + vscode.postMessage({ ...d, tab: tabId }); + } +}); + +// --------------------------------------------------------------------------- +// Boot +// --------------------------------------------------------------------------- + +if (deck.groups.length === 0) deck = addTab(deck, uid(), draftTab()); +render(); diff --git a/packages/extension/src/deck_panel.ts b/packages/extension/src/deck_panel.ts new file mode 100644 index 00000000..2e17dbb6 --- /dev/null +++ b/packages/extension/src/deck_panel.ts @@ -0,0 +1,121 @@ +import * as vscode from "vscode"; +import { randomBytes } from "node:crypto"; +import { handleAmicodeBridgeMessage } from "./chat_bridge"; +import { tabIconPath, themeKindToScheme } from "./chat_panel"; + +// ============================================================================ +// DeckPanel — the Chat Deck: MANY chat panes inside ONE editor tab. The heavy +// lifting lives in the shell bundle (src/deck/shell.ts → dist/deck_shell.js): +// pane groups, tab strips, drag/split/merge physics, sashes, and the per-pane +// relay. This class owns the host seam only: bootstrap config (origin, boot +// credential, scheme — minted per render, never persisted), CSP that frames +// the server origin, theme fan-out, and the shared command-bridge handler. +// Singleton per window: one deck, N panes inside it. +// ============================================================================ + +export class DeckPanel { + private static current?: DeckPanel; + private readonly disposables: vscode.Disposable[] = []; + + private constructor( + private readonly ctx: vscode.ExtensionContext, + private readonly panel: vscode.WebviewPanel, + opencodeUrl: URL, + authToken?: string, + hideProjectDir?: string, + ) { + this.panel.webview.html = this.renderHtml(opencodeUrl, authToken, hideProjectDir); + this.panel.onDidDispose(() => this.dispose(), null, this.disposables); + // Theme fan-out: extension → shell → EVERY pane's iframe (the shell owns + // the per-pane relay; boot scheme rides the bootstrap config). + vscode.window.onDidChangeActiveColorTheme( + (t) => + void this.panel.webview.postMessage({ + source: "amicode", + kind: "theme", + colorScheme: themeKindToScheme(t.kind), + }), + null, + this.disposables, + ); + this.panel.webview.onDidReceiveMessage( + (msg) => { + // Pane → extension bridge: the shell tags each envelope with the asking + // pane's `tab` id; the shared handler echoes it on replies so the shell + // routes answers to the right pane (chat_bridge.ts). + const handled = handleAmicodeBridgeMessage(msg, { + visible: () => this.panel.visible, + postToWebview: (m) => void this.panel.webview.postMessage(m), + }); + if (!handled) console.log("[amicode/deck] webview msg:", msg); + }, + null, + this.disposables, + ); + } + + static openOrReveal( + ctx: vscode.ExtensionContext, + opencodeUrl: URL, + authToken?: string, + hideProjectDir?: string, + ): DeckPanel { + if (DeckPanel.current) { + DeckPanel.current.panel.reveal(); + return DeckPanel.current; + } + const panel = vscode.window.createWebviewPanel("amicode.deck", "Amicode Chat Deck", vscode.ViewColumn.Beside, { + enableScripts: true, + retainContextWhenHidden: true, + // The deck iframes the server's http origin like ChatPanel; the only + // extension-local asset it loads is the shell bundle under dist/. + localResourceRoots: [vscode.Uri.joinPath(ctx.extensionUri, "dist")], + }); + panel.iconPath = tabIconPath(ctx); + DeckPanel.current = new DeckPanel(ctx, panel, opencodeUrl, authToken, hideProjectDir); + return DeckPanel.current; + } + + private renderHtml(opencodeUrl: URL, authToken?: string, hideProjectDir?: string): string { + const nonce = randomBytes(16).toString("base64"); + const scriptUri = this.panel.webview.asWebviewUri(vscode.Uri.joinPath(this.ctx.extensionUri, "dist", "deck_shell.js")); + // Bootstrap config: the credential rides ONLY here (JS memory of the + // shell) and on frame srcs the shell mints — it is never persisted into + // webview state. " + + + + + + + + + +`; + } + + dispose(): void { + for (const d of this.disposables) { + try { + d.dispose(); + } catch {} + } + this.disposables.length = 0; + if (DeckPanel.current === this) DeckPanel.current = undefined; + } +} diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index 714c41a2..084e0922 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -5,6 +5,7 @@ import { ServerManager } from "./server_manager"; import { fetchProviderSignal } from "./llm_creds.mjs"; import { resolveOpencodeBinary, OpencodeMissingError, unsupportedHostAdvice } from "./opencode_binary"; import { ChatPanel } from "./chat_panel"; +import { DeckPanel } from "./deck_panel"; import { registerRunInspector, revealInspector } from "./run_inspector"; import { registerCatalogCard } from "./catalog_card_shell"; import { registerTrees } from "./trees"; @@ -1032,6 +1033,48 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { } ChatPanel.openOrReveal(ctx, readyUrl, serverAuthToken(serverPassword), opencodeProject.projectDir); }), + // Side-by-side sessions: ALWAYS a fresh editor tab (ViewColumn.Beside, so + // it splits next to whatever is focused) pinned to the app's /new-session + // draft route — each tab owns its conversation over the one server. Same + // ready/creds gates as openChat: a second tab that can't chat is worse + // than a named warning. + vscode.commands.registerCommand("amicode.newChat", async () => { + const readyUrl = opencodeReadyUrl; + if (!readyUrl) { + vscode.window.showWarningMessage( + "Amicode: opencode server isn't ready yet. Check the 'Amicode — opencode' output channel.", + ); + return; + } + const creds = await fetchProviderSignal(readyUrl.toString(), { headers: serverAuthHeaders }); + if (!creds.ok) { + vscode.window.showWarningMessage(`Amicode: ${creds.reason} → ${creds.fix}`); + return; + } + const draftUrl = new URL(readyUrl.href); + draftUrl.pathname = "/new-session"; + draftUrl.search = ""; + draftUrl.hash = ""; + ChatPanel.openNew(ctx, draftUrl, serverAuthToken(serverPassword), opencodeProject.projectDir); + }), + // Chat Deck: MANY panes inside ONE editor tab — tab strips, drag-to-split, + // merge-back, sashes (dist/deck_shell.js). Same ready/creds gates as the + // other chat entries. The deck shares the one server with every ChatPanel. + vscode.commands.registerCommand("amicode.chatDeck", async () => { + const readyUrl = opencodeReadyUrl; + if (!readyUrl) { + vscode.window.showWarningMessage( + "Amicode: opencode server isn't ready yet. Check the 'Amicode — opencode' output channel.", + ); + return; + } + const creds = await fetchProviderSignal(readyUrl.toString(), { headers: serverAuthHeaders }); + if (!creds.ok) { + vscode.window.showWarningMessage(`Amicode: ${creds.reason} → ${creds.fix}`); + return; + } + DeckPanel.openOrReveal(ctx, readyUrl, serverAuthToken(serverPassword), opencodeProject.projectDir); + }), vscode.commands.registerCommand("amicode.openInspector", async () => { await revealInspector(); }), diff --git a/packages/extension/test/__mocks__/vscode.ts b/packages/extension/test/__mocks__/vscode.ts index 1ad98c76..23e2233b 100644 --- a/packages/extension/test/__mocks__/vscode.ts +++ b/packages/extension/test/__mocks__/vscode.ts @@ -6,6 +6,7 @@ export const window = { showErrorMessage: () => Promise.resolve(undefined), showWarningMessage: () => Promise.resolve(undefined), showInputBox: () => Promise.resolve(undefined), + showSaveDialog: () => Promise.resolve(undefined), createOutputChannel: () => ({ appendLine() {}, append() {}, dispose() {} }), registerWebviewViewProvider: () => ({ dispose() {} }), activeColorTheme: { kind: 2 }, // ColorThemeKind.Dark @@ -35,6 +36,7 @@ export const window = { }; const registeredCommands = new Map unknown>(); export const commands = { + executed: [] as string[], registerCommand: (id: string, fn: (...a: unknown[]) => unknown) => { registeredCommands.set(id, fn); return { @@ -43,16 +45,46 @@ export const commands = { }, }; }, - executeCommand: (id: string, ...a: unknown[]) => Promise.resolve(registeredCommands.get(id)?.(...a)), + executeCommand: (id: string, ...a: unknown[]) => { + commands.executed.push(id); + return Promise.resolve(registeredCommands.get(id)?.(...a)); + }, }; -export const ViewColumn = { One: 1, Two: 2 }; +export const ViewColumn = { One: 1, Two: 2, Beside: -2 }; export const ColorThemeKind = { Light: 1, Dark: 2, HighContrast: 3, HighContrastLight: 4 }; +export const ConfigurationTarget = { Global: 1, Workspace: 2, WorkspaceFolder: 3 }; +export const env = { + opened: [] as unknown[], + openExternal: (u: unknown) => { + env.opened.push(u); + return Promise.resolve(true); + }, + clipboard: { + text: "", + readText(): Promise { + return Promise.resolve(env.clipboard.text); + }, + writeText(t: string): Promise { + env.clipboard.text = t; + return Promise.resolve(); + }, + }, +}; export const workspace = { workspaceFolders: [] as unknown[], - getConfiguration: () => ({ get: (_k: string, d?: unknown) => d ?? "" }), + configUpdates: [] as Array<[string, unknown]>, + getConfiguration: () => ({ + get: (_k: string, d?: unknown) => d ?? "", + update: (k: string, v: unknown) => { + workspace.configUpdates.push([k, v]); + return Promise.resolve(); + }, + }), + fs: { writeFile: (_u: unknown, _b: unknown) => Promise.resolve() }, }; export const Uri = { file: (p: string) => ({ fsPath: p, toString: () => p }), + parse: (s: string) => ({ fsPath: s, toString: () => s }), joinPath: (base: { fsPath?: string } | string, ...parts: string[]) => { const root = typeof base === "string" ? base : (base.fsPath ?? ""); const full = [root, ...parts].join("/"); diff --git a/packages/extension/test/chat_bridge.test.ts b/packages/extension/test/chat_bridge.test.ts new file mode 100644 index 00000000..280a1bdf --- /dev/null +++ b/packages/extension/test/chat_bridge.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import * as vscode from "vscode"; +import { handleAmicodeBridgeMessage, type BridgeIo } from "../src/chat_bridge"; + +// ============================================================================ +// The shared iframe⇄extension bridge: strict allowlists, https-only externals, +// visibility-gated clipboard, and the pane `tab` tag echoed on replies so the +// deck shell can route answers to the asking pane. +// ============================================================================ + +const env = vscode.env as unknown as { opened: unknown[]; clipboard: { text: string } }; +const ws = vscode.workspace as unknown as { configUpdates: Array<[string, unknown]> }; + +function io(visible = true): BridgeIo & { posted: unknown[] } { + const posted: unknown[] = []; + return { + posted, + visible: () => visible, + postToWebview: (m) => { + posted.push(m); + }, + }; +} + +const flush = () => new Promise((r) => setTimeout(r, 0)); + +beforeEach(() => { + env.opened.length = 0; + env.clipboard.text = ""; + ws.configUpdates.length = 0; +}); + +describe("amicode bridge — open-external", () => { + it("opens https URLs and nothing else", () => { + const host = io(); + expect(handleAmicodeBridgeMessage({ source: "amicode", kind: "open-external", url: "https://example.com/x" }, host)).toBe(true); + expect(env.opened).toHaveLength(1); + for (const url of ["http://evil.test", "file:///etc/passwd", "javascript:alert(1)"]) { + expect(handleAmicodeBridgeMessage({ source: "amicode", kind: "open-external", url }, host)).toBe(false); + } + expect(env.opened).toHaveLength(1); + }); +}); + +describe("amicode bridge — clipboard", () => { + it("clipboard-request answers with the OS clipboard text and echoes the pane tab", async () => { + const host = io(); + env.clipboard.text = "ω = 4.9 GHz"; + expect(handleAmicodeBridgeMessage({ source: "amicode", kind: "clipboard-request", nonce: "n1", tab: "pane-7" }, host)).toBe(true); + await flush(); + expect(host.posted).toEqual([ + { source: "amicode", kind: "clipboard", nonce: "n1", text: "ω = 4.9 GHz", tab: "pane-7" }, + ]); + }); + + it("a hidden panel never answers clipboard reads", async () => { + const host = io(false); + env.clipboard.text = "secret"; + expect(handleAmicodeBridgeMessage({ source: "amicode", kind: "clipboard-request", nonce: "n2" }, host)).toBe(true); + await flush(); + expect(host.posted).toHaveLength(0); + }); + + it("clipboard-write stores bounded text, drops the unbounded", async () => { + const host = io(); + expect(handleAmicodeBridgeMessage({ source: "amicode", kind: "clipboard-write", text: "pulse" }, host)).toBe(true); + await flush(); + expect(env.clipboard.text).toBe("pulse"); + env.clipboard.text = ""; + expect(handleAmicodeBridgeMessage({ source: "amicode", kind: "clipboard-write", text: "x".repeat(5_000_001) }, host)).toBe(true); + await flush(); + expect(env.clipboard.text).toBe(""); + }); +}); + +describe("amicode bridge — commands & settings", () => { + it("runs allowlisted commands only", async () => { + const host = io(); + expect(handleAmicodeBridgeMessage({ source: "amicode", kind: "command", command: "amicode.stopRun" }, host)).toBe(true); + await flush(); + const ran = (vscode.commands as unknown as { executed: string[] }).executed ?? []; + expect(ran).toContain("amicode.stopRun"); + expect(handleAmicodeBridgeMessage({ source: "amicode", kind: "command", command: "workbench.action.terminal.kill" }, host)).toBe(false); + }); + + it("set-default-model accepts provider/model-id shapes and mirrors them to config", () => { + const host = io(); + expect(handleAmicodeBridgeMessage({ source: "amicode", kind: "set-default-model", model: "anthropic/claude-sonnet-5" }, host)).toBe(true); + expect(ws.configUpdates).toEqual([["defaultModel", "anthropic/claude-sonnet-5"]]); + ws.configUpdates.length = 0; + expect(handleAmicodeBridgeMessage({ source: "amicode", kind: "set-default-model", model: "not a model" }, host)).toBe(true); + expect(ws.configUpdates).toEqual([]); + }); + + it("ignores foreign envelopes entirely", () => { + const host = io(); + expect(handleAmicodeBridgeMessage({ source: "elsewhere", kind: "command", command: "amicode.stopRun" }, host)).toBe(false); + expect(handleAmicodeBridgeMessage("a string", host)).toBe(false); + }); +}); diff --git a/packages/extension/test/chat_panel_sessions.test.ts b/packages/extension/test/chat_panel_sessions.test.ts new file mode 100644 index 00000000..65fa7f29 --- /dev/null +++ b/packages/extension/test/chat_panel_sessions.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, afterEach } from "vitest"; +import * as vscode from "vscode"; +import { ChatPanel } from "../src/chat_panel"; +import { mintServerPassword, serverAuthToken } from "../src/server_auth"; + +// ============================================================================ +// Side-by-side chat sessions: ChatPanel is a multi-instance registry, not a +// singleton. `openOrReveal` keeps its primary semantics (one front door), and +// `openNew` always spawns an ADDITIONAL editor tab — pointed at the app's +// /new-session draft route — so tabs can be split across editor groups. +// ============================================================================ + +type CapturedPanel = { webview: { html: string }; revealCount: number; dispose(): void }; +type CreatedArgs = { viewType: string; title: string; column: unknown }; + +/** Wrap the mock's createWebviewPanel to capture both the panel and the + * constructor args (title/column never land on the mock panel itself). */ +function capturePanels(): { created: CapturedPanel[]; args: CreatedArgs[]; restore: () => void } { + const created: CapturedPanel[] = []; + const args: CreatedArgs[] = []; + const w = vscode.window as unknown as { + createWebviewPanel: (viewType: string, title: string, column?: unknown, opts?: unknown) => CapturedPanel; + }; + const orig = w.createWebviewPanel; + w.createWebviewPanel = (viewType: string, title: string, column?: unknown, opts?: unknown) => { + const p = orig(viewType, title, column, opts); + created.push(p); + args.push({ viewType, title, column }); + return p; + }; + return { + created, + args, + restore: () => { + w.createWebviewPanel = orig; + }, + }; +} + +function fakeCtx(): vscode.ExtensionContext { + return { extensionUri: { fsPath: "/ext" } } as unknown as vscode.ExtensionContext; +} + +const iframeSrc = (html: string): URL => { + const m = html.match(/