From 978e2a8cf3f0568e584566c1f0a7ad8351e65312 Mon Sep 17 00:00:00 2001 From: Robert Gruen Date: Wed, 15 Jul 2026 10:56:54 -0700 Subject: [PATCH 01/19] Updated retry backoff timeouts (increased) --- ts/packages/chat-ui/src/connectionStatus.ts | 17 +++++- ts/packages/chat-ui/src/conversationBar.ts | 57 ++++++++++++++++++- .../chat-ui/test/conversationBar.spec.ts | 41 +++++++++++++ ts/packages/shell/src/main/instance.ts | 10 ++-- .../vscode-shell/src/agentServerBridge.ts | 14 ++--- 5 files changed, 124 insertions(+), 15 deletions(-) diff --git a/ts/packages/chat-ui/src/connectionStatus.ts b/ts/packages/chat-ui/src/connectionStatus.ts index b7caa8129..29b453bfc 100644 --- a/ts/packages/chat-ui/src/connectionStatus.ts +++ b/ts/packages/chat-ui/src/connectionStatus.ts @@ -38,8 +38,18 @@ export const DEFAULT_ACTION_LABELS: Record = { start: "Start server", }; -/** Human-readable status line (without any action links). */ -export function formatConnectionStatusText(status: ConnectionStatus): string { +/** + * Human-readable status line (without any action links). + * + * Pass `omitCountdown` for the `waiting` phase to drop the volatile "in Ns" + * countdown, yielding a stable string suitable for a hover tooltip / accessible + * label that would otherwise flicker (native tooltips dismiss themselves when + * their text changes) on every one-second tick. + */ +export function formatConnectionStatusText( + status: ConnectionStatus, + options?: { omitCountdown?: boolean }, +): string { const attemptSuffix = status.attempt && status.attempt > 0 ? ` (attempt ${status.attempt})` @@ -49,6 +59,9 @@ export function formatConnectionStatusText(status: ConnectionStatus): string { case "connecting": return `Disconnected — connecting${attemptSuffix}…`; case "waiting": { + if (options?.omitCountdown) { + return `Disconnected — retrying${attemptSuffix}${errorSuffix}…`; + } const sec = status.secondsRemaining ?? 0; return `Disconnected — retrying in ${sec}s${attemptSuffix}${errorSuffix}`; } diff --git a/ts/packages/chat-ui/src/conversationBar.ts b/ts/packages/chat-ui/src/conversationBar.ts index 63492fef5..4e99e2905 100644 --- a/ts/packages/chat-ui/src/conversationBar.ts +++ b/ts/packages/chat-ui/src/conversationBar.ts @@ -158,6 +158,15 @@ export class ConversationBar { private isRenamingCurrent = false; private inlineRenameConversationId: string | undefined; + // Structural signature of the last-rendered status indicator. A reconnect + // countdown ticks once a second but only changes the hidden status text, so + // a matching key takes the in-place refresh path in renderStatusIndicator() + // instead of rebuilding the node - which would restart the pulse animation + // and dismiss the native tooltip the user is hovering. + private lastStatusKey: string | undefined; + // The visually-hidden status-text node, refreshed in place on those ticks. + private statusSrEl: HTMLElement | undefined; + private readonly rootEl: HTMLDivElement; private readonly nameBtn: HTMLButtonElement; private readonly renameEditorEl: HTMLDivElement; @@ -540,12 +549,19 @@ export class ConversationBar { */ private renderConnectionIndicator(connection: ConnectionStatus): void { const fullText = formatConnectionStatusText(connection); + // Stable tooltip / accessible-label text: the same status WITHOUT the + // per-second countdown, so hovering the reconnecting plug doesn't + // flicker as the "retrying in Ns" line ticks down. + const titleText = formatConnectionStatusText(connection, { + omitCountdown: true, + }); if (connection.phase === "stopped") { const actions = connection.actions ?? []; if (actions.length === 0) { this.renderStatusIndicator({ stateClass: "disconnected", fullText, + titleText, }); return; } @@ -556,6 +572,7 @@ export class ConversationBar { this.renderStatusIndicator({ stateClass: "disconnected", fullText, + titleText, primaryAction: primary, secondaryActions, }); @@ -567,6 +584,7 @@ export class ConversationBar { stateClass: "connecting", pulsing: true, fullText, + titleText, badge: attempt && attempt > 0 ? { text: `-${attempt}`, attempt: true } @@ -587,6 +605,14 @@ export class ConversationBar { private renderStatusIndicator(opts: { stateClass: string; fullText: string; + /** + * Stable tooltip / accessible-label text. Defaults to {@link fullText}. + * The reconnect countdown passes a version WITHOUT the per-second + * "retrying in Ns", so the native `title` stays put (native tooltips + * dismiss themselves when the attribute changes) while only the hidden + * live-status text is refreshed each tick. + */ + titleText?: string; pulsing?: boolean; badge?: { text: string; attempt?: boolean }; primaryAction?: ConnectionActionId; @@ -594,6 +620,32 @@ export class ConversationBar { }): void { const secondaryActions = opts.secondaryActions ?? []; const hasActions = secondaryActions.length > 0; + const titleText = opts.titleText ?? opts.fullText; + + // Everything that shapes the DOM (tint, pulse, badge, actions, tooltip) + // except the volatile full-status text. A reconnect countdown only + // changes that text, so successive ticks share this key and refresh in + // place below instead of rebuilding the node (which restarts the pulse + // and dismisses the tooltip the user is hovering). + const key = [ + opts.stateClass, + opts.pulsing ? "1" : "0", + opts.badge + ? `${opts.badge.text}/${opts.badge.attempt ? 1 : 0}` + : "-", + opts.primaryAction ?? "-", + secondaryActions.join(","), + titleText, + ].join("|"); + if (key === this.lastStatusKey) { + // Structure unchanged - refresh only the hidden live-status text. + if (this.statusSrEl) { + this.statusSrEl.textContent = opts.fullText; + } + return; + } + this.lastStatusKey = key; + this.statusSrEl = undefined; this.statusEl.className = `conversation-status-summary ${opts.stateClass} as-indicator` + @@ -607,7 +659,7 @@ export class ConversationBar { action === "start" ? "click to start the server" : "click to reconnect"; - const label = `${opts.fullText} — ${hint}`; + const label = `${titleText} — ${hint}`; const plugBtn = document.createElement("button"); plugBtn.type = "button"; plugBtn.className = "conversation-status-plug"; @@ -625,7 +677,7 @@ export class ConversationBar { }); this.statusEl.appendChild(plugBtn); } else { - this.statusEl.title = opts.fullText; + this.statusEl.title = titleText; const iconEl = document.createElement("span"); iconEl.className = "conversation-status-icon" + (opts.pulsing ? " pulsing" : ""); @@ -647,6 +699,7 @@ export class ConversationBar { srEl.className = "conversation-status-sr"; srEl.textContent = opts.fullText; this.statusEl.appendChild(srEl); + this.statusSrEl = srEl; } for (const id of secondaryActions) { diff --git a/ts/packages/chat-ui/test/conversationBar.spec.ts b/ts/packages/chat-ui/test/conversationBar.spec.ts index 088b45248..934de2e1a 100644 --- a/ts/packages/chat-ui/test/conversationBar.spec.ts +++ b/ts/packages/chat-ui/test/conversationBar.spec.ts @@ -260,6 +260,47 @@ describe("ConversationBar", () => { ).toBe(0); }); + it("refreshes the reconnect countdown in place without rebuilding the plug", () => { + const { root, bar } = makeBar(); + const status = root.querySelector( + ".conversation-status-summary", + )!; + + bar.setStatus({ + connected: false, + connection: { phase: "waiting", attempt: 2, secondsRemaining: 5 }, + }); + const iconBefore = status.querySelector(".conversation-status-icon"); + const titleBefore = status.getAttribute("title"); + + // Next one-second tick: same attempt, one fewer second remaining. + bar.setStatus({ + connected: false, + connection: { phase: "waiting", attempt: 2, secondsRemaining: 4 }, + }); + + // The pulsing icon node is reused (not torn down), so its animation and + // the hovered native tooltip don't restart on every countdown tick. + expect(status.querySelector(".conversation-status-icon")).toBe( + iconBefore, + ); + // The tooltip text is the stable, countdown-free variant, so it never + // carries the ticking seconds that made it flicker. + expect(titleBefore).toBe("Disconnected — retrying (attempt 2)…"); + expect(status.getAttribute("title")).toBe(titleBefore); + // ...while the visually-hidden live text still reflects the latest count. + expect(status.textContent).toContain("retrying in 4s"); + + // A structural change (new attempt) still rebuilds the node. + bar.setStatus({ + connected: false, + connection: { phase: "waiting", attempt: 3, secondsRemaining: 8 }, + }); + expect(status.querySelector(".conversation-status-icon")).not.toBe( + iconBefore, + ); + }); + it("makes the plug a reconnect button once auto-retry has stopped and routes clicks", () => { const { root, bar, controller } = makeBar(); const status = root.querySelector( diff --git a/ts/packages/shell/src/main/instance.ts b/ts/packages/shell/src/main/instance.ts index 559147df6..f3a4e6e2d 100644 --- a/ts/packages/shell/src/main/instance.ts +++ b/ts/packages/shell/src/main/instance.ts @@ -286,8 +286,8 @@ async function initializeDispatcher( // brief server hiccups (server restart, transient network // blip when running with --connect to a remote host). // Backoff schedule mirrors the vscode-shell extension's - // AgentServerBridge.scheduleReconnect (4/6/8/...30s cap). - const MAX_RECONNECT_ATTEMPTS = 12; // ~5 minutes total at the 30s cap + // AgentServerBridge.scheduleReconnect (5/10/20/40/60s cap). + const MAX_RECONNECT_ATTEMPTS = 5; // ~135s total (5/10/20/40/60s backoff) let reconnectAttempt = 0; let reconnecting = false; // Set once auto-reconnect has exhausted MAX_RECONNECT_ATTEMPTS. The @@ -336,9 +336,11 @@ async function initializeDispatcher( reconnectAttempt < MAX_RECONNECT_ATTEMPTS ) { reconnectAttempt++; + // Exponential backoff (5/10/20/40/60s, capped at 60s), + // matching the vscode-shell AgentServerBridge schedule. const backoffSec = Math.min( - 30, - 2 + reconnectAttempt * 2, + 60, + 5 * 2 ** (reconnectAttempt - 1), ); debugShellInit( `Reconnect attempt ${reconnectAttempt} in ${backoffSec}s`, diff --git a/ts/packages/vscode-shell/src/agentServerBridge.ts b/ts/packages/vscode-shell/src/agentServerBridge.ts index 2133e2457..70a585406 100644 --- a/ts/packages/vscode-shell/src/agentServerBridge.ts +++ b/ts/packages/vscode-shell/src/agentServerBridge.ts @@ -56,9 +56,9 @@ export type { // Auto-reconnect gives up after this many failed attempts and surfaces a // "stopped" ribbon with manual Retry / Start links instead of retrying -// forever. With the 2s→30s backoff this is roughly a 5-minute window; a +// forever. With the 5s→60s backoff this is roughly a 2-minute window; a // manual retry resets the counter and resumes from here. -const MAX_RECONNECT_ATTEMPTS = 12; +const MAX_RECONNECT_ATTEMPTS = 5; /** HTML-escape untrusted strings (names, ids, errors) for inline notification HTML. */ function escapeHtml(str: string): string { @@ -155,12 +155,12 @@ export class AgentServerBridge { // wait between reconnect attempts. Replaces the old behavior of // broadcasting a fresh error/disconnect message every retry cycle. private reconnectCountdown: NodeJS.Timeout | undefined; - // Exponential backoff (2s, 4s, 8s, … capped at 30s) shared with the Studio - // service connection. Quick first retries recover fast when the server - // restarts; the cap keeps the long-tail polite for genuinely-down servers. + // Exponential backoff (5s, 10s, 20s, 40s, capped at 60s). Longer gaps keep + // retries polite for a genuinely-down server; the quick-ish first retry + // still recovers fast when the server just restarted. private readonly reconnectBackoff: Backoff = createBackoff({ - baseMs: 2000, - maxMs: 30000, + baseMs: 5000, + maxMs: 60000, }); private reconnectRemainingSec: number | undefined; private lastConnectError: string | undefined; From 89f18b30a41d139a83f5ff9a3fb45d9119818eeb Mon Sep 17 00:00:00 2001 From: Robert Gruen Date: Wed, 15 Jul 2026 14:36:06 -0700 Subject: [PATCH 02/19] readiness hardening --- .../agents/code/src/codeActionHandler.ts | 9 +++ .../code/test/codeUpdateContext.spec.ts | 73 +++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/ts/packages/agents/code/src/codeActionHandler.ts b/ts/packages/agents/code/src/codeActionHandler.ts index 0cefac12f..038571825 100644 --- a/ts/packages/agents/code/src/codeActionHandler.ts +++ b/ts/packages/agents/code/src/codeActionHandler.ts @@ -225,6 +225,15 @@ function attachSharedOnMessage(server: CodeAgentWebSocketServer): void { "default", sc === primary ? count : 0, ); + // Refresh the dispatcher's cached readiness as the Coda + // extension connects/disconnects. Readiness is cached when a + // schema is enabled (before the extension has connected), so it + // stays `setup-required` until something re-probes it. Without + // this, the first code action after enabling (e.g. `@copilot + // fix`) trips the setupOnFirstUse gate, which runs `setup` + // ("VS Code is already connected.") in place of the action and + // drops it. Best-effort; swallows errors internally. + void sc.notifyReadinessChanged(); } }; } diff --git a/ts/packages/agents/code/test/codeUpdateContext.spec.ts b/ts/packages/agents/code/test/codeUpdateContext.spec.ts index 2f4d181f5..837488f7d 100644 --- a/ts/packages/agents/code/test/codeUpdateContext.spec.ts +++ b/ts/packages/agents/code/test/codeUpdateContext.spec.ts @@ -24,6 +24,7 @@ import { instantiate, getSharedCodePort } from "../src/codeActionHandler.js"; import type { SessionContext } from "@typeagent/agent-sdk"; +import { WebSocket } from "ws"; // Minimal stub of SessionContext — only the surface the code agent uses // during updateAgentContext. registerPort records every call so we can @@ -35,11 +36,13 @@ type StubContext = { registerCalls: RegisterCall[]; releaseSpy: () => void; releaseCount: number; + readinessNotifyCount: number; }; function makeStubContext(agentContext: any): StubContext { const registerCalls: RegisterCall[] = []; let releaseCount = 0; + let readinessNotifyCount = 0; const releaseSpy = () => { releaseCount++; }; @@ -52,6 +55,11 @@ function makeStubContext(agentContext: any): StubContext { notifyClientCountChanged(_role: string, _count: number) { // no-op stub; tested elsewhere via registrar unit tests }, + async notifyReadinessChanged() { + // Records the readiness-refresh pushes the code agent fires from + // the server's onClientCountChanged fanout on connect/disconnect. + readinessNotifyCount++; + }, // The rest of SessionContext isn't touched by updateCodeContext. } as unknown as SessionContext; return { @@ -60,10 +68,30 @@ function makeStubContext(agentContext: any): StubContext { get releaseCount() { return releaseCount; }, + get readinessNotifyCount() { + return readinessNotifyCount; + }, releaseSpy, } as any; } +// Poll a predicate until it holds or the timeout elapses. Used to await the +// asynchronous onClientCountChanged fanout that fires after a ws client +// connects or closes. +async function waitFor( + predicate: () => boolean, + timeoutMs = 2000, +): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (predicate()) return; + await new Promise((r) => setTimeout(r, 20)); + } + if (!predicate()) { + throw new Error(`waitFor timed out after ${timeoutMs}ms`); + } +} + describe("code agent shared server + per-session registration", () => { const agent = instantiate(); // Track all sessions created during a test so afterEach can defensively @@ -219,4 +247,49 @@ describe("code agent shared server + per-session registration", () => { await agent.updateAgentContext!(false, b.sessionContext, "code"); expect(getSharedCodePort()).toBeUndefined(); }); + + test("a client connecting and disconnecting refreshes cached readiness", async () => { + // Regression: the code agent used to fan out only client-count + // updates on connect/disconnect, leaving the dispatcher's cached + // readiness stuck at the value probed on enable (usually + // `setup-required`, before the Coda extension connected). The first + // code action after enabling then tripped the setupOnFirstUse gate + // and ran `setup` in place of the action. The onClientCountChanged + // fanout must also push a readiness refresh so the cache tracks the + // live connection state. + const s = await newSession(); + await agent.updateAgentContext!(true, s.sessionContext, "code"); + try { + const port = getSharedCodePort(); + expect(port).toBeDefined(); + // No client yet, so no readiness push has fired from a count change. + expect(s.readinessNotifyCount).toBe(0); + + // A Node `ws` client sends no Origin header, which the code + // agent's allowlist permits (loopback / no-Origin baseline). + const client = new WebSocket(`ws://127.0.0.1:${port}`); + try { + await new Promise((resolve, reject) => { + client.once("open", () => resolve()); + client.once("error", reject); + }); + // Connecting flips the cached readiness toward `ready`. + await waitFor(() => s.readinessNotifyCount >= 1); + const afterConnect = s.readinessNotifyCount; + expect(afterConnect).toBeGreaterThanOrEqual(1); + + // Disconnecting flips it back toward `setup-required`. + client.close(); + await waitFor(() => s.readinessNotifyCount > afterConnect); + expect(s.readinessNotifyCount).toBeGreaterThan(afterConnect); + } finally { + // Double close is a no-op; guards against a thrown assertion + // leaking the socket. + client.close(); + } + } finally { + await agent.updateAgentContext!(false, s.sessionContext, "code"); + expect(getSharedCodePort()).toBeUndefined(); + } + }); }); From 6768495c20f43df76df0aa1c69e38f52a5eed8ce Mon Sep 17 00:00:00 2001 From: Robert Gruen Date: Wed, 15 Jul 2026 16:47:50 -0700 Subject: [PATCH 03/19] icon update --- ts/packages/vscode-shell/src/webview/vscode-theme.css | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ts/packages/vscode-shell/src/webview/vscode-theme.css b/ts/packages/vscode-shell/src/webview/vscode-theme.css index 3de7f81fa..4378b984f 100644 --- a/ts/packages/vscode-shell/src/webview/vscode-theme.css +++ b/ts/packages/vscode-shell/src/webview/vscode-theme.css @@ -174,12 +174,12 @@ background-size: cover !important; color: transparent !important; } +/* The shell paints the agent avatar disc the same color as the message + surface, so the emoji reads as a bare glyph with no visible circle. A + contrasting badge color here drew a disc behind the icon that the shell + never shows, so keep it transparent to match. */ .agent-icon { - background-color: var( - --vscode-badge-background, - var(--vscode-editorWidget-background, #3c3c3c) - ) !important; - color: var(--vscode-badge-foreground, var(--vscode-foreground)) !important; + background-color: transparent !important; } /* Timestamps */ From 6e0585b7632b4a82fa198964f49cc0c448fb193c Mon Sep 17 00:00:00 2001 From: Robert Gruen Date: Wed, 15 Jul 2026 17:36:41 -0700 Subject: [PATCH 04/19] Updated message UI --- ts/packages/vscode-shell/.vscodeignore | 1 + .../prototypes/message-width-scrollbars.html | 714 ++++++++++++++++++ .../vscode-shell/src/webview/vscode-theme.css | 110 +++ 3 files changed, 825 insertions(+) create mode 100644 ts/packages/vscode-shell/prototypes/message-width-scrollbars.html diff --git a/ts/packages/vscode-shell/.vscodeignore b/ts/packages/vscode-shell/.vscodeignore index 9c1a602ad..5dc0b2cb2 100644 --- a/ts/packages/vscode-shell/.vscodeignore +++ b/ts/packages/vscode-shell/.vscodeignore @@ -1,6 +1,7 @@ .gitignore .vscode/** src/** +prototypes/** tsconfig.json esbuild.mjs node_modules/** diff --git a/ts/packages/vscode-shell/prototypes/message-width-scrollbars.html b/ts/packages/vscode-shell/prototypes/message-width-scrollbars.html new file mode 100644 index 000000000..46c16a796 --- /dev/null +++ b/ts/packages/vscode-shell/prototypes/message-width-scrollbars.html @@ -0,0 +1,714 @@ + + + + + + + + vscode-shell chat — width & scrollbar prototypes + + + +
+
+ + + + + +
+
+ + + + +
+
+ +
+ +
+
+
Panel width: 900px
+
+
+
+
+ +
+
+ you- 4:53:44 PM +
+
R
+
+
+ run the powershell flow test and fix whatever is failing +
+
+
+ + +
+
+ dispatcher.reasoningAction.copilot- 4:53:51 PM +
+
+
+
+
+ Thinking +
The testPowerShellFlow is failing because the sandbox doesn't allow external executables to spawn child processes without an explicit allowlist entry, so the conditional branch never runs and the here-string is parsed as a literal.
+
+
+
+
+ + +
+
+ dispatcher.reasoningAction.copilot- 4:53:51 PM +
+
+
+
+

Tool: execute_action — powershell.?

+
+
+
+ + +
+
+ dispatcher.reasoningAction.copilot- 4:53:55 PM +
+
+
+
+
+ Thinking +
The PowerShell test keeps breaking—maybe the sandbox doesn't support conditional statements in the way the harness expects, or the mocked Invoke-Expression shim swallows the exit code before assertion.
+
+
+
+
+ + +
+
+ dispatcher- 4:54:03 PM +
+
🤖
+
+
+
+ Here is the failing assertion and a suggested fix for the + allowlist entry: +
+
Describe "testPowerShellFlow" {
+    It "runs the conditional branch when the allowlist entry is present" {
+        $result = Invoke-Flow -Name 'powershell.conditional' -AllowList @('pwsh.exe','cmd.exe')
+        $result.ExitCode | Should -Be 0        # was failing: sandbox blocked child process spawn
+    }
+}
+
+
+
{
+  "actionName": "executePowerShell",
+  "parameters": { "script": "if ($ok) { Write-Output 'done' } else { throw 'sandbox blocked external executable spawn without allowlist entry' }", "timeoutMs": 60000 }
+}
+
+
+
+ + +
+
+ dispatcher- 4:54:03 PM +
+
🤖
+
+
+ Error: Timeout after 60000ms waiting for session.idle +
+
+
+
+
+
+
+
+
+ + + + diff --git a/ts/packages/vscode-shell/src/webview/vscode-theme.css b/ts/packages/vscode-shell/src/webview/vscode-theme.css index 4378b984f..51910d1a8 100644 --- a/ts/packages/vscode-shell/src/webview/vscode-theme.css +++ b/ts/packages/vscode-shell/src/webview/vscode-theme.css @@ -288,3 +288,113 @@ .completion-toggle { color: var(--vscode-textLink-foreground, var(--vscode-foreground)) !important; } + +/* ── Message width + scrollbars (vscode-shell only) ───────────────── + The shared chat-ui base (packages/chat-ui/styles/chat.css) caps every + message at `width: 55%` and lets `.chat-message-content` plus each + markdown
 scroll horizontally (`overflow-x: auto` over the UA
+   `white-space: pre` default). In a docked VS Code panel that wastes
+   ~45% of the width and stacks a horizontal scrollbar on every reasoning
+   "Thinking" block, code block and JSON detail.
+
+   These rules widen the column and wrap prose while keeping real code
+   blocks scrollable. They live here (not in chat.css) so only vscode-shell
+   is affected: the Electron shell reads its own overlay
+   (packages/shell/src/renderer/assets/styles.less) and is untouched.
+   Injected after chat.css (see webview/main.ts injectStyles order), so
+   they win at equal specificity without needing !important. */
+
+/* 1. Widen messages: replace the 55% cap with a near-full-width column. */
+.chat-message-container-agent,
+.chat-message-container-user {
+  width: auto;
+  max-width: 96%;
+}
+
+/* 2. The base offsets the bubble to clear the absolutely-positioned avatar
+      (agent `left: 30px`, user `margin-right: 30px`). At 55% that fits; at
+      96% the agent's relative `left` shift overflows the panel and adds a
+      page-level horizontal scrollbar. Swap it for a left margin (which
+      reduces width instead of shifting) so the avatar gutter is preserved
+      without overflow. */
+.chat-message-agent {
+  left: 0;
+  margin-left: 34px;
+}
+.chat-message-user {
+  margin-right: 34px;
+}
+
+/* 3. The bubble itself no longer scrolls horizontally; its children manage
+      their own overflow (prose wraps, code scrolls in its own box). */
+.chat-message-content {
+  overflow-x: hidden;
+}
+
+/* 4. Real code blocks (markdown fenced code -> 
) keep their
+      horizontal scroll so alignment is preserved, but stay contained to
+      the bubble width and are height-capped instead of ballooning the
+      message. */
+.chat-message-content pre {
+  max-width: 100%;
+  overflow-x: auto;
+  max-height: 420px;
+  overflow-y: auto;
+}
+
+/* 5. Reasoning "Thinking" is prose, not code. The engine emits it as
+      
Thinking +
...
(dispatcher reasoning/copilot.ts) and the + base leaves the
 at the UA `white-space: pre` default, so long
+      lines scroll. Group it into a subtle box and wrap the text (more
+      specific than rule 4, so wrapping wins over the code-scroll rule). */
+.chat-message-content .reasoning-thinking {
+  margin: 2px 0;
+  border: 1px solid var(--vscode-editorWidget-border, rgba(127, 127, 127, 0.25));
+  border-radius: 6px;
+  overflow: hidden;
+}
+.chat-message-content .reasoning-thinking > summary {
+  padding: 3px 8px;
+  cursor: pointer;
+  font-size: 12px;
+  color: var(--vscode-descriptionForeground, gray);
+  background-color: rgba(127, 127, 127, 0.06);
+}
+.chat-message-content .reasoning-thinking > pre {
+  margin: 0;
+  padding: 8px;
+  white-space: pre-wrap;
+  word-break: break-word;
+  overflow-x: hidden;
+  max-height: none;
+  font-family: var(
+    --vscode-editor-font-family,
+    Consolas,
+    "Courier New",
+    monospace
+  );
+  font-size: 12px;
+  line-height: 1.4;
+}
+
+/* 6. Slim, theme-colored scrollbars for the remaining scroll areas: code
+      blocks and the collapsible JSON action details. */
+.chat-message-content pre::-webkit-scrollbar,
+.chat-message-details pre::-webkit-scrollbar {
+  width: 10px;
+  height: 8px;
+}
+.chat-message-content pre::-webkit-scrollbar-thumb,
+.chat-message-details pre::-webkit-scrollbar-thumb {
+  background-color: var(--vscode-scrollbarSlider-background);
+  border-radius: 5px;
+}
+.chat-message-content pre::-webkit-scrollbar-thumb:hover,
+.chat-message-details pre::-webkit-scrollbar-thumb:hover {
+  background-color: var(--vscode-scrollbarSlider-hoverBackground);
+}
+.chat-message-content pre::-webkit-scrollbar-track,
+.chat-message-details pre::-webkit-scrollbar-track {
+  background-color: transparent;
+}

From d1c6e66cd89bd69e91b7412672bee265e6374455 Mon Sep 17 00:00:00 2001
From: Robert Gruen 
Date: Thu, 16 Jul 2026 09:53:51 -0700
Subject: [PATCH 05/19] bumped reasoning itmeout

---
 .../dispatcher/src/reasoning/copilot.ts       | 33 +++++++++-
 .../dispatcher/test/reasoningTimeout.spec.ts  | 61 +++++++++++++++++++
 2 files changed, 92 insertions(+), 2 deletions(-)
 create mode 100644 ts/packages/dispatcher/dispatcher/test/reasoningTimeout.spec.ts

diff --git a/ts/packages/dispatcher/dispatcher/src/reasoning/copilot.ts b/ts/packages/dispatcher/dispatcher/src/reasoning/copilot.ts
index 159eb33cd..b7a14e522 100644
--- a/ts/packages/dispatcher/dispatcher/src/reasoning/copilot.ts
+++ b/ts/packages/dispatcher/dispatcher/src/reasoning/copilot.ts
@@ -69,6 +69,35 @@ const FALLBACK_MODEL = "claude-opus-4.8";
 // instead of narrating an intent ("Let me confirm…") and stopping.
 const FALLBACK_REASONING_EFFORT = "high" as const;
 
+// Largest delay Node's setTimeout accepts without overflowing (~24.8 days). The
+// Copilot SDK feeds sendAndWait's timeout straight into setTimeout, so a larger
+// value (or Infinity) would wrap around to a near-zero delay and fire immediately.
+const MAX_SETTIMEOUT_MS = 2_147_483_647;
+
+// Client-side cap on how long session.sendAndWait blocks waiting for the Copilot
+// session to go idle (finish one full agentic turn). The SDK default is only 60s,
+// which spuriously fails legitimate multi-tool reasoning turns that run longer:
+// the wait does not abort the agent's in-flight work, so a too-short cap just
+// rejects a turn that is still making progress. Genuine cancellation stays with
+// context.abortSignal. Matches the Claude path's DEFAULT_REASONING_TIMEOUT_MS and
+// honors the same TYPEAGENT_REASONING_TIMEOUT_MS override.
+const DEFAULT_REASONING_TIMEOUT_MS = 20 * 60 * 1000;
+
+// Resolve the sendAndWait idle-wait timeout (ms) from TYPEAGENT_REASONING_TIMEOUT_MS,
+// falling back to DEFAULT_REASONING_TIMEOUT_MS. 0 means "disabled"; since the SDK
+// cannot take 0/Infinity (setTimeout would fire immediately), disabled and any
+// too-large value are clamped to the largest delay setTimeout accepts.
+export function resolveReasoningTimeoutMs(): number {
+    const raw = process.env.TYPEAGENT_REASONING_TIMEOUT_MS;
+    const parsed = raw !== undefined ? Number(raw) : NaN;
+    if (!Number.isFinite(parsed) || parsed < 0) {
+        return DEFAULT_REASONING_TIMEOUT_MS;
+    }
+    return parsed === 0
+        ? MAX_SETTIMEOUT_MS
+        : Math.min(parsed, MAX_SETTIMEOUT_MS);
+}
+
 function resolveModel(context: ActionContext): string {
     // Live @config override wins, then the COPILOT_REASONING_MODEL env var
     // (from config.yaml), then the built-in fallback.
@@ -1111,7 +1140,7 @@ async function executeReasoningWithoutPlanning(
         }
 
         const response: any = await withAbortSignal(
-            session.sendAndWait({ prompt }),
+            session.sendAndWait({ prompt }, resolveReasoningTimeoutMs()),
             context.abortSignal,
         );
         debug("Received response from Copilot");
@@ -1390,7 +1419,7 @@ async function executeReasoningWithTracing(
             debug(`Sending prompt: ${prompt.substring(0, 100)}...`);
 
             const response: any = await withAbortSignal(
-                session.sendAndWait({ prompt }),
+                session.sendAndWait({ prompt }, resolveReasoningTimeoutMs()),
                 context.abortSignal,
             );
             debug("Received response from Copilot");
diff --git a/ts/packages/dispatcher/dispatcher/test/reasoningTimeout.spec.ts b/ts/packages/dispatcher/dispatcher/test/reasoningTimeout.spec.ts
new file mode 100644
index 000000000..60e707c59
--- /dev/null
+++ b/ts/packages/dispatcher/dispatcher/test/reasoningTimeout.spec.ts
@@ -0,0 +1,61 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import { resolveReasoningTimeoutMs } from "../src/reasoning/copilot.js";
+
+const ENV_KEY = "TYPEAGENT_REASONING_TIMEOUT_MS";
+const DEFAULT_MS = 20 * 60 * 1000;
+const MAX_SETTIMEOUT_MS = 2_147_483_647;
+
+describe("resolveReasoningTimeoutMs", () => {
+    let saved: string | undefined;
+    beforeEach(() => {
+        saved = process.env[ENV_KEY];
+        delete process.env[ENV_KEY];
+    });
+    afterEach(() => {
+        if (saved === undefined) {
+            delete process.env[ENV_KEY];
+        } else {
+            process.env[ENV_KEY] = saved;
+        }
+    });
+
+    it("falls back to the 20 minute default when unset", () => {
+        expect(resolveReasoningTimeoutMs()).toBe(DEFAULT_MS);
+    });
+
+    it("uses an explicit positive value as-is", () => {
+        process.env[ENV_KEY] = "90000";
+        expect(resolveReasoningTimeoutMs()).toBe(90000);
+    });
+
+    it("treats 0 as disabled by clamping to the max setTimeout delay", () => {
+        // The SDK feeds the value straight into setTimeout, so 0 (or Infinity)
+        // would fire immediately. Disabled must map to the largest safe delay.
+        process.env[ENV_KEY] = "0";
+        expect(resolveReasoningTimeoutMs()).toBe(MAX_SETTIMEOUT_MS);
+    });
+
+    it("clamps values above the max setTimeout delay", () => {
+        process.env[ENV_KEY] = String(MAX_SETTIMEOUT_MS + 1000);
+        expect(resolveReasoningTimeoutMs()).toBe(MAX_SETTIMEOUT_MS);
+    });
+
+    it("falls back to the default for negative values", () => {
+        process.env[ENV_KEY] = "-5";
+        expect(resolveReasoningTimeoutMs()).toBe(DEFAULT_MS);
+    });
+
+    it("falls back to the default for non-numeric values", () => {
+        process.env[ENV_KEY] = "not-a-number";
+        expect(resolveReasoningTimeoutMs()).toBe(DEFAULT_MS);
+    });
+
+    it("never returns the SDK's spurious 60s idle-wait default", () => {
+        // Regression guard: the original bug was session.sendAndWait using its
+        // built-in 60000ms idle-wait cap, which rejected legitimate long
+        // multi-tool reasoning turns that were still making progress.
+        expect(resolveReasoningTimeoutMs()).toBeGreaterThan(60000);
+    });
+});

From cbb431d9894957174074054005d3b3eba3ae203e Mon Sep 17 00:00:00 2001
From: Robert Gruen 
Date: Thu, 16 Jul 2026 10:58:47 -0700
Subject: [PATCH 06/19] fixed a display bug with action name and updated
 utility agent to create sub folders as necessary.

---
 ts/packages/agents/utility/src/actionHandler.mts  | 10 +++++++---
 .../dispatcher/src/reasoning/copilot.ts           | 15 ++++++++++++++-
 2 files changed, 21 insertions(+), 4 deletions(-)

diff --git a/ts/packages/agents/utility/src/actionHandler.mts b/ts/packages/agents/utility/src/actionHandler.mts
index 5935da6d3..673c50b63 100644
--- a/ts/packages/agents/utility/src/actionHandler.mts
+++ b/ts/packages/agents/utility/src/actionHandler.mts
@@ -13,6 +13,7 @@ import {
     createActionResultFromError,
 } from "@typeagent/agent-sdk/helpers/action";
 import {
+    mkdir,
     readFile as fsReadFile,
     writeFile as fsWriteFile,
 } from "node:fs/promises";
@@ -227,9 +228,12 @@ async function handleReadFile(path: string) {
     return createActionResultFromTextDisplay(content);
 }
 
-async function handleWriteFile(path: string, content: string) {
-    await fsWriteFile(path, content, "utf-8");
-    return createActionResultFromTextDisplay(`File written: ${path}`);
+async function handleWriteFile(filePath: string, content: string) {
+    // fs.writeFile does not create missing parent directories, so create them
+    // first - otherwise writing into a brand-new folder fails with ENOENT.
+    await mkdir(path.dirname(filePath), { recursive: true });
+    await fsWriteFile(filePath, content, "utf-8");
+    return createActionResultFromTextDisplay(`File written: ${filePath}`);
 }
 
 async function handleLlmTransform(
diff --git a/ts/packages/dispatcher/dispatcher/src/reasoning/copilot.ts b/ts/packages/dispatcher/dispatcher/src/reasoning/copilot.ts
index b7a14e522..826e6ca97 100644
--- a/ts/packages/dispatcher/dispatcher/src/reasoning/copilot.ts
+++ b/ts/packages/dispatcher/dispatcher/src/reasoning/copilot.ts
@@ -466,7 +466,20 @@ function formatToolCallDisplay(toolName: string, input: any): string {
         return `**Tool:** discover_actions — schema: \`${schema}\``;
     } else if (toolName === "execute_action") {
         const schema = input?.schemaName ?? "?";
-        const actionName = input?.action?.actionName ?? "?";
+        // The model sometimes supplies `action` as a JSON string rather than an
+        // object, and at tool.execution_start that string can still be
+        // mid-stream (truncated). Parse it when possible, otherwise pull the
+        // actionName out directly so the label shows the real action, not "?".
+        let action = input?.action;
+        if (typeof action === "string") {
+            try {
+                action = JSON.parse(action);
+            } catch {
+                const match = action.match(/"actionName"\s*:\s*"([^"]+)"/);
+                action = match ? { actionName: match[1] } : undefined;
+            }
+        }
+        const actionName = action?.actionName ?? "?";
         return `**Tool:** execute_action — \`${schema}.${actionName}\``;
     } else if (toolName === "search_memory") {
         const question = input?.question ?? JSON.stringify(input);

From 3345ab69e545c034c69e4d3d0178212499fd3d8d Mon Sep 17 00:00:00 2001
From: Robert Gruen 
Date: Thu, 16 Jul 2026 11:23:25 -0700
Subject: [PATCH 07/19] handle parallel tool calls

---
 .../dispatcher/src/reasoning/copilot.ts       | 132 ++++++++++--------
 1 file changed, 77 insertions(+), 55 deletions(-)

diff --git a/ts/packages/dispatcher/dispatcher/src/reasoning/copilot.ts b/ts/packages/dispatcher/dispatcher/src/reasoning/copilot.ts
index 826e6ca97..a40db8a68 100644
--- a/ts/packages/dispatcher/dispatcher/src/reasoning/copilot.ts
+++ b/ts/packages/dispatcher/dispatcher/src/reasoning/copilot.ts
@@ -35,6 +35,7 @@ import {
 import { nullClientIO } from "../context/interactiveIO.js";
 import { ClientIO, IAgentMessage } from "@typeagent/dispatcher-types";
 import { createActionResultNoDisplay } from "@typeagent/agent-sdk/helpers/action";
+import { createLimiter } from "@typeagent/common-utils";
 import { ReasoningTraceCollector } from "./tracing/traceCollector.js";
 import { ReasoningRecipeGenerator } from "./recipeGenerator.js";
 
@@ -542,6 +543,24 @@ function getCopilotSessionConfig(
     // Define custom tools using Copilot SDK (mirrors Claude's MCP tools)
     let actionIndex = 1;
 
+    // Serialize execute_action invocations (see its handler below): the display
+    // capture there swaps the shared systemContext.clientIO around an await, and
+    // the Copilot SDK dispatches parallel tool calls concurrently. Overlapping
+    // handlers would clobber that slot and strand clientIO on an abandoned
+    // capture buffer, dropping the final answer and hanging the UI spinner.
+    //
+    // TODO: Revisit parallel action execution in the dispatcher. This mutex
+    // makes concurrent execute_action calls safe by running them one at a time,
+    // which is fine for fast actions but loses real overlap when a turn fires
+    // multiple slow I/O actions (e.g. two webFetch or claudeTask calls). Doing
+    // it properly means capturing per call instead of swapping the shared
+    // clientIO (thread a clientIO sink through executeAction/getActionContext),
+    // AND isolating the other per-session state executeAction mutates
+    // (lastActionSchemaName, streamingActionContext, commandResult accumulation,
+    // activityContext, actionIndex). The dispatcher is serial by design today
+    // (commandLock = createLimiter(1)), so this is a broader change.
+    const executeActionLock = createLimiter(1);
+
     const discoverTool = defineTool("discover_actions", {
         description: [
             "Discover actions available with a schema name.",
@@ -609,67 +628,70 @@ function getCopilotSessionConfig(
             },
             required: ["schemaName", "action"],
         },
-        handler: async (args: any) => {
-            const { schemaName, action: actionJson } = args;
-            debug(`Executing action: ${schemaName}.${actionJson.actionName}`);
-            const validator = validators.get(schemaName);
-            if (!validator) {
-                throw new Error(`Invalid schema name '${schemaName}'`);
-            }
+        handler: async (args: any) =>
+            executeActionLock(async () => {
+                const { schemaName, action: actionJson } = args;
+                debug(
+                    `Executing action: ${schemaName}.${actionJson.actionName}`,
+                );
+                const validator = validators.get(schemaName);
+                if (!validator) {
+                    throw new Error(`Invalid schema name '${schemaName}'`);
+                }
 
-            const validationResult = validator.validate(actionJson);
-            if (!validationResult.success) {
-                throw new Error(validationResult.message);
-            }
+                const validationResult = validator.validate(actionJson);
+                if (!validationResult.success) {
+                    throw new Error(validationResult.message);
+                }
 
-            // Capture action execution results (same as Claude)
-            const result: IAgentMessage[] = [];
-            const capturingClientIO: ClientIO = {
-                ...nullClientIO,
-                setDisplay: (message) => {
-                    result.push(message);
-                },
-                appendDisplay: (message, mode) => {
-                    if (mode !== "temporary") {
+                // Capture action execution results (same as Claude)
+                const result: IAgentMessage[] = [];
+                const capturingClientIO: ClientIO = {
+                    ...nullClientIO,
+                    setDisplay: (message) => {
                         result.push(message);
-                    }
-                },
-            };
-
-            const savedClientIO = systemContext.clientIO;
-            try {
-                systemContext.clientIO = capturingClientIO;
-                await executeAction(
-                    {
-                        action: {
-                            schemaName,
-                            ...actionJson,
-                        },
                     },
-                    context,
-                    actionIndex++,
-                );
-                systemContext.clientIO = savedClientIO;
-
-                // Return result in Copilot SDK format
-                return {
-                    textResultForLlm: JSON.stringify(result),
-                    resultType: "success" as const,
+                    appendDisplay: (message, mode) => {
+                        if (mode !== "temporary") {
+                            result.push(message);
+                        }
+                    },
                 };
-            } catch (error) {
-                systemContext.clientIO = savedClientIO;
-                const errorMessage =
-                    error instanceof Error ? error.message : String(error);
-                debug(`Error executing action: ${errorMessage}`);
 
-                // Return error in Copilot SDK format
-                return {
-                    textResultForLlm: `Error executing ${schemaName}.${actionJson.actionName}: ${errorMessage}`,
-                    resultType: "failure" as const,
-                    error: errorMessage,
-                };
-            }
-        },
+                const savedClientIO = systemContext.clientIO;
+                try {
+                    systemContext.clientIO = capturingClientIO;
+                    await executeAction(
+                        {
+                            action: {
+                                schemaName,
+                                ...actionJson,
+                            },
+                        },
+                        context,
+                        actionIndex++,
+                    );
+                    systemContext.clientIO = savedClientIO;
+
+                    // Return result in Copilot SDK format
+                    return {
+                        textResultForLlm: JSON.stringify(result),
+                        resultType: "success" as const,
+                    };
+                } catch (error) {
+                    systemContext.clientIO = savedClientIO;
+                    const errorMessage =
+                        error instanceof Error ? error.message : String(error);
+                    debug(`Error executing action: ${errorMessage}`);
+
+                    // Return error in Copilot SDK format
+                    return {
+                        textResultForLlm: `Error executing ${schemaName}.${actionJson.actionName}: ${errorMessage}`,
+                        resultType: "failure" as const,
+                        error: errorMessage,
+                    };
+                }
+            }),
     });
 
     const searchMemoryTool = defineTool("search_memory", {

From 0f9489f221de1e19e8203125423148ebe2b89f39 Mon Sep 17 00:00:00 2001
From: Robert Gruen 
Date: Thu, 16 Jul 2026 12:32:18 -0700
Subject: [PATCH 08/19] added parity assessment.

---
 .../copilot-parity-assessment-2026-07-16.md   | 113 ++++++++++++++++++
 1 file changed, 113 insertions(+)
 create mode 100644 ts/docs/plans/vscode-devx/copilot-parity-assessment-2026-07-16.md

diff --git a/ts/docs/plans/vscode-devx/copilot-parity-assessment-2026-07-16.md b/ts/docs/plans/vscode-devx/copilot-parity-assessment-2026-07-16.md
new file mode 100644
index 000000000..30f1d831b
--- /dev/null
+++ b/ts/docs/plans/vscode-devx/copilot-parity-assessment-2026-07-16.md
@@ -0,0 +1,113 @@
+# TypeAgent VS Code Shell ⇄ GitHub Copilot — Parity & Gap Assessment
+
+> **Status:** Baseline snapshot — **2026-07-16**. Point-in-time inventory to track progress toward unifying the TypeAgent VS Code experience with GitHub Copilot.
+> **Audience:** Planning material for closing the code-editing inner-loop gap while preserving TypeAgent's multi-agent / voice / cross-surface strengths.
+> **How to use:** Each gap has a stable ID (`G-A1`, `G-B2`, …). Update the **Status** column and append to the **Progress log** as work lands. Re-run the assessment and add a new dated section rather than overwriting rows.
+
+---
+
+## 0. Scope & framing
+
+"TypeAgent in VS Code" is actually **three extensions plus a reasoning backend**, assessed together because they ship one underlying experience:
+
+| Surface | Role |
+| --- | --- |
+| [`packages/vscode-shell`](../../../packages/vscode-shell/README.md) | Custom webview chat panel (sidebar + editor tabs), talks to the agent server over WebSocket. The main "shell in VS Code." Marketplace-eligible. |
+| [`packages/vscode-chat`](../../../packages/vscode-chat/README.md) | Registers TypeAgent as a native Chat-view session provider (proposed `chatSessionsProvider` API; Insiders-only). |
+| [`packages/coda`](../../../packages/coda/README.md) | The in-editor actuator for the `code` agent (create files, split editors, themes) — and it **delegates code generation to Copilot**. |
+| [`reasoning/copilot.ts`](../../../packages/dispatcher/dispatcher/src/reasoning/copilot.ts) + [`packages/copilot-plugin`](../../../packages/copilot-plugin/README.md) | TypeAgent *uses* `@github/copilot-sdk` as a reasoning engine, and can *embed into* Copilot CLI as a plugin. |
+
+**Central structural finding:** TypeAgent's VS Code coding capability is an NL/voice orchestration layer **on top of** Copilot, not a replacement for it. [`packages/coda/src/helpers.ts`](../../../packages/coda/src/helpers.ts) calls `isCopilotEnabled()`, `triggerAndMaybeAcceptInlineSuggestion()` (`editor.action.inlineSuggest.trigger`), `requestCopilotFix()`, and hands off to `workbench.action.chat.open`. TypeAgent does no code completion of its own.
+
+Legend — **Gap:** Large / Medium / Small / None · **Status:** Open · In progress · Closed · Met (TypeAgent already at/above parity).
+
+---
+
+## A. In-editor coding assistance
+
+| ID | Capability | GitHub Copilot | TypeAgent VS Code shell | Gap | Status |
+| --- | --- | --- | --- | --- | --- |
+| G-A1 | Inline completions (ghost text) | Yes | Partial (triggers *Copilot's* via coda) | Depends on Copilot | Open |
+| G-A2 | Next-edit suggestions (NES) | Yes | No | Large | Open |
+| G-A3 | Inline chat (Ctrl+I in editor) | Yes | No | Large | Open |
+| G-A4 | Multi-file edits w/ diff → review → apply | Yes (Edit/Agent) | No (coda writes whole files; no diff/apply loop) | Large | Open |
+| G-A5 | Autonomous agent loop (edit + tools + iterate) | Yes (Agent mode) | Partial (reasoning=copilot has `github/fs/*`, `shell`; no in-editor diff/checkpoint UX) | Medium | Open |
+| G-A6 | Undo / checkpoints / edit history | Yes | No (conversation mgmt only) | Medium | Open |
+
+## B. Context & grounding
+
+| ID | Capability | GitHub Copilot | TypeAgent VS Code shell | Gap | Status |
+| --- | --- | --- | --- | --- | --- |
+| G-B1 | `#file` / `#selection` / `#editor` context | Yes | **No** — shell reads no editor state (no `activeTextEditor`/`selection` in extension source) | Large | Open |
+| G-B2 | `@workspace` / `#codebase` semantic index | Yes | Partial (`github/search/*` via reasoning SDK; no persistent index) | Medium | Open |
+| G-B3 | `#problems` / diagnostics, test-failure context | Yes | No (coda can *ask Copilot* to fix a diagnostic) | Medium | Open |
+| G-B4 | `#changes` / git/staged diff context | Yes | No | Medium | Open |
+| G-B5 | Vision / image input | Yes | Partial (chat-ui has `attachments` plumbing; not editor/vision-oriented) | Medium | Open |
+| G-B6 | Terminal selection / command context | Yes (`@terminal`) | No | Small | Open |
+
+## C. Chat UX & modes
+
+| ID | Capability | GitHub Copilot | TypeAgent VS Code shell | Gap | Status |
+| --- | --- | --- | --- | --- | --- |
+| G-C1 | Distinct Ask / Edit / Agent modes | Yes | No (one conversational mode + optional reasoning) | Medium | Open |
+| G-C2 | Code slash commands (`/explain`, `/fix`, `/tests`, `/doc`) | Yes | No (has `@agent` routing + `@config`, not code slashes) | Medium | Open |
+| G-C3 | In-chat model picker | Yes (many models) | Partial (config/env `reasoningModel`, not a UI picker) | Small | Open |
+| G-C4 | Streaming + cancellation/queue | Yes | Yes (rich queue/cancel, cross-client) | None | Met |
+| G-C5 | Voice input | Yes | Yes (Azure Speech, [`azureSpeechProvider.ts`](../../../packages/vscode-shell/src/webview/azureSpeechProvider.ts)) | None | Met |
+
+## D. Customization & extensibility
+
+| ID | Capability | GitHub Copilot | TypeAgent VS Code shell | Gap | Status |
+| --- | --- | --- | --- | --- | --- |
+| G-D1 | Custom instructions (`copilot-instructions.md`, `AGENTS.md`) | Yes | No shell equivalent | Medium | Open |
+| G-D2 | Prompt files (`.prompt.md`) | Yes | No (taskflow recipes are auto-generated, not authored) | Medium | Open |
+| G-D3 | Custom chat modes / agents (`.chatmode.md`, `.agent.md`) | Yes (file-based) | Partial (agents are compiled TS plugins — heavier) | Medium | Open |
+| G-D4 | Skills (`SKILL.md`) | Yes | No | Small | Open |
+| G-D5 | MCP servers (as client) | Yes | Partial (mcp agent + copilot-plugin "mcp mode"; no shell-level MCP client UI) | Medium | Open |
+| G-D6 | Per-tool approval / auto-approve controls | Yes (granular) | No (`onPermissionRequest: approveAll` in reasoning) | Medium (safety) | Open |
+
+## E. Platform / ecosystem
+
+| ID | Capability | GitHub Copilot | TypeAgent VS Code shell | Gap | Status |
+| --- | --- | --- | --- | --- | --- |
+| G-E1 | Cloud coding agent (issue → PR) | Yes | No | Large (different product) | Open |
+| G-E2 | Code review (`/review`, PR review) | Yes | No | Medium | Open |
+| G-E3 | Marketplace distribution | Yes | No (vscode-shell unpublished; vscode-chat needs proposed API) | Medium | Open |
+| G-E4 | Enterprise auth, content exclusion, policy | Yes | No (own keys via `config.local.yaml`) | Medium | Open |
+
+---
+
+## F. Where TypeAgent leads (differentiators to preserve, not close)
+
+- **Multi-domain agents beyond code** — [`packages/agents`](../../../packages/agents) ships browser automation, calendar, email, music/player, desktop, discord, image, montage, weather, lists, etc. Copilot is code-scoped.
+- **One conversation across surfaces** — the same session is joinable from CLI, Electron shell, `vscode-shell`, and the native Chat view, with real-time shared queue/cancel state.
+- **Deterministic action grammar + translation cache** — matches NL to typed actions without an LLM call where possible (`actionGrammar` / `cache`).
+- **Conversation memory as a tool** — `search_memory` / `remember` over knowPro RAG, exposed to the reasoning loop.
+- **Taskflow recipe auto-generation** — successful reasoning traces are distilled into reusable, grammar-matchable flows (`saveTaskFlowRecipeToStorage`).
+- **Interoperates with Copilot both directions** — uses the Copilot SDK as a backend *and* plugs into Copilot CLI.
+
+---
+
+## G. Integration / leverage opportunities (unify, don't rebuild)
+
+Because the relationship is symbiotic, several gaps are cheaper to close by wiring in Copilot than by reimplementing it. Ordered by ROI:
+
+1. **Feed editor context into the shell (highest ROI).** Closes most of §B (`G-B1`–`G-B4`) with an extension-host change only — no server changes. Pipe `activeTextEditor` / selection / visible files / diagnostics into the prompt.
+2. **Surface a per-tool approval UI (`G-D6`).** `approveAll` is the biggest safety gap vs. Copilot's confirmation model — route `onPermissionRequest` to a webview prompt.
+3. **Adopt file-based customization (`G-D1`, `G-D2`).** Honor `AGENTS.md` / `copilot-instructions.md` and support `.prompt.md`-style saved prompts, mapping them onto the existing taskflow/recipe machinery.
+4. **Expose Copilot's Edit/Agent surfaces from NL/voice (`G-A3`–`G-A5`).** coda already triggers inline suggest and Copilot Chat; extend it to drive Edit/Agent mode so repo-wide changes get Copilot's diff/apply/checkpoint UX for free.
+5. **MCP client at the shell level (`G-D5`)** to reach parity with Copilot's tool ecosystem.
+
+---
+
+## Bottom line
+
+The **largest true gaps** are all in the *code-editing inner loop*: no editor context (`G-B1`), no inline/edit/agent-mode diff-and-apply UX (`G-A3`–`G-A5`), and no code-aware slash commands (`G-C2`). Everything else is meaningful but secondary. TypeAgent's shell is best understood not as a Copilot competitor but as a **voice-first, multi-agent, cross-surface conversational layer** that can *orchestrate* Copilot — so the pragmatic unification path is to pipe editor context in and delegate deep coding work to Copilot's existing surfaces.
+
+---
+
+## Progress log
+
+| Date | Change | Gaps touched | Notes |
+| --- | --- | --- | --- |
+| 2026-07-16 | Baseline assessment captured | — | Initial snapshot; all rows Open except `G-C4`, `G-C5` (Met). |

From ff95a8b07aba758354b2f37409cb81f55a422fb6 Mon Sep 17 00:00:00 2001
From: Robert Gruen 
Date: Thu, 16 Jul 2026 17:49:12 -0700
Subject: [PATCH 09/19] added camera capability to vscode-shell (hid the
 buttons since this isn't yet supported).

---
 ts/packages/chat-ui/src/providers.ts          |  15 +-
 .../vscode-shell/src/chatViewProvider.ts      |   1 +
 .../vscode-shell/src/webview/cameraView.ts    | 278 ++++++++++++++++++
 ts/packages/vscode-shell/src/webview/main.ts  |  65 +++-
 .../vscode-shell/src/webview/vscode-theme.css |  74 +++++
 5 files changed, 422 insertions(+), 11 deletions(-)
 create mode 100644 ts/packages/vscode-shell/src/webview/cameraView.ts

diff --git a/ts/packages/chat-ui/src/providers.ts b/ts/packages/chat-ui/src/providers.ts
index 7ec0cb5f3..78f159541 100644
--- a/ts/packages/chat-ui/src/providers.ts
+++ b/ts/packages/chat-ui/src/providers.ts
@@ -76,16 +76,19 @@ export interface TtsProvider {
 }
 
 /**
- * Image input capability. ChatPanel renders an attach-file button when
- * pickFile is present and a camera button when openCamera is present.
- * Both return base64 data URLs added to the next message's attachments.
+ * Image input capability. The attach-file button always renders (falling back
+ * to a web-native file input); supplying `pickFile` overrides that with a
+ * host-native picker. The camera button renders only when `openCamera` is
+ * present. Both hooks are independent and optional. Both return base64 data
+ * URLs added to the next message's attachments.
  */
 export interface ImageCaptureProvider {
     /**
-     * Open a native file picker and return the selected image(s) as base64
-     * data URLs, or undefined if cancelled.
+     * Optional host-native file picker returning the selected image(s) as
+     * base64 data URLs, or undefined if cancelled. When omitted, ChatPanel
+     * uses a built-in web-native `` for the attach button.
      */
-    pickFile(): Promise;
+    pickFile?(): Promise;
     /**
      * Optional in-app camera capture returning a single base64 data URL,
      * or undefined if cancelled.
diff --git a/ts/packages/vscode-shell/src/chatViewProvider.ts b/ts/packages/vscode-shell/src/chatViewProvider.ts
index 22bdf6027..10fccd6b0 100644
--- a/ts/packages/vscode-shell/src/chatViewProvider.ts
+++ b/ts/packages/vscode-shell/src/chatViewProvider.ts
@@ -134,6 +134,7 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
                    style-src ${webview.cspSource} 'unsafe-inline';
                    script-src 'nonce-${nonce}';
                    img-src ${webview.cspSource} data:;
+                   media-src blob: mediastream: data:;
                    font-src ${webview.cspSource};">
     
     
diff --git a/ts/packages/vscode-shell/src/webview/cameraView.ts b/ts/packages/vscode-shell/src/webview/cameraView.ts
new file mode 100644
index 000000000..930ebae43
--- /dev/null
+++ b/ts/packages/vscode-shell/src/webview/cameraView.ts
@@ -0,0 +1,278 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+/**
+ * In-webview camera capture overlay for the VS Code shell, mirroring the
+ * Electron shell's CameraView (packages/shell/src/renderer/src/cameraView.ts).
+ * getUserMedia works inside the VS Code webview - the Azure speech mic path
+ * relies on it too - so the same live-preview + snapshot flow applies here.
+ *
+ * The overlay is a full-screen scrim with a