diff --git a/ts/docs/architecture/agentServerConversations.md b/ts/docs/architecture/agentServerConversations.md index f587e37468..d0dbcba2a5 100644 --- a/ts/docs/architecture/agentServerConversations.md +++ b/ts/docs/architecture/agentServerConversations.md @@ -261,6 +261,18 @@ All HTML interpolated into either path is escaped via a local `escapeHtml()` (ma See `packages/vscode-shell/src/agentServerBridge.ts` (`handleManageConversation`, `connectImpl`) and `packages/vscode-shell/src/extension.ts` for the implementation. +### Browser Extension + +The browser extension (`packages/agents/browser/src/extension`) is a Chrome MV3 extension that runs in **connected mode only** — its service worker maintains a WebSocket to the agentServer. The chat panel surfaces the same `@conversation` slash commands and NL phrases as the Shell and CLI. + +The chat panel forwards the dispatcher's `manage-conversation` `takeAction` payload to the service worker via a `chatPanelManageConversation` invoke RPC. The service worker (`extension/serviceWorker/dispatcherConnection.ts`) implements all eight subcommands (`new`, `list`, `info`, `switch`, `prev`, `next`, `rename`, `delete`) against `AgentServerInvokeFunctions` and returns a rendered HTML message plus a `switched` flag. + +When `switched` is set, the chat panel clears its DOM and re-runs `loadSessionHistory()` (mirroring the Shell's `replayDisplayHistory` on `conversationChanged`), then renders the confirmation message so it lands after the replayed history. Live display events arriving during the replay are queued via a `runOrDefer` gate and flushed in order on completion. + +Switching follows the bind-new → leave-old → delete-old-channels ordering used by `agentServerClient.ts` and the CLI's `commands/connect.ts`: if the new join throws, the existing dispatcher and channels stay live so the user can retry. The chat panel joins with `filter: false` (matching Shell), so display events from peer clients (Shell or CLI joined to the same conversation) are also visible. + +See `packages/agents/browser/src/extension/serviceWorker/dispatcherConnection.ts` (`bindToConversation`, `switchToConversationId`, `manageConversation`) and `packages/agents/browser/src/extension/views/chatPanel.ts` (`dispatcherTakeAction`, `runOrDefer`, `loadSessionHistory`) for the implementation. + --- ## Natural Language Conversation Management diff --git a/ts/packages/agents/browser/README.md b/ts/packages/agents/browser/README.md index 849869b50a..aadf2ebbe5 100644 --- a/ts/packages/agents/browser/README.md +++ b/ts/packages/agents/browser/README.md @@ -28,6 +28,15 @@ To build the browser extension, run `pnpm run build` in this folder. For debug s - go back - etc. +## Chat panel + +The extension's chat panel supports the same `@conversation` slash +commands and natural-language conversation management as the Shell and +CLI (`new`, `list`, `info`, `switch`, `prev`, `next`, `rename`, +`delete`). Switching, creating, or moving between conversations clears +the panel and replays the new conversation's history, so peer activity +from a Shell or CLI joined to the same conversation is also visible. + ## Architecture ### Agent WebSocket Server diff --git a/ts/packages/agents/browser/docs/dev-onboarding.md b/ts/packages/agents/browser/docs/dev-onboarding.md index 03377e62f1..f7d8611968 100644 --- a/ts/packages/agents/browser/docs/dev-onboarding.md +++ b/ts/packages/agents/browser/docs/dev-onboarding.md @@ -148,6 +148,11 @@ DEBUG=typeagent:webAgent:proxy pnpm run cli:dev keep-alive (20s interval) prevents this during normal operation. - After extension reload, the service worker restarts and re-establishes the WebSocket connection. +- **After `pnpm --filter browser-typeagent build`, the running service + worker does NOT auto-reload.** Click the ↻ reload icon for the extension + on `chrome://extensions` to pick up new code. A symptom of stale SW + code is an RPC error like `No invoke handler ` for a handler you + just added. ### Content script diff --git a/ts/packages/agents/browser/src/common/serviceTypes.mts b/ts/packages/agents/browser/src/common/serviceTypes.mts index 5c76148f20..17d370a48c 100644 --- a/ts/packages/agents/browser/src/common/serviceTypes.mts +++ b/ts/packages/agents/browser/src/common/serviceTypes.mts @@ -369,6 +369,23 @@ export type ChatPanelInvokeFunctions = { actionName: string; actionDescription: string; }): Promise<{ success: boolean; flowName?: string; error?: string }>; + + // @conversation slash commands and NL conversation actions. Returns + // an HTML message the chat panel renders inline; `switched` signals + // that the active conversation changed and history should be reloaded. + chatPanelManageConversation(params: { + subcommand: + | "new" + | "list" + | "info" + | "switch" + | "prev" + | "next" + | "rename" + | "delete"; + name?: string; + newName?: string; + }): Promise<{ kind: "ok" | "error"; html: string; switched?: boolean }>; }; // ============================================= diff --git a/ts/packages/agents/browser/src/extension/serviceWorker/dispatcherConnection.ts b/ts/packages/agents/browser/src/extension/serviceWorker/dispatcherConnection.ts index 7c332ce7a8..9b5b53bc9a 100644 --- a/ts/packages/agents/browser/src/extension/serviceWorker/dispatcherConnection.ts +++ b/ts/packages/agents/browser/src/extension/serviceWorker/dispatcherConnection.ts @@ -22,6 +22,7 @@ import { import type { ClientIO, Dispatcher } from "@typeagent/dispatcher-rpc/types"; import type { AgentServerInvokeFunctions, + ConversationInfo, DispatcherConnectOptions, JoinConversationResult, } from "@typeagent/agent-server-protocol"; @@ -44,6 +45,16 @@ let dispatcher: Dispatcher | undefined; let dispatcherWs: WebSocket | undefined; let connectionPromise: Promise | undefined; +// Hoisted from doConnect() so conversation management can issue +// AgentServer RPC calls and re-bind channels outside the initial connect. +type ServerRpc = ReturnType>; +type ChannelProvider = ReturnType; +let serverRpc: ServerRpc | undefined; +let serverChannel: ChannelProvider | undefined; +let chatPanelClientIO: ClientIO | undefined; +let activeConversationId: string | undefined; +let activeConversationName: string | undefined; + // RPC functions for communicating with the chat panel. // rpcSend: fire-and-forget (display updates) // rpcInvoke: awaited (question, proposeAction) @@ -219,57 +230,30 @@ async function doConnect(): Promise { ws.send(JSON.stringify(message)); }, ); + serverChannel = channel; const rpc = createRpc( "agent-server:extension", channel.createChannel(AgentServerChannelName), ); + serverRpc = rpc; const clientIO = createChatPanelClientIO(); + chatPanelClientIO = clientIO; let resolved = false; ws.onopen = () => { debug("WebSocket connected to Agent Server"); const options: DispatcherConnectOptions = { - filter: true, + // filter:false so we see display events from all clients + // (Shell/CLI peers) joined to the same conversation. + filter: false, clientType: "extension", }; - rpc.invoke("joinConversation", options) - .then((result: JoinConversationResult) => { - debug( - "Joined conversation=%s, connectionId=%s", - result.conversationId, - result.connectionId, - ); + bindToConversation(options, ws) + .then((d) => { resolved = true; - - const { - dispatcher: d, - notifyCommandComplete, - notifyRequestCancelled, - } = createDispatcherRpcClient( - channel.createChannel( - getDispatcherChannelName(result.conversationId), - ), - result.connectionId, - ); - - createClientIORpcServer( - wrapClientIOForCompletion(clientIO, { - notifyCommandComplete, - notifyRequestCancelled, - }), - channel.createChannel( - getClientIOChannelName(result.conversationId), - ), - ); - - // Override close to close our WebSocket - d.close = async () => { - debug("Closing dispatcher WebSocket"); - ws.close(); - }; resolve(d); }) .catch((err: any) => { @@ -296,6 +280,11 @@ async function doConnect(): Promise { channel.notifyDisconnected(); dispatcher = undefined; dispatcherWs = undefined; + serverRpc = undefined; + serverChannel = undefined; + chatPanelClientIO = undefined; + activeConversationId = undefined; + activeConversationName = undefined; if (!resolved) { reject( new Error(`Failed to connect to Agent Server at ${url}`), @@ -310,6 +299,59 @@ async function doConnect(): Promise { }); } +/** + * Join (or rejoin) a conversation and wire up its per-conversation + * dispatcher RPC client + clientIO server on top of the current WebSocket. + */ +async function bindToConversation( + options: DispatcherConnectOptions, + ws: WebSocket, +): Promise { + if (!serverRpc || !serverChannel || !chatPanelClientIO) { + throw new Error("Agent server RPC is not initialized"); + } + const result: JoinConversationResult = await serverRpc.invoke( + "joinConversation", + options, + ); + debug( + "Joined conversation=%s (%s), connectionId=%s", + result.name, + result.conversationId, + result.connectionId, + ); + + const { + dispatcher: d, + notifyCommandComplete, + notifyRequestCancelled, + } = createDispatcherRpcClient( + serverChannel.createChannel( + getDispatcherChannelName(result.conversationId), + ), + result.connectionId, + ); + + createClientIORpcServer( + wrapClientIOForCompletion(chatPanelClientIO, { + notifyCommandComplete, + notifyRequestCancelled, + }), + serverChannel.createChannel( + getClientIOChannelName(result.conversationId), + ), + ); + + d.close = async () => { + debug("Closing dispatcher WebSocket"); + ws.close(); + }; + + activeConversationId = result.conversationId; + activeConversationName = result.name; + return d; +} + /** * Get whether the dispatcher is currently connected. */ @@ -329,6 +371,12 @@ export async function disconnectDispatcher(): Promise { await dispatcher.close(); dispatcher = undefined; } + connectionPromise = undefined; + serverRpc = undefined; + serverChannel = undefined; + chatPanelClientIO = undefined; + activeConversationId = undefined; + activeConversationName = undefined; } /** @@ -337,3 +385,381 @@ export async function disconnectDispatcher(): Promise { export function getDispatcher(): Dispatcher | undefined { return dispatcher; } + +// ── Conversation management ──────────────────────────────────────────── +// Handles the dispatcher's `manage-conversation` takeAction payload, which +// both @conversation slash commands and the NL conversation agent emit. + +type ManageConversationPayload = { + subcommand: + | "new" + | "list" + | "info" + | "switch" + | "prev" + | "next" + | "rename" + | "delete"; + name?: string; + newName?: string; +}; + +type ManageConversationResult = { + kind: "ok" | "error"; + html: string; + // True when the active conversation changed; chat panel clears + + // replays history on this signal. + switched?: boolean; +}; + +function escapeHtml(s: string): string { + return s.replace(/[&<>"']/g, (c) => { + switch (c) { + case "&": + return "&"; + case "<": + return "<"; + case ">": + return ">"; + case '"': + return """; + default: + return "'"; + } + }); +} + +function ok(html: string, switched?: boolean): ManageConversationResult { + return switched + ? { kind: "ok", html, switched: true } + : { kind: "ok", html }; +} + +function err(html: string): ManageConversationResult { + return { kind: "error", html }; +} + +function defaultNewName(): string { + const dt = new Date(); + const pad = (n: number) => n.toString().padStart(2, "0"); + return `Conversation ${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}`; +} + +function findByName( + sessions: ConversationInfo[], + name: string, +): ConversationInfo | undefined { + const lower = name.toLowerCase(); + return sessions.find((s) => s.name.toLowerCase() === lower); +} + +async function switchToConversationId( + newId: string, +): Promise { + if (!serverRpc || !serverChannel) { + throw new Error("Not connected to agent server."); + } + if (!dispatcherWs || dispatcherWs.readyState !== WebSocket.OPEN) { + throw new Error("Agent server WebSocket is not open."); + } + if (newId === activeConversationId) { + return undefined; + } + + const oldId = activeConversationId; + + // Join new before tearing down old (matches agentServerClient + CLI). + // If the join throws, the existing dispatcher + channels stay live so + // the user can retry. No duplicate-channel risk: newId !== oldId here. + const newDispatcher = await bindToConversation( + { + filter: false, + clientType: "extension", + conversationId: newId, + }, + dispatcherWs, + ); + dispatcher = newDispatcher; + + // Leave old server-side first; in-flight completions for the prior + // dispatcher were resolved before this point because callers await + // their dispatcher.submitCommand. Then drop our local channel adapters + // so a future re-bind to the same id doesn't hit createChannel's + // duplicate-name guard. + if (oldId && oldId !== activeConversationId) { + try { + await serverRpc.invoke("leaveConversation", oldId); + } catch (e) { + debugErr("leaveConversation failed for %s: %o", oldId, e); + } + serverChannel.deleteChannel(getDispatcherChannelName(oldId)); + serverChannel.deleteChannel(getClientIOChannelName(oldId)); + } + + try { + const all = await serverRpc.invoke("listConversations", undefined); + return all.find((s) => s.conversationId === newId); + } catch { + return undefined; + } +} + +// Serialize all manageConversation calls so overlapping switches (e.g. a +// `next` issued while a previous `switch` is still binding) can't +// interleave on shared module state. +let conversationOpQueue: Promise = Promise.resolve(); + +/** + * Resolves once any in-flight conversation management op has settled. + * Callers that need the active dispatcher (e.g. chatPanelProcessCommand) + * await this first so user prompts don't race with a switch and end up + * submitted to the wrong (or about-to-be-deleted) channel. + */ +export function awaitConversationOps(): Promise { + return conversationOpQueue.then( + () => undefined, + () => undefined, + ); +} + +/** + * Handle a `manage-conversation` payload. Returns an HTML message the chat + * panel renders inline. Calls are serialized; one failure doesn't poison + * subsequent calls. + */ +export function manageConversation( + payload: ManageConversationPayload, +): Promise { + const result = conversationOpQueue.then( + () => doManageConversation(payload), + () => doManageConversation(payload), + ); + // Keep the chain alive across rejections so a single failure doesn't + // poison every subsequent call. + conversationOpQueue = result.catch(() => undefined); + return result; +} + +async function doManageConversation( + payload: ManageConversationPayload, +): Promise { + if (!serverRpc) { + return err("❌ Not connected to agent server."); + } + try { + switch (payload.subcommand) { + case "new": { + const name = payload.name?.trim() || defaultNewName(); + const created = await serverRpc.invoke( + "createConversation", + name, + ); + // Auto-switch into the newly created conversation (matches Shell). + let switched = false; + try { + await switchToConversationId(created.conversationId); + switched = true; + } catch (e) { + debugErr("auto-switch after new failed: %o", e); + } + return ok( + switched + ? `✅ Created and switched to conversation "${escapeHtml(created.name)}"` + : `✅ Created conversation "${escapeHtml(created.name)}" but could not switch.`, + switched, + ); + } + case "list": { + const sessions = await serverRpc.invoke( + "listConversations", + payload.name, + ); + if (sessions.length === 0) { + return ok("No conversations found."); + } + sessions.sort( + (a, b) => + new Date(b.createdAt).getTime() - + new Date(a.createdAt).getTime(), + ); + const items = sessions + .map((s) => { + const cur = + s.conversationId === activeConversationId + ? "▸ " + : ""; + return `
  • ${cur}${escapeHtml(s.name)}
  • `; + }) + .join(""); + return ok(`
      ${items}
    `); + } + case "info": { + if (!activeConversationId || !activeConversationName) { + return err("Not currently in a conversation."); + } + return ok( + `Current conversation:
    ` + + `Name: ${escapeHtml(activeConversationName)}
    ` + + `${escapeHtml(activeConversationId)}`, + ); + } + case "switch": { + const name = payload.name?.trim(); + if (!name) { + return err( + "A conversation name is required to switch. Usage: @conversation switch <name>", + ); + } + const sessions = await serverRpc.invoke( + "listConversations", + undefined, + ); + const target = findByName(sessions, name); + if (!target) { + return err( + `❌ Conversation "${escapeHtml(name)}" not found.`, + ); + } + if (target.conversationId === activeConversationId) { + return ok( + `Already in conversation "${escapeHtml(target.name)}".`, + ); + } + await switchToConversationId(target.conversationId); + return ok( + `🔀 Switched to conversation "${escapeHtml(target.name)}".`, + true, + ); + } + case "prev": + case "next": { + const sessions = await serverRpc.invoke( + "listConversations", + undefined, + ); + if (sessions.length < 2) { + return err("No other conversations to switch to."); + } + sessions.sort( + (a, b) => + new Date(b.createdAt).getTime() - + new Date(a.createdAt).getTime(), + ); + const idx = sessions.findIndex( + (s) => s.conversationId === activeConversationId, + ); + if (idx === -1) { + return err("Current conversation not found in list."); + } + const delta = payload.subcommand === "next" ? 1 : -1; + const target = + sessions[(idx + delta + sessions.length) % sessions.length]; + await switchToConversationId(target.conversationId); + return ok( + `🔀 Switched to ${payload.subcommand === "next" ? "next" : "previous"} conversation "${escapeHtml(target.name)}".`, + true, + ); + } + case "rename": { + if (!payload.newName) { + return err( + "A new name is required. Usage: @conversation rename [<oldName>] <newName>", + ); + } + let conversationId: string | undefined; + let oldName: string | undefined; + if (payload.name) { + const sessions = await serverRpc.invoke( + "listConversations", + undefined, + ); + const match = findByName(sessions, payload.name); + if (!match) { + return err( + `❌ Conversation "${escapeHtml(payload.name)}" not found.`, + ); + } + conversationId = match.conversationId; + oldName = match.name; + } else { + conversationId = activeConversationId; + oldName = activeConversationName; + } + if (!conversationId) { + return err("No conversation to rename."); + } + await serverRpc.invoke( + "renameConversation", + conversationId, + payload.newName, + ); + if (conversationId === activeConversationId) { + activeConversationName = payload.newName; + } + return ok( + `✅ Renamed "${escapeHtml(oldName ?? "")}" to "${escapeHtml(payload.newName)}".`, + ); + } + case "delete": { + const name = payload.name?.trim(); + if (!name) { + return err( + "A conversation name is required. Usage: @conversation delete <name>", + ); + } + const sessions = await serverRpc.invoke( + "listConversations", + undefined, + ); + const target = findByName(sessions, name); + if (!target) { + return err( + `❌ Conversation "${escapeHtml(name)}" not found.`, + ); + } + if (target.conversationId === activeConversationId) { + return err( + "Cannot delete the active conversation. Switch to another conversation first.", + ); + } + // Confirm via the chat panel. Treat absence of `rpcInvoke` + // or a rejected prompt as "not confirmed" — refuse the + // destructive op rather than silently proceeding. + if (!rpcInvoke) { + return err( + "Cannot confirm deletion (chat panel not connected). Aborted.", + ); + } + let confirmed: boolean; + try { + confirmed = (await rpcInvoke("chatPanelAskYesNo", { + message: `Delete conversation '${target.name}'?`, + defaultValue: false, + })) as boolean; + } catch (e) { + debugErr("yes/no confirm failed: %o", e); + return err("Could not confirm deletion. Aborted."); + } + if (!confirmed) { + return ok("Cancelled."); + } + await serverRpc.invoke( + "deleteConversation", + target.conversationId, + ); + return ok( + `🗑️ Deleted conversation "${escapeHtml(target.name)}".`, + ); + } + default: + return err( + `Unknown manage-conversation subcommand: "${escapeHtml( + (payload as { subcommand: string }).subcommand, + )}"`, + ); + } + } catch (e: any) { + debugErr("manageConversation failed: %o", e); + return err(`❌ ${escapeHtml(e?.message ?? String(e))}`); + } +} diff --git a/ts/packages/agents/browser/src/extension/serviceWorker/serviceWorkerRpcHandlers.ts b/ts/packages/agents/browser/src/extension/serviceWorker/serviceWorkerRpcHandlers.ts index 674e753d12..ef354fd050 100644 --- a/ts/packages/agents/browser/src/extension/serviceWorker/serviceWorkerRpcHandlers.ts +++ b/ts/packages/agents/browser/src/extension/serviceWorker/serviceWorkerRpcHandlers.ts @@ -12,6 +12,8 @@ import { screenshotCoordinator } from "./screenshotCoordinator"; import { connectToDispatcher, isDispatcherConnected, + manageConversation, + awaitConversationOps, } from "./dispatcherConnection"; import { startRecording, @@ -225,6 +227,10 @@ export function createAllHandlers(): AllServiceWorkerInvokeFunctions { async chatPanelProcessCommand(params: any) { try { + // Wait for any in-flight conversation switch to finish so the + // prompt is routed to the new dispatcher, not an old one + // whose channel is about to be torn down. + await awaitConversationOps(); const dispatcher = await connectToDispatcher(); const result = await awaitCommand( dispatcher, @@ -243,6 +249,11 @@ export function createAllHandlers(): AllServiceWorkerInvokeFunctions { } }, + async chatPanelManageConversation(params: any) { + await connectToDispatcher(); + return manageConversation(params); + }, + async chatPanelGetCompletions(params: any) { try { const dispatcher = await connectToDispatcher(); diff --git a/ts/packages/agents/browser/src/extension/views/chatPanel.ts b/ts/packages/agents/browser/src/extension/views/chatPanel.ts index 1840c283b4..a29a28c413 100644 --- a/ts/packages/agents/browser/src/extension/views/chatPanel.ts +++ b/ts/packages/agents/browser/src/extension/views/chatPanel.ts @@ -50,6 +50,33 @@ let bannerHideTimer: ReturnType | undefined; // RPC client for communicating with the service worker let rpc: ReturnType["rpc"]; +// Display-replay gate: queues live display events until history replay +// completes so they render after history, not interleaved with it. +// Initialized false so events arriving before the first loadSessionHistory +// are queued; matches the Shell's pendingDisplayOps pattern. +let replayDone = false; +const pendingDisplayOps: Array<() => void> = []; + +function runOrDefer(op: () => void) { + if (replayDone) { + op(); + } else { + pendingDisplayOps.push(op); + } +} + +function flushPendingDisplayOps() { + replayDone = true; + const ops = pendingDisplayOps.splice(0); + for (const op of ops) { + try { + op(); + } catch (e) { + console.error("pendingDisplayOps op threw:", e); + } + } +} + /** * Initialize the chat panel. */ @@ -111,151 +138,208 @@ function initialize() { // No-op in extension }, dispatcherSetDisplayInfo(data) { - chatPanel.setDisplayInfo( - data.source, - undefined, - data.action, - extractThreadId(data.requestId), + runOrDefer(() => + chatPanel.setDisplayInfo( + data.source, + undefined, + data.action, + extractThreadId(data.requestId), + ), ); }, dispatcherSetDisplay(data) { - const msg = data.message; - const tid = extractThreadId(msg.requestId); - if (msg.kind === "toast") { - chatPanel.showToast( + runOrDefer(() => { + const msg = data.message; + const tid = extractThreadId(msg.requestId); + if (msg.kind === "toast") { + chatPanel.showToast( + msg.message as DisplayContent, + msg.source, + msg.sourceIcon, + ); + return; + } + if (msg.kind === "inline") { + chatPanel.showInline( + msg.message as DisplayContent, + msg.source, + ); + return; + } + chatPanel.replaceAgentMessage( msg.message as DisplayContent, msg.source, msg.sourceIcon, + tid, ); - return; - } - if (msg.kind === "inline") { - chatPanel.showInline( - msg.message as DisplayContent, - msg.source, - ); - return; - } - chatPanel.replaceAgentMessage( - msg.message as DisplayContent, - msg.source, - msg.sourceIcon, - tid, - ); + }); }, dispatcherAppendDisplay(data) { - const msg = data.message; - const tid = extractThreadId(msg.requestId); - if (msg.kind === "toast") { - chatPanel.showToast( + runOrDefer(() => { + const msg = data.message; + const tid = extractThreadId(msg.requestId); + if (msg.kind === "toast") { + chatPanel.showToast( + msg.message as DisplayContent, + msg.source, + msg.sourceIcon, + ); + return; + } + if (msg.kind === "inline") { + chatPanel.showInline( + msg.message as DisplayContent, + msg.source, + ); + return; + } + chatPanel.addAgentMessage( msg.message as DisplayContent, msg.source, msg.sourceIcon, + data.mode as DisplayAppendMode, + tid, ); - return; - } - if (msg.kind === "inline") { - chatPanel.showInline( - msg.message as DisplayContent, - msg.source, - ); - return; - } - chatPanel.addAgentMessage( - msg.message as DisplayContent, - msg.source, - msg.sourceIcon, - data.mode as DisplayAppendMode, - tid, - ); + }); }, dispatcherSetDynamicDisplay(data) { - chatPanel.setDynamicDisplay( - data.source, - data.displayId, - data.nextRefreshMs, + runOrDefer(() => + chatPanel.setDynamicDisplay( + data.source, + data.displayId, + data.nextRefreshMs, + ), ); }, dispatcherNotify(data) { - switch (data.event) { - case "explained": - if (data.data?.error) { + runOrDefer(() => { + switch (data.event) { + case "explained": + if (data.data?.error) { + chatPanel.addAgentMessage( + { + type: "text", + content: data.data.error, + kind: "warning", + }, + data.source, + ); + } + break; + case "error": chatPanel.addAgentMessage( { type: "text", - content: data.data.error, + content: + typeof data.data === "string" + ? data.data + : (data.data?.message ?? "Error"), + kind: "error", + }, + data.source, + ); + break; + case "warning": + chatPanel.addAgentMessage( + { + type: "text", + content: + typeof data.data === "string" + ? data.data + : (data.data?.message ?? "Warning"), kind: "warning", }, data.source, ); + break; + case "inline": { + const content: DisplayContent = + typeof data.data === "string" + ? { + type: "text", + content: data.data, + kind: "info", + } + : (data.data as DisplayContent); + chatPanel.showInline(content, data.source); + break; } - break; - case "error": - chatPanel.addAgentMessage( - { - type: "text", - content: - typeof data.data === "string" - ? data.data - : (data.data?.message ?? "Error"), - kind: "error", - }, - data.source, - ); - break; - case "warning": - chatPanel.addAgentMessage( - { - type: "text", - content: - typeof data.data === "string" - ? data.data - : (data.data?.message ?? "Warning"), - kind: "warning", - }, - data.source, - ); - break; - case "inline": { - const content: DisplayContent = - typeof data.data === "string" - ? { - type: "text", - content: data.data, - kind: "info", - } - : (data.data as DisplayContent); - chatPanel.showInline(content, data.source); - break; - } - case "toast": { - const content: DisplayContent = - typeof data.data === "string" - ? { - type: "text", - content: data.data, - kind: "info", - } - : (data.data as DisplayContent); - chatPanel.showToast(content, data.source); - break; + case "toast": { + const content: DisplayContent = + typeof data.data === "string" + ? { + type: "text", + content: data.data, + kind: "info", + } + : (data.data as DisplayContent); + chatPanel.showToast(content, data.source); + break; + } + case "info": + chatPanel.addAgentMessage( + typeof data.data === "string" + ? { + type: "text", + content: data.data, + kind: "info", + } + : (data.data as DisplayContent), + data.source, + ); + break; } - case "info": - chatPanel.addAgentMessage( - typeof data.data === "string" - ? { - type: "text", - content: data.data, - kind: "info", - } - : (data.data as DisplayContent), - data.source, - ); - break; - } + }); }, - dispatcherTakeAction(_data) { - // Not supported in extension chat panel + dispatcherTakeAction(data) { + // Forward `manage-conversation` (from @conversation slash + // commands or NL agent) to the service worker for handling. + if (data && data.action === "manage-conversation") { + ( + rpc.invoke( + "chatPanelManageConversation", + data.data, + ) as Promise<{ + kind: "ok" | "error"; + html: string; + switched?: boolean; + }> + ).then( + (res) => { + const renderResult = () => + chatPanel.showInline( + { + type: "html", + content: res.html, + kind: + res.kind === "error" + ? "warning" + : "info", + }, + "conversation", + ); + if (res.switched) { + chatPanel.clear(); + // Replay history then render confirmation + // so it lands below any prior log. + void loadSessionHistory().then(renderResult); + } else { + renderResult(); + } + }, + (e: any) => { + chatPanel.showInline( + { + type: "text", + content: `Conversation command failed: ${e?.message ?? String(e)}`, + kind: "error", + }, + "conversation", + ); + }, + ); + return; + } }, dispatcherConnectionStatus(data) { setConnectionStatus(data.connected); @@ -657,19 +741,26 @@ function attemptConnect() { if (response?.connected) { setConnectionStatus(true); if (!historyLoaded) { - historyLoaded = true; await loadSessionHistory(); + historyLoaded = true; + } else { + // Already loaded earlier; flush any events queued + // since reconnect so they don't stay stuck. + flushPendingDisplayOps(); } } else { setConnectionStatus(false); + flushPendingDisplayOps(); } }) .catch(() => { setConnectionStatus(false); + flushPendingDisplayOps(); }); } async function loadSessionHistory() { + replayDone = false; try { const entries: any[] = (await rpc.invoke("chatPanelGetHistory")) as any; if (!entries || entries.length === 0) return; @@ -699,6 +790,8 @@ async function loadSessionHistory() { chatPanel.resetHistoryAgent(); } catch { // History loading is best-effort + } finally { + flushPendingDisplayOps(); } }