diff --git a/dotnet/autoShell/Services/WindowsWindowService.cs b/dotnet/autoShell/Services/WindowsWindowService.cs index 6c943618b1..160c551b4a 100644 --- a/dotnet/autoShell/Services/WindowsWindowService.cs +++ b/dotnet/autoShell/Services/WindowsWindowService.cs @@ -21,6 +21,7 @@ internal class WindowsWindowService : IWindowService private const uint WM_SYSCOMMAND = 0x112; private const uint SC_MAXIMIZE = 0xF030; private const uint SC_MINIMIZE = 0xF020; + private const uint SW_RESTORE = 9; private struct RECT { @@ -30,40 +31,55 @@ private struct RECT public int Bottom; } + private delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam); + + [DllImport(NativeDlls.User32, SetLastError = true)] + private static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool fAttach); + [DllImport(NativeDlls.User32)] - private static extern bool GetWindowRect(IntPtr hWnd, ref RECT rect); + private static extern bool BringWindowToTop(IntPtr hWnd); [DllImport(NativeDlls.User32)] - private static extern IntPtr GetDesktopWindow(); + private static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam); [DllImport(NativeDlls.User32)] - private static extern bool SetForegroundWindow(IntPtr hWnd); + private static extern IntPtr FindWindowEx(IntPtr hWndParent, IntPtr hWndChildAfter, string lpClassName, string lpWindowName); - [DllImport(NativeDlls.User32, EntryPoint = "SendMessage", SetLastError = true)] - private static extern IntPtr SendMessage(IntPtr hWnd, uint msg, uint wParam, IntPtr lParam); + [DllImport("kernel32.dll")] + private static extern uint GetCurrentThreadId(); [DllImport(NativeDlls.User32)] - private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int x, int y, int cx, int cy, uint uFlags); + private static extern IntPtr GetDesktopWindow(); [DllImport(NativeDlls.User32)] - private static extern bool ShowWindow(IntPtr hWnd, uint nCmdShow); + private static extern IntPtr GetForegroundWindow(); [DllImport(NativeDlls.User32)] - private static extern IntPtr FindWindowEx(IntPtr hWndParent, IntPtr hWndChildAfter, string lpClassName, string lpWindowName); + private static extern bool GetWindowRect(IntPtr hWnd, ref RECT rect); - private delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam); + [DllImport(NativeDlls.User32)] + private static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount); [DllImport(NativeDlls.User32)] - private static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam); + private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId); [DllImport(NativeDlls.User32)] - private static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount); + private static extern bool IsIconic(IntPtr hWnd); [DllImport(NativeDlls.User32)] private static extern bool IsWindowVisible(IntPtr hWnd); + [DllImport(NativeDlls.User32, EntryPoint = "SendMessage", SetLastError = true)] + private static extern IntPtr SendMessage(IntPtr hWnd, uint msg, uint wParam, IntPtr lParam); + [DllImport(NativeDlls.User32)] - private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId); + private static extern bool SetForegroundWindow(IntPtr hWnd); + + [DllImport(NativeDlls.User32)] + private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int x, int y, int cx, int cy, uint uFlags); + + [DllImport(NativeDlls.User32)] + private static extern bool ShowWindow(IntPtr hWnd, uint nCmdShow); #endregion @@ -118,6 +134,57 @@ public void MinimizeWindow(string processName) } } + /// + /// Bring a window to the foreground reliably. Windows blocks + /// SetForegroundWindow from background processes by design (taskbar + /// flash only). The standard workaround is to attach the calling + /// thread's input queue to the current foreground window's thread, + /// during which SetForegroundWindow is permitted. + /// + private static void ForceForeground(IntPtr hWnd) + { + if (hWnd == IntPtr.Zero) return; + + if (IsIconic(hWnd)) + { + ShowWindow(hWnd, SW_RESTORE); + } + + IntPtr foregroundHwnd = GetForegroundWindow(); + uint foregroundThreadId = foregroundHwnd != IntPtr.Zero + ? GetWindowThreadProcessId(foregroundHwnd, out _) + : 0; + uint targetThreadId = GetWindowThreadProcessId(hWnd, out _); + uint currentThreadId = GetCurrentThreadId(); + + bool attachedFg = false; + bool attachedTarget = false; + try + { + if (foregroundThreadId != 0 && foregroundThreadId != currentThreadId) + { + attachedFg = AttachThreadInput(currentThreadId, foregroundThreadId, true); + } + if (targetThreadId != 0 && targetThreadId != currentThreadId && targetThreadId != foregroundThreadId) + { + attachedTarget = AttachThreadInput(currentThreadId, targetThreadId, true); + } + BringWindowToTop(hWnd); + SetForegroundWindow(hWnd); + } + finally + { + if (attachedFg) + { + AttachThreadInput(currentThreadId, foregroundThreadId, false); + } + if (attachedTarget) + { + AttachThreadInput(currentThreadId, targetThreadId, false); + } + } + } + /// public void RaiseWindow(string processName, string executablePath) { @@ -126,7 +193,7 @@ public void RaiseWindow(string processName, string executablePath) { if (p.MainWindowHandle != IntPtr.Zero) { - SetForegroundWindow(p.MainWindowHandle); + ForceForeground(p.MainWindowHandle); Interaction.AppActivate(p.Id); return; } @@ -143,7 +210,7 @@ public void RaiseWindow(string processName, string executablePath) (nint hWnd1, int pid) = FindWindowByTitle(processName); if (hWnd1 != nint.Zero) { - SetForegroundWindow(hWnd1); + ForceForeground(hWnd1); Interaction.AppActivate(pid); } } @@ -228,7 +295,6 @@ public void TileWindows(string processName1, string processName2) uint showWindow = 0x40; // Restore windows first (SetWindowPos won't work on maximized windows) - uint SW_RESTORE = 9; ShowWindow(hWnd1, SW_RESTORE); ShowWindow(hWnd2, SW_RESTORE); diff --git a/ts/packages/agentRpc/src/rpc.ts b/ts/packages/agentRpc/src/rpc.ts index f4b551de92..4d5bc72987 100644 --- a/ts/packages/agentRpc/src/rpc.ts +++ b/ts/packages/agentRpc/src/rpc.ts @@ -62,7 +62,21 @@ export function createRpc< if (f === undefined) { debugError("No call handler", message); } else { - f(...message.args); + // Call handlers are fire-and-forget (no callId), so any + // synchronous throw cannot be reported back to the caller. + // Swallow it here to keep the RPC bus alive — otherwise a + // single bad notify (e.g. cancelCommand after the remote + // dispatcher channel disconnected) would surface as an + // uncaught exception in the host process. + try { + f(...message.args); + } catch (e: any) { + debugError( + "Call handler threw", + message.name, + e?.message ?? e, + ); + } } return; } diff --git a/ts/packages/agentServer/server/src/conversationManager.ts b/ts/packages/agentServer/server/src/conversationManager.ts index a0d75165d4..a7ff64e5c8 100644 --- a/ts/packages/agentServer/server/src/conversationManager.ts +++ b/ts/packages/agentServer/server/src/conversationManager.ts @@ -88,24 +88,57 @@ export async function createConversationManager( idleTimeoutMs: number = DEFAULT_IDLE_TIMEOUT_MS, ): Promise { const conversationsDir = path.join(baseDir, CONVERSATIONS_DIR); - await fs.promises.mkdir(conversationsDir, { recursive: true }); - // Migrate old on-disk layout: "server-sessions/" → "conversations/" + // TODO: deprecate and remove this on-disk migration once enough time has + // passed that no production install still has a "server-sessions/" + // directory hanging around. + // Migrate old on-disk layout: "server-sessions/" → "conversations/". + // IMPORTANT: do this BEFORE creating the destination — otherwise + // `fs.rename` fails with EPERM/EEXIST on Windows when the target + // already exists, silently stranding all historical conversations + // in the old directory. const oldConversationsDir = path.join(baseDir, "server-sessions"); - try { - await fs.promises.rename(oldConversationsDir, conversationsDir); - debugConversation( - `Migrated on-disk directory "server-sessions" → "conversations"`, - ); - } catch (e: any) { - if ( - e?.code !== "ENOENT" && - e?.code !== "EEXIST" && - e?.code !== "EPERM" - ) { + if ( + !fs.existsSync(conversationsDir) && + fs.existsSync(oldConversationsDir) + ) { + try { + await fs.promises.rename(oldConversationsDir, conversationsDir); + debugConversation( + `Migrated on-disk directory "server-sessions" → "conversations"`, + ); + } catch (e: any) { debugConversationErr("Failed to migrate server-sessions dir:", e); } + } else if (fs.existsSync(oldConversationsDir)) { + // Both directories exist — earlier builds raced and pre-created + // the destination. Move stragglers across so users don't lose history. + try { + for (const entry of await fs.promises.readdir(oldConversationsDir, { + withFileTypes: true, + })) { + const src = path.join(oldConversationsDir, entry.name); + const dst = path.join(conversationsDir, entry.name); + if (fs.existsSync(dst)) continue; + try { + await fs.promises.rename(src, dst); + } catch (e: any) { + debugConversationErr(`Failed to migrate ${entry.name}:`, e); + } + } + // Best-effort cleanup; will fail silently if non-empty. + await fs.promises.rmdir(oldConversationsDir).catch(() => undefined); + debugConversation( + `Merged stragglers from "server-sessions" → "conversations"`, + ); + } catch (e: any) { + debugConversationErr( + "Failed to merge server-sessions stragglers:", + e, + ); + } } + await fs.promises.mkdir(conversationsDir, { recursive: true }); // Migrate old metadata filename: "sessions.json" → "conversations.json" const oldMetadataPath = path.join(conversationsDir, "sessions.json"); const newMetadataPath = path.join(conversationsDir, METADATA_FILE); @@ -127,6 +160,11 @@ export async function createConversationManager( const conversations = new Map(); + // Single-flight lock for "auto-create the default conversation". Two + // concurrent first-connects could both observe "no conversations exist" + // and race; this serializes them so only one create happens. + let defaultCreateP: Promise | undefined; + // Load persisted metadata await loadMetadata(); @@ -315,6 +353,23 @@ export async function createConversationManager( } } + /** + * Throw if `name` collides (case-insensitive) with another existing + * conversation. `selfId` is excluded from the check so renaming a + * conversation to its current name is a no-op rather than an error. + */ + function ensureNameAvailable(name: string, selfId?: string): void { + const norm = name.trim().toLowerCase(); + for (const [id, record] of conversations) { + if (id === selfId) continue; + if (record.name.trim().toLowerCase() === norm) { + throw new Error( + `A conversation named "${record.name}" already exists. Pick a different name.`, + ); + } + } + } + // Sweep orphaned ephemeral conversations left behind by unclean CLI exits { const toSweep: string[] = []; @@ -350,6 +405,7 @@ export async function createConversationManager( const manager: ConversationManager = { async createConversation(name: string): Promise { validateConversationName(name); + ensureNameAvailable(name); const conversationId = randomUUID(); const createdAt = new Date().toISOString(); const record: ConversationRecord = { @@ -391,9 +447,23 @@ export async function createConversationManager( if (resolved !== undefined) { return resolved; } - // No conversations exist — auto-create a default - const info = await manager.createConversation("default"); - return info.conversationId; + // No conversations exist — auto-create a default. Serialize so two + // concurrent first-connects don't both try to create "default" and + // race the duplicate-name check. + if (defaultCreateP === undefined) { + defaultCreateP = (async () => { + // Re-check inside the critical section in case another caller + // raced us between the early check above and acquiring the lock. + const existing = + getDefaultConversationId() ?? getAnyConversationId(); + if (existing !== undefined) return existing; + const info = await manager.createConversation("default"); + return info.conversationId; + })().finally(() => { + defaultCreateP = undefined; + }); + } + return defaultCreateP; }, async prewarmMostRecentConversation(): Promise { @@ -440,7 +510,7 @@ export async function createConversationManager( // Notify existing clients that a new client has joined if (sharedDispatcher.clientCount > 1 && dispatcher.connectionId) { sharedDispatcher.broadcastSystemMessage( - `[A new client has joined this conversation. You are connected to '${record.name}'.]`, + `[A new client has joined this conversation.]`, dispatcher.connectionId, ); } @@ -474,7 +544,7 @@ export async function createConversationManager( // Notify remaining clients before this client leaves if (record.sharedDispatcher.clientCount > 1) { record.sharedDispatcher.broadcastSystemMessage( - `[A client has left this conversation. You remain connected to '${record.name}'.]`, + `[A client has left this conversation.]`, connectionId, ); } @@ -518,6 +588,7 @@ export async function createConversationManager( if (record === undefined) { throw new Error(`Conversation not found: ${conversationId}`); } + ensureNameAvailable(newName, conversationId); record.name = newName; await saveMetadata(); debugConversation( diff --git a/ts/packages/agentServer/server/src/server.ts b/ts/packages/agentServer/server/src/server.ts index df111361ca..1fddd4fa16 100644 --- a/ts/packages/agentServer/server/src/server.ts +++ b/ts/packages/agentServer/server/src/server.ts @@ -434,4 +434,13 @@ process.on("uncaughtException", (err) => { // Log but do not exit for non-fatal errors. }); -await main(); +await main().catch((err: any) => { + if (err?.code === "ERR_INSTANCE_LOCKED") { + // Friendly, single-line message — no stack trace for this expected + // case (another shell/server already owns the profile directory). + console.error(`\n[agent-server] ${err.message}\n`); + process.exit(1); + } + console.error("[agent-server] Fatal startup error:", err); + process.exit(1); +}); diff --git a/ts/packages/agentServer/server/src/sharedDispatcher.ts b/ts/packages/agentServer/server/src/sharedDispatcher.ts index f6ac8258db..5a90b3d4c6 100644 --- a/ts/packages/agentServer/server/src/sharedDispatcher.ts +++ b/ts/packages/agentServer/server/src/sharedDispatcher.ts @@ -28,6 +28,7 @@ import registerDebug from "debug"; const debugConnect = registerDebug("agent-server:connect"); const debugClientIOError = registerDebug("agent-server:clientIO:error"); const debugInteraction = registerDebug("agent-server:interaction"); +const debugCommand = registerDebug("agent-server:command"); type ClientRecord = { clientIO: ClientIO; @@ -284,7 +285,30 @@ export async function createSharedDispatcher( const origAppendDisplay = orig.appendDisplay.bind(orig); orig.appendDisplay = (message, mode, ...rest) => { origAppendDisplay(message, mode, ...rest); - log.logAppendDisplay(message, mode); + // Don't persist transient status messages (mode === "temporary"). + // They're meant to be ephemeral indicators (e.g. "Executing + // action ...", reasoning's "Thinking..." stream). Persisting them + // causes: + // - replayed bubbles for late-joining peers / reconnects + // - DisplayLog growth proportional to streaming token count + // - apparent "duplicate" bubbles when a stale temporary + // re-appears alongside the real reply + if (mode !== "temporary") { + log.logAppendDisplay(message, mode); + log.saveQueued(); + } + }; + + const origSetDisplayInfo = orig.setDisplayInfo.bind(orig); + orig.setDisplayInfo = ( + requestId, + source, + actionIndex, + action, + ...rest + ) => { + origSetDisplayInfo(requestId, source, actionIndex, action, ...rest); + log.logSetDisplayInfo(requestId, source, actionIndex, action); log.saveQueued(); }; } @@ -352,6 +376,88 @@ export async function createSharedDispatcher( shared.cancelInteraction(interactionId); }; + // Wrap processCommand so each completed request logs a + // command-result entry into the DisplayLog (carrying its + // metrics). This lets history replay re-render timing data + // exactly the way live commandComplete does. + const origProcessCommand = + dispatcher.processCommand.bind(dispatcher); + dispatcher.processCommand = async ( + command: any, + clientRequestId?: any, + attachments?: any, + processOptions?: any, + ) => { + const result = await origProcessCommand( + command, + clientRequestId, + attachments, + processOptions, + ); + try { + context.displayLog.logCommandResult( + { + connectionId, + // The actual server-side requestId UUID is + // generated inside processCommand and not + // exposed to the wrapper, so leave it empty; + // consumers correlate via clientRequestId. + requestId: "", + clientRequestId, + }, + result?.metrics, + result?.tokenUsage, + ); + context.displayLog.saveQueued(); + } catch { + // best effort + } + try { + // Notify peer panels (other clients sharing this + // session) that the command finished so they can + // clear lingering temporary status messages and + // apply timing metrics. We deliberately skip the + // originator: it already gets the result back via + // the resolved processCommand RPC promise, and a + // duplicate notify would run completion twice (in + // the VS Code webview that produces a stray + // "⚠ Cancelled" bubble after the first pass cleared + // the request mapping). + let sent = 0; + for (const [peerId, peerRecord] of clients) { + if (peerId === connectionId) continue; + if (peerRecord.filter && peerId !== connectionId) { + // Filtered clients only receive their own + // events; don't leak peer completions. + continue; + } + try { + peerRecord.clientIO.notify( + { + connectionId, + requestId: "", + clientRequestId, + }, + "commandComplete", + { result: result ?? null }, + "system", + ); + sent++; + } catch (e) { + debugClientIOError( + `commandComplete notify failed for ${peerId}: ${e}`, + ); + } + } + debugCommand( + `commandComplete broadcast: connectionId=${connectionId} clientRequestId=${clientRequestId} sent=${sent} clients=${clients.size}`, + ); + } catch (e) { + debugCommand(`commandComplete broadcast failed: ${e}`); + } + return result; + }; + return dispatcher; }, respondToInteraction(response: PendingInteractionResponse): void { diff --git a/ts/packages/agents/code/package.json b/ts/packages/agents/code/package.json index dc0542e57e..a5a437f052 100644 --- a/ts/packages/agents/code/package.json +++ b/ts/packages/agents/code/package.json @@ -18,12 +18,13 @@ }, "scripts": { "asc": "asc -i ./src/codeActionsSchema.ts -o ./dist/codeSchema.pas.json -t CodeActions -a CodeActivity", - "asc:all": "concurrently npm:asc npm:asc:debug npm:asc:display npm:asc:general npm:asc:editor npm:asc:workbench npm:asc:extension", + "asc:all": "concurrently npm:asc npm:asc:debug npm:asc:display npm:asc:general npm:asc:editor npm:asc:workbench npm:asc:extension npm:asc:vscode-shell", "asc:debug": "asc -i ./src/vscode/debugActionsSchema.ts -o ./dist/debugSchema.pas.json -t CodeDebugActions", "asc:display": "asc -i ./src/vscode/displayActionsSchema.ts -o ./dist/displaySchema.pas.json -t CodeDisplayActions", "asc:editor": "asc -i ./src/vscode/editorCodeActionsSchema.ts -o ./dist/editorSchema.pas.json -t EditorCodeActions", "asc:extension": "asc -i ./src/vscode/extensionsActionsSchema.ts -o ./dist/extensionSchema.pas.json -t CodeWorkbenchExtensionActions", "asc:general": "asc -i ./src/vscode/generalActionsSchema.ts -o ./dist/generalSchema.pas.json -t CodeGeneralActions", + "asc:vscode-shell": "asc -i ./src/vscode/vscodeConversationActionsSchema.ts -o ./dist/vscodeShellSchema.pas.json -t VSCodeConversationActions", "asc:workbench": "asc -i ./src/vscode/workbenchCommandActionsSchema.ts -o ./dist/workbenchSchema.pas.json -t CodeWorkbenchActions", "build": "concurrently npm:tsc npm:asc:all", "clean": "rimraf --glob dist *.tsbuildinfo *.done.build.log", @@ -142,6 +143,19 @@ "dist/workbenchSchema.pas.json" ] } + }, + "asc:vscode-shell": { + "dependsOn": [ + "@typeagent/action-schema-compiler#tsc" + ], + "files": { + "inputGlobs": [ + "src/vscode/vscodeConversationActionsSchema.ts" + ], + "outputGlobs": [ + "dist/vscodeShellSchema.pas.json" + ] + } } } } diff --git a/ts/packages/agents/code/src/codeActionHandler.ts b/ts/packages/agents/code/src/codeActionHandler.ts index 9a187a860c..9460ffeba8 100644 --- a/ts/packages/agents/code/src/codeActionHandler.ts +++ b/ts/packages/agents/code/src/codeActionHandler.ts @@ -16,10 +16,35 @@ import { fileURLToPath } from "url"; import os from "os"; import registerDebug from "debug"; import chalk from "chalk"; -import { createActionResultFromError } from "@typeagent/agent-sdk/helpers/action"; +import { + createActionResult, + createActionResultFromError, +} from "@typeagent/agent-sdk/helpers/action"; const debug = registerDebug("typeagent:code"); +// Shared WebSocket server that bridges this code agent to the Coda VS Code +// extension (ts/packages/coda) on port 8082. Created on first enable, closed +// when the last session disables the code agent. Storing it per-session caused +// "No websocket connection" errors when an action ran on a session different +// from the one that originally created the server (e.g. after schema enable on +// a different conversation), and also masked EADDRINUSE failures from a second +// bind attempt on port 8082. +let sharedWebSocketServer: CodeAgentWebSocketServer | undefined; +let sharedWebSocketRefCount = 0; +const sharedPendingCalls: Map< + number, + { + resolve: (value?: undefined) => void; + context?: ActionContext | undefined; + } +> = new Map(); +// Global call-id counter. The pending-calls map is module-scoped (one +// websocket server is shared across all sessions), so the id space must +// also be global — per-session counters would collide on 0,1,2,... and +// route a response to the wrong session's pending call. +let nextSharedCallId = 0; + export function instantiate(): AppAgent { return { initializeAgentContext: initializeCodeContext, @@ -31,12 +56,11 @@ export function instantiate(): AppAgent { type CodeActionContext = { enabled: Set; webSocketServer?: CodeAgentWebSocketServer | undefined; - nextCallId: number; pendingCall: Map< number, { resolve: (value?: undefined) => void; - context: ActionContext; + context?: ActionContext | undefined; } >; }; @@ -45,7 +69,6 @@ async function initializeCodeContext(): Promise { return { enabled: new Set(), webSocketServer: undefined, - nextCallId: 0, pendingCall: new Map(), }; } @@ -57,28 +80,32 @@ async function updateCodeContext( ): Promise { const agentContext = context.agentContext; if (enable) { - agentContext.enabled.add(schemaName); - if (agentContext.webSocketServer?.isConnected()) { + if (agentContext.enabled.has(schemaName)) { return; } + if (agentContext.enabled.size === 0) { + sharedWebSocketRefCount++; + } + agentContext.enabled.add(schemaName); - if (!context.agentContext.webSocketServer) { + if (!sharedWebSocketServer) { + // TODO: stop hardcoding the port. The dispatcher should hand each + // agent a free port at initialize time so multiple TypeAgent + // installs / sessions on one host can't collide on 8082. const port = parseInt(process.env["CODE_WEBSOCKET_PORT"] || "8082"); - const webSocketServer = new CodeAgentWebSocketServer(port); - agentContext.webSocketServer = webSocketServer; - agentContext.pendingCall = new Map(); + sharedWebSocketServer = new CodeAgentWebSocketServer(port); - webSocketServer.onMessage = (message: string) => { + sharedWebSocketServer.onMessage = (message: string) => { try { const data = JSON.parse(message) as WebSocketMessageV2; if (data.id !== undefined && data.result !== undefined) { - const pendingCall = agentContext.pendingCall.get( + const pendingCall = sharedPendingCalls.get( Number(data.id), ); if (pendingCall) { - agentContext.pendingCall.delete(Number(data.id)); + sharedPendingCalls.delete(Number(data.id)); const { resolve, context } = pendingCall; if (context?.actionIO) { context.actionIO.setDisplay(data.result); @@ -90,15 +117,21 @@ async function updateCodeContext( debug("Error parsing WebSocket message:", error); } }; - } else { - agentContext.enabled.delete(schemaName); - if (agentContext.enabled.size === 0) { - const webSocketServer = context.agentContext.webSocketServer; - if (webSocketServer) { - webSocketServer.close(); - } - - delete context.agentContext.webSocketServer; + } + agentContext.webSocketServer = sharedWebSocketServer; + agentContext.pendingCall = sharedPendingCalls; + } else { + if (!agentContext.enabled.has(schemaName)) { + return; + } + agentContext.enabled.delete(schemaName); + if (agentContext.enabled.size === 0) { + agentContext.webSocketServer = undefined; + sharedWebSocketRefCount = Math.max(0, sharedWebSocketRefCount - 1); + if (sharedWebSocketRefCount === 0 && sharedWebSocketServer) { + sharedWebSocketServer.close(); + sharedWebSocketServer = undefined; + sharedPendingCalls.clear(); } } } @@ -182,7 +215,7 @@ async function sendPingToCodaExtension( const server = agentContext.webSocketServer; if (!server || !server.isConnected()) return false; - const callId = agentContext.nextCallId++; + const callId = nextSharedCallId++; return new Promise((resolve) => { const timeout = setTimeout(() => { agentContext.pendingCall.delete(callId); @@ -224,7 +257,7 @@ export async function getActiveFileFromVSCode( return undefined; } - const callId = agentContext.nextCallId++; + const callId = nextSharedCallId++; return new Promise((resolve) => { // Hard timeout so we never hang @@ -258,6 +291,42 @@ export async function getActiveFileFromVSCode( }); } +import { VSCodeConversationActions } from "./vscode/vscodeConversationActionsSchema.js"; + +async function executeConversationAction( + action: VSCodeConversationActions, + context: ActionContext, +) { + context.actionIO.takeAction("vscode-shell-action" as any, { + actionName: action.actionName, + parameters: action.parameters, + }); + switch (action.actionName) { + case "newConversation": + return createActionResult( + action.parameters.name + ? `Created new conversation "${action.parameters.name}".` + : "Creating a new conversation.", + ); + case "renameConversation": + return createActionResult( + `Renamed current conversation to "${action.parameters.newName}".`, + ); + case "switchConversation": + return createActionResult( + action.parameters.name + ? `Switched to conversation "${action.parameters.name}".` + : "Switching conversation.", + ); + default: { + const _exhaustive: never = action; + throw new Error( + `Unhandled conversation action: ${(_exhaustive as VSCodeConversationActions).actionName}`, + ); + } + } +} + async function executeCodeAction( action: AppAction, context: ActionContext, @@ -267,6 +336,17 @@ async function executeCodeAction( return undefined; } + // Conversation-management actions (code-vscode-shell sub-schema) are + // handled locally and routed back to the originating extension webview + // via takeAction. All other code sub-schemas are forwarded to the Coda + // VS Code extension over the WebSocket bridge below. + if (action.schemaName === "code-vscode-shell") { + return executeConversationAction( + action as VSCodeConversationActions, + context, + ); + } + const agentContext = context.sessionContext.agentContext; const webSocketServer = agentContext.webSocketServer; @@ -280,10 +360,25 @@ async function executeCodeAction( ); } - const callId = agentContext.nextCallId++; + const callId = nextSharedCallId++; return new Promise((resolve) => { + const timeoutMs = 5000; + const timeoutHandle = setTimeout(() => { + if (agentContext.pendingCall.has(callId)) { + agentContext.pendingCall.delete(callId); + if (context.actionIO) { + context.actionIO.setDisplay( + `No connected coda extension handled action "${action.actionName}". If multiple VS Code windows are open, reload the others (Ctrl+Shift+P → Developer: Reload Window) so they pick up the latest coda bundle.`, + ); + } + resolve(undefined); + } + }, timeoutMs); agentContext.pendingCall.set(callId, { - resolve, + resolve: (value?: undefined) => { + clearTimeout(timeoutHandle); + resolve(value); + }, context, }); webSocketServer.broadcast( diff --git a/ts/packages/agents/code/src/codeActionsSchema.ts b/ts/packages/agents/code/src/codeActionsSchema.ts index b38f25ce48..21f3f4cade 100644 --- a/ts/packages/agents/code/src/codeActionsSchema.ts +++ b/ts/packages/agents/code/src/codeActionsSchema.ts @@ -5,7 +5,9 @@ export type CodeActions = | ChangeColorThemeAction | SplitEditorAction | ChangeEditorLayoutAction - | NewFileAction; + | NewCodeFileAction + | NewMarkdownFileAction + | NewTextFileAction; export type CodeActivity = LaunchVSCodeAction; @@ -31,11 +33,15 @@ export type ColorTheme = | "Abyss" | "Default High Contrast Light"; -// Change the color scheme of the editor +// Change the VS Code editor's color theme. +// +// Example: +// User: change my vscode color theme to Monokai +// Agent: { actionName: "changeColorScheme", parameters: { theme: "Monokai" } } export type ChangeColorThemeAction = { actionName: "changeColorScheme"; parameters: { - // e.g., "Light+", "Dark+", "Monokai", "Solarized Dark", "Solarized Light" + // The VS Code color theme name, e.g. "Monokai", "Solarized Dark", "Dark+". theme: ColorTheme; }; }; @@ -43,26 +49,24 @@ export type ChangeColorThemeAction = { export type SplitDirection = "right" | "left" | "up" | "down"; export type EditorPosition = "first" | "last" | "active"; -// ACTION: Split an editor window into multiple panes showing the same file or different files side-by-side. -// This creates a new editor pane (split view) for working with multiple files simultaneously. -// USE THIS for: "split editor", "split the editor with X", "duplicate this editor to the right", "split X" +// Split an editor window into multiple panes showing the same file or different files side-by-side. +// +// Example: +// User: split editor to the right +// Agent: { actionName: "splitEditor", parameters: { direction: "right" } } // -// Examples: -// - "split editor to the right" → splits active editor -// - "split the first editor" → splits leftmost editor -// - "split app.tsx to the left" → finds editor showing app.tsx and splits it -// - "split the last editor down" → splits rightmost editor downward -// - "split the editor with utils.ts" → finds editor showing utils.ts and splits it +// Example: +// User: split the editor with utils.ts +// Agent: { actionName: "splitEditor", parameters: { fileName: "utils.ts" } } export type SplitEditorAction = { actionName: "splitEditor"; parameters: { // Direction to split: "right", "left", "up", "down". Only include if user specifies direction. direction?: SplitDirection; - // Which editor to split by position. Use "first" for leftmost editor, "last" for rightmost, "active" for current editor, or a number (0-based index). + // Which editor to split by position: "first" (leftmost), "last" (rightmost), "active" (current), + // or a 0-based index. editorPosition?: EditorPosition | number; // Which editor to split by file name. Extract the file name or pattern from user request. - // Examples: "app.tsx", "main.py", "utils", "codeActionHandler" - // Use this when user says "split X" or "split the editor with X" where X is a file name. fileName?: string; }; }; @@ -78,25 +82,59 @@ export type ChangeEditorLayoutAction = { }; }; -export type CodeLanguage = - | "plaintext" +export type CodeFileLanguage = | "html" + | "css" + | "json" | "python" | "javaScript" - | "typeScript" - | "markdown"; + | "typeScript"; + +// Create a new code file (Python, JavaScript, TypeScript, HTML, CSS, JSON, etc.) in the editor. +// +// Example: +// User: create a new typescript file called scratch.ts with a hello world function +// Agent: { actionName: "newCodeFile", parameters: { fileName: "scratch.ts", +// language: "typeScript", content: "function helloWorld() { console.log('Hello, World!'); }\n" } } +export type NewCodeFileAction = { + actionName: "newCodeFile"; + parameters: { + // The file name; if omitted or empty, "untitled" is used. + fileName: string | "untitled"; + // The programming language of the file. + language: CodeFileLanguage; + // Initial file content; empty string for an empty scratch file. + content: string; + }; +}; -// Create a new file, this is not same as opening a file or finding a file in the workspace -export type NewFileAction = { - actionName: "newFile"; +// Create a new Markdown (.md) file in the editor. +// +// Example: +// User: create a markdown file with a list of top ten AI papers +// Agent: { actionName: "newMarkdownFile", parameters: { fileName: "papers.md", +// content: "# Top 10 AI Papers\n..." } } +export type NewMarkdownFileAction = { + actionName: "newMarkdownFile"; + parameters: { + // The file name; if omitted or empty, "untitled" is used. + fileName: string | "untitled"; + // Initial Markdown content; empty string for an empty scratch file. + content: string; + }; +}; + +// Create a new plain text (.txt) file in the editor. +// +// Example: +// User: make a new text file called notes +// Agent: { actionName: "newTextFile", parameters: { fileName: "notes.txt", content: "" } } +export type NewTextFileAction = { + actionName: "newTextFile"; parameters: { - fileName: string; // If not filename is provided, "Untitled" is used - language: CodeLanguage; // plaintext, html, python, javaScript, typeScript, markdown etc. - // Content of the new file is based on language and user input, default is "Hello, World!", - // for ex: Create a markdown file with a list of top ten AI papers and links should fill - // the content field with a list of papers and their links from - // arxiv. If the user asks to create a python file with code to merge two arrays of strings - // the content field should be filled with the python function to merge the arrays + // The file name; if omitted or empty, "untitled" is used. + fileName: string | "untitled"; + // Initial text content; empty string for an empty scratch file. content: string; }; }; diff --git a/ts/packages/agents/code/src/codeManifest.json b/ts/packages/agents/code/src/codeManifest.json index 49284f9799..77a5536fd2 100644 --- a/ts/packages/agents/code/src/codeManifest.json +++ b/ts/packages/agents/code/src/codeManifest.json @@ -30,7 +30,7 @@ }, "code-general": { "schema": { - "description": "Code agent that helps you perform actions like finding a file, go to a symbol or a line in a file, Open new vscode window, show the command palette, user settings json etc.", + "description": "Code agent that helps you navigate within an already-open editor: find a file in the workspace, go to a symbol or line, open a new vscode window, show the command palette, open user settings json, etc.", "originalSchemaFile": "./vscode/generalActionsSchema.ts", "schemaFile": "../dist/generalSchema.pas.json", "schemaType": "CodeGeneralActions" @@ -38,7 +38,7 @@ }, "code-editor": { "schema": { - "description": "Code agent that helps you perform vscode editor actions like create a new file, go to a reference, reveal a declaration, clipboard copy or paste, add a comment line etc.", + "description": "Code agent for in-editor refactor actions: insert/move cursor, fix code problems, generate code with Copilot, save current/all files.", "originalSchemaFile": "./vscode/editorCodeActionsSchema.ts", "schemaFile": "../dist/editorSchema.pas.json", "schemaType": "EditorCodeActions" @@ -59,6 +59,15 @@ "schemaFile": "../dist/extensionSchema.pas.json", "schemaType": "CodeWorkbenchExtensionActions" } + }, + "code-vscode-shell": { + "defaultEnabled": false, + "schema": { + "description": "Manage TypeAgent Shell conversations (chat tabs in the TypeAgent VS Code Shell extension): create a new conversation, rename the current conversation, or switch to an existing conversation by name.", + "originalSchemaFile": "./vscode/vscodeConversationActionsSchema.ts", + "schemaFile": "../dist/vscodeShellSchema.pas.json", + "schemaType": "VSCodeConversationActions" + } } } } diff --git a/ts/packages/agents/code/src/codeSchema.agr b/ts/packages/agents/code/src/codeSchema.agr index f415ac71fc..c4ddd11ce5 100644 --- a/ts/packages/agents/code/src/codeSchema.agr +++ b/ts/packages/agents/code/src/codeSchema.agr @@ -4,16 +4,26 @@ import { CodeActions } from "./codeActionsSchema.ts"; // Start rule - all available actions - : CodeActions = | | | | ; + : CodeActions = | | | | | | | | ; // Main action rules = ('change' | 'switch' | 'set') ('to' | 'the')? $(theme:string) ('color scheme' | 'theme') -> { actionName: "changeColorScheme", parameters: { theme: theme } }; +// Theme name AFTER 'to': "change my [vscode] color theme to Monokai", "set the editor theme to Solarized Dark" + = ('change' | 'switch' | 'set') ('my' | 'the')? ('vscode' | 'code' | 'editor')? ('color scheme' | 'color theme' | 'theme') 'to' $(theme:string) -> { actionName: "changeColorScheme", parameters: { theme: theme } }; + = 'split' ('the'? 'editor')? $(direction:string) ('and'? ('open' | 'put' | 'place'))? $(fileName:string) ('in' | 'on' | 'to') 'the'? $(editorPosition:string) ('side' | 'panel' | 'half')? -> { actionName: "splitEditor", parameters: { direction: direction, editorPosition: editorPosition, fileName: fileName } }; +// Direction-only split for the active editor: "split the editor to the right", "split editor right" + = 'split' ('the')? 'editor' ('to' 'the'?)? $(direction:string) ('side' | 'panel' | 'half')? -> { actionName: "splitEditor", parameters: { direction: direction } }; + = ('change' | 'switch' | 'set' | 'go back') ('to' | 'the'? 'editor' 'to')? ('a'? | 'up')? $(columnCount:string) ('column' | 'pane') ('layout' | 'view' | 'mode')? -> { actionName: "changeEditorLayout", parameters: { columnCount: columnCount } }; - = ('create' | 'make') ('a'? 'new')? $(language:string)? 'file' ('called' | 'named')? $(fileName:string) ('with' | 'and' 'add')? $(content:string)? -> { actionName: "newFile", parameters: { fileName: fileName, language: language, content: content } }; + = ('create' | 'make') ('a'? 'new')? $(language:string) 'file' ('called' | 'named')? $(fileName:string) ('with' | 'and' 'add')? $(content:string)? -> { actionName: "newCodeFile", parameters: { fileName: fileName, language: language, content: content } }; + + = ('create' | 'make') ('a'? 'new')? ('markdown' | 'md') 'file' ('called' | 'named')? $(fileName:string) ('with' | 'and' 'add')? $(content:string)? -> { actionName: "newMarkdownFile", parameters: { fileName: fileName, content: content } }; + + = ('create' | 'make') ('a'? 'new')? ('text' | 'txt' | 'plain') 'file' ('called' | 'named')? $(fileName:string) ('with' | 'and' 'add')? $(content:string)? -> { actionName: "newTextFile", parameters: { fileName: fileName, content: content } }; = ('launch' | 'open' | 'start') ('vs code' | 'vscode' | 'code') ('in' $(mode:string) 'mode')? ('at' | 'with' | 'for')? $(path:string)? -> { actionName: "launchVSCode", parameters: { path: path } }; diff --git a/ts/packages/agents/code/src/codeSchema.tests.json b/ts/packages/agents/code/src/codeSchema.tests.json index 3d8e3ecd82..5b497c5b33 100644 --- a/ts/packages/agents/code/src/codeSchema.tests.json +++ b/ts/packages/agents/code/src/codeSchema.tests.json @@ -3,7 +3,7 @@ "request": "Can you create a new Python file called main.py with a basic hello world function?", "schemaName": "codeSchema", "action": { - "actionName": "newFile", + "actionName": "newCodeFile", "parameters": {} } }, @@ -11,7 +11,7 @@ "request": "Make a new JavaScript file utils.js and add some helper functions for string manipulation", "schemaName": "codeSchema", "action": { - "actionName": "newFile", + "actionName": "newCodeFile", "parameters": {} } }, @@ -19,7 +19,7 @@ "request": "I need a new CSS file styles.css - just put some basic reset styles in there", "schemaName": "codeSchema", "action": { - "actionName": "newFile", + "actionName": "newCodeFile", "parameters": {} } }, @@ -27,7 +27,7 @@ "request": "Create config.json with some sample configuration settings", "schemaName": "codeSchema", "action": { - "actionName": "newFile", + "actionName": "newCodeFile", "parameters": {} } }, diff --git a/ts/packages/agents/code/src/vscode/editorCodeActionsSchema.ts b/ts/packages/agents/code/src/vscode/editorCodeActionsSchema.ts index 38126e23a2..5bc173f969 100644 --- a/ts/packages/agents/code/src/vscode/editorCodeActionsSchema.ts +++ b/ts/packages/agents/code/src/vscode/editorCodeActionsSchema.ts @@ -13,7 +13,7 @@ export type EditorCodeActions = | EditorActionSaveCurrentFile | EditorActionSaveAllFiles; -// Action to create a new file in the editor +// @deprecated Use `NewCodeFileAction`, `NewMarkdownFileAction`, or `NewTextFileAction` instead. export type EditorActionCreateFile = { actionName: "createFile"; parameters: { diff --git a/ts/packages/agents/code/src/vscode/vscodeConversationActionsSchema.ts b/ts/packages/agents/code/src/vscode/vscodeConversationActionsSchema.ts new file mode 100644 index 0000000000..4c084a7b65 --- /dev/null +++ b/ts/packages/agents/code/src/vscode/vscodeConversationActionsSchema.ts @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export type VSCodeConversationActions = + | NewConversationAction + | RenameConversationAction + | SwitchConversationAction; + +// Create a brand-new TypeAgent Shell conversation (a new chat tab in the +// TypeAgent Shell VS Code extension) and switch the current tab to it. +// +// Example: +// User: start a new conversation +// Agent: { actionName: "newConversation", parameters: {} } +// +// Example: +// User: new conversation called "design review" +// Agent: { actionName: "newConversation", parameters: { name: "design review" } } +export type NewConversationAction = { + actionName: "newConversation"; + parameters: { + // Optional display name for the new conversation. If omitted, + // the user will be prompted in the extension UI. + name?: string; + }; +}; + +// Rename the TypeAgent Shell conversation that is currently active in +// this chat tab. +// +// Example: +// User: rename this conversation to "vscode shell PR" +// Agent: { actionName: "renameConversation", parameters: { newName: "vscode shell PR" } } +export type RenameConversationAction = { + actionName: "renameConversation"; + parameters: { + // The new display name to apply to the current conversation. + newName: string; + }; +}; + +// Switch the current TypeAgent Shell chat tab to a different existing +// conversation, identified by its display name. +// +// Example: +// User: switch to the "design review" conversation +// Agent: { actionName: "switchConversation", parameters: { name: "design review" } } +// +// Example: +// User: switch conversation +// Agent: { actionName: "switchConversation", parameters: {} } +export type SwitchConversationAction = { + actionName: "switchConversation"; + parameters: { + // The display name of the conversation to switch to. If omitted, + // the user will be shown a picker in the extension UI. + name?: string; + }; +}; diff --git a/ts/packages/agents/desktop/src/actionsSchema.ts b/ts/packages/agents/desktop/src/actionsSchema.ts index 3e5179df6c..589be05961 100644 --- a/ts/packages/agents/desktop/src/actionsSchema.ts +++ b/ts/packages/agents/desktop/src/actionsSchema.ts @@ -75,7 +75,11 @@ export type MinimizeWindowAction = { }; }; -// Sets focus to a program window on a Windows Desktop +// Sets focus to a program window on a Windows Desktop. +// +// Example: +// User: switch to Chrome +// Agent: { actionName: "SwitchTo", parameters: { name: "Chrome" } } export type SwitchToWindowAction = { actionName: "SwitchTo"; parameters: { @@ -135,11 +139,23 @@ export type ChangeThemeModeAction = { }; }; -// Applies a Windows theme by name (e.g. "Captured Motion", "Glow", "Sunrise") or file path. Use this when the user wants to switch to a specific named theme. +// Apply a Windows desktop theme by name (e.g. "Captured Motion", "Glow", "Sunrise") or .theme +// file path. Affects the Windows wallpaper / sounds / cursors / window chrome. +// +// Example: +// User: change my windows theme to Glow +// Agent: { actionName: "ApplyTheme", parameters: { filePath: "Glow", themeName: "Glow" } } export type ApplyThemeAction = { actionName: "ApplyTheme"; parameters: { - filePath: string; // The theme name or .theme file path to apply (use "previous" to revert) + // The Windows theme name or .theme file path to apply (use + // "previous" to revert). + filePath: string; + // Optional theme name parroted by the LLM when the user mentions a + // specific theme; never read by the action — exists only to give the + // model a slot for the name so it doesn't squeeze a VS Code editor + // theme into `filePath`. + themeName?: string; }; }; diff --git a/ts/packages/agents/github-cli/src/github-cliActionHandler.ts b/ts/packages/agents/github-cli/src/github-cliActionHandler.ts index 3cfc899347..bff7955fdc 100644 --- a/ts/packages/agents/github-cli/src/github-cliActionHandler.ts +++ b/ts/packages/agents/github-cli/src/github-cliActionHandler.ts @@ -137,6 +137,11 @@ function buildArgs( if (p.repo) args.push("--repo", String(p.repo)); return args; } + case "issueDelete": { + const args = ["issue", "delete", String(p.number), "--yes"]; + if (p.repo) args.push("--repo", String(p.repo)); + return args; + } case "issueReopen": { const args = ["issue", "reopen"]; if (p.number) args.push(String(p.number)); @@ -223,6 +228,11 @@ function buildArgs( if (p.branch) args.push("--branch", String(p.branch)); return args; } + case "prChecks": { + const args = ["pr", "checks", String(p.number)]; + if (p.repo) args.push("--repo", String(p.repo)); + return args; + } // ── Project ── case "projectCreate": { @@ -399,6 +409,12 @@ function buildArgs( if (p.color) args.push("--color", String(p.color)); return args; } + case "issueAddLabel": { + const args = ["issue", "edit", String(p.number)]; + if (p.label) args.push("--add-label", String(p.label)); + if (p.repo) args.push("--repo", String(p.repo)); + return args; + } case "licensesView": return ["repo", "license", "view"]; case "previewExecute": { @@ -433,6 +449,22 @@ function buildArgs( } case "statusPrint": return ["status"]; + case "myAssignedIssues": { + const limit = p.limit ?? 20; + // gh search issues --assignee @me --state open --json … + return [ + "search", + "issues", + "--assignee", + "@me", + "--state", + "open", + "--limit", + String(limit), + "--json", + "number,title,url,repository,state,updatedAt,labels", + ]; + } case "variableCreate": { const args = ["variable", "set"]; if (p.name) args.push(String(p.name)); @@ -681,6 +713,30 @@ function formatListResults( .join("\n"); } + // Issues assigned to the current user (gh search issues --assignee @me) + if (actionName === "myAssignedIssues" && "number" in items[0]) { + return items + .map((it) => { + const repo = it.repository as + | Record + | undefined; + const repoFull = + (repo?.nameWithOwner as string | undefined) ?? + (repo?.name as string | undefined) ?? + ""; + const labels = Array.isArray(it.labels) + ? (it.labels as Record[]) + .map((l) => l.name) + .filter(Boolean) + .join(", ") + : ""; + const labelStr = labels ? ` _[${labels}]_` : ""; + const repoPrefix = repoFull ? `${repoFull} ` : ""; + return `- ${repoPrefix}[#${it.number} ${it.title}](${it.url})${labelStr}`; + }) + .join("\n"); + } + // Search repos if (actionName === "searchRepos" && "fullName" in items[0]) { return items @@ -714,6 +770,10 @@ function getMutationSuccessMessage( return `✅ Checked out PR **#${p.number}** locally.`; case "issueClose": return `✅ Closed issue **#${p.number}**.`; + case "issueDelete": + return `🗑️ Deleted issue **#${p.number}**.`; + case "issueAddLabel": + return `🏷️ Added label **${p.label}** to issue **#${p.number}**.`; case "issueReopen": return `✅ Reopened issue **#${p.number}**.`; case "prClose": @@ -740,6 +800,48 @@ async function executeAction( try { const output = await runGh(args); + + // Mutations that print a URL like https://github.com/owner/repo/issues/123 + // — emit an entity so follow-ups ("that issue", "delete it") can resolve. + if ( + output && + (action.actionName === "issueCreate" || + action.actionName === "prCreate") + ) { + const m = output.match( + /https:\/\/github\.com\/([^/\s]+\/[^/\s]+)\/(?:issues|pull)\/(\d+)/, + ); + if (m) { + const repo = m[1]; + const number = parseInt(m[2], 10); + const url = m[0]; + const isIssue = action.actionName === "issueCreate"; + const title = String(p.title ?? ""); + const kindLabel = isIssue ? "issue" : "PR"; + const entity = { + name: `#${number}`, + type: [isIssue ? "issue" : "pullRequest", "github"], + uniqueId: url, + facets: [ + { name: "number", value: number }, + { name: "repo", value: repo }, + { name: "url", value: url }, + ...(title ? [{ name: "title", value: title }] : []), + ], + }; + const md = + `✅ Created ${kindLabel} **#${number}** in **${repo}**` + + (title ? `: ${title}` : "") + + `\n\n${url}`; + return createActionResultFromMarkdownDisplay( + md, + undefined, + [entity], + entity, + ); + } + } + if (!output) { // Friendly success messages for write/mutation actions const msg = getMutationSuccessMessage(action); diff --git a/ts/packages/agents/github-cli/src/github-cliSchema.agr b/ts/packages/agents/github-cli/src/github-cliSchema.agr index e3a2a42088..272bccae3a 100644 --- a/ts/packages/agents/github-cli/src/github-cliSchema.agr +++ b/ts/packages/agents/github-cli/src/github-cliSchema.agr @@ -36,6 +36,26 @@ } }; + = delete issue $(number:number) -> { + actionName: "issueDelete", + parameters: { + number + } +} + | delete issue $(number:number) in $(repo:wildcard) -> { + actionName: "issueDelete", + parameters: { + number, + repo + } +} + | remove issue $(number:number) -> { + actionName: "issueDelete", + parameters: { + number + } +}; + = reopen issue $(number:number) -> { actionName: "issueReopen", parameters: { @@ -155,6 +175,32 @@ } }; + = show check runs for PR $(number:number) -> { + actionName: "prChecks", + parameters: { + number + } +} + | show checks for PR $(number:number) -> { + actionName: "prChecks", + parameters: { + number + } +} + | show check runs for PR $(number:number) in $(repo:wildcard) -> { + actionName: "prChecks", + parameters: { + number, + repo + } +} + | show CI status for PR $(number:number) -> { + actionName: "prChecks", + parameters: { + number + } +}; + = create a new repository named $(name:wildcard) -> { actionName: "repoCreate", parameters: { @@ -242,6 +288,49 @@ parameters: {} }; + = show my assigned issues (on github)? -> { + actionName: "myAssignedIssues", + parameters: {} +} + | (show | list) issues assigned to me -> { + actionName: "myAssignedIssues", + parameters: {} +} + | what issues are assigned to me -> { + actionName: "myAssignedIssues", + parameters: {} +}; + + = add label $(label:wildcard) to issue $(number:number) -> { + actionName: "issueAddLabel", + parameters: { + number, + label + } +} + | add label $(label:wildcard) to issue $(number:number) in $(repo:wildcard) -> { + actionName: "issueAddLabel", + parameters: { + number, + label, + repo + } +} + | label issue $(number:number) as $(label:wildcard) -> { + actionName: "issueAddLabel", + parameters: { + number, + label + } +} + | tag issue $(number:number) with $(label:wildcard) -> { + actionName: "issueAddLabel", + parameters: { + number, + label + } +}; + = view workflow run $(id:word) -> { actionName: "runView", parameters: { @@ -309,6 +398,7 @@ import { GithubCliActions } from "./github-cliSchema.ts"; | | | + | | | | @@ -318,6 +408,7 @@ import { GithubCliActions } from "./github-cliSchema.ts"; | | | + | | | | @@ -325,6 +416,8 @@ import { GithubCliActions } from "./github-cliSchema.ts"; | | | + | + | | | | diff --git a/ts/packages/agents/github-cli/src/github-cliSchema.ts b/ts/packages/agents/github-cli/src/github-cliSchema.ts index 682871865b..6b6e0d9ab6 100644 --- a/ts/packages/agents/github-cli/src/github-cliSchema.ts +++ b/ts/packages/agents/github-cli/src/github-cliSchema.ts @@ -16,6 +16,7 @@ export type GithubCliActions = | GistListAction | IssueCreateAction | IssueCloseAction + | IssueDeleteAction | IssueReopenAction | IssueListAction | IssueViewAction @@ -27,6 +28,7 @@ export type GithubCliActions = | PrListAction | PrViewAction | PrCheckoutAction + | PrChecksAction | ProjectCreateAction | ProjectDeleteAction | ProjectListAction @@ -60,6 +62,8 @@ export type GithubCliActions = | SecretCreateAction | SshKeyAddAction | StatusPrintAction + | MyAssignedIssuesAction + | IssueAddLabelAction | VariableCreateAction | DependabotAlertsAction; @@ -184,6 +188,15 @@ export type IssueCloseAction = { }; }; +// Permanently delete a GitHub issue (uses `gh issue delete --yes`). +export type IssueDeleteAction = { + actionName: "issueDelete"; + parameters: { + number: number; + repo?: string; + }; +}; + export type IssueReopenAction = { actionName: "issueReopen"; parameters: { @@ -206,11 +219,24 @@ export type IssueListAction = { }; }; +// View / open a specific GitHub issue by number. +// +// Example: +// User: show issue 2222 +// Agent: { actionName: "issueView", parameters: { number: 2222 } } +// +// Example: +// User: view issue #42 in microsoft/TypeAgent +// Agent: { actionName: "issueView", parameters: { number: 42, repo: "microsoft/TypeAgent" } } export type IssueViewAction = { actionName: "issueView"; parameters: { + // The issue number. Omit when the user references an issue via entity + // resolution ("that issue", "the issue we just opened") — the dispatcher + // will substitute it. number?: number; + // OWNER/REPO slug (e.g. "microsoft/TypeAgent"). Omit unless the user names the repo. repo?: string; }; }; @@ -273,11 +299,24 @@ export type PrListAction = { }; }; +// View / open a specific GitHub pull request by number. +// +// Example: +// User: show PR 2196 +// Agent: { actionName: "prView", parameters: { number: 2196 } } +// +// Example: +// User: view pull request #42 in microsoft/TypeAgent +// Agent: { actionName: "prView", parameters: { number: 42, repo: "microsoft/TypeAgent" } } export type PrViewAction = { actionName: "prView"; parameters: { + // The pull request number. Omit when the user references a PR via entity + // resolution ("that PR", "the PR we just opened") — the dispatcher will + // substitute it. number?: number; + // OWNER/REPO slug (e.g. "microsoft/TypeAgent"). Omit unless the user names the repo. repo?: string; }; }; @@ -291,6 +330,14 @@ export type PrCheckoutAction = { }; }; +export type PrChecksAction = { + actionName: "prChecks"; + parameters: { + number: number; + repo?: string; + }; +}; + export type ProjectCreateAction = { actionName: "projectCreate"; parameters: { @@ -474,6 +521,17 @@ export type LabelCreateAction = { }; }; +export type IssueAddLabelAction = { + actionName: "issueAddLabel"; + parameters: { + number: number; + + label: string; + + repo?: string; + }; +}; + export type LicensesViewAction = { actionName: "licensesView"; parameters: {}; @@ -548,6 +606,16 @@ export type StatusPrintAction = { parameters: {}; }; +// List issues assigned to the current authenticated user across all +// repositories. Maps to `gh search issues --assignee @me --state open`. +export type MyAssignedIssuesAction = { + actionName: "myAssignedIssues"; + parameters: { + // Maximum number of issues to return (default 20) + limit?: number; + }; +}; + export type VariableCreateAction = { actionName: "variableCreate"; parameters: { diff --git a/ts/packages/agents/visualStudio/src/visualStudioActionHandler.ts b/ts/packages/agents/visualStudio/src/visualStudioActionHandler.ts index 15bd870a96..6f85ec3ecb 100644 --- a/ts/packages/agents/visualStudio/src/visualStudioActionHandler.ts +++ b/ts/packages/agents/visualStudio/src/visualStudioActionHandler.ts @@ -101,6 +101,15 @@ class VisualStudioBridge { type Context = { bridge: VisualStudioBridge }; +// Shared, process-singleton bridge: the VS extension only ever opens one +// WebSocket connection on port 5680, so per-session bridges would collide on +// `new WebSocketServer({ port: 5680 })` (EADDRINUSE) the moment a second +// session/conversation initializes the visualStudio agent. Created on first +// initialize, closed when the last session disables. Mirrors the pattern used +// by the code agent's CodeAgentWebSocketServer. +let sharedBridge: VisualStudioBridge | undefined; +let sharedBridgeRefCount = 0; + export function instantiate(): AppAgent { return { initializeAgentContext, @@ -111,9 +120,12 @@ export function instantiate(): AppAgent { } async function initializeAgentContext(): Promise { - const bridge = new VisualStudioBridge(); - bridge.start(); - return { bridge }; + if (sharedBridge === undefined) { + sharedBridge = new VisualStudioBridge(); + sharedBridge.start(); + } + sharedBridgeRefCount++; + return { bridge: sharedBridge }; } async function updateAgentContext( @@ -123,9 +135,17 @@ async function updateAgentContext( ): Promise {} async function closeAgentContext( - context: SessionContext, + _context: SessionContext, ): Promise { - await context.agentContext.bridge.stop(); + if (sharedBridgeRefCount === 0) { + return; + } + sharedBridgeRefCount--; + if (sharedBridgeRefCount === 0 && sharedBridge !== undefined) { + const toStop = sharedBridge; + sharedBridge = undefined; + await toStop.stop(); + } } async function executeAction( diff --git a/ts/packages/chat-ui/README.md b/ts/packages/chat-ui/README.md new file mode 100644 index 0000000000..738b8007b1 --- /dev/null +++ b/ts/packages/chat-ui/README.md @@ -0,0 +1,44 @@ +# chat-ui + +Shared DOM-based chat rendering for TypeAgent surfaces. + +`ChatPanel` is a hand-rolled, framework-free chat UI used by the +[VS Code shell extension](../vscode-shell/) and the [browser +extension](../agents/browser/) chat panel. It exposes a host-driven API +for rendering user/agent bubbles, streaming display updates, dynamic +status, history replay, command completions, and metrics tooltips. + +## Usage + +```ts +import { ChatPanel } from "chat-ui"; +import "chat-ui/styles"; + +const panel = new ChatPanel(rootElement, { + onSubmit: (text) => host.send(text), + onCompletion: (prefix) => host.requestCompletions(prefix), +}); + +// Stream agent updates +panel.addAgentMessage(content, source, sourceIcon, "block", requestId); +panel.setDisplayInfo(source, sourceIcon, action, requestId); + +// Replay persisted history +panel.replayHistory(entries); +``` + +## Notes + +- The package ships TypeScript sources and CSS. Hosts must bundle + `chat-ui/styles` alongside their own stylesheets. +- Avatars come from a built-in `DEFAULT_AVATAR_MAP` (one emoji per known + agent). Hosts may override via `setAvatarMap`. +- HTML in `DisplayContent` is sanitized with DOMPurify before insertion. + +## Trademarks + +This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft +trademarks or logos is subject to and must follow +[Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/en-us/legal/intellectualproperty/trademarks/usage/general). +Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship. +Any use of third-party trademarks or logos are subject to those third-party's policies. diff --git a/ts/packages/chat-ui/package.json b/ts/packages/chat-ui/package.json index 3ae297a596..44aba3d6c3 100644 --- a/ts/packages/chat-ui/package.json +++ b/ts/packages/chat-ui/package.json @@ -22,6 +22,7 @@ }, "dependencies": { "@typeagent/agent-sdk": "workspace:*", + "@typeagent/completion-ui": "workspace:*", "ansi_up": "^6.0.2", "dompurify": "^3.4.0", "markdown-it": "^14.1.0" diff --git a/ts/packages/chat-ui/src/chatPanel.ts b/ts/packages/chat-ui/src/chatPanel.ts index b441964787..63a7bb1558 100644 --- a/ts/packages/chat-ui/src/chatPanel.ts +++ b/ts/packages/chat-ui/src/chatPanel.ts @@ -9,13 +9,73 @@ * recognition dependencies. */ +import DOMPurify from "dompurify"; import { DisplayAppendMode, DisplayContent } from "@typeagent/agent-sdk"; import { setContent } from "./setContent.js"; + +// Restrictive sanitize config used at .innerHTML sinks below. The HTML +// passed in is built from values that, while in practice come from +// trusted dispatcher metadata (timing labels, JSON action data, +// per-color SVG fills), is treated by CodeQL as "library input". Running +// the final string through DOMPurify gives us defence-in-depth and +// satisfies js/xss / js/html-constructed-from-input. +const SANITIZE_CONFIG = { + ALLOWED_TAGS: ["div", "span", "b", "i", "br", "pre", "svg", "path"], + ALLOWED_ATTR: ["class", "xmlns", "width", "height", "viewBox", "fill", "d"], +}; +function sanitize(html: string): string { + return DOMPurify.sanitize(html, SANITIZE_CONFIG) as string; +} import { PlatformAdapter, ChatSettingsView, defaultChatSettings, } from "./platformAdapter.js"; +import { + PartialCompletion, + type PcCompletionState, + type PcPost, +} from "./partialCompletion.js"; + +/** + * Default per-agent emoji map used when a host calls add/replaceAgentMessage + * without an explicit `sourceIcon`. Sourced from the manifest emojiChar values + * in ts/packages/agents/* /src/*Manifest.json. Hosts can extend or override + * via `ChatPanel.setAvatarMap`. + */ +export const DEFAULT_AVATAR_MAP: Readonly> = { + androidmobile: "📱", + browser: "🌐", + calendar: "📅", + chat: "💬", + code: "⚛️", + desktop: "🪟", + dispatcher: "🤖", + email: "📩", + "github-cli": "🐙", + greeting: "🖐️", + image: "🖼️", + list: "📝", + localplayer: "🎵", + markdown: "🗎", + montage: "🎞", + music: "🎵", + onboarding: "🛠️", + photo: "📷", + player: "🎧", + scriptflow: "🔁", + settings: "⚙️", + shell: "🐚", + spelunker: "⛏", + system: "⚙", + taskflow: "📜", + test: "➕", + turtle: "🐢", + utility: "🔧", + video: "📹", + weather: "⛅", + word: "📄", +}; export interface CompletionResult { completions: string[]; @@ -32,6 +92,7 @@ export interface DynamicDisplayResult { // pulling the full dispatcher-types dependency into chat-ui. export interface PhaseTiming { duration?: number; + marks?: Record; } // Local mirror of dispatcher-types CompletionUsageStats. @@ -50,6 +111,49 @@ export interface NotifyExplainedData { time: string; } +/** + * One entry in a session history transcript replayed via + * `ChatPanel.replayHistory`. Discriminated by `kind`. Hosts construct + * these from whatever persisted format they use (file, IndexedDB, + * VS Code globalState, etc.) and hand them to the panel. + */ +export type HistoryEntry = + | { kind: "user"; text: string; requestId?: string; timestamp?: string } + | { + kind: "agent-replace"; + content: DisplayContent; + source?: string; + sourceIcon?: string; + requestId?: string; + timestamp?: string; + } + | { + kind: "agent-append"; + content: DisplayContent; + source?: string; + sourceIcon?: string; + mode?: DisplayAppendMode; + requestId?: string; + timestamp?: string; + } + | { + kind: "display-info"; + source: string; + sourceIcon?: string; + action?: unknown; + requestId?: string; + } + | { + kind: "command-result"; + requestId?: string; + actionPhase?: PhaseTiming; + totalDuration?: number; + tokenUsage?: CompletionUsageStats; + parsePhase?: PhaseTiming; + firstMessageMs?: number; + } + | { kind: "system"; text: string }; + function formatDuration(ms: number): string { if (ms < 1) return `${ms.toFixed(2)}ms`; if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`; @@ -57,7 +161,7 @@ function formatDuration(ms: number): string { } function metricsLine(label: string, duration: number): string { - return `${label}: ${formatDuration(duration)}`; + return `${escapeHtml(label)}: ${formatDuration(duration)}`; } function escapeHtml(s: string): string { @@ -69,6 +173,91 @@ function escapeHtml(s: string): string { .replace(/'/g, "'"); } +// Lightweight JSON syntax highlighter — returns HTML with span wrappers +// around tokens. Implemented as a hand-rolled scanner rather than a +// single tokenizing regex so we have no chance of polynomial backtracking +// on adversarial input (the JSON comes from action data which can carry +// arbitrary user content). Also escapes <, >, & in any character that +// passes through. +// Avoids pulling in highlight.js / Prism just to colorize the action +// JSON popup. +function highlightJson(json: string): string { + const escapeChar = (c: string): string => + c === "&" ? "&" : c === "<" ? "<" : c === ">" ? ">" : c; + const wrap = (cls: string, text: string): string => + `${text}`; + + let out = ""; + let i = 0; + const n = json.length; + while (i < n) { + const ch = json[i]; + if (ch === '"') { + // Linear scan to the matching closing quote, honoring `\\` + // and `\"` escapes. Each character is consumed at most once, + // so this is O(n) worst case. + let j = i + 1; + let raw = '"'; + while (j < n) { + const cj = json[j]; + if (cj === "\\" && j + 1 < n) { + raw += "\\" + escapeChar(json[j + 1]); + j += 2; + continue; + } + raw += escapeChar(cj); + j++; + if (cj === '"') break; + } + i = j; + // If a colon follows (optionally with whitespace), this is a + // JSON object key; otherwise a string value. + let k = i; + while (k < n && (json[k] === " " || json[k] === "\t")) k++; + if (json[k] === ":") { + out += wrap("json-key", raw + json.slice(i, k + 1)); + i = k + 1; + } else { + out += wrap("json-string", raw); + } + } else if ( + (ch >= "0" && ch <= "9") || + (ch === "-" && + i + 1 < n && + json[i + 1] >= "0" && + json[i + 1] <= "9") + ) { + let j = i + 1; + while ( + j < n && + ((json[j] >= "0" && json[j] <= "9") || + json[j] === "." || + json[j] === "e" || + json[j] === "E" || + json[j] === "+" || + json[j] === "-") + ) { + j++; + } + out += wrap("json-number", json.slice(i, j)); + i = j; + } else if (json.startsWith("true", i)) { + out += wrap("json-bool", "true"); + i += 4; + } else if (json.startsWith("false", i)) { + out += wrap("json-bool", "false"); + i += 5; + } else if (json.startsWith("null", i)) { + out += wrap("json-null", "null"); + i += 4; + } else { + out += escapeChar(ch); + i++; + } + } + return out; +} + // Generates a UUID for tagging user-message bubbles. Falls back to a // time + random hex blend when crypto.randomUUID is unavailable (older // browsers, non-secure contexts). @@ -86,6 +275,84 @@ function generateRequestId(): string { ); } +/** + * Wire the hover-push behavior onto a chat bubble's body element. + * + * When the user hovers a bubble that has populated metrics (signaled by + * the `chat-message-has-metrics` class on `containerDiv`), the metrics + * tooltip overlay reveals via CSS — but it would otherwise cover the + * next bubble. To make room, we translate every DOM-earlier sibling + * (= visually-lower bubble in the column-reverse `.chat` layout) DOWN + * by the actual measured overlay height. + * + * We use the individual `translate` CSS property (not `transform`) so + * the translation isn't clobbered by the container's appearance + * `animation: message ... forwards` which locks `transform: scale(1)`. + * Per CSS Transforms Level 2, `translate` composes independently. + */ +function attachHoverPush( + bodyDiv: HTMLElement, + containerDiv: HTMLElement, + metricsDiv: HTMLElement, +) { + bodyDiv.addEventListener("mouseenter", () => { + if (!containerDiv.classList.contains("chat-message-has-metrics")) { + return; + } + // Measure on demand — wrap heights vary per bubble. + metricsDiv.style.visibility = "hidden"; + metricsDiv.style.display = "block"; + const overlayH = metricsDiv.offsetHeight; + metricsDiv.style.display = ""; + metricsDiv.style.visibility = ""; + const offset = `${overlayH + 4}px`; + const hasEarlier = containerDiv.previousElementSibling !== null; + if (hasEarlier) { + // Normal case: hovered bubble is NOT the bottommost. Push + // visually-lower (DOM-earlier) bubbles DOWN to make room + // for the overlay rendered below this bubble. + let sibling: Element | null = containerDiv.previousElementSibling; + while (sibling) { + (sibling as HTMLElement).style.translate = `0 ${offset}`; + (sibling as HTMLElement).style.transition = + "translate 0.15s ease-out"; + sibling = sibling.previousElementSibling; + } + } else { + // Bottommost bubble: there's nothing visually below it to + // push down, AND the overlay would be clipped by the input + // area. Slide the bubble itself (plus all visually-higher + // = DOM-later siblings) UP by the overlay height so the + // overlay renders above the input. We translate all of + // them together so the chat's vertical stacking stays + // intact. + (containerDiv as HTMLElement).style.translate = `0 -${offset}`; + (containerDiv as HTMLElement).style.transition = + "translate 0.15s ease-out"; + let sibling: Element | null = containerDiv.nextElementSibling; + while (sibling) { + (sibling as HTMLElement).style.translate = `0 -${offset}`; + (sibling as HTMLElement).style.transition = + "translate 0.15s ease-out"; + sibling = sibling.nextElementSibling; + } + } + }); + bodyDiv.addEventListener("mouseleave", () => { + (containerDiv as HTMLElement).style.translate = ""; + let sibling: Element | null = containerDiv.previousElementSibling; + while (sibling) { + (sibling as HTMLElement).style.translate = ""; + sibling = sibling.previousElementSibling; + } + sibling = containerDiv.nextElementSibling; + while (sibling) { + (sibling as HTMLElement).style.translate = ""; + sibling = sibling.nextElementSibling; + } + }); +} + // Inline SVG roadrunner icon used by `notifyExplained` / `updateGrammarResult` // to mark a user bubble as "translated by ...". Color is supplied per call: // green for cached/grammar paths, gold for model translations, blue for @@ -97,7 +364,19 @@ const ROADRUNNER_SVG_PATH = function iconRoadrunner(fill: string): HTMLElement { const wrapper = document.createElement("i"); wrapper.className = "chat-message-explained-icon"; - wrapper.innerHTML = ``; + // Build the SVG via DOM nodes instead of innerHTML so the per-call + // `fill` color cannot be construed as an XSS sink. + const SVG_NS = "http://www.w3.org/2000/svg"; + const svg = document.createElementNS(SVG_NS, "svg"); + svg.setAttribute("xmlns", SVG_NS); + svg.setAttribute("width", "20"); + svg.setAttribute("height", "20"); + svg.setAttribute("viewBox", "0 0 567.896 567.896"); + const path = document.createElementNS(SVG_NS, "path"); + path.setAttribute("fill", fill); + path.setAttribute("d", ROADRUNNER_SVG_PATH); + svg.appendChild(path); + wrapper.appendChild(svg); return wrapper; } @@ -106,10 +385,11 @@ export interface ChatPanelOptions { settingsView?: ChatSettingsView; /** * Callback when user sends a message. The `requestId` is a UUID - * generated by the panel for this submission — pass it through to the - * dispatcher (e.g. as `processCommand`'s `clientRequestId`) so that - * subsequent `notifyExplained` / `updateGrammarResult` calls can - * address the same user-message bubble. + * generated by the panel (or supplied by the host via + * `typeAndSend(text, requestId)`) — pass it through to the dispatcher + * (e.g. as `processCommand`'s `clientRequestId`) so subsequent calls + * (`notifyExplained` / `updateGrammarResult` / metrics) can target + * the same user-message bubble. */ onSend?: ( text: string, @@ -142,15 +422,50 @@ export class ChatPanel { private readonly stopButton: HTMLButtonElement; private readonly ghostSpan: HTMLSpanElement; + private reconnectBanner: HTMLDivElement | undefined; private currentAgentContainer: AgentMessageContainer | undefined; private statusContainer: AgentMessageContainer | undefined; private historyAgentContainer: AgentMessageContainer | undefined; + /** + * Per-requestId agent bubble lookup. Populated when add/replaceAgentMessage + * is called with a requestId. Allows out-of-order or concurrent flows to + * route follow-up content to the correct bubble (instead of always + * appending to the most recent one). + */ + private agentContainersByRequestId = new Map< + string, + AgentMessageContainer + >(); private pendingDisplayInfo: | { source: string; sourceIcon?: string; action?: unknown } | undefined; private commandHistory: string[] = []; private historyIndex = -1; + /** Local user's display name + initial used in user-bubble headers. */ + private userName = "You"; + private userInitial = "U"; + /** + * Optional host-driven command-completion controller. Mounted by + * attachCompletion(); pcState messages are forwarded via applyPcState(). + */ + private partialCompletion: PartialCompletion | undefined; private activeRequestId?: string; + private isSwitching = false; + private isHistoryLoading = false; + private isDemoPaused = false; + private isDemoRunning = false; + // Flipped by `cancelTypingAnimation()` (called from the host when the + // user cancels the demo). `typeAndSend` checks this on every iteration + // of its character loop so a cancel mid-typing actually halts the + // current line instead of typing it out before we honor the cancel. + private demoTypingCancelled = false; + private inputHint?: string; + // Tracks the hint string most recently rendered into the ghost span + // so setInputHint(undefined) (or a hint change) can identify and + // clear the prior ghost without disturbing a completion preview. + private lastAppliedInputHint?: string; + private demoKeyHandler?: (e: KeyboardEvent) => void; + private avatarMap: Record = { ...DEFAULT_AVATAR_MAP }; // Completion state private completions: string[] = []; @@ -177,12 +492,31 @@ export class ChatPanel { // dispatcher reports back. Cleared by clear(). private userMessageById = new Map(); + // Timestamp (ms since epoch) when the user sent each requestId. Used + // to compute the "First Message" elapsed time when the agent's first + // bubble for that request is created. Cleared on completeRequest(). + private requestStartByRequestId = new Map(); + // Elapsed ms from request send to first agent message for each + // requestId (populated when the first agent bubble appears). + private firstMessageMsByRequestId = new Map(); + // Disables the requestStart/firstMessage timestamp capture during + // history replay (those timestamps would reflect replay speed, not + // the original interaction). + private suppressFirstMessageTracking = false; + public onSend?: ( text: string, attachments: string[] | undefined, requestId: string, ) => void; public onCancel?: (requestId: string) => void; + /** + * Fired when the user presses Ctrl/Meta+→ ("continue") or Esc + * ("cancel") while a demo script is paused. Hosts wire this to + * their own demo-runner to advance or abort the script. The panel + * owns the keystroke capture so the input field doesn't swallow it. + */ + public onDemoAction?: (action: "continue" | "cancel") => void; public getCompletions?: (input: string) => Promise; public getDynamicDisplay?: ( source: string, @@ -204,6 +538,13 @@ export class ChatPanel { const wrapper = document.createElement("div"); wrapper.className = "chat-panel-wrapper"; + // Reconnect banner — hidden by default. Hosts call setReconnectStatus() + // to show retry/connecting state to the user instead of a silent UI. + this.reconnectBanner = document.createElement("div"); + this.reconnectBanner.className = "chat-reconnect-banner"; + this.reconnectBanner.style.display = "none"; + wrapper.appendChild(this.reconnectBanner); + // Scrollable message area this.messageDiv = document.createElement("div"); this.messageDiv.className = "chat"; @@ -251,6 +592,25 @@ export class ChatPanel { textWrapper.className = "chat-input-text-wrapper"; textWrapper.appendChild(this.textInput); textWrapper.appendChild(this.ghostSpan); + // The contentEditable .user-textarea has display:inline and + // min-width:1px, so when empty its native click target is a + // 1px stripe at the top-left of the wrapper. Clicking + // elsewhere in the wrapper would land on the flex container + // and never focus the input — leaving no caret. Forward those + // clicks to focus the input and place the caret at the end. + textWrapper.addEventListener("mousedown", (event) => { + const target = event.target as Node | null; + if (target === this.textInput) return; + if (target && this.textInput.contains(target)) return; + event.preventDefault(); + this.textInput.focus(); + const range = document.createRange(); + range.selectNodeContents(this.textInput); + range.collapse(false); + const sel = window.getSelection(); + sel?.removeAllRanges(); + sel?.addRange(range); + }); this.inputArea.appendChild(textWrapper); this.inputArea.appendChild(this.sendButton); @@ -264,6 +624,12 @@ export class ChatPanel { private setupInputHandlers() { this.textInput.addEventListener("keydown", (e) => { + // Give the partial-completion (host-driven) controller first + // crack at the keystroke so its Tab/Enter/Esc/Arrow handling + // wins over chat-ui's local completions and history nav. + if (this.partialCompletion?.handleKeyDownPreSend(e)) { + return; + } if (e.key === "Tab" && this.completions.length > 0) { e.preventDefault(); this.acceptCompletion(); @@ -306,6 +672,7 @@ export class ChatPanel { this.textInput.addEventListener("input", () => { this.sendButton.disabled = !this.textInput.textContent?.trim(); this.scheduleCompletionFetch(); + this.applyInputHint(); }); this.sendButton.addEventListener("click", () => this.send()); @@ -478,7 +845,7 @@ export class ChatPanel { this.sendButton.disabled = !this.textInput.textContent?.trim(); } - private send() { + private send(requestId?: string) { const text = this.textInput.textContent?.trim(); if (!text) return; @@ -486,6 +853,9 @@ export class ChatPanel { this.historyIndex = -1; this.textInput.textContent = ""; this.sendButton.disabled = true; + // Tell the host-driven completion controller that the input has + // been consumed so the next typed char registers as forward. + this.partialCompletion?.reset(); const attachments = this.pendingAttachments.length > 0 @@ -494,9 +864,144 @@ export class ChatPanel { this.pendingAttachments = []; this.clearAttachmentPreview(); - const requestId = generateRequestId(); - this.addUserMessage(text, requestId); - this.onSend?.(text, attachments, requestId); + const id = requestId ?? generateRequestId(); + this.addUserMessage(text, id); + // Toggle input controls into "processing" state — swaps the + // send button for the stop button so the user can cancel an + // in-flight command. setIdle() is invoked by the host on + // commandComplete. + this.setProcessing(id); + this.onSend?.(text, attachments, id); + } + + /** + * Programmatically type `text` into the input character-by-character + * (25-40ms per char, matching the Electron shell's expandableTextArea) + * and submit it via the same path as a manual Enter press, using the + * supplied `requestId` so the user bubble and the host's command share + * the same id. + * + * Used by demo replay drivers to produce a natural typing animation + * instead of an instant paste-and-send. + * + * Resolves once the message has been submitted (after onSend fires). + * Returns `true` if submitted, `false` if cancellation was requested + * mid-animation (in which case `send()` is NOT invoked and the host + * must release any waiters of its own). + */ + public async typeAndSend( + text: string, + requestId: string, + ): Promise { + // Each new typeAndSend starts fresh — a stale cancel from a prior + // line shouldn't suppress this one. + this.demoTypingCancelled = false; + // Wait for any in-flight disable (e.g., session loading) to clear. + for ( + let i = 0; + this.textInput.contentEditable !== "true" && i < 50; + i++ + ) { + await new Promise((r) => setTimeout(r, 100)); + } + this.textInput.focus(); + this.textInput.textContent = ""; + // Block manual input while we animate so a keystroke (e.g. Esc to + // cancel a demo) doesn't clobber the in-flight typed text. The + // demo orchestrator owns the input during this window. + const wasEditable = this.textInput.contentEditable; + this.textInput.contentEditable = "false"; + try { + for (let i = 0; i < text.length; i++) { + if (this.demoTypingCancelled) { + // Wipe whatever we typed so the input doesn't keep a + // partial command for the user to inadvertently send, + // then bail without firing send(). + this.textInput.textContent = ""; + return false; + } + this.textInput.textContent = + (this.textInput.textContent ?? "") + text[i]; + // 25-40ms per char (random within range), matches Electron shell. + const delay = 25 + Math.floor(Math.random() * 15); + await new Promise((r) => setTimeout(r, delay)); + } + } finally { + this.textInput.contentEditable = wasEditable; + } + if (this.demoTypingCancelled) { + this.textInput.textContent = ""; + return false; + } + // Defer to send() so all bookkeeping (command history, attachments, + // user-bubble creation, sendButton/ghost reset) stays in one place. + this.send(requestId); + return true; + } + + /** + * Signal `typeAndSend()` to stop typing the current line. Safe to call + * when no animation is in flight — the flag is reset at the start of + * each `typeAndSend()`. Use this from the host on demo cancel so the + * remainder of the current line isn't typed out before the cancel + * takes effect. + */ + public cancelTypingAnimation(): void { + this.demoTypingCancelled = true; + } + + /** + * Toggle "demo running" mode. While running, the input ghost-hint + * shown via `setInputHint` is preserved across input events so the + * Alt+→/Esc reminder stays visible at the pause point. + */ + public setDemoRunning(running: boolean): void { + this.isDemoRunning = running; + this.refreshDemoKeyHandler(); + if (!running) { + this.setInputHint(undefined); + } + } + + /** + * Show a host-supplied hint string in the input box's ghost span + * when the textbox is empty. Used to display "Alt+→ continue · + * Esc cancel" while the demo is paused. Pass `undefined` to clear. + */ + public setInputHint(hint: string | undefined): void { + this.inputHint = hint; + this.applyInputHint(); + } + + private applyInputHint(): void { + // The hint shares the ghost span with completion previews. Show + // it only when the input is empty AND no completion suggestion + // is currently rendered (completion text takes precedence). + const hasText = (this.textInput.textContent ?? "").length > 0; + const hasCompletionGhost = + this.completions.length > 0 || + (this.partialCompletion !== undefined && + this.ghostSpan.textContent !== this.lastAppliedInputHint && + this.ghostSpan.textContent !== this.inputHint && + (this.ghostSpan.textContent ?? "").length > 0); + if (this.inputHint && !hasText && !hasCompletionGhost) { + this.ghostSpan.textContent = this.inputHint; + this.lastAppliedInputHint = this.inputHint; + } else if ( + this.lastAppliedInputHint !== undefined && + this.ghostSpan.textContent === this.lastAppliedInputHint + ) { + // Clear the previously-rendered hint when it should no longer + // be shown (hint cleared, hint changed to a value that doesn't + // currently apply, or the input is no longer empty). Comparing + // against `lastAppliedInputHint` (not the current `inputHint`) + // is essential — by the time we get here the caller may have + // already set `inputHint = undefined`, so a comparison against + // `inputHint` would never match and the stale text would + // linger in the ghost span. + this.ghostSpan.textContent = ""; + this.lastAppliedInputHint = undefined; + } } /** Set the active request ID and show the stop button. */ @@ -516,22 +1021,28 @@ export class ChatPanel { /** * Display a user message bubble. * - * If `requestId` is provided, the container is indexed so that later - * `notifyExplained` / `updateGrammarResult` calls keyed by the same - * id can attach decorations to this bubble. `send()` always supplies - * one; external callers can omit it for fire-and-forget messages. + * `requestId` is stamped on the container as `data-request-id` so later + * targeting (metrics, explained overlays, peer updates) can find it. + * The container is also indexed in `userMessageById` so subsequent + * `notifyExplained` / `updateGrammarResult` calls keyed by the same id + * can attach decorations to this bubble. `send()` always supplies one; + * external callers can omit it for fire-and-forget messages. */ public addUserMessage(text: string, requestId?: string) { const sentinel = this.messageDiv.firstElementChild!; const container = document.createElement("div"); container.className = "chat-message-container-user"; + container.dataset.requestId = requestId ?? generateRequestId(); + // A new user request invalidates the previous "current" agent bubble so + // a follow-up addAgentMessage with no requestId starts a fresh one. + this.currentAgentContainer = undefined; - const timestamp = this.createTimestamp("user", "You"); + const timestamp = this.createTimestamp("user", this.userName); container.appendChild(timestamp); const iconDiv = document.createElement("div"); iconDiv.className = "user-icon"; - iconDiv.textContent = "U"; + iconDiv.textContent = this.userInitial; container.appendChild(iconDiv); const bodyDiv = document.createElement("div"); @@ -546,96 +1057,386 @@ export class ChatPanel { messageDiv.appendChild(span); bodyDiv.appendChild(messageDiv); + + // Empty user-side metrics strip — populated later by + // applyUserMetrics() when the dispatcher reports `metrics.parse`. + const userMetricsDiv = document.createElement("div"); + userMetricsDiv.className = + "chat-message-metrics chat-message-metrics-user"; + bodyDiv.appendChild(userMetricsDiv); + container.appendChild(bodyDiv); + attachHoverPush(bodyDiv, container, userMetricsDiv); + sentinel.before(container); this.scrollToBottom(); - if (requestId !== undefined) { - container.dataset.requestId = requestId; - this.userMessageById.set(requestId, container); + const id = container.dataset.requestId!; + this.userMessageById.set(id, container); + if (!this.suppressFirstMessageTracking) { + this.requestStartByRequestId.set(id, Date.now()); } + } - // Reset current agent container for the new request - this.currentAgentContainer = undefined; + /** + * Stamp a metrics tooltip (hover-revealed) onto the user bubble. + * Used to display the dispatcher's `metrics.parse` (Translation) timing + * on the user side of the conversation. + */ + public applyUserMetrics( + requestId: string, + label: string, + phase?: PhaseTiming, + totalDuration?: number, + ) { + const container = this.userMessageById.get(requestId); + // Note: do NOT bail when phase has no duration — chat-only requests + // sometimes return a parse PhaseTiming with marks but no duration, + // and we still want to render those marks. We bail only if there + // is genuinely nothing to show. + const hasContent = + (phase?.duration !== undefined && phase.duration !== null) || + (phase?.marks && Object.keys(phase.marks).length > 0) || + totalDuration !== undefined; + if (!container || !hasContent) return; + const metricsDiv = container.querySelector( + ".chat-message-metrics-user", + ) as HTMLElement | null; + if (!metricsDiv) return; + const mainLines: string[] = []; + if (phase?.duration !== undefined) { + mainLines.push(metricsLine(`${label} Elapsed`, phase.duration)); + } + if (totalDuration !== undefined) { + mainLines.push(metricsLine("Total Elapsed", totalDuration)); + } + const markLines: string[] = []; + if (phase?.marks) { + for (const [key, value] of Object.entries(phase.marks)) { + const avg = value.duration / Math.max(value.count, 1); + const suffix = + value.count !== 1 ? `(out of ${value.count})` : ""; + markLines.push( + `${escapeHtml(key)}: ${formatDuration(avg)}${suffix}`, + ); + } + } + metricsDiv.innerHTML = sanitize( + `
` + + `
${markLines.join("
")}
` + + `
` + + `
${mainLines.join("
")}
` + + `
`, + ); + // Mark the container as having metrics so the hover-push handler + // (attached in addUserMessage) actually fires for user bubbles. + container.classList.add("chat-message-has-metrics"); } /** * Replace the current agent message content (reuses existing container). * If no container exists, creates one. + * If `requestId` is supplied, the per-request bubble is targeted; otherwise + * the most recently created agent bubble is used. */ public replaceAgentMessage( content: DisplayContent, source?: string, sourceIcon?: string, + requestId?: string, ) { if (this.statusContainer) { this.statusContainer.remove(); this.statusContainer = undefined; } - if (!this.currentAgentContainer) { - this.currentAgentContainer = this.createAgentContainer( - source ?? "assistant", - sourceIcon ?? "🤖", - ); - } - this.currentAgentContainer.setMessage(content, source, undefined); + const container = this.getOrCreateAgentContainer( + source, + sourceIcon, + requestId, + ); + container.setMessage(content, source, undefined); this.scrollToBottom(); } /** * Display or append an agent message. * Call with appendMode to add to the current agent message. + * If `requestId` is supplied, the per-request bubble is targeted; otherwise + * the most recently created agent bubble is used. */ public addAgentMessage( content: DisplayContent, source?: string, sourceIcon?: string, appendMode?: DisplayAppendMode, + requestId?: string, ) { // "temporary" mode = transient status (e.g. dispatcher's - // "Executing action X" indicator). Route to a dedicated status - // container so it doesn't stamp the main agent bubble with the - // status emitter's source and doesn't linger after the real - // response arrives. + // "Executing action X" indicator, reasoning's streaming "Thinking…" + // chunks). Route into the per-request bubble itself: the bubble + // starts as a status indicator and "upgrades" in place when the + // real reply arrives. AgentMessageContainer.setMessage already + // handles this — temporary content is appended as a last-child + // div tagged via lastAppendMode and flushed before the next + // non-temporary append. This avoids a separate status bubble + // appearing alongside the real reply (which looked like a duplicate). + // + // The free-standing `statusContainer` is kept only as a fallback + // for host-driven `showStatus()` calls that have no request context. + if ( + appendMode === "temporary" && + (requestId || this.currentAgentContainer) + ) { + const tempContainer = this.getOrCreateAgentContainer( + source, + sourceIcon, + requestId, + ); + tempContainer.setMessage(content, source, "temporary"); + this.scrollToBottom(); + return; + } + if (appendMode === "temporary") { - if (this.statusContainer) { - this.statusContainer.remove(); + // No request context — fall back to a floating, visually- + // distinct status bubble (e.g. used by host-driven showStatus). + if (!this.statusContainer) { + this.statusContainer = this.createAgentContainer("", ""); + this.statusContainer.markAsStatusBubble(); } - this.statusContainer = this.createAgentContainer( - source ?? "", - sourceIcon ?? "", - ); - this.statusContainer.setMessage(content, source, undefined); + this.statusContainer.setMessage(content, undefined, undefined); this.scrollToBottom(); return; } - // Remove lingering status message when a real response arrives + // Remove any lingering free-standing status bubble when a real + // response arrives. if (this.statusContainer) { this.statusContainer.remove(); this.statusContainer = undefined; } - if (!this.currentAgentContainer || !appendMode) { - this.currentAgentContainer = this.createAgentContainer( - source ?? "assistant", - sourceIcon ?? "🤖", - ); - // Apply any action metadata that arrived via setDisplayInfo - // before the first render — the dispatcher fires it before - // the agent's first setDisplay/appendDisplay. - if (this.pendingDisplayInfo?.action !== undefined) { - this.currentAgentContainer.setActionData( - this.pendingDisplayInfo.action, - ); + const container = this.getOrCreateAgentContainer( + source, + sourceIcon, + requestId, + ); + + container.setMessage(content, source, appendMode); + + this.scrollToBottom(); + } + + /** + * Drop the bubble association for a completed request id. Future + * add/replaceAgentMessage calls with this id will create a fresh bubble. + * Called by hosts when a request completes; safe to call for unknown ids. + */ + public clearRequest(requestId: string): void { + this.agentContainersByRequestId.delete(requestId); + } + + private getOrCreateAgentContainer( + source: string | undefined, + sourceIcon: string | undefined, + requestId: string | undefined, + ): AgentMessageContainer { + if (requestId && this.agentContainersByRequestId.has(requestId)) { + return this.agentContainersByRequestId.get(requestId)!; + } + if (!requestId && this.currentAgentContainer) { + return this.currentAgentContainer; + } + // If the dispatcher already announced this action's source/icon + // via setDisplayInfo, use those when creating the bubble — even + // if the first message routed into it is the dispatcher's own + // transient "[X] Executing action ..." status (source="dispatcher", + // no icon). Without this, the bubble would be created with the + // dispatcher robot avatar and stay that way (subsequent setMessage + // calls only update the name label, not the icon). + const effectiveSource = this.pendingDisplayInfo?.source ?? source; + const effectiveIcon = + this.pendingDisplayInfo?.sourceIcon ?? + sourceIcon ?? + this.iconForSource(effectiveSource); + const container = this.createAgentContainer( + effectiveSource ?? "assistant", + effectiveIcon, + ); + this.currentAgentContainer = container; + if (requestId) { + this.agentContainersByRequestId.set(requestId, container); + // Capture the elapsed time from request send to first agent + // bubble for this request — drives the "First Message" + // metric line on the agent metrics tooltip. + if (!this.firstMessageMsByRequestId.has(requestId)) { + const start = this.requestStartByRequestId.get(requestId); + if (start !== undefined) { + this.firstMessageMsByRequestId.set( + requestId, + Date.now() - start, + ); + } + } + } + // Apply any action metadata that arrived via setDisplayInfo + // before the first render — the dispatcher fires it before + // the agent's first setDisplay/appendDisplay (including the + // "Executing action ..." temporary status emitted via displayStatus). + if (this.pendingDisplayInfo?.action !== undefined) { + container.setActionData(this.pendingDisplayInfo.action); + } + this.pendingDisplayInfo = undefined; + return container; + } + + /** + * Look up the avatar emoji/icon for a given agent source name. Falls back + * to "🤖" if the source is unknown. Source names are matched + * case-insensitively against the first dot-separated segment, so + * "code.code-editor" looks up "code". + */ + public iconForSource(source?: string): string { + if (!source) return "🤖"; + const root = source.split(".")[0].toLowerCase(); + return this.avatarMap[root] ?? "🤖"; + } + + /** + * Override or extend the per-source avatar map. Passed entries are merged + * over DEFAULT_AVATAR_MAP. Pass an entry with value "" to suppress a + * default mapping. + */ + public setAvatarMap(map: Record): void { + this.avatarMap = { ...DEFAULT_AVATAR_MAP, ...map }; + } + + /** + * Add a non-conversational system message styled distinctly from agent + * messages (no avatar, no source label, no timestamp). Use for `@`-config + * confirmations, session lifecycle events, and similar host notices. + */ + public addSystemMessage(text: string): void { + const sentinel = this.messageDiv.firstElementChild!; + const el = document.createElement("div"); + el.className = "chat-message-system"; + el.textContent = text; + sentinel.before(el); + this.scrollToBottom(); + } + + /** + * Atomically replay a list of past entries from a session history. + * Each replayed DOM element is marked with the `.history` class so + * hosts can style replayed turns differently (e.g. dimmed). + * + * Recognized entry kinds: + * - `{ kind: "user", text, requestId?, timestamp? }` + * - `{ kind: "agent-replace", content, source?, sourceIcon?, requestId?, timestamp? }` + * - `{ kind: "agent-append", content, source?, sourceIcon?, mode?, requestId?, timestamp? }` + * - `{ kind: "system", text }` + * + * Temporary append entries (`mode === "temporary"`) are skipped — they're + * ephemeral status text from the original interaction and would otherwise + * appear as orphan status lines in the replayed transcript. + * + * After replay, the per-request bubble map is cleared so future live + * messages don't accidentally route into history bubbles. + */ + public replayHistory(entries: HistoryEntry[]): void { + if (!entries || entries.length === 0) return; + + const firstHistoryIdx = this.messageDiv.children.length; + + // Reset live state so replay starts fresh. + this.currentAgentContainer = undefined; + + // Suppress first-message timing tracking during replay — those + // timestamps would reflect the speed of replay, not the original + // request-to-first-response time. + this.suppressFirstMessageTracking = true; + try { + for (const entry of entries) { + switch (entry.kind) { + case "user": + this.addUserMessage(entry.text, entry.requestId); + break; + case "agent-replace": + this.replaceAgentMessage( + entry.content, + entry.source, + entry.sourceIcon, + entry.requestId, + ); + break; + case "agent-append": + if (entry.mode === "temporary") break; + this.addAgentMessage( + entry.content, + entry.source, + entry.sourceIcon, + entry.mode, + entry.requestId, + ); + break; + case "system": + this.addSystemMessage(entry.text); + break; + case "display-info": + this.setDisplayInfo( + entry.source, + entry.sourceIcon, + entry.action, + entry.requestId, + ); + break; + case "command-result": + if (entry.requestId) { + // Pre-seed the per-request firstMessageMs so the + // metrics tooltip can show "First Message" on + // history-replayed bubbles (live tracking is + // suppressed during replay). + if (entry.firstMessageMs !== undefined) { + this.firstMessageMsByRequestId.set( + entry.requestId, + entry.firstMessageMs, + ); + } + this.completeRequest(entry.requestId, { + actionPhase: entry.actionPhase, + totalDuration: entry.totalDuration, + tokenUsage: entry.tokenUsage, + parsePhase: entry.parsePhase, + }); + } + break; + } } - this.pendingDisplayInfo = undefined; + } finally { + this.suppressFirstMessageTracking = false; } - this.currentAgentContainer.setMessage(content, source, appendMode); + // Mark everything just appended as history. Iteration is over the + // live NodeList so `children.length` reflects current count. + for ( + let i = firstHistoryIdx; + i < this.messageDiv.children.length; + i++ + ) { + this.messageDiv.children[i].classList.add("history"); + } + // Reset state so the next live message starts a fresh bubble and + // doesn't reuse a history bubble via the requestId map. + // Also clear userMessageById: clientRequestIds from prior sessions + // (e.g. the shell's cmd-N counter resets each launch) can collide + // with new live requests, causing hasUserMessage() to return a + // false positive and silently drop the live user-message bubble. + this.currentAgentContainer = undefined; + this.agentContainersByRequestId.clear(); + this.userMessageById.clear(); this.scrollToBottom(); } @@ -644,11 +1445,15 @@ export class ChatPanel { source: string, sourceIcon?: string, action?: unknown, + requestId?: string, ) { - if (this.currentAgentContainer) { - this.currentAgentContainer.updateSource(source, sourceIcon); + const target = + (requestId && this.agentContainersByRequestId.get(requestId)) || + this.currentAgentContainer; + if (target) { + target.updateSource(source, sourceIcon); if (action !== undefined) { - this.currentAgentContainer.setActionData(action); + target.setActionData(action); } return; } @@ -658,13 +1463,36 @@ export class ChatPanel { this.pendingDisplayInfo = { source, sourceIcon, action }; } + /** Returns true if a user-message bubble for `requestId` already exists. */ + public hasUserMessage(requestId: string): boolean { + return this.userMessageById.has(requestId); + } + /** Clear all messages. */ public clear() { - while (this.messageDiv.children.length > 1) { - this.messageDiv.removeChild(this.messageDiv.lastChild!); + // Wipe everything then re-create the sticky scroll-anchor + // sentinel that the constructor installs as messageDiv's first + // child. addUserMessage / replaceAgentMessage / showSeparator / + // historyReplay all depend on `messageDiv.firstElementChild` + // being that sentinel and call `sentinel.before(...)`; without + // it they throw and the next command surfaces as a dispatcher + // error in the UI. + while (this.messageDiv.firstChild) { + this.messageDiv.removeChild(this.messageDiv.firstChild); } + const sentinel = document.createElement("div"); + sentinel.className = "chat-sentinel"; + this.messageDiv.appendChild(sentinel); this.currentAgentContainer = undefined; + this.agentContainersByRequestId.clear(); this.userMessageById.clear(); + this.requestStartByRequestId.clear(); + this.firstMessageMsByRequestId.clear(); + this.pendingDisplayInfo = undefined; + if (this.statusContainer) { + this.statusContainer.remove(); + this.statusContainer = undefined; + } } /** @@ -730,29 +1558,83 @@ export class ChatPanel { * Signal that the current request has finished. Mirrors the shell's * `statusMessage.complete()` hook — clears any lingering status / * dispatcher-temporary bubble regardless of the arrival order of the - * agent's real response. If `result` is provided, finalize the - * current agent bubble's hover-reveal metrics with overall duration - * and token usage. Also resets the current agent container so the - * next request starts a fresh bubble. + * agent's real response. If `result` is provided, finalize the target + * agent bubble's hover-reveal metrics with overall duration and token + * usage. If `requestId` is supplied, that bubble is finalized; + * otherwise the most recently created bubble is used. Also resets the + * current agent container so the next request starts a fresh bubble. */ - public completeRequest(result?: { - actionPhase?: PhaseTiming; - totalDuration?: number; - tokenUsage?: CompletionUsageStats; - }) { + public completeRequest( + requestId?: string, + result?: { + actionPhase?: PhaseTiming; + totalDuration?: number; + tokenUsage?: CompletionUsageStats; + parsePhase?: PhaseTiming; + cancelled?: boolean; + }, + ) { if (this.statusContainer) { this.statusContainer.remove(); this.statusContainer = undefined; } - if (result && this.currentAgentContainer) { - this.currentAgentContainer.updateMetrics( + const target = + (requestId && this.agentContainersByRequestId.get(requestId)) || + this.currentAgentContainer; + const firstMessageMs = + requestId !== undefined + ? this.firstMessageMsByRequestId.get(requestId) + : undefined; + if (result?.cancelled) { + // Mirror the Electron shell's "⚠ Cancelled" status line so the + // user has visible confirmation that Stop / Esc cancelled the + // in-flight command. If no agent bubble was created yet (cancel + // landed before any agent output), spin up a minimal one so + // the status is still visible. + const cancelTarget = + target ?? + this.createAgentContainer("shell", this.iconForSource("shell")); + cancelTarget.setMessage( + { + type: "text", + content: "⚠ Cancelled", + kind: "status", + }, + "shell", + "block", + ); + } + if (result && target) { + target.updateMetrics( "Action", result.actionPhase, result.totalDuration, result.tokenUsage, + firstMessageMs, ); } - this.currentAgentContainer = undefined; + if (result && requestId) { + // Always attempt to populate user-side metrics. Even when the + // request had no parse phase (e.g. cached translations or + // chat-only paths), we still show the total elapsed so the + // user bubble gets a metrics tooltip just like the agent's. + this.applyUserMetrics( + requestId, + "Translation", + result.parsePhase, + result.totalDuration, + ); + } + if (requestId) { + this.agentContainersByRequestId.delete(requestId); + this.requestStartByRequestId.delete(requestId); + this.firstMessageMsByRequestId.delete(requestId); + } + // If we just finalized the active bubble, reset it so the next + // request starts fresh. + if (!requestId || target === this.currentAgentContainer) { + this.currentAgentContainer = undefined; + } } /** Show a status message (temporary, removed when the next real message arrives). */ @@ -971,13 +1853,18 @@ export class ChatPanel { /** Programmatically inject and send a command. * @param displayText Optional friendly text shown in the user bubble instead of the raw command. + * @param requestId Optional request id; one is generated if not supplied. */ - public injectCommand(command: string, displayText?: string) { + public injectCommand( + command: string, + displayText?: string, + requestId?: string, + ) { this.commandHistory.unshift(command); this.historyIndex = -1; - const requestId = generateRequestId(); - this.addUserMessage(displayText ?? command, requestId); - this.onSend?.(command, undefined, requestId); + const id = requestId ?? generateRequestId(); + this.addUserMessage(displayText ?? command, id); + this.onSend?.(command, undefined, id); } /** Add a separator for previous session history. */ @@ -1037,8 +1924,239 @@ export class ChatPanel { this.historyAgentContainer = undefined; } + /** + * Set the local user's display name (and optional initial). Used in the + * user-bubble timestamp label and the round avatar to the right of the + * bubble. Affects bubbles created AFTER this call; existing bubbles are + * not retroactively updated. The initial defaults to the first + * non-whitespace character of `name` (uppercased). + */ + public setUserInfo(name: string, initial?: string) { + const trimmed = (name ?? "").trim(); + if (trimmed.length > 0) { + this.userName = trimmed; + } + if (initial !== undefined) { + const trimmedInitial = initial.trim(); + if (trimmedInitial.length > 0) { + this.userInitial = trimmedInitial.charAt(0).toUpperCase(); + } + } else if (trimmed.length > 0) { + this.userInitial = trimmed.charAt(0).toUpperCase(); + } + } + + /** + * Mount inline + dropdown command-completion driven by the host. The + * `post` callback receives messages (`pcUpdate` / `pcAccept` / + * `pcDismiss` / `pcHide` / `pcDispose`) which the host should forward + * to its CompletionController. The host pushes state updates back via + * `applyPcState(state)`. + * + * Returns the underlying PartialCompletion instance for callers that + * need direct access (e.g. to call `reset()` on session change). + * Re-attaching disposes the previous instance. + */ + /** + * Tear down the panel's lifetime-bound listeners. Call this when the + * host (e.g. a VS Code webview) is being disposed so the window-level + * demo key handler doesn't survive the panel and fire stale callbacks + * (or leak memory across panel reincarnations). + * + * Idempotent. Safe to call even if attachCompletion / setDemoPaused + * were never invoked. + */ + public dispose(): void { + if (this.demoKeyHandler) { + window.removeEventListener("keydown", this.demoKeyHandler, true); + this.demoKeyHandler = undefined; + } + this.isDemoPaused = false; + this.isDemoRunning = false; + this.partialCompletion?.dispose(); + this.partialCompletion = undefined; + } + + public attachCompletion( + post: PcPost, + opts?: { inline?: boolean }, + ): PartialCompletion { + this.partialCompletion?.dispose(); + // The textInput is wrapped in `chat-input-text-wrapper`; mount the + // toggle button on the wrapper so it sits flush with the input. + const wrapper = + (this.textInput.parentElement as HTMLElement | null) ?? + this.inputArea; + this.partialCompletion = new PartialCompletion( + wrapper, + this.textInput, + this.ghostSpan, + post, + opts, + ); + return this.partialCompletion; + } + + /** Forward a host-pushed completion state update to the controller. */ + public applyPcState(state: PcCompletionState | undefined) { + this.partialCompletion?.applyState(state); + } + /** Enable or disable the input. */ public setEnabled(enabled: boolean) { + // setSwitching/setHistoryLoading take precedence: if either is active, + // the host should not be able to re-enable the input until they clear. + if (enabled && (this.isSwitching || this.isHistoryLoading)) { + return; + } + this.textInput.contentEditable = enabled ? "true" : "false"; + this.sendButton.disabled = !enabled; + if (enabled) { + this.inputArea.classList.remove("chat-input-disabled"); + } else { + this.inputArea.classList.add("chat-input-disabled"); + // No commands can be in flight when input is disabled (typically + // because the dispatcher disconnected). Hide the stop button so + // users can't try to cancel into a dead RPC channel. + if (this.activeRequestId !== undefined) { + this.setIdle(); + } + } + } + + /** + * Show or hide the reconnect banner above the chat. Pass `undefined` to + * hide. The banner is plain text only — hosts format the message + * (countdown, attempt number, etc.) before passing it in. + */ + public setReconnectStatus(message: string | undefined): void { + if (this.reconnectBanner === undefined) return; + if (message === undefined) { + this.reconnectBanner.style.display = "none"; + this.reconnectBanner.textContent = ""; + } else { + this.reconnectBanner.textContent = message; + this.reconnectBanner.style.display = ""; + } + } + + /** + * Disable input and show a placeholder while a conversation switch is in + * progress. Re-enables input on `setSwitching(false)` (unless history is + * still loading). + */ + public setSwitching(switching: boolean, targetName?: string) { + this.isSwitching = switching; + if (switching) { + this.setEnabledInternal(false); + const label = targetName + ? `Switching to conversation "${targetName}"…` + : "Switching conversation…"; + this.textInput.setAttribute("data-placeholder", label); + this.inputArea.classList.add("chat-input-switching"); + } else { + this.inputArea.classList.remove("chat-input-switching"); + if (!this.isHistoryLoading) { + this.setEnabledInternal(true); + this.textInput.setAttribute( + "data-placeholder", + "Type a message...", + ); + } + } + } + + /** + * Disable input and show a "Loading history…" placeholder until the host + * finishes replaying past messages on (re)connect or session restore. + * Re-enables input on `setHistoryLoading(false)` (unless a switch is + * still in progress). + */ + public setHistoryLoading(loading: boolean) { + this.isHistoryLoading = loading; + if (loading) { + this.setEnabledInternal(false); + this.textInput.setAttribute("data-placeholder", "Loading history…"); + this.inputArea.classList.add("chat-input-history-loading"); + } else { + this.inputArea.classList.remove("chat-input-history-loading"); + if (!this.isSwitching) { + this.setEnabledInternal(true); + this.textInput.setAttribute( + "data-placeholder", + "Type a message...", + ); + } + } + } + + /** + * Toggle "demo paused" mode. While paused, the panel installs a + * window-level capture-phase keydown listener that swallows + * Ctrl/Meta+→ (continue) and Esc (cancel) before the focused input + * field sees them, and forwards the action via `onDemoAction`. + * + * The host is responsible for any user-facing "Demo paused" indicator + * (e.g., status ribbon suffix) — chat-ui no longer renders its own + * banner so the host can integrate the state into its existing UI + * without an extra component getting in the way. + */ + public setDemoPaused(paused: boolean, _message?: string): void { + this.isDemoPaused = paused; + this.refreshDemoKeyHandler(); + // When pausing, ensure the chat input has focus so the + // window-level demoKeyHandler reliably receives Ctrl+Right / + // Esc. VS Code's `vscode-shell.chatView.focus` reveals the + // webview view but doesn't always land focus inside the + // contenteditable, so without this nudge the key events can + // be swallowed by the surrounding VS Code UI before the + // capture-phase handler runs. + if (paused) { + try { + this.textInput.focus(); + } catch { + // best-effort + } + } + } + + private refreshDemoKeyHandler(): void { + const wantHandler = this.isDemoPaused || this.isDemoRunning; + if (wantHandler && !this.demoKeyHandler) { + this.demoKeyHandler = (e: KeyboardEvent) => { + if (!this.isDemoPaused && !this.isDemoRunning) return; + if ( + e.key === "ArrowRight" && + (e.altKey || e.ctrlKey || e.metaKey) && + this.isDemoPaused + ) { + e.preventDefault(); + e.stopPropagation(); + this.onDemoAction?.("continue"); + } else if (e.key === "Escape") { + // Esc cancels the demo whether it's currently + // paused at @pauseForInput or actively running a + // line. The host's requestDemoCancel() sets a + // sticky flag so the loop sees it on the next + // iteration even mid-line. + e.preventDefault(); + e.stopPropagation(); + this.onDemoAction?.("cancel"); + } + }; + window.addEventListener("keydown", this.demoKeyHandler, true); + } else if (!wantHandler && this.demoKeyHandler) { + window.removeEventListener("keydown", this.demoKeyHandler, true); + this.demoKeyHandler = undefined; + } + } + + /** + * Internal enable/disable that bypasses the isSwitching/isHistoryLoading + * guard in setEnabled. Used by setSwitching and setHistoryLoading + * themselves to actually toggle input state. + */ + private setEnabledInternal(enabled: boolean) { this.textInput.contentEditable = enabled ? "true" : "false"; this.sendButton.disabled = !enabled; if (enabled) { @@ -1132,6 +2250,12 @@ class AgentMessageContainer { // rendered response and a
 of the action JSON.
     private actionDataHtml?: string;
     private savedMessageHtml?: string;
+    // When setActionData receives an action with schemaName/actionName,
+    // we display "schema.action" as the bubble title instead of the raw
+    // source agent name. setMessage's source-driven label-update is then
+    // suppressed so the action label doesn't get clobbered by later
+    // setDisplay calls.
+    private actionDerivedName?: string;
 
     constructor(
         beforeElement: Element,
@@ -1201,6 +2325,8 @@ class AgentMessageContainer {
 
         this.div.appendChild(bodyDiv);
 
+        attachHoverPush(bodyDiv, this.div, this.metricsDiv);
+
         // Insert into DOM (column-reverse order)
         beforeElement.before(this.div);
     }
@@ -1210,22 +2336,62 @@ class AgentMessageContainer {
         phase?: PhaseTiming,
         totalDuration?: number,
         tokenUsage?: CompletionUsageStats,
+        firstMessageMs?: number,
     ) {
-        const lines: string[] = [];
+        // Layout: .metrics-details flex row with three columns
+        //   left   — "First Message" + phase.marks (one line each)
+        //   middle — (reserved; tts metrics in the future)
+        //   right  — main metrics (Action Elapsed / Total Elapsed / Tokens)
+        // This mirrors the Electron shell's MessageContainer layout so the
+        // tooltip reads as "marks on the left, totals on the right".
+        const mainLines: string[] = [];
         if (phase?.duration !== undefined) {
-            lines.push(metricsLine(`${actionLabel} Elapsed`, phase.duration));
+            mainLines.push(
+                metricsLine(`${actionLabel} Elapsed`, phase.duration),
+            );
         }
         if (totalDuration !== undefined) {
-            lines.push(metricsLine("Total Elapsed", totalDuration));
+            mainLines.push(metricsLine("Total Elapsed", totalDuration));
         }
         if (tokenUsage) {
-            lines.push(
+            // Compact form: "Tokens: 14356 (14257+99)" — the long
+            // "(prompt N, completion M)" form overflowed the metrics
+            // tooltip in narrow webview sidebars.
+            mainLines.push(
                 `Tokens: ${tokenUsage.total_tokens} ` +
-                    `(prompt ${tokenUsage.prompt_tokens}, ` +
-                    `completion ${tokenUsage.completion_tokens})`,
+                    `(${tokenUsage.prompt_tokens}+${tokenUsage.completion_tokens})`,
             );
         }
-        this.metricsDiv.innerHTML = lines.join("
"); + const leftLines: string[] = []; + if (firstMessageMs !== undefined) { + leftLines.push(metricsLine("First Message", firstMessageMs)); + } + if (phase?.marks) { + for (const [key, value] of Object.entries(phase.marks)) { + const avg = value.duration / Math.max(value.count, 1); + const suffix = + value.count !== 1 ? `(out of ${value.count})` : ""; + leftLines.push( + `${escapeHtml(key)}: ${formatDuration(avg)}${suffix}`, + ); + } + } + this.metricsDiv.innerHTML = sanitize( + `
` + + `
${leftLines.join("
")}
` + + `
` + + `
${mainLines.join("
")}
` + + `
`, + ); + // Flag the container so the chat-level selector that pushes + // visually-below bubbles down on hover can target it without + // relying on a nested `:has()` chain (which proved unreliable in + // some webview versions). + if (leftLines.length > 0 || mainLines.length > 0) { + this.div.classList.add("chat-message-has-metrics"); + } else { + this.div.classList.remove("chat-message-has-metrics"); + } } /** @@ -1236,31 +2402,79 @@ class AgentMessageContainer { public setActionData(action: unknown) { if (action === undefined || action === null) return; let html: string; + let label: string | undefined; if (Array.isArray(action)) { - html = `
${escapeHtml(action.join(" "))}
`; - } else if (typeof action === "object") { - html = `
${escapeHtml(
+            // Skip arrays-of-primitives (e.g. dispatcher's ['request']
+            // housekeeping events) and empty arrays — making the agent
+            // name clickable here would produce a no-op popup.
+            const objectEntries = action.filter(
+                (v) => typeof v === "object" && v !== null,
+            );
+            if (objectEntries.length === 0) return;
+            // Skip arrays whose objects are all empty `{}` — same
+            // rationale (some agents emit `[{}]` placeholder events).
+            if (
+                objectEntries.every(
+                    (v) => Object.keys(v as object).length === 0,
+                )
+            ) {
+                return;
+            }
+            // Derive a label from the first object that carries a
+            // schema/action name (matches Electron shell behavior).
+            for (const entry of objectEntries) {
+                const o = entry as {
+                    schemaName?: unknown;
+                    actionName?: unknown;
+                };
+                if (
+                    typeof o.schemaName === "string" &&
+                    typeof o.actionName === "string"
+                ) {
+                    label = `${o.schemaName}.${o.actionName}`;
+                    break;
+                }
+            }
+            html = `
${highlightJson(
                 JSON.stringify(action, undefined, 2),
             )}
`; + } else if (typeof action === "object") { + // Skip empty objects — the popup would show only `{}` which + // is misleading (looks broken to the user). + if (Object.keys(action as object).length === 0) return; + const obj = action as { + schemaName?: unknown; + actionName?: unknown; + }; + if ( + typeof obj.schemaName === "string" && + typeof obj.actionName === "string" + ) { + label = `${obj.schemaName}.${obj.actionName}`; + } + const json = JSON.stringify(action, undefined, 2); + html = `
${highlightJson(json)}
`; } else { - html = `
${escapeHtml(String(action))}
`; + // Primitives (string/number/bool) aren't useful as a JSON + // popup. Don't make the bubble clickable for them. + return; } + // Render the JSON below the message in the collapsible details + // area, instead of swapping out the message body. The agent name + // becomes a click affordance to toggle the details panel. + this.detailsDiv.innerHTML = sanitize(html); this.actionDataHtml = html; + if (label) { + this.nameSpan.textContent = label; + this.actionDerivedName = label; + } this.nameSpan.classList.add("clickable"); - this.nameSpan.title = "Click to show action JSON"; + this.nameSpan.title = "Click to show / hide action JSON"; } private toggleActionData() { if (this.actionDataHtml === undefined) return; - if (this.savedMessageHtml === undefined) { - this.savedMessageHtml = this.messageDiv.innerHTML; - this.messageDiv.innerHTML = this.actionDataHtml; - this.messageDiv.classList.add("chat-message-action-data"); - } else { - this.messageDiv.innerHTML = this.savedMessageHtml; - this.savedMessageHtml = undefined; - this.messageDiv.classList.remove("chat-message-action-data"); - } + this.detailsDiv.classList.toggle("chat-details-visible"); } public setMessage( @@ -1268,7 +2482,7 @@ class AgentMessageContainer { source?: string, appendMode?: DisplayAppendMode, ) { - if (source) { + if (source && !this.actionDerivedName) { this.nameSpan.textContent = source; } @@ -1320,12 +2534,25 @@ class AgentMessageContainer { } public updateSource(source: string, icon?: string) { - this.nameSpan.textContent = source; + if (!this.actionDerivedName) { + this.nameSpan.textContent = source; + } if (icon) { this.iconDiv.textContent = icon; } } + /** + * Mark this container as a transient status indicator (used for + * "Executing action ...", reasoning's streaming "Thinking…" chunks, + * etc.). Adds a CSS class that styles the bubble as a small italicized + * indicator with no agent label/avatar/border — so the user can't + * mistake it for a duplicate of the real reply bubble. + */ + public markAsStatusBubble() { + this.div.classList.add("chat-message-status-bubble"); + } + /** Mark this container as a dimmed history message. */ public setHistoryStyle() { this.div.classList.add("chat-history-message"); diff --git a/ts/packages/chat-ui/src/index.ts b/ts/packages/chat-ui/src/index.ts index 72ea7bb7de..7dace7793b 100644 --- a/ts/packages/chat-ui/src/index.ts +++ b/ts/packages/chat-ui/src/index.ts @@ -9,10 +9,20 @@ export { export { setContent, swapContent } from "./setContent.js"; +export { + PartialCompletion, + PcCompletionState, + PcDirection, + PcPostMessage, + PcPost, +} from "./partialCompletion.js"; + export { ChatPanel, ChatPanelOptions, CompletionResult, DynamicDisplayResult, + DEFAULT_AVATAR_MAP, NotifyExplainedData, + HistoryEntry, } from "./chatPanel.js"; diff --git a/ts/packages/chat-ui/src/partialCompletion.ts b/ts/packages/chat-ui/src/partialCompletion.ts new file mode 100644 index 0000000000..ee6e39d93a --- /dev/null +++ b/ts/packages/chat-ui/src/partialCompletion.ts @@ -0,0 +1,528 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + CompletionToggle, + LocalSearchMenuUI, + type SearchMenuItem, + type SearchMenuPosition, +} from "@typeagent/completion-ui"; + +/** + * Subset of agent-dispatcher's CompletionState received from the host + * over the pcState postMessage protocol. + */ +export type PcCompletionState = { + prefix: string; + items: SearchMenuItem[]; + generation: number; + anchorIndex: number; +}; + +export type PcDirection = "forward" | "backward"; + +export type PcPostMessage = + | { type: "pcUpdate"; input: string; direction: PcDirection } + | { type: "pcAccept" } + | { type: "pcDismiss"; input: string; direction: PcDirection } + | { type: "pcHide" } + | { type: "pcDispose" }; + +export type PcPost = (msg: PcPostMessage) => void; + +/** + * Adapts ChatPanel's contentEditable input + ghost span for command + * completion using @typeagent/completion-ui. + * + * Architecture mirrors the legacy vscode-shell partialCompletion: + * - The CompletionController lives in the host (extension / agent-server). + * - This class drives it from the webview via postMessage: + * pcUpdate(input, direction) on input / focus / caret moves + * pcAccept() when the user picks a completion + * pcDismiss(input, direction) when the user explicitly hides (Esc) + * pcHide() when the caret leaves the end of input + * pcDispose() on dispose + * - The host sends pcState back on every state change. We render two UIs: + * Inline ghost-text suffix in the existing ChatPanel ghost span. + * Dropdown menu (LocalSearchMenuUI) anchored above the input. + * + * This adaptation differs from the legacy version (which targeted a + *