From 547b65928a7cc1aefc3d289adc7dee89b5b61ba3 Mon Sep 17 00:00:00 2001 From: Dhirender Choudhary Date: Wed, 22 Jul 2026 01:35:27 +0530 Subject: [PATCH 01/13] Add web runtime bridge for browser chat (#695) --- source/app/App.tsx | 46 ++++ source/app/types.ts | 6 + source/cli.tsx | 57 +++-- source/hooks/chat-handler/types.ts | 1 + .../chat-handler/useChatHandler.spec.tsx | 37 +++ source/hooks/chat-handler/useChatHandler.tsx | 4 + source/web/page.spec.ts | 21 ++ source/web/page.ts | 238 +++++++++++++++++- source/web/runtime-bridge.spec.ts | 147 +++++++++++ source/web/runtime-bridge.ts | 132 ++++++++++ 10 files changed, 656 insertions(+), 33 deletions(-) create mode 100644 source/web/runtime-bridge.spec.ts create mode 100644 source/web/runtime-bridge.ts diff --git a/source/app/App.tsx b/source/app/App.tsx index 2ec95d2b0..ac43f6628 100644 --- a/source/app/App.tsx +++ b/source/app/App.tsx @@ -62,6 +62,7 @@ export default function App({ altScreenActive = false, initialSession, openSessionSelectorOnStart = false, + webRuntimeBridge, }: AppProps) { // Resolve the initial development mode with this precedence: // 1. --mode CLI flag (highest priority) @@ -255,8 +256,12 @@ export default function App({ appState.setCompactToolCounts(null); appState.compactToolCountsRef.current = {}; appState.setLiveTaskList(null); + webRuntimeBridge?.completeTurn(); drainQueuedUserMessage(); }, + onError: error => { + webRuntimeBridge?.failTurn(error); + }, // A turn that started in plan mode finished uninterrupted — a plan was // produced. Flag it so the interactive UI can show the plan review bar. onPlanTurnComplete: () => { @@ -489,6 +494,47 @@ export default function App({ handleChatMessage: chatHandler.handleChatMessage, dismissActiveEditor: vscodeServer.dismissActiveEditor, }); + const webRuntimeStateRef = React.useRef({ + isGenerating: chatHandler.isGenerating, + submitMessage: appHandlers.handleMessageSubmit, + cancel: appHandlers.handleCancel, + }); + webRuntimeStateRef.current = { + isGenerating: chatHandler.isGenerating, + submitMessage: appHandlers.handleMessageSubmit, + cancel: appHandlers.handleCancel, + }; + + React.useEffect(() => { + if ( + !webRuntimeBridge || + !isEffectivelyTrusted || + !appState.client || + !appState.toolManager + ) { + return; + } + + return webRuntimeBridge.bindRuntimeHandlers({ + submitMessage: message => { + if (webRuntimeStateRef.current.isGenerating) { + throw new Error('Nanocoder is already processing a turn.'); + } + + return webRuntimeStateRef.current.submitMessage(message); + }, + cancel: () => webRuntimeStateRef.current.cancel(), + }); + }, [ + webRuntimeBridge, + isEffectivelyTrusted, + appState.client, + appState.toolManager, + ]); + + React.useEffect(() => { + webRuntimeBridge?.publishAssistantContent(chatHandler.streamingContent); + }, [webRuntimeBridge, chatHandler.streamingContent]); // Apply a session resolved by cli.tsx from --continue/--resume (or open // the picker for a bare --resume), once on mount. Reuses the exact same diff --git a/source/app/types.ts b/source/app/types.ts index 2246f8a74..79792e338 100644 --- a/source/app/types.ts +++ b/source/app/types.ts @@ -1,5 +1,6 @@ import type {Session} from '@/session/session-manager'; import type {DevelopmentMode} from '@/types/core'; +import type {WebRuntimeBridge} from '@/web/runtime-bridge'; /** * Valid user-selectable boot modes. Single source of truth — used by the @@ -54,6 +55,11 @@ export interface AppProps { * (activeMode `'sessionSelector'`) as soon as the app mounts. */ openSessionSelectorOnStart?: boolean; + /** + * Optional bridge for the localhost browser surface. The normal terminal + * runtime remains the owner of providers, tools, and conversation state. + */ + webRuntimeBridge?: WebRuntimeBridge; } /** diff --git a/source/cli.tsx b/source/cli.tsx index 3cf5b3ff5..9f4326750 100644 --- a/source/cli.tsx +++ b/source/cli.tsx @@ -11,6 +11,7 @@ // defeating the purpose. Heavy imports live inside `main()` below and are // pulled in via dynamic `await import()` only when the app actually boots. import nodeModule from 'node:module'; +import type {WebRuntimeBridge} from '@/web/runtime-bridge'; // Enable V8 compile cache (Node 22.8+). After the first run, Node caches // bytecode for every module on disk so subsequent launches skip parsing @@ -24,6 +25,7 @@ const {version} = require('../package.json'); // Parse CLI arguments const args = process.argv.slice(2); +const webMode = args.includes('--web') || args.includes('--gui'); // Handle --version/-v flag — fast path, no heavy imports if (args.includes('--version') || args.includes('-v')) { @@ -118,28 +120,6 @@ Examples: process.exit(0); } -if (args.includes('--web') || args.includes('--gui')) { - const {startLocalWebServer} = await import('@/web/server'); - const webServer = await startLocalWebServer(); - - console.log('Nanocoder web mode started.'); - console.log(`Local URL: ${webServer.url}`); - console.log('Press Ctrl+C to stop.'); - - const shutdown = async () => { - await webServer.close(); - process.exit(0); - }; - process.once('SIGINT', () => { - void shutdown(); - }); - process.once('SIGTERM', () => { - void shutdown(); - }); - - await new Promise(() => {}); -} - // Validate output format value to prevent injection function isValidOutputFormat(value: unknown): value is 'text' | 'json' { return value === 'text' || value === 'json'; @@ -605,6 +585,38 @@ async function main(): Promise { }) as unknown as NodeJS.ReadStream; } + let webRuntimeBridge: WebRuntimeBridge | undefined; + if (webMode) { + const [ + {createWebRuntimeBridge}, + {startLocalWebServer}, + {getShutdownManager}, + ] = await Promise.all([ + import('@/web/runtime-bridge'), + import('@/web/server'), + import('@/utils/shutdown'), + ]); + let broadcastEvent: Parameters[0] = + () => {}; + webRuntimeBridge = createWebRuntimeBridge(event => { + broadcastEvent(event); + }); + const webServer = await startLocalWebServer({ + onClientEvent: webRuntimeBridge.handleClientEvent, + }); + broadcastEvent = webServer.broadcastEvent; + + getShutdownManager().register({ + name: 'web-server', + priority: 10, + handler: webServer.close, + }); + + console.log('Nanocoder web mode started.'); + console.log(`Local URL: ${webServer.url}`); + console.log('Press Ctrl+C to stop.'); + } + const result = render( { altScreenActive={useAltScreen} initialSession={initialSession} openSessionSelectorOnStart={openSessionSelectorOnStart} + webRuntimeBridge={webRuntimeBridge} />, { // Ctrl+C is handled inside App (routed through the shutdown diff --git a/source/hooks/chat-handler/types.ts b/source/hooks/chat-handler/types.ts index ca88c4bd4..f4b928084 100644 --- a/source/hooks/chat-handler/types.ts +++ b/source/hooks/chat-handler/types.ts @@ -31,6 +31,7 @@ export interface UseChatHandlerProps { >; nonInteractiveMode?: boolean; onConversationComplete?: () => void; + onError?: (error: unknown) => void; // Fired when a turn that STARTED in plan mode runs to completion without // being interrupted — i.e. a plan was actually produced. Decided here rather // than inferred from ambient state (isConversationComplete + current mode), diff --git a/source/hooks/chat-handler/useChatHandler.spec.tsx b/source/hooks/chat-handler/useChatHandler.spec.tsx index 74e5a6cac..1fac23ba5 100644 --- a/source/hooks/chat-handler/useChatHandler.spec.tsx +++ b/source/hooks/chat-handler/useChatHandler.spec.tsx @@ -338,6 +338,43 @@ test('useChatHandler - callbacks are provided', t => { t.is(typeof props.onConversationComplete, 'function'); }); +test('useChatHandler - reports setup failures through the error observer', async t => { + let hookResult: ChatHandlerReturn | null = null; + const observedErrors: unknown[] = []; + const throwingToolManager = { + ...createMockToolManager(), + getToolNames: () => { + throw new Error('command prompt failed'); + }, + } as NonNullable; + const customCommandLoader = { + findRelevantCommands: () => [], + } as unknown as NonNullable; + + const rendered = render( + { + observedErrors.push(error); + }, + })} + onResult={result => { + hookResult = result; + }} + />, + ); + + await waitForCondition(() => hookResult !== null); + await hookResult!.handleChatMessage('current turn'); + + t.is(observedErrors.length, 1); + t.is((observedErrors[0] as Error).message, 'command prompt failed'); + rendered.unmount(); +}); + test('useChatHandler - drains queued message when setup fails before conversation loop', async t => { type QueueDrainHarnessResult = { chatHandler: ChatHandlerReturn; diff --git a/source/hooks/chat-handler/useChatHandler.tsx b/source/hooks/chat-handler/useChatHandler.tsx index 0266f021b..231802e97 100644 --- a/source/hooks/chat-handler/useChatHandler.tsx +++ b/source/hooks/chat-handler/useChatHandler.tsx @@ -78,6 +78,7 @@ export function useChatHandler({ developmentModeRef, nonInteractiveMode = false, onConversationComplete, + onError, onPlanTurnComplete, reasoningExpandedRef, compactToolDisplayRef, @@ -262,6 +263,7 @@ export function useChatHandler({ }, }); } catch (error) { + onError?.(error); displayError(error, 'chat-error'); // Signal completion on error to avoid hanging in non-interactive mode onConversationComplete?.(); @@ -282,6 +284,7 @@ export function useChatHandler({ developmentModeRef, nonInteractiveMode, onConversationComplete, + onError, reasoningExpandedRef, compactToolDisplayRef, compactToolCountsRef, @@ -381,6 +384,7 @@ export function useChatHandler({ onPlanTurnComplete?.(); } } catch (error) { + onError?.(error); displayError(error, 'chat-error'); onConversationComplete?.(); } finally { diff --git a/source/web/page.spec.ts b/source/web/page.spec.ts index 2ef55385f..864f5f3c8 100644 --- a/source/web/page.spec.ts +++ b/source/web/page.spec.ts @@ -41,3 +41,24 @@ test('web mode messages layer does not block empty-state prompt clicks', t => { t.true(page.includes('.empty-state {')); t.true(page.includes('z-index: 2;')); }); + +test('web mode composer sends cancellation for the active runtime turn', t => { + const page = renderWebModePage(); + + t.true(page.includes('let activeTurnId = null')); + t.true(page.includes("sendClientEvent({type: 'cancel', id: activeTurnId})")); + t.true(page.includes("sendButton.classList.toggle('is-cancel', isActive)")); + t.true(page.includes("isActive ? 'Cancel response' : 'Send message'")); + t.true(page.includes('Nanocoder is working. Use the stop button to cancel.')); + t.false(page.includes("appendMessage('system', 'Turn completed'")); +}); + +test('web mode renders streamed assistant output as safe markdown', t => { + const page = renderWebModePage(); + + t.true(page.includes('function renderAssistantText(element, text)')); + t.true(page.includes("document.createElement('pre')")); + t.true(page.includes("document.createElement('li')")); + t.true(page.includes('textElement.dataset.rawText = nextText')); + t.false(page.includes("appendMessage('assistant', '', 'Assistant output')")); +}); diff --git a/source/web/page.ts b/source/web/page.ts index 57b92e297..e4e626fb1 100644 --- a/source/web/page.ts +++ b/source/web/page.ts @@ -269,18 +269,80 @@ export function renderWebModePage(): string { background: rgba(245, 242, 235, 0.055); color: #f5f2eb; line-height: 1.5; - white-space: pre-wrap; overflow-wrap: anywhere; box-shadow: 0 12px 40px rgba(0, 0, 0, 0.14); } + .message:not(.assistant) .message-content { + white-space: pre-wrap; + } .message.user { align-self: flex-end; + width: auto; + max-width: min(680px, 85%); + border-radius: 16px 16px 4px 16px; background: #f5f2eb; color: #17151d; } .message.assistant { align-self: flex-start; - background: rgba(245, 242, 235, 0.08); + width: min(820px, 100%); + padding: 4px 0 18px; + border: 0; + background: transparent; + box-shadow: none; + } + .markdown { + font-size: 15px; + line-height: 1.7; + } + .markdown > :first-child { + margin-top: 0; + } + .markdown > :last-child { + margin-bottom: 0; + } + .markdown h1, + .markdown h2, + .markdown h3 { + margin: 24px 0 10px; + color: var(--tn-text); + font-size: 18px; + line-height: 1.35; + } + .markdown p, + .markdown ul, + .markdown ol, + .markdown pre { + margin: 0 0 14px; + } + .markdown p, + .markdown li { + color: inherit; + } + .markdown ul, + .markdown ol { + padding-left: 24px; + } + .markdown li + li { + margin-top: 5px; + } + .markdown code { + border-radius: 4px; + background: rgba(125, 207, 255, 0.1); + padding: 2px 5px; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.9em; + } + .markdown pre { + overflow-x: auto; + border: 1px solid var(--tn-border); + border-radius: 8px; + background: #12131c; + padding: 14px 16px; + } + .markdown pre code { + background: transparent; + padding: 0; } .message.system { align-self: center; @@ -459,6 +521,13 @@ export function renderWebModePage(): string { transform: translateY(-1px); background: #6ee7a1; } + .send-button.is-cancel { + background: #ff7675; + color: #08090b; + } + .send-button.is-cancel:not(:disabled):hover { + background: #ff9493; + } .send-button:disabled, textarea:disabled { cursor: not-allowed; @@ -646,11 +715,13 @@ export function renderWebModePage(): string { border-color: var(--tn-border); background: rgba(36, 40, 59, 0.82); } - .message.assistant, .message.system, .mode-pill { background: rgba(36, 40, 59, 0.72); } + .message.assistant { + background: transparent; + } .message.system { color: var(--tn-text); } @@ -735,7 +806,7 @@ export function renderWebModePage(): string {
Nanocoder local session
-

Enter sends. Shift+Enter creates a new line.

+

Enter sends. Shift+Enter creates a new line.

@@ -752,6 +823,7 @@ export function renderWebModePage(): string { const sessionMenuButton = document.querySelector('#sessionMenuButton'); const historyButton = document.querySelector('#historyButton'); const settingsButton = document.querySelector('#settingsButton'); + const composerNote = document.querySelector('#composerNote'); const threadSearchInput = document.querySelector('#threadSearchInput'); const threadButtons = Array.from(document.querySelectorAll('.thread-item')); const token = new URLSearchParams(window.location.search).get('token'); @@ -775,6 +847,8 @@ export function renderWebModePage(): string { ]; let messageCounter = 0; let storedMessages = []; + let activeTurnId = null; + let isConnected = false; function setStatus(text, state) { statusElement.textContent = text; @@ -782,10 +856,28 @@ export function renderWebModePage(): string { } function setComposerEnabled(isEnabled) { - messageInput.disabled = !isEnabled; + isConnected = isEnabled; + messageInput.disabled = !isEnabled || activeTurnId !== null; sendButton.disabled = !isEnabled; } + function setActiveTurn(id) { + activeTurnId = id; + const isActive = id !== null; + messageInput.disabled = !isConnected || isActive; + sendButton.disabled = !isConnected; + sendButton.classList.toggle('is-cancel', isActive); + sendButton.textContent = isActive ? '■' : '↑'; + sendButton.setAttribute( + 'aria-label', + isActive ? 'Cancel response' : 'Send message', + ); + composerNote.textContent = isActive + ? 'Nanocoder is working. Use the stop button to cancel.' + : 'Enter sends. Shift+Enter creates a new line.'; + newChatButton.disabled = isActive; + } + function readStoredMessages() { try { const storedValue = window.localStorage.getItem(storageKey); @@ -859,13 +951,116 @@ export function renderWebModePage(): string { emptyState.hidden = true; } + function appendInlineMarkdown(element, text) { + const inlineCodeMarker = String.fromCharCode(96); + let remainingText = text; + + while (remainingText) { + const strongIndex = remainingText.indexOf('**'); + const codeIndex = remainingText.indexOf(inlineCodeMarker); + const markerIndexes = [strongIndex, codeIndex].filter(index => index >= 0); + + if (markerIndexes.length === 0) { + element.append(document.createTextNode(remainingText)); + return; + } + + const markerIndex = Math.min(...markerIndexes); + if (markerIndex > 0) { + element.append(document.createTextNode(remainingText.slice(0, markerIndex))); + remainingText = remainingText.slice(markerIndex); + } + + const marker = remainingText.startsWith('**') ? '**' : inlineCodeMarker; + const closingIndex = remainingText.indexOf(marker, marker.length); + if (closingIndex < 0) { + element.append(document.createTextNode(remainingText)); + return; + } + + const inlineElement = document.createElement(marker === '**' ? 'strong' : 'code'); + inlineElement.textContent = remainingText.slice(marker.length, closingIndex); + element.append(inlineElement); + remainingText = remainingText.slice(closingIndex + marker.length); + } + } + + function renderAssistantText(element, text) { + element.replaceChildren(); + const codeFence = String.fromCharCode(96).repeat(3); + let codeElement = null; + let listElement = null; + + for (const line of text.split('\\n')) { + if (line.trim().startsWith(codeFence)) { + if (codeElement) { + codeElement = null; + } else { + const preElement = document.createElement('pre'); + codeElement = document.createElement('code'); + preElement.append(codeElement); + element.append(preElement); + } + listElement = null; + continue; + } + + if (codeElement) { + codeElement.textContent += (codeElement.textContent ? '\\n' : '') + line; + continue; + } + + if (!line.trim()) { + listElement = null; + continue; + } + + const headingMatch = /^(#{1,3})\\s+(.*)$/.exec(line); + const unorderedMatch = /^[-*]\\s+(.*)$/.exec(line); + const orderedMatch = /^\\d+\\.\\s+(.*)$/.exec(line); + + if (headingMatch) { + const headingElement = document.createElement('h' + headingMatch[1].length); + appendInlineMarkdown(headingElement, headingMatch[2]); + element.append(headingElement); + listElement = null; + continue; + } + + const listMatch = unorderedMatch ?? orderedMatch; + if (listMatch) { + const listTag = unorderedMatch ? 'UL' : 'OL'; + if (!listElement || listElement.tagName !== listTag) { + listElement = document.createElement(listTag.toLowerCase()); + element.append(listElement); + } + const itemElement = document.createElement('li'); + appendInlineMarkdown(itemElement, listMatch[1]); + listElement.append(itemElement); + continue; + } + + const paragraphElement = document.createElement('p'); + appendInlineMarkdown(paragraphElement, line); + element.append(paragraphElement); + listElement = null; + } + } + function appendMessage(role, text, metaText, shouldStore = true) { hideEmptyState(); const messageElement = document.createElement('div'); messageElement.className = 'message ' + role; - const textElement = document.createElement('div'); - textElement.textContent = text; - messageElement.append(textElement); + const textElement = document.createElement('div'); + textElement.className = 'message-content'; + if (role === 'assistant') { + textElement.classList.add('markdown'); + textElement.dataset.rawText = text; + renderAssistantText(textElement, text); + } else { + textElement.textContent = text; + } + messageElement.append(textElement); if (metaText) { const metaElement = document.createElement('div'); @@ -934,12 +1129,14 @@ export function renderWebModePage(): string { function appendAssistantDelta(id, text) { let messageElement = assistantMessages.get(id); if (!messageElement) { - messageElement = appendMessage('assistant', '', 'Assistant output'); + messageElement = appendMessage('assistant', ''); assistantMessages.set(id, messageElement); } const textElement = messageElement.firstElementChild; - textElement.textContent += text; + const nextText = (textElement.dataset.rawText ?? '') + text; + textElement.dataset.rawText = nextText; + renderAssistantText(textElement, nextText); messageList.scrollTop = messageList.scrollHeight; } @@ -999,11 +1196,15 @@ export function renderWebModePage(): string { } if (message.type === 'turn_completed') { - appendMessage('system', 'Turn completed', message.id); + if (message.id === activeTurnId) { + setActiveTurn(null); + messageInput.focus(); + } return; } if (message.type === 'error') { + setActiveTurn(null); appendMessage('system error', message.message); return; } @@ -1012,6 +1213,10 @@ export function renderWebModePage(): string { } function submitUserMessage(text) { + if (activeTurnId) { + return; + } + const trimmedText = text.trim(); if (!trimmedText) { return; @@ -1021,10 +1226,12 @@ export function renderWebModePage(): string { const messageElement = appendMessage('user', trimmedText, 'Sending...'); pendingMessages.set(id, messageElement); messageInput.value = ''; + setActiveTurn(id); if (!sendClientEvent({type: 'user_message', id, text: trimmedText})) { updateMessageMeta(messageElement, 'Not sent'); pendingMessages.delete(id); + setActiveTurn(null); } } @@ -1059,11 +1266,13 @@ export function renderWebModePage(): string { } }); socket.addEventListener('close', () => { + setActiveTurn(null); setStatus('Disconnected', 'disconnected'); setComposerEnabled(false); setEmptyState('Disconnected', 'Restart nanocoder --web to open a fresh local session.'); }); socket.addEventListener('error', () => { + setActiveTurn(null); setStatus('Connection failed', 'failed'); setComposerEnabled(false); setEmptyState('Connection failed', 'The browser could not reach the local Nanocoder server.'); @@ -1071,6 +1280,13 @@ export function renderWebModePage(): string { messageForm.addEventListener('submit', event => { event.preventDefault(); + if (activeTurnId) { + sendClientEvent({type: 'cancel', id: activeTurnId}); + sendButton.disabled = true; + composerNote.textContent = 'Cancelling the active Nanocoder turn...'; + return; + } + submitUserMessage(messageInput.value); }); diff --git a/source/web/runtime-bridge.spec.ts b/source/web/runtime-bridge.spec.ts new file mode 100644 index 000000000..f339aca70 --- /dev/null +++ b/source/web/runtime-bridge.spec.ts @@ -0,0 +1,147 @@ +import test from 'ava'; +import type {WebServerEvent} from './protocol.js'; +import {createWebRuntimeBridge} from './runtime-bridge.js'; + +const userMessage = (id: string, text = 'hello') => ({ + type: 'user_message' as const, + id, + text, +}); + +test('web runtime bridge rejects messages until the runtime is ready', async t => { + const bridge = createWebRuntimeBridge(() => {}); + + await t.throwsAsync(bridge.handleClientEvent(userMessage('turn-1')), { + message: 'Nanocoder runtime is still starting.', + }); +}); + +test('web runtime bridge accepts one browser turn without waiting for completion', async t => { + const submittedMessages: string[] = []; + let resolveSubmission: (() => void) | undefined; + const submission = new Promise(resolve => { + resolveSubmission = resolve; + }); + const bridge = createWebRuntimeBridge(() => {}); + bridge.bindRuntimeHandlers({ + submitMessage: text => { + submittedMessages.push(text); + return submission; + }, + cancel: () => {}, + }); + + await bridge.handleClientEvent(userMessage('turn-1', 'from browser')); + + t.deepEqual(submittedMessages, ['from browser']); + await t.throwsAsync(bridge.handleClientEvent(userMessage('turn-2')), { + message: 'Nanocoder is already processing a browser turn.', + }); + + resolveSubmission?.(); + await submission; +}); + +test('web runtime bridge publishes assistant deltas and completion for the active turn', async t => { + const events: WebServerEvent[] = []; + let resolveSubmission: (() => void) | undefined; + const submission = new Promise(resolve => { + resolveSubmission = resolve; + }); + const bridge = createWebRuntimeBridge(event => { + events.push(event); + }); + bridge.bindRuntimeHandlers({ + submitMessage: () => submission, + cancel: () => {}, + }); + + await bridge.handleClientEvent(userMessage('turn-1')); + bridge.publishAssistantContent('Hel'); + bridge.publishAssistantContent('Hello'); + bridge.publishAssistantContent(''); + bridge.publishAssistantContent('Again'); + bridge.completeTurn(); + + t.deepEqual(events, [ + {type: 'assistant_delta', id: 'turn-1', text: 'Hel'}, + {type: 'assistant_delta', id: 'turn-1', text: 'lo'}, + {type: 'assistant_delta', id: 'turn-1', text: 'Again'}, + {type: 'turn_completed', id: 'turn-1'}, + ]); + + resolveSubmission?.(); + await submission; + t.is(events.length, 4); +}); + +test('web runtime bridge cancels only the matching active browser turn', async t => { + let cancelCount = 0; + const bridge = createWebRuntimeBridge(() => {}); + bridge.bindRuntimeHandlers({ + submitMessage: () => new Promise(() => {}), + cancel: () => { + cancelCount++; + }, + }); + + await bridge.handleClientEvent(userMessage('turn-1')); + await t.throwsAsync( + bridge.handleClientEvent({type: 'cancel', id: 'turn-2'}), + {message: 'This browser turn is no longer active.'}, + ); + await bridge.handleClientEvent({type: 'cancel', id: 'turn-1'}); + + t.is(cancelCount, 1); +}); + +test('web runtime bridge reports asynchronous submission failures and clears the turn', async t => { + const events: WebServerEvent[] = []; + const submittedMessages: string[] = []; + const bridge = createWebRuntimeBridge(event => { + events.push(event); + }); + bridge.bindRuntimeHandlers({ + submitMessage: async text => { + submittedMessages.push(text); + if (text === 'fail') { + throw new Error('Model request failed.'); + } + }, + cancel: () => {}, + }); + + await bridge.handleClientEvent(userMessage('turn-1', 'fail')); + await new Promise(resolve => setTimeout(resolve, 0)); + await bridge.handleClientEvent(userMessage('turn-2', 'retry')); + await new Promise(resolve => setTimeout(resolve, 0)); + + t.deepEqual(submittedMessages, ['fail', 'retry']); + t.deepEqual(events, [ + {type: 'error', message: 'Model request failed.'}, + {type: 'turn_completed', id: 'turn-2'}, + ]); +}); + +test('web runtime bridge cleanup does not remove a newer handler binding', async t => { + const submittedMessages: string[] = []; + const bridge = createWebRuntimeBridge(() => {}); + const releaseFirstBinding = bridge.bindRuntimeHandlers({ + submitMessage: text => { + submittedMessages.push(`first:${text}`); + }, + cancel: () => {}, + }); + bridge.bindRuntimeHandlers({ + submitMessage: text => { + submittedMessages.push(`second:${text}`); + }, + cancel: () => {}, + }); + + releaseFirstBinding(); + await bridge.handleClientEvent(userMessage('turn-1', 'hello')); + await new Promise(resolve => setTimeout(resolve, 0)); + + t.deepEqual(submittedMessages, ['second:hello']); +}); diff --git a/source/web/runtime-bridge.ts b/source/web/runtime-bridge.ts new file mode 100644 index 000000000..82ddaf7c9 --- /dev/null +++ b/source/web/runtime-bridge.ts @@ -0,0 +1,132 @@ +import type {WebClientEvent, WebServerEvent} from './protocol.js'; + +export interface WebRuntimeHandlers { + submitMessage: (text: string) => void | Promise; + cancel: () => void; +} + +export interface WebRuntimeBridge { + handleClientEvent: (event: WebClientEvent) => Promise; + bindRuntimeHandlers: (handlers: WebRuntimeHandlers) => () => void; + publishAssistantContent: (content: string) => void; + completeTurn: () => void; + failTurn: (error: unknown) => void; +} + +export function createWebRuntimeBridge( + broadcastEvent: (event: WebServerEvent) => void, +): WebRuntimeBridge { + let runtimeHandlers: WebRuntimeHandlers | null = null; + let activeTurnId: string | null = null; + let previousAssistantContent = ''; + + const clearActiveTurn = () => { + activeTurnId = null; + previousAssistantContent = ''; + }; + + const completeActiveTurn = (expectedTurnId?: string) => { + if (!activeTurnId || (expectedTurnId && activeTurnId !== expectedTurnId)) { + return; + } + + broadcastEvent({type: 'turn_completed', id: activeTurnId}); + clearActiveTurn(); + }; + + const failActiveTurn = (error: unknown, expectedTurnId?: string) => { + if (!activeTurnId || (expectedTurnId && activeTurnId !== expectedTurnId)) { + return; + } + + broadcastEvent({ + type: 'error', + message: + error instanceof Error + ? error.message + : 'Nanocoder could not complete this turn.', + }); + clearActiveTurn(); + }; + + return { + async handleClientEvent(event) { + if (event.type === 'hello') { + return; + } + + if (!runtimeHandlers) { + throw new Error('Nanocoder runtime is still starting.'); + } + + if (event.type === 'cancel') { + if (!activeTurnId || event.id !== activeTurnId) { + throw new Error('This browser turn is no longer active.'); + } + + runtimeHandlers.cancel(); + return; + } + + if (activeTurnId) { + throw new Error('Nanocoder is already processing a browser turn.'); + } + + activeTurnId = event.id; + previousAssistantContent = ''; + + try { + const submission = runtimeHandlers.submitMessage(event.text); + void Promise.resolve(submission).then( + () => completeActiveTurn(event.id), + error => failActiveTurn(error, event.id), + ); + } catch (error) { + clearActiveTurn(); + throw error; + } + }, + + bindRuntimeHandlers(handlers) { + runtimeHandlers = handlers; + + return () => { + if (runtimeHandlers === handlers) { + runtimeHandlers = null; + } + }; + }, + + publishAssistantContent(content) { + if (!activeTurnId) { + return; + } + + if (content.length === 0) { + previousAssistantContent = ''; + return; + } + + const delta = content.startsWith(previousAssistantContent) + ? content.slice(previousAssistantContent.length) + : content; + previousAssistantContent = content; + + if (delta.length > 0) { + broadcastEvent({ + type: 'assistant_delta', + id: activeTurnId, + text: delta, + }); + } + }, + + completeTurn() { + completeActiveTurn(); + }, + + failTurn(error) { + failActiveTurn(error); + }, + }; +} From 5690d18b9f32c5c963fd910102327451b5736df3 Mon Sep 17 00:00:00 2001 From: Dhirender Choudhary Date: Wed, 29 Jul 2026 18:00:23 +0530 Subject: [PATCH 02/13] feat: add browser approvals, questions, and tool status for web mode (#713) Close the local coding-agent interaction loop so browser turns can approve tools, answer questions, and show live tool progress without changing runtime policy ownership (#628 Phase 5). --- .changeset/calm-web-world.md | 5 + source/app/App.tsx | 18 ++ source/app/hooks/useGlobalHandlerQueues.tsx | 34 +++ source/cli.tsx | 3 + .../conversation/tool-executor.tsx | 38 ++- source/web/page.spec.ts | 26 ++ source/web/page.ts | 228 ++++++++++++++++- source/web/protocol.spec.ts | 79 ++++++ source/web/protocol.ts | 48 +++- source/web/runtime-bridge.spec.ts | 129 ++++++++++ source/web/runtime-bridge.ts | 229 ++++++++++++++++++ source/web/server.ts | 4 + source/web/tool-lifecycle.ts | 30 +++ 13 files changed, 857 insertions(+), 14 deletions(-) create mode 100644 .changeset/calm-web-world.md create mode 100644 source/web/protocol.spec.ts create mode 100644 source/web/tool-lifecycle.ts diff --git a/.changeset/calm-web-world.md b/.changeset/calm-web-world.md new file mode 100644 index 000000000..d77080bb9 --- /dev/null +++ b/.changeset/calm-web-world.md @@ -0,0 +1,5 @@ +--- +"@nanocollective/nanocoder": minor +--- + +Add browser approvals, questions, and live tool status to local web mode so coding-agent turns can finish safely in the browser UI. diff --git a/source/app/App.tsx b/source/app/App.tsx index ac43f6628..8e07c8932 100644 --- a/source/app/App.tsx +++ b/source/app/App.tsx @@ -49,6 +49,7 @@ import {setGlobalMessageQueue} from '@/utils/message-queue'; import {setNotificationsConfig} from '@/utils/notifications'; import {getShutdownManager} from '@/utils/shutdown'; import {isExtensionInstalled} from '@/vscode/extension-installer'; +import {setWebToolLifecyclePublisher} from '@/web/tool-lifecycle'; export default function App({ vscodeMode = false, @@ -196,6 +197,7 @@ export default function App({ } = useGlobalHandlerQueues({ setPendingQuestion: appState.setPendingQuestion, setIsQuestionMode: appState.setIsQuestionMode, + webRuntimeBridge, }); // Initialize notifications config from app config (once) @@ -536,6 +538,22 @@ export default function App({ webRuntimeBridge?.publishAssistantContent(chatHandler.streamingContent); }, [webRuntimeBridge, chatHandler.streamingContent]); + React.useEffect(() => { + if (!webRuntimeBridge) { + return; + } + + setWebToolLifecyclePublisher({ + started: (id, name) => webRuntimeBridge.publishToolStarted(id, name), + finished: (id, name, ok) => + webRuntimeBridge.publishToolFinished(id, name, ok), + }); + + return () => { + setWebToolLifecyclePublisher(null); + }; + }, [webRuntimeBridge]); + // Apply a session resolved by cli.tsx from --continue/--resume (or open // the picker for a bare --resume), once on mount. Reuses the exact same // applySession path as the in-app /resume command so messages, provider, diff --git a/source/app/hooks/useGlobalHandlerQueues.tsx b/source/app/hooks/useGlobalHandlerQueues.tsx index 1fa7c37fa..7d70c674d 100644 --- a/source/app/hooks/useGlobalHandlerQueues.tsx +++ b/source/app/hooks/useGlobalHandlerQueues.tsx @@ -11,10 +11,12 @@ import { type PendingToolConfirmation, setGlobalToolConfirmHandler, } from '@/utils/tool-confirm-queue'; +import type {WebRuntimeBridge} from '@/web/runtime-bridge'; interface UseGlobalHandlerQueuesProps { setPendingQuestion: (question: PendingQuestion | null) => void; setIsQuestionMode: (mode: boolean) => void; + webRuntimeBridge?: WebRuntimeBridge; } interface GlobalHandlerQueues { @@ -30,6 +32,9 @@ interface GlobalHandlerQueues { * - question-queue (ask_question tool) drives the question prompt UI * - tool-approval-queue (subagent tool calls) drives a parallel approval flow * + * During an active browser-owned turn, requests route through the web runtime + * bridge instead of the terminal Ink prompts so the browser can resolve them. + * * The tool-approval queue uses a dedicated state slot so it doesn't conflict * with the main agent's tool confirmation flow — a subagent's tool can need * approval while the parent agent is mid-conversation. @@ -37,11 +42,23 @@ interface GlobalHandlerQueues { export function useGlobalHandlerQueues({ setPendingQuestion, setIsQuestionMode, + webRuntimeBridge, }: UseGlobalHandlerQueuesProps): GlobalHandlerQueues { const questionResolverRef = useRef<((answer: string) => void) | null>(null); + const webRuntimeBridgeRef = useRef(webRuntimeBridge); + webRuntimeBridgeRef.current = webRuntimeBridge; useEffect(() => { setGlobalQuestionHandler((question: PendingQuestion) => { + const bridge = webRuntimeBridgeRef.current; + if (bridge?.hasActiveBrowserTurn()) { + return bridge.requestQuestion({ + question: question.question, + options: question.options, + allowFreeform: question.allowFreeform, + }); + } + return new Promise(resolve => { questionResolverRef.current = resolve; setPendingQuestion(question); @@ -70,6 +87,15 @@ export function useGlobalHandlerQueues({ useEffect(() => { setGlobalToolApprovalHandler((approval: PendingToolApproval) => { + const bridge = webRuntimeBridgeRef.current; + if (bridge?.hasActiveBrowserTurn()) { + return bridge.requestApproval({ + toolName: approval.toolCall.function.name, + arguments: approval.toolCall.function.arguments, + context: `Subagent: ${approval.subagentName}`, + }); + } + return new Promise(resolve => { toolApprovalResolverRef.current = resolve; setPendingSubagentApproval(approval); @@ -97,6 +123,14 @@ export function useGlobalHandlerQueues({ useEffect(() => { setGlobalToolConfirmHandler((confirmation: PendingToolConfirmation) => { + const bridge = webRuntimeBridgeRef.current; + if (bridge?.hasActiveBrowserTurn()) { + return bridge.requestApproval({ + toolName: confirmation.toolCall.function.name, + arguments: confirmation.toolCall.function.arguments, + }); + } + return new Promise(resolve => { toolConfirmResolverRef.current = resolve; setPendingToolConfirmation(confirmation); diff --git a/source/cli.tsx b/source/cli.tsx index 9f4326750..1a5b93351 100644 --- a/source/cli.tsx +++ b/source/cli.tsx @@ -603,6 +603,9 @@ async function main(): Promise { }); const webServer = await startLocalWebServer({ onClientEvent: webRuntimeBridge.handleClientEvent, + onAllClientsDisconnected: () => { + webRuntimeBridge?.handleDisconnect(); + }, }); broadcastEvent = webServer.broadcastEvent; diff --git a/source/hooks/chat-handler/conversation/tool-executor.tsx b/source/hooks/chat-handler/conversation/tool-executor.tsx index 84ea777df..b195d3df8 100644 --- a/source/hooks/chat-handler/conversation/tool-executor.tsx +++ b/source/hooks/chat-handler/conversation/tool-executor.tsx @@ -38,10 +38,20 @@ const executeOne = async ( toolCall: ToolCall; result: ToolResult; }> => { + const {publishWebToolFinished, publishWebToolStarted} = await import( + '@/web/tool-lifecycle' + ); + publishWebToolStarted(toolCall.id, toolCall.function.name); try { const result = await processToolUse(toolCall); + publishWebToolFinished( + toolCall.id, + toolCall.function.name, + !result.content.startsWith('Error: '), + ); return {toolCall, result}; } catch (error) { + publishWebToolFinished(toolCall.id, toolCall.function.name, false); return { toolCall, result: { @@ -90,7 +100,7 @@ export interface ToolDisplayOptions { * the validated registry handler. The single per-tool execution primitive * shared by the auto-execute batch and the (post-approval) confirmation path. */ -export const executeApprovedTool = ( +export const executeApprovedTool = async ( toolCall: ToolCall, toolManager: ToolManager | null, processToolUse: (toolCall: ToolCall) => Promise, @@ -98,12 +108,28 @@ export const executeApprovedTool = ( signal?: AbortSignal, ): Promise => { if (toolCall.function.name === 'execute_bash' && setLiveComponent) { - return executeBashStreaming( - toolCall, - toolManager, - setLiveComponent, - signal, + const {publishWebToolFinished, publishWebToolStarted} = await import( + '@/web/tool-lifecycle' ); + publishWebToolStarted(toolCall.id, toolCall.function.name); + try { + const execution = await executeBashStreaming( + toolCall, + toolManager, + setLiveComponent, + signal, + ); + publishWebToolFinished( + toolCall.id, + toolCall.function.name, + !execution.result.content.startsWith('Error: ') && + !execution.result.content.startsWith('⚒ Validation failed'), + ); + return execution; + } catch (error) { + publishWebToolFinished(toolCall.id, toolCall.function.name, false); + throw error; + } } return executeOne(toolCall, processToolUse); }; diff --git a/source/web/page.spec.ts b/source/web/page.spec.ts index 864f5f3c8..54226c29b 100644 --- a/source/web/page.spec.ts +++ b/source/web/page.spec.ts @@ -62,3 +62,29 @@ test('web mode renders streamed assistant output as safe markdown', t => { t.true(page.includes('textElement.dataset.rawText = nextText')); t.false(page.includes("appendMessage('assistant', '', 'Assistant output')")); }); + +test('web mode renders actionable approval and question cards', t => { + const page = renderWebModePage(); + + t.true(page.includes('function renderApprovalCard(message)')); + t.true(page.includes('function renderQuestionCard(message)')); + t.true(page.includes("type: 'approval_response'")); + t.true(page.includes("type: 'question_response'")); + t.true(page.includes("approveButton.textContent = 'Approve'")); + t.true(page.includes("denyButton.textContent = 'Deny'")); + t.true(page.includes("message.allowFreeform")); + t.false(page.includes("appendMessage('system', message.reason, 'Approval required')")); + t.false(page.includes("appendMessage('system', message.question, 'Question required')")); +}); + +test('web mode shows live tool running and completed status', t => { + const page = renderWebModePage(); + + t.true(page.includes("'system tool-status', 'Running tool: ' + message.name")); + t.true(page.includes("'Tool finished: ' + message.name")); + t.true( + page.includes( + "'Provider and model stay in the terminal runtime. During a browser turn, approvals and questions are answered here.'", + ), + ); +}); diff --git a/source/web/page.ts b/source/web/page.ts index e4e626fb1..6e7cadfe7 100644 --- a/source/web/page.ts +++ b/source/web/page.ts @@ -350,6 +350,73 @@ export function renderWebModePage(): string { background: rgba(8, 9, 11, 0.26); color: #beb7c7; } + .message.interaction { + align-self: stretch; + width: min(760px, 100%); + border-color: rgba(125, 207, 255, 0.28); + background: rgba(36, 40, 59, 0.92); + } + .interaction-card { + display: grid; + gap: 12px; + } + .interaction-card pre { + margin: 0; + overflow-x: auto; + border: 1px solid var(--tn-border); + border-radius: 8px; + background: #12131c; + padding: 12px 14px; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 13px; + white-space: pre-wrap; + } + .interaction-actions, + .question-options { + display: flex; + flex-wrap: wrap; + gap: 10px; + } + .interaction-actions button, + .question-options button { + min-height: 36px; + padding: 0 14px; + border: 1px solid rgba(125, 207, 255, 0.28); + border-radius: 8px; + background: rgba(125, 207, 255, 0.12); + color: #f5f2eb; + cursor: pointer; + font-size: 14px; + font-weight: 650; + } + .interaction-actions button[data-approved="false"] { + border-color: rgba(247, 118, 142, 0.35); + background: rgba(247, 118, 142, 0.12); + } + .interaction-actions button:disabled, + .question-options button:disabled, + .question-freeform button:disabled { + opacity: 0.55; + cursor: default; + } + .question-freeform { + display: grid; + gap: 8px; + grid-template-columns: minmax(0, 1fr) auto; + } + .question-freeform input { + min-height: 36px; + padding: 0 12px; + border: 1px solid var(--tn-border); + border-radius: 8px; + background: rgba(8, 9, 11, 0.35); + color: #f5f2eb; + } + .message.tool-status { + align-self: center; + width: min(760px, 100%); + border-style: dashed; + } .empty-state { position: absolute; z-index: 2; @@ -1150,6 +1217,150 @@ export function renderWebModePage(): string { return true; } + function formatToolArguments(args) { + try { + return JSON.stringify(args ?? {}, null, 2); + } catch { + return '{}'; + } + } + + function disableInteractionCard(card) { + for (const control of card.querySelectorAll('button, input')) { + control.disabled = true; + } + } + + function renderApprovalCard(message) { + hideEmptyState(); + const messageElement = document.createElement('div'); + messageElement.className = 'message system interaction'; + const card = document.createElement('div'); + card.className = 'interaction-card'; + + const title = document.createElement('strong'); + title.textContent = 'Approve tool: ' + message.toolName; + card.append(title); + + if (message.context) { + const context = document.createElement('div'); + context.className = 'meta'; + context.textContent = message.context; + card.append(context); + } + + const args = document.createElement('pre'); + args.textContent = formatToolArguments(message.arguments); + card.append(args); + + const actions = document.createElement('div'); + actions.className = 'interaction-actions'; + const approveButton = document.createElement('button'); + approveButton.type = 'button'; + approveButton.dataset.approved = 'true'; + approveButton.textContent = 'Approve'; + const denyButton = document.createElement('button'); + denyButton.type = 'button'; + denyButton.dataset.approved = 'false'; + denyButton.textContent = 'Deny'; + actions.append(approveButton, denyButton); + card.append(actions); + messageElement.append(card); + messageList.append(messageElement); + messageList.scrollTop = messageList.scrollHeight; + + const respond = (approved) => { + disableInteractionCard(card); + const meta = document.createElement('div'); + meta.className = 'meta'; + meta.textContent = approved ? 'Approved' : 'Denied'; + messageElement.append(meta); + sendClientEvent({ + type: 'approval_response', + id: message.id, + approved, + }); + }; + + approveButton.addEventListener('click', () => respond(true)); + denyButton.addEventListener('click', () => respond(false)); + } + + function renderQuestionCard(message) { + hideEmptyState(); + const messageElement = document.createElement('div'); + messageElement.className = 'message system interaction'; + const card = document.createElement('div'); + card.className = 'interaction-card'; + + const title = document.createElement('strong'); + title.textContent = message.question; + card.append(title); + + const options = document.createElement('div'); + options.className = 'question-options'; + for (const option of message.options || []) { + const optionButton = document.createElement('button'); + optionButton.type = 'button'; + optionButton.textContent = option; + optionButton.addEventListener('click', () => { + disableInteractionCard(card); + const meta = document.createElement('div'); + meta.className = 'meta'; + meta.textContent = 'Answered'; + messageElement.append(meta); + sendClientEvent({ + type: 'question_response', + id: message.id, + answer: option, + }); + }); + options.append(optionButton); + } + card.append(options); + + if (message.allowFreeform) { + const freeform = document.createElement('div'); + freeform.className = 'question-freeform'; + const input = document.createElement('input'); + input.type = 'text'; + input.placeholder = 'Type a custom answer'; + input.autocomplete = 'off'; + const answerButton = document.createElement('button'); + answerButton.type = 'button'; + answerButton.textContent = 'Send answer'; + const submitFreeform = () => { + const answer = input.value.trim(); + if (!answer) { + return; + } + disableInteractionCard(card); + const meta = document.createElement('div'); + meta.className = 'meta'; + meta.textContent = 'Answered'; + messageElement.append(meta); + sendClientEvent({ + type: 'question_response', + id: message.id, + answer, + }); + }; + answerButton.addEventListener('click', submitFreeform); + input.addEventListener('keydown', event => { + if (event.key === 'Enter') { + event.preventDefault(); + submitFreeform(); + } + }); + freeform.append(input, answerButton); + card.append(freeform); + } + + messageElement.append(card); + messageList.append(messageElement); + messageList.scrollTop = messageList.scrollHeight; + } + function handleServerEvent(message) { if (message.type === 'ready') { setStatus('Connected', 'connected'); @@ -1176,22 +1387,26 @@ export function renderWebModePage(): string { } if (message.type === 'tool_started') { - appendMessage('system', 'Tool started: ' + message.name); + appendMessage('system tool-status', 'Running tool: ' + message.name, 'In progress'); return; } if (message.type === 'tool_finished') { - appendMessage('system', 'Tool finished: ' + message.name, message.ok ? 'Completed' : 'Failed'); + appendMessage( + 'system tool-status', + 'Tool finished: ' + message.name, + message.ok ? 'Completed' : 'Failed', + ); return; } if (message.type === 'approval_required') { - appendMessage('system', message.reason, 'Approval required'); + renderApprovalCard(message); return; } if (message.type === 'question_required') { - appendMessage('system', message.question, 'Question required'); + renderQuestionCard(message); return; } @@ -1317,7 +1532,10 @@ export function renderWebModePage(): string { }); settingsButton.addEventListener('click', () => { - addSystemNotice('Provider, model, tools, and approvals are still owned by the terminal runtime in this phase.', 'Session settings'); + addSystemNotice( + 'Provider and model stay in the terminal runtime. During a browser turn, approvals and questions are answered here.', + 'Session settings', + ); }); threadSearchInput.addEventListener('input', () => { diff --git a/source/web/protocol.spec.ts b/source/web/protocol.spec.ts new file mode 100644 index 000000000..84959e1b4 --- /dev/null +++ b/source/web/protocol.spec.ts @@ -0,0 +1,79 @@ +import test from 'ava'; +import {parseWebClientEvent, WEB_PROTOCOL_VERSION} from './protocol.js'; + +test('parseWebClientEvent accepts approval and question responses', t => { + t.deepEqual( + parseWebClientEvent( + JSON.stringify({ + type: 'approval_response', + id: 'approval-1', + approved: true, + }), + ), + {type: 'approval_response', id: 'approval-1', approved: true}, + ); + + t.deepEqual( + parseWebClientEvent( + JSON.stringify({ + type: 'question_response', + id: 'question-1', + answer: 'Use the existing bridge', + }), + ), + { + type: 'question_response', + id: 'question-1', + answer: 'Use the existing bridge', + }, + ); +}); + +test('parseWebClientEvent still accepts the phase 4 handshake events', t => { + t.deepEqual( + parseWebClientEvent( + JSON.stringify({ + type: 'hello', + protocolVersion: WEB_PROTOCOL_VERSION, + }), + ), + {type: 'hello', protocolVersion: WEB_PROTOCOL_VERSION}, + ); + + t.deepEqual( + parseWebClientEvent( + JSON.stringify({ + type: 'user_message', + id: 'turn-1', + text: 'hello', + }), + ), + {type: 'user_message', id: 'turn-1', text: 'hello'}, + ); +}); + +test('parseWebClientEvent rejects malformed interaction responses', t => { + t.throws( + () => + parseWebClientEvent( + JSON.stringify({type: 'approval_response', id: 'a', approved: 'yes'}), + ), + {message: 'Approval response approved flag is required.'}, + ); + + t.throws( + () => + parseWebClientEvent( + JSON.stringify({type: 'question_response', id: 'q', answer: 12}), + ), + {message: 'Question response answer is required.'}, + ); + + t.throws( + () => + parseWebClientEvent( + JSON.stringify({type: 'approval_response', approved: false}), + ), + {message: 'Approval response id is required.'}, + ); +}); diff --git a/source/web/protocol.ts b/source/web/protocol.ts index 69648f533..1b3367dfd 100644 --- a/source/web/protocol.ts +++ b/source/web/protocol.ts @@ -3,7 +3,9 @@ export const WEB_PROTOCOL_VERSION = 1; export type WebClientEvent = | {type: 'hello'; protocolVersion: typeof WEB_PROTOCOL_VERSION} | {type: 'user_message'; id: string; text: string} - | {type: 'cancel'; id: string}; + | {type: 'cancel'; id: string} + | {type: 'approval_response'; id: string; approved: boolean} + | {type: 'question_response'; id: string; answer: string}; export type WebServerEvent = | {type: 'ready'; protocolVersion: typeof WEB_PROTOCOL_VERSION} @@ -11,8 +13,20 @@ export type WebServerEvent = | {type: 'assistant_delta'; id: string; text: string} | {type: 'tool_started'; id: string; name: string} | {type: 'tool_finished'; id: string; name: string; ok: boolean} - | {type: 'approval_required'; id: string; reason: string} - | {type: 'question_required'; id: string; question: string} + | { + type: 'approval_required'; + id: string; + toolName: string; + arguments: Record; + context?: string; + } + | { + type: 'question_required'; + id: string; + question: string; + options: string[]; + allowFreeform: boolean; + } | {type: 'turn_completed'; id: string} | {type: 'error'; message: string}; @@ -61,6 +75,34 @@ export function parseWebClientEvent(rawMessage: string): WebClientEvent { type: 'cancel', id: parsed.id, }; + case 'approval_response': + if (typeof parsed.id !== 'string' || parsed.id.length === 0) { + throw new Error('Approval response id is required.'); + } + + if (typeof parsed.approved !== 'boolean') { + throw new Error('Approval response approved flag is required.'); + } + + return { + type: 'approval_response', + id: parsed.id, + approved: parsed.approved, + }; + case 'question_response': + if (typeof parsed.id !== 'string' || parsed.id.length === 0) { + throw new Error('Question response id is required.'); + } + + if (typeof parsed.answer !== 'string') { + throw new Error('Question response answer is required.'); + } + + return { + type: 'question_response', + id: parsed.id, + answer: parsed.answer, + }; default: throw new Error(`Unsupported web event type: ${parsed.type}.`); } diff --git a/source/web/runtime-bridge.spec.ts b/source/web/runtime-bridge.spec.ts index f339aca70..204798742 100644 --- a/source/web/runtime-bridge.spec.ts +++ b/source/web/runtime-bridge.spec.ts @@ -145,3 +145,132 @@ test('web runtime bridge cleanup does not remove a newer handler binding', async t.deepEqual(submittedMessages, ['second:hello']); }); + +test('web runtime bridge resolves matching approval responses during a browser turn', async t => { + const events: WebServerEvent[] = []; + const bridge = createWebRuntimeBridge(event => { + events.push(event); + }); + bridge.bindRuntimeHandlers({ + submitMessage: () => new Promise(() => {}), + cancel: () => {}, + }); + + await bridge.handleClientEvent(userMessage('turn-1')); + const approvalPromise = bridge.requestApproval({ + toolName: 'write_file', + arguments: {path: 'README.md'}, + context: 'Subagent: researcher', + }); + + t.like(events.at(-1), { + type: 'approval_required', + toolName: 'write_file', + arguments: {path: 'README.md'}, + context: 'Subagent: researcher', + }); + + const approvalEvent = events.at(-1); + if (!approvalEvent || approvalEvent.type !== 'approval_required') { + t.fail('expected approval_required event'); + return; + } + + await bridge.handleClientEvent({ + type: 'approval_response', + id: approvalEvent.id, + approved: true, + }); + + t.true(await approvalPromise); + await t.throwsAsync( + bridge.handleClientEvent({ + type: 'approval_response', + id: approvalEvent.id, + approved: false, + }), + {message: 'This approval response does not match a pending request.'}, + ); +}); + +test('web runtime bridge rejects stale question responses and clears on cancel', async t => { + const events: WebServerEvent[] = []; + let cancelCount = 0; + const bridge = createWebRuntimeBridge(event => { + events.push(event); + }); + bridge.bindRuntimeHandlers({ + submitMessage: () => new Promise(() => {}), + cancel: () => { + cancelCount++; + }, + }); + + await bridge.handleClientEvent(userMessage('turn-1')); + const questionPromise = bridge.requestQuestion({ + question: 'Which approach?', + options: ['A', 'B'], + allowFreeform: true, + }); + + const questionEvent = events.at(-1); + if (!questionEvent || questionEvent.type !== 'question_required') { + t.fail('expected question_required event'); + return; + } + + await t.throwsAsync( + bridge.handleClientEvent({ + type: 'question_response', + id: 'stale-id', + answer: 'A', + }), + {message: 'This question response does not match a pending request.'}, + ); + + await bridge.handleClientEvent({type: 'cancel', id: 'turn-1'}); + await t.throwsAsync(questionPromise, { + message: 'The browser turn was cancelled before the question was answered.', + }); + t.is(cancelCount, 1); +}); + +test('web runtime bridge publishes tool lifecycle only during an active browser turn', async t => { + const events: WebServerEvent[] = []; + const bridge = createWebRuntimeBridge(event => { + events.push(event); + }); + bridge.bindRuntimeHandlers({ + submitMessage: () => new Promise(() => {}), + cancel: () => {}, + }); + + bridge.publishToolStarted('tool-1', 'read_file'); + t.deepEqual(events, []); + + await bridge.handleClientEvent(userMessage('turn-1')); + bridge.publishToolStarted('tool-1', 'read_file'); + bridge.publishToolFinished('tool-1', 'read_file', true); + + t.deepEqual(events, [ + {type: 'tool_started', id: 'tool-1', name: 'read_file'}, + {type: 'tool_finished', id: 'tool-1', name: 'read_file', ok: true}, + ]); +}); + +test('web runtime bridge denies pending approval on disconnect', async t => { + const bridge = createWebRuntimeBridge(() => {}); + bridge.bindRuntimeHandlers({ + submitMessage: () => new Promise(() => {}), + cancel: () => {}, + }); + + await bridge.handleClientEvent(userMessage('turn-1')); + const approvalPromise = bridge.requestApproval({ + toolName: 'execute_bash', + arguments: {command: 'ls'}, + }); + bridge.handleDisconnect(); + + t.false(await approvalPromise); +}); diff --git a/source/web/runtime-bridge.ts b/source/web/runtime-bridge.ts index 82ddaf7c9..0395bdb0f 100644 --- a/source/web/runtime-bridge.ts +++ b/source/web/runtime-bridge.ts @@ -5,31 +5,99 @@ export interface WebRuntimeHandlers { cancel: () => void; } +export interface WebApprovalRequest { + toolName: string; + arguments: Record; + context?: string; +} + +export interface WebQuestionRequest { + question: string; + options: string[]; + allowFreeform: boolean; +} + export interface WebRuntimeBridge { handleClientEvent: (event: WebClientEvent) => Promise; bindRuntimeHandlers: (handlers: WebRuntimeHandlers) => () => void; publishAssistantContent: (content: string) => void; + publishToolStarted: (id: string, name: string) => void; + publishToolFinished: (id: string, name: string, ok: boolean) => void; + hasActiveBrowserTurn: () => boolean; + requestApproval: (request: WebApprovalRequest) => Promise; + requestQuestion: (request: WebQuestionRequest) => Promise; completeTurn: () => void; failTurn: (error: unknown) => void; + handleDisconnect: () => void; } +type PendingInteraction = + | { + id: string; + kind: 'approval'; + resolve: (approved: boolean) => void; + } + | { + id: string; + kind: 'question'; + resolve: (answer: string) => void; + reject: (error: Error) => void; + }; + export function createWebRuntimeBridge( broadcastEvent: (event: WebServerEvent) => void, ): WebRuntimeBridge { let runtimeHandlers: WebRuntimeHandlers | null = null; let activeTurnId: string | null = null; let previousAssistantContent = ''; + let pendingInteraction: PendingInteraction | null = null; + let interactionCounter = 0; const clearActiveTurn = () => { activeTurnId = null; previousAssistantContent = ''; }; + const settlePendingInteraction = (options: { + denyApprovals: boolean; + rejectQuestions: boolean; + questionMessage?: string; + }) => { + const pending = pendingInteraction; + pendingInteraction = null; + if (!pending) { + return; + } + + if (pending.kind === 'approval') { + pending.resolve(false); + return; + } + + if (options.rejectQuestions) { + pending.reject( + new Error( + options.questionMessage ?? + 'The browser question was cancelled before an answer arrived.', + ), + ); + return; + } + + pending.resolve(''); + }; + const completeActiveTurn = (expectedTurnId?: string) => { if (!activeTurnId || (expectedTurnId && activeTurnId !== expectedTurnId)) { return; } + settlePendingInteraction({ + denyApprovals: true, + rejectQuestions: true, + questionMessage: + 'The browser turn completed before the question was answered.', + }); broadcastEvent({type: 'turn_completed', id: activeTurnId}); clearActiveTurn(); }; @@ -39,6 +107,12 @@ export function createWebRuntimeBridge( return; } + settlePendingInteraction({ + denyApprovals: true, + rejectQuestions: true, + questionMessage: + 'The browser turn failed before the question was answered.', + }); broadcastEvent({ type: 'error', message: @@ -49,6 +123,11 @@ export function createWebRuntimeBridge( clearActiveTurn(); }; + const nextInteractionId = (kind: 'approval' | 'question') => { + interactionCounter += 1; + return `browser-${kind}-${interactionCounter}`; + }; + return { async handleClientEvent(event) { if (event.type === 'hello') { @@ -59,11 +138,59 @@ export function createWebRuntimeBridge( throw new Error('Nanocoder runtime is still starting.'); } + if (event.type === 'approval_response') { + if (!activeTurnId) { + throw new Error('No browser turn is waiting for approval.'); + } + + if ( + !pendingInteraction || + pendingInteraction.kind !== 'approval' || + pendingInteraction.id !== event.id + ) { + throw new Error( + 'This approval response does not match a pending request.', + ); + } + + const pending = pendingInteraction; + pendingInteraction = null; + pending.resolve(event.approved); + return; + } + + if (event.type === 'question_response') { + if (!activeTurnId) { + throw new Error('No browser turn is waiting for a question answer.'); + } + + if ( + !pendingInteraction || + pendingInteraction.kind !== 'question' || + pendingInteraction.id !== event.id + ) { + throw new Error( + 'This question response does not match a pending request.', + ); + } + + const pending = pendingInteraction; + pendingInteraction = null; + pending.resolve(event.answer); + return; + } + if (event.type === 'cancel') { if (!activeTurnId || event.id !== activeTurnId) { throw new Error('This browser turn is no longer active.'); } + settlePendingInteraction({ + denyApprovals: true, + rejectQuestions: true, + questionMessage: + 'The browser turn was cancelled before the question was answered.', + }); runtimeHandlers.cancel(); return; } @@ -92,6 +219,12 @@ export function createWebRuntimeBridge( return () => { if (runtimeHandlers === handlers) { + settlePendingInteraction({ + denyApprovals: true, + rejectQuestions: true, + questionMessage: + 'The browser runtime was unbound before the question was answered.', + }); runtimeHandlers = null; } }; @@ -121,6 +254,83 @@ export function createWebRuntimeBridge( } }, + publishToolStarted(id, name) { + if (!activeTurnId) { + return; + } + + broadcastEvent({type: 'tool_started', id, name}); + }, + + publishToolFinished(id, name, ok) { + if (!activeTurnId) { + return; + } + + broadcastEvent({type: 'tool_finished', id, name, ok}); + }, + + hasActiveBrowserTurn() { + return activeTurnId !== null; + }, + + requestApproval(request) { + if (!activeTurnId) { + return Promise.resolve(false); + } + + if (pendingInteraction) { + return Promise.resolve(false); + } + + const id = nextInteractionId('approval'); + return new Promise(resolve => { + pendingInteraction = { + id, + kind: 'approval', + resolve, + }; + broadcastEvent({ + type: 'approval_required', + id, + toolName: request.toolName, + arguments: sanitizeJsonRecord(request.arguments), + ...(request.context ? {context: request.context} : {}), + }); + }); + }, + + requestQuestion(request) { + if (!activeTurnId) { + return Promise.reject( + new Error('No browser turn is active for this question.'), + ); + } + + if (pendingInteraction) { + return Promise.reject( + new Error('Another browser interaction is already pending.'), + ); + } + + const id = nextInteractionId('question'); + return new Promise((resolve, reject) => { + pendingInteraction = { + id, + kind: 'question', + resolve, + reject, + }; + broadcastEvent({ + type: 'question_required', + id, + question: request.question, + options: [...request.options], + allowFreeform: request.allowFreeform, + }); + }); + }, + completeTurn() { completeActiveTurn(); }, @@ -128,5 +338,24 @@ export function createWebRuntimeBridge( failTurn(error) { failActiveTurn(error); }, + + handleDisconnect() { + settlePendingInteraction({ + denyApprovals: true, + rejectQuestions: true, + questionMessage: + 'The browser disconnected before the question was answered.', + }); + }, }; } + +function sanitizeJsonRecord( + value: Record, +): Record { + try { + return JSON.parse(JSON.stringify(value)) as Record; + } catch { + return {}; + } +} diff --git a/source/web/server.ts b/source/web/server.ts index 551db215d..5c4e6ad94 100644 --- a/source/web/server.ts +++ b/source/web/server.ts @@ -18,6 +18,7 @@ export interface LocalWebServerOptions { token?: string; openBrowser?: boolean; onClientEvent?: (event: WebClientEvent) => void | Promise; + onAllClientsDisconnected?: () => void; } export interface LocalWebServer { @@ -111,6 +112,9 @@ export async function startLocalWebServer( clientSocket.on('close', () => { connectedClients.delete(clientSocket); + if (connectedClients.size === 0) { + options.onAllClientsDisconnected?.(); + } }); }); diff --git a/source/web/tool-lifecycle.ts b/source/web/tool-lifecycle.ts new file mode 100644 index 000000000..016c4bf14 --- /dev/null +++ b/source/web/tool-lifecycle.ts @@ -0,0 +1,30 @@ +/** + * Optional publisher used by the conversation tool executor to surface + * tool start/finish events to an active browser turn. Terminal mode leaves + * this unset; web mode binds it through the runtime bridge. + */ + +export interface WebToolLifecyclePublisher { + started: (id: string, name: string) => void; + finished: (id: string, name: string, ok: boolean) => void; +} + +let publisher: WebToolLifecyclePublisher | null = null; + +export function setWebToolLifecyclePublisher( + next: WebToolLifecyclePublisher | null, +): void { + publisher = next; +} + +export function publishWebToolStarted(id: string, name: string): void { + publisher?.started(id, name); +} + +export function publishWebToolFinished( + id: string, + name: string, + ok: boolean, +): void { + publisher?.finished(id, name, ok); +} From 9310399198498e9178ba4344ceb18f177af0b506 Mon Sep 17 00:00:00 2001 From: Dhirender Choudhary Date: Wed, 5 Aug 2026 22:34:31 +0530 Subject: [PATCH 03/13] fix: address web mode review feedback (security, reliability, a11y) Responds to akramcodez's review on #628: adds a nonce-scoped CSP plus X-Frame-Options/X-Content-Type-Options, serves the Nanocoder favicon properly, resets the real backend conversation (not just localStorage) when starting a new chat, restores rejected message text into the composer instead of losing it, adds WebSocket auto-reconnect with backoff, extends keyboard focus states to the sidebar/send buttons, themes the scrollbars, and fixes several inline-script indentation bugs in page.ts. --- source/app/App.tsx | 11 + source/web/page.spec.ts | 69 +++- source/web/page.ts | 585 +++++++++++++++++------------- source/web/protocol.spec.ts | 17 + source/web/protocol.ts | 14 +- source/web/runtime-bridge.spec.ts | 46 +++ source/web/runtime-bridge.ts | 12 + source/web/server.spec.ts | 45 +++ source/web/server.ts | 37 +- 9 files changed, 577 insertions(+), 259 deletions(-) diff --git a/source/app/App.tsx b/source/app/App.tsx index 8e07c8932..b89d070bd 100644 --- a/source/app/App.tsx +++ b/source/app/App.tsx @@ -500,11 +500,13 @@ export default function App({ isGenerating: chatHandler.isGenerating, submitMessage: appHandlers.handleMessageSubmit, cancel: appHandlers.handleCancel, + resetSession: appHandlers.clearMessages, }); webRuntimeStateRef.current = { isGenerating: chatHandler.isGenerating, submitMessage: appHandlers.handleMessageSubmit, cancel: appHandlers.handleCancel, + resetSession: appHandlers.clearMessages, }; React.useEffect(() => { @@ -526,6 +528,15 @@ export default function App({ return webRuntimeStateRef.current.submitMessage(message); }, cancel: () => webRuntimeStateRef.current.cancel(), + resetSession: () => { + if (webRuntimeStateRef.current.isGenerating) { + throw new Error( + 'Cannot start a new chat while Nanocoder is processing a turn.', + ); + } + + return webRuntimeStateRef.current.resetSession(); + }, }); }, [ webRuntimeBridge, diff --git a/source/web/page.spec.ts b/source/web/page.spec.ts index 54226c29b..13332aa9e 100644 --- a/source/web/page.spec.ts +++ b/source/web/page.spec.ts @@ -1,5 +1,5 @@ import test from 'ava'; -import {nanocoderLogoSvg, renderWebModePage} from './page.js'; +import {createPageNonce, nanocoderLogoSvg, renderWebModePage} from './page.js'; test('web mode page renders the Nanocoder logo asset markup', t => { t.true(nanocoderLogoSvg.includes('aria-label="Nanocoder"')); @@ -7,6 +7,73 @@ test('web mode page renders the Nanocoder logo asset markup', t => { t.true(nanocoderLogoSvg.includes('#7dcfff')); }); +test('web mode page gives icon, thread, and send buttons a visible keyboard focus state', t => { + const page = renderWebModePage(); + + t.true(page.includes('.icon-button:hover,\n\t\t.icon-button:focus-visible {')); + t.true(page.includes('.thread-item:hover,\n\t\t.thread-item:focus-visible {')); + t.true( + page.includes( + '.send-button:not(:disabled):hover,\n\t\t.send-button:not(:disabled):focus-visible {', + ), + ); +}); + +test('web mode page styles the sidebar and message scrollbars instead of using the browser default', t => { + const page = renderWebModePage(); + + t.true(page.includes('.thread-list::-webkit-scrollbar {')); + t.true(page.includes('.messages::-webkit-scrollbar {')); + t.true(page.includes('scrollbar-width: thin;')); +}); + +test('web mode page tells the backend to reset the session when starting a new chat', t => { + const page = renderWebModePage(); + + t.true( + page.includes( + "sendClientEvent({type: 'reset_session', id: 'browser-reset-' + Date.now()});", + ), + ); + const newChatHandlerIndex = page.indexOf("newChatButton.addEventListener('click'"); + const resetEventIndex = page.indexOf("type: 'reset_session'"); + t.true(newChatHandlerIndex >= 0 && resetEventIndex > newChatHandlerIndex); +}); + +test('web mode page restores a rejected message into the composer instead of losing it', t => { + const page = renderWebModePage(); + + t.true(page.includes('const pendingMessageElement = message.id')); + t.true(page.includes("pendingMessageElement.querySelector('.message-content').textContent")); + t.true(page.includes("updateMessageMeta(pendingMessageElement, 'Not sent — ' + message.message)")); + t.true(page.includes('pendingMessages.delete(message.id)')); + t.true(page.includes('setPromptText(failedText)')); +}); + +test('web mode page reconnects the WebSocket with backoff instead of requiring a manual refresh', t => { + const page = renderWebModePage(); + + t.true(page.includes('function connectSocket() {')); + t.true(page.includes('function scheduleReconnect() {')); + t.true(page.includes('scheduleReconnect();')); + t.true(page.includes('reconnectDelayMs = Math.min(reconnectDelayMs * 2, maxReconnectDelayMs);')); + t.true(page.includes("setStatus('Reconnecting…', '');")); + t.false(page.includes("setStatus('Disconnected', 'disconnected');")); +}); + +test('web mode page links the favicon and scopes inline style/script to the given nonce', t => { + const nonce = createPageNonce(); + const page = renderWebModePage(nonce); + + t.true( + page.includes( + '', + ), + ); + t.true(page.includes(` @@ -878,13 +919,13 @@ export function renderWebModePage(): string { - `; diff --git a/source/web/protocol.spec.ts b/source/web/protocol.spec.ts index 191efa232..f888f55fa 100644 --- a/source/web/protocol.spec.ts +++ b/source/web/protocol.spec.ts @@ -69,6 +69,43 @@ test('parseWebClientEvent accepts a reset_session event', t => { ); }); +test('parseWebClientEvent accepts list_sessions and load_session events', t => { + t.deepEqual( + parseWebClientEvent( + JSON.stringify({type: 'list_sessions', id: 'list-1'}), + ), + {type: 'list_sessions', id: 'list-1'}, + ); + + t.deepEqual( + parseWebClientEvent( + JSON.stringify({ + type: 'load_session', + id: 'load-1', + sessionId: '18d51c0d-becb-4efc-8d0d-b8c1f3b61802', + }), + ), + { + type: 'load_session', + id: 'load-1', + sessionId: '18d51c0d-becb-4efc-8d0d-b8c1f3b61802', + }, + ); + + t.throws( + () => parseWebClientEvent(JSON.stringify({type: 'list_sessions'})), + {message: 'List sessions id is required.'}, + ); + + t.throws( + () => + parseWebClientEvent( + JSON.stringify({type: 'load_session', id: 'load-1'}), + ), + {message: 'Load session sessionId is required.'}, + ); +}); + test('parseWebClientEvent rejects malformed interaction responses', t => { t.throws( () => diff --git a/source/web/protocol.ts b/source/web/protocol.ts index 1b226c148..acb6ac73a 100644 --- a/source/web/protocol.ts +++ b/source/web/protocol.ts @@ -1,12 +1,26 @@ export const WEB_PROTOCOL_VERSION = 1; +export interface WebSessionSummary { + id: string; + title: string; + lastAccessedAt: string; + messageCount: number; +} + +export interface WebSessionMessage { + role: 'user' | 'assistant'; + content: string; +} + export type WebClientEvent = | {type: 'hello'; protocolVersion: typeof WEB_PROTOCOL_VERSION} | {type: 'user_message'; id: string; text: string} | {type: 'cancel'; id: string} | {type: 'approval_response'; id: string; approved: boolean} | {type: 'question_response'; id: string; answer: string} - | {type: 'reset_session'; id: string}; + | {type: 'reset_session'; id: string} + | {type: 'list_sessions'; id: string} + | {type: 'load_session'; id: string; sessionId: string}; export type WebServerEvent = | {type: 'ready'; protocolVersion: typeof WEB_PROTOCOL_VERSION} @@ -29,7 +43,14 @@ export type WebServerEvent = allowFreeform: boolean; } | {type: 'turn_completed'; id: string} - | {type: 'error'; message: string; id?: string}; + | {type: 'error'; message: string; id?: string} + | {type: 'sessions'; id: string; sessions: WebSessionSummary[]} + | { + type: 'session_loaded'; + id: string; + session: WebSessionSummary; + messages: WebSessionMessage[]; + }; export function parseWebClientEvent(rawMessage: string): WebClientEvent { let parsed: unknown; @@ -113,6 +134,32 @@ export function parseWebClientEvent(rawMessage: string): WebClientEvent { type: 'reset_session', id: parsed.id, }; + case 'list_sessions': + if (typeof parsed.id !== 'string' || parsed.id.length === 0) { + throw new Error('List sessions id is required.'); + } + + return { + type: 'list_sessions', + id: parsed.id, + }; + case 'load_session': + if (typeof parsed.id !== 'string' || parsed.id.length === 0) { + throw new Error('Load session id is required.'); + } + + if ( + typeof parsed.sessionId !== 'string' || + parsed.sessionId.length === 0 + ) { + throw new Error('Load session sessionId is required.'); + } + + return { + type: 'load_session', + id: parsed.id, + sessionId: parsed.sessionId, + }; default: throw new Error(`Unsupported web event type: ${parsed.type}.`); } diff --git a/source/web/runtime-bridge.spec.ts b/source/web/runtime-bridge.spec.ts index 6173c315e..8a756c205 100644 --- a/source/web/runtime-bridge.spec.ts +++ b/source/web/runtime-bridge.spec.ts @@ -303,6 +303,140 @@ test('web runtime bridge refuses to reset the session while a browser turn is ac t.is(resetCount, 0); }); +test('web runtime bridge broadcasts the session list on list_sessions', async t => { + const events: WebServerEvent[] = []; + const bridge = createWebRuntimeBridge(event => events.push(event)); + bridge.bindRuntimeHandlers({ + submitMessage: () => new Promise(() => {}), + cancel: () => {}, + resetSession: () => {}, + listSessions: async () => [ + { + id: 'session-1', + title: 'Fix the flaky test', + lastAccessedAt: '2026-08-01T00:00:00.000Z', + messageCount: 4, + }, + ], + loadSession: async () => null, + }); + + await bridge.handleClientEvent({type: 'list_sessions', id: 'list-1'}); + + t.deepEqual(events, [ + { + type: 'sessions', + id: 'list-1', + sessions: [ + { + id: 'session-1', + title: 'Fix the flaky test', + lastAccessedAt: '2026-08-01T00:00:00.000Z', + messageCount: 4, + }, + ], + }, + ]); +}); + +test('web runtime bridge broadcasts the loaded session on load_session', async t => { + const events: WebServerEvent[] = []; + const loadedIds: string[] = []; + const bridge = createWebRuntimeBridge(event => events.push(event)); + bridge.bindRuntimeHandlers({ + submitMessage: () => new Promise(() => {}), + cancel: () => {}, + resetSession: () => {}, + listSessions: async () => [], + loadSession: async sessionId => { + loadedIds.push(sessionId); + return { + session: { + id: sessionId, + title: 'Fix the flaky test', + lastAccessedAt: '2026-08-01T00:00:00.000Z', + messageCount: 2, + }, + messages: [ + {role: 'user', content: 'why is this test flaky?'}, + {role: 'assistant', content: 'it races the file watcher'}, + ], + }; + }, + }); + + await bridge.handleClientEvent({ + type: 'load_session', + id: 'load-1', + sessionId: 'session-1', + }); + + t.deepEqual(loadedIds, ['session-1']); + t.deepEqual(events, [ + { + type: 'session_loaded', + id: 'load-1', + session: { + id: 'session-1', + title: 'Fix the flaky test', + lastAccessedAt: '2026-08-01T00:00:00.000Z', + messageCount: 2, + }, + messages: [ + {role: 'user', content: 'why is this test flaky?'}, + {role: 'assistant', content: 'it races the file watcher'}, + ], + }, + ]); +}); + +test('web runtime bridge rejects load_session for an unknown session', async t => { + const bridge = createWebRuntimeBridge(() => {}); + bridge.bindRuntimeHandlers({ + submitMessage: () => new Promise(() => {}), + cancel: () => {}, + resetSession: () => {}, + listSessions: async () => [], + loadSession: async () => null, + }); + + await t.throwsAsync( + bridge.handleClientEvent({ + type: 'load_session', + id: 'load-1', + sessionId: 'missing', + }), + {message: 'Session not found.'}, + ); +}); + +test('web runtime bridge refuses to switch sessions while a browser turn is active', async t => { + const loadedIds: string[] = []; + const bridge = createWebRuntimeBridge(() => {}); + bridge.bindRuntimeHandlers({ + submitMessage: () => new Promise(() => {}), + cancel: () => {}, + resetSession: () => {}, + listSessions: async () => [], + loadSession: async sessionId => { + loadedIds.push(sessionId); + return null; + }, + }); + + await bridge.handleClientEvent(userMessage('turn-1')); + await t.throwsAsync( + bridge.handleClientEvent({ + type: 'load_session', + id: 'load-1', + sessionId: 'session-1', + }), + {message: 'Cannot switch sessions while a browser turn is active.'}, + ); + + t.deepEqual(loadedIds, []); +}); + test('web runtime bridge denies pending approval on disconnect', async t => { const bridge = createWebRuntimeBridge(() => {}); bridge.bindRuntimeHandlers({ diff --git a/source/web/runtime-bridge.ts b/source/web/runtime-bridge.ts index cf64eb9ea..45d52d50b 100644 --- a/source/web/runtime-bridge.ts +++ b/source/web/runtime-bridge.ts @@ -1,9 +1,21 @@ -import type {WebClientEvent, WebServerEvent} from './protocol.js'; +import type { + WebClientEvent, + WebServerEvent, + WebSessionMessage, + WebSessionSummary, +} from './protocol.js'; + +export interface WebSessionLoadResult { + session: WebSessionSummary; + messages: WebSessionMessage[]; +} export interface WebRuntimeHandlers { submitMessage: (text: string) => void | Promise; cancel: () => void; resetSession: () => void | Promise; + listSessions: () => Promise; + loadSession: (sessionId: string) => Promise; } export interface WebApprovalRequest { @@ -207,6 +219,33 @@ export function createWebRuntimeBridge( return; } + if (event.type === 'list_sessions') { + const sessions = await runtimeHandlers.listSessions(); + broadcastEvent({type: 'sessions', id: event.id, sessions}); + return; + } + + if (event.type === 'load_session') { + if (activeTurnId) { + throw new Error( + 'Cannot switch sessions while a browser turn is active.', + ); + } + + const result = await runtimeHandlers.loadSession(event.sessionId); + if (!result) { + throw new Error('Session not found.'); + } + + broadcastEvent({ + type: 'session_loaded', + id: event.id, + session: result.session, + messages: result.messages, + }); + return; + } + if (activeTurnId) { throw new Error('Nanocoder is already processing a browser turn.'); } From 059f0a0eecfdea0403bd129ef6a91f1b820dc84a Mon Sep 17 00:00:00 2001 From: SK Akram Date: Thu, 27 Aug 2026 11:50:13 +0000 Subject: [PATCH 05/13] feat(web): implement local Web/GUI mode with organisation theme --- source/app/App.tsx | 8 +- source/web/page.spec.ts | 16 +- source/web/page.ts | 566 ++++++++++---------- source/web/protocol.spec.ts | 2 +- source/web/protocol.ts | 26 +- source/web/runtime-bridge.ts | 10 +- source/web/server.spec.ts | 1 + source/wizards/steps/mode-provider-step.tsx | 6 +- 8 files changed, 343 insertions(+), 292 deletions(-) diff --git a/source/app/App.tsx b/source/app/App.tsx index 639e84ae4..652d5b1cf 100644 --- a/source/app/App.tsx +++ b/source/app/App.tsx @@ -523,12 +523,16 @@ export default function App({ } return webRuntimeBridge.bindRuntimeHandlers({ - submitMessage: message => { + submitMessage: (message, images) => { if (webRuntimeStateRef.current.isGenerating) { throw new Error('Nanocoder is already processing a turn.'); } - return webRuntimeStateRef.current.submitMessage(message); + return webRuntimeStateRef.current.submitMessage( + message, + undefined, + images, + ); }, cancel: () => webRuntimeStateRef.current.cancel(), resetSession: () => { diff --git a/source/web/page.spec.ts b/source/web/page.spec.ts index 448e8030e..87b26660e 100644 --- a/source/web/page.spec.ts +++ b/source/web/page.spec.ts @@ -160,11 +160,6 @@ test('web mode shows live tool running and completed status', t => { t.true(page.includes("'system tool-status', 'Running tool: ' + message.name")); t.true(page.includes("'Tool finished: ' + message.name")); - t.true( - page.includes( - 'Provider and model stay in the terminal runtime; during a browser turn, approvals and questions are answered here.', - ), - ); }); test('web mode page ships a light theme that cannot affect the dark default', t => { @@ -189,7 +184,7 @@ test('web mode page toggles and persists the theme', t => { t.true(page.includes("window.localStorage.setItem(themeStorageKey, theme)")); t.true( page.includes( - "window.matchMedia('(prefers-color-scheme: light)').matches", + "return 'light'; // Default to light mode (Organisation theme)", ), ); t.true( @@ -309,13 +304,6 @@ test('web mode settings button shows real current state instead of a canned noti const page = renderWebModePage(); t.true( - page.includes( - "document.documentElement.dataset.theme === 'light' ? 'Light' : 'Dark'", - ), - ); - t.true( - page.includes( - "appShell.classList.contains('sidebar-collapsed')\n\t\t\t\t\t? 'collapsed'\n\t\t\t\t\t: 'expanded'", - ), + page.includes('id="settingsModal"'), ); }); diff --git a/source/web/page.ts b/source/web/page.ts index 37bd15252..fd716bb5c 100644 --- a/source/web/page.ts +++ b/source/web/page.ts @@ -12,6 +12,18 @@ export function createPageNonce(): string { return randomBytes(16).toString('base64'); } +export const IconSidebar = ``; +export const IconHistory = ``; +export const IconSettings = ``; +export const IconSend = ``; +export const IconUpload = ``; +export const IconClose = ``; +export const IconTheme = ``; +export const IconCreate = ``; +export const IconExplore = ``; +export const IconCode = ``; +export const IconLearn = ``; + export function renderWebModePage(nonce: string = createPageNonce()): string { return ` @@ -33,8 +45,8 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { body { margin: 0; min-height: 100vh; - background: #08090b; - color: #f5f2eb; + background: #09090b; + color: #fafafa; overflow: hidden; } button, @@ -49,8 +61,7 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { display: grid; grid-template-columns: 280px minmax(0, 1fr); min-height: 100vh; - background: - linear-gradient(90deg, #090b0d 0, #0d1114 280px, #11161a 280px, #151a1f 100%); + background: #09090b; } .app-shell.sidebar-collapsed { grid-template-columns: 0 minmax(0, 1fr); @@ -67,8 +78,8 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { gap: 14px; min-height: 100vh; padding: 16px 14px 18px; - border-right: 1px solid rgba(245, 242, 235, 0.08); - background: rgba(9, 12, 14, 0.96); + border-right: 1px solid #27272a; + background: #09090b; overflow: hidden; transition: opacity 150ms ease; } @@ -115,15 +126,15 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { } .new-chat { height: 40px; - border: 1px solid rgba(85, 217, 141, 0.28); + border: 1px solid #0000ee; border-radius: 8px; - background: rgba(50, 91, 70, 0.46); - color: #d8f7e6; + background: #0000ee; + color: #ffffff; font-weight: 730; transition: background 140ms ease, transform 140ms ease; } .new-chat:hover { - background: rgba(61, 111, 85, 0.62); + background: #0000cc; transform: translateY(-1px); } .search-box { @@ -211,9 +222,7 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { grid-template-rows: auto minmax(0, 1fr) auto; min-width: 0; min-height: 100vh; - background: - radial-gradient(circle at 50% 18%, rgba(85, 217, 141, 0.055), transparent 30rem), - linear-gradient(180deg, #171b20 0%, #14191d 46%, #101417 100%); + background: #09090b; } .topbar { display: flex; @@ -314,10 +323,10 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { pointer-events: auto; width: min(760px, 100%); padding: 14px 16px; - border: 1px solid rgba(245, 242, 235, 0.08); + border: 1px solid #27272a; border-radius: 8px; - background: rgba(245, 242, 235, 0.055); - color: #f5f2eb; + background: #18181b; + color: #fafafa; line-height: 1.5; overflow-wrap: anywhere; box-shadow: 0 12px 40px rgba(0, 0, 0, 0.14); @@ -410,14 +419,14 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { .message.system { align-self: center; width: min(760px, 100%); - background: rgba(8, 9, 11, 0.26); - color: #beb7c7; + background: #121214; + color: #a1a1aa; } .message.interaction { align-self: stretch; width: min(760px, 100%); - border-color: rgba(125, 207, 255, 0.28); - background: rgba(36, 40, 59, 0.92); + border-color: #0000ee; + background: #18181b; } .interaction-card { display: grid; @@ -515,10 +524,10 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { gap: 8px; min-height: 38px; padding: 0 18px; - border: 1px solid rgba(245, 242, 235, 0.08); + border: 1px solid #27272a; border-radius: 999px; - background: rgba(245, 242, 235, 0.045); - color: #cfc7d8; + background: #18181b; + color: #fafafa; cursor: pointer; font-size: 14px; font-weight: 720; @@ -530,9 +539,9 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { } .mode-pill:hover, .mode-pill:focus-visible { - background: rgba(125, 207, 255, 0.12); - border-color: rgba(125, 207, 255, 0.32); - color: #f5f2eb; + background: rgba(0, 0, 238, 0.1); + border-color: #0000ee; + color: #fafafa; outline: 0; transform: translateY(-1px); } @@ -552,10 +561,10 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { width: 100%; min-height: 54px; padding: 0 16px; - border: 1px solid rgba(245, 242, 235, 0.075); + border: 1px solid #27272a; border-radius: 8px; - background: rgba(245, 242, 235, 0.028); - color: #c7bfce; + background: #18181b; + color: #fafafa; cursor: pointer; text-align: left; font-size: 15px; @@ -573,9 +582,9 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { } .prompt-button:hover, .prompt-button:focus-visible { - background: rgba(245, 242, 235, 0.06); - border-color: rgba(125, 207, 255, 0.28); - color: #f5f2eb; + background: rgba(0, 0, 238, 0.05); + border-color: #0000ee; + color: #fafafa; outline: 0; transform: translateY(-1px); } @@ -607,16 +616,16 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { gap: 12px; align-items: end; min-height: 104px; - border: 1px solid rgba(245, 242, 235, 0.09); + border: 1px solid #27272a; border-radius: 8px; - background: rgba(29, 35, 40, 0.94); + background: #18181b; padding: 14px; box-shadow: 0 24px 80px rgba(0, 0, 0, 0.32); } .composer.is-attention { - border-color: rgba(125, 207, 255, 0.56); + border-color: #0000ee; box-shadow: - 0 0 0 3px rgba(125, 207, 255, 0.12), + 0 0 0 3px rgba(0, 0, 238, 0.2), 0 24px 80px rgba(0, 0, 0, 0.32); } textarea { @@ -626,7 +635,7 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { resize: none; border: 0; background: transparent; - color: #f5f2eb; + color: #fafafa; font: inherit; line-height: 1.5; padding: 6px 2px; @@ -635,7 +644,7 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { outline: 0; } textarea::placeholder { - color: #928899; + color: #a1a1aa; } .send-button { display: grid; @@ -643,14 +652,14 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { width: 42px; height: 42px; border-radius: 8px; - background: #55d98d; - color: #0d1114; + background: #0000ee; + color: #ffffff; transition: transform 120ms ease, opacity 120ms ease; } .send-button:not(:disabled):hover, .send-button:not(:disabled):focus-visible { transform: translateY(-1px); - background: #6ee7a1; + background: #0000cc; outline: 0; } .send-button.is-cancel { @@ -727,196 +736,36 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { min-height: 48px; } } - /* Match Nanocoder's default Tokyo Night terminal theme. */ + /* Syntax highlighting variables (formerly Tokyo Night, now mapped to org theme) */ :root { - --tn-text: #c0caf5; - --tn-base: #1a1b26; - --tn-primary: #bb9af7; - --tn-tool: #7dcfff; - --tn-success: #7AF778; - --tn-error: #f7768e; - --tn-secondary: #565f89; - --tn-info: #2ac3de; - --tn-warning: #e0af68; - --tn-panel: #16161f; - --tn-surface: #24283b; - --tn-border: rgba(192, 202, 245, 0.14); - } - body { - background: var(--tn-base); - color: var(--tn-text); - } - .app-shell { - background: - linear-gradient(90deg, #12131c 0, #12131c 280px, var(--tn-base) 280px, var(--tn-base) 100%); - } - .sidebar { - border-right-color: var(--tn-border); - background: #12131c; - } - .brand, - .thread-item.active, - .thread-item:hover, - .prompt-button:hover, - .empty-state, - .message, - textarea, - .model-pill { - color: var(--tn-text); - } - .brand-mark, - .message.user { - background: var(--tn-text); - color: var(--tn-base); - } - .brand-mark { - background: #16161f; - } - .icon-button { - background: rgba(192, 202, 245, 0.08); - color: var(--tn-text); - } - .icon-button:hover, - .icon-button:focus-visible { - background: rgba(125, 207, 255, 0.14); - } - .new-chat { - border-color: rgba(187, 154, 247, 0.42); - background: rgba(187, 154, 247, 0.14); - color: var(--tn-primary); - } - .new-chat:hover { - background: rgba(187, 154, 247, 0.24); - } - .search-box { - border-color: rgba(192, 202, 245, 0.08); - color: var(--tn-secondary); - } - .search-box:focus-within { - border-color: rgba(125, 207, 255, 0.28); - background: rgba(36, 40, 59, 0.45); - } - .search-box input { - color: var(--tn-text); - } - .search-box input::placeholder, - .thread-item, - .thread-list-empty, - .sidebar-footer, - .session-note, - p, - .empty-state span, - .note { - color: var(--tn-secondary); - } - .thread-item.active, - .thread-item:hover, - .thread-item:focus-visible, - .mode-pill, - .prompt-button:hover { - background: rgba(125, 207, 255, 0.1); - } - .workspace { - background: - radial-gradient(circle at 50% 16%, rgba(187, 154, 247, 0.13), transparent 28rem), - linear-gradient(180deg, #1a1b26 0%, #171823 52%, #12131c 100%); - } - .status { - border-color: var(--tn-border); - background: rgba(22, 22, 31, 0.78); - color: var(--tn-text); - } - .status::before { - background: var(--tn-warning); - box-shadow: 0 0 0 5px rgba(224, 175, 104, 0.12); - } - .status.connected { - color: var(--tn-success); - } - .status.connected::before { - background: var(--tn-success); - box-shadow: 0 0 0 5px rgba(122, 247, 120, 0.14); - } - .status.disconnected, - .status.failed, - .message.error { - color: var(--tn-error); - } - .status.disconnected::before, - .status.failed::before { - background: var(--tn-error); - box-shadow: 0 0 0 5px rgba(247, 118, 142, 0.14); - } - .message { - border-color: var(--tn-border); - background: rgba(36, 40, 59, 0.82); - } - .message.system, - .mode-pill { - background: rgba(36, 40, 59, 0.72); - } - .message.assistant { - background: transparent; - } - .message.system { - color: var(--tn-text); - } - .mode-pill { - border-color: var(--tn-border); - } - .prompt-button { - border-color: rgba(192, 202, 245, 0.12); - background: rgba(36, 40, 59, 0.5); - color: var(--tn-text); - } - .prompt-button::after { - color: rgba(125, 207, 255, 0.54); - } - .message.error { - border-color: rgba(247, 118, 142, 0.45); - } - .meta { - color: rgba(192, 202, 245, 0.52); - } - .message.user .meta { - color: rgba(26, 27, 38, 0.64); - } - .composer { - border-color: rgba(125, 207, 255, 0.24); - background: rgba(36, 40, 59, 0.98); - } - textarea::placeholder { - color: var(--tn-secondary); - } - .send-button { - background: var(--tn-tool); - color: var(--tn-base); - } - .send-button:not(:disabled):hover, - .send-button:not(:disabled):focus-visible { - background: #9de1ff; + --tn-text: #fafafa; + --tn-base: #09090b; + --tn-primary: #0000ee; + --tn-tool: #0000ee; + --tn-success: #22c55e; + --tn-error: #ef4444; + --tn-secondary: #005a9c; + --tn-panel: #18181b; + --tn-surface: #18181b; + --tn-border: #27272a; } /* * Light theme. Scoped with the [data-theme="light"] attribute selector - * (set on by the theme toggle below) rather than editing the dark - * rules above, so light mode is purely additive: the attribute selector - * always outranks the plain class selectors it targets, regardless of - * source order, and the dark theme's appearance is provably unchanged. + * Matches the organisation high-contrast white/blue theme. */ :root[data-theme="light"] { color-scheme: light; } :root[data-theme="light"] body { - background: #f6f6fb; - color: #1c1d2b; + background: #ffffff; + color: #000000; } :root[data-theme="light"] .app-shell { - background: - linear-gradient(90deg, #ffffff 0, #ffffff 280px, #f6f6fb 280px, #f0f0f7 100%); + background: #ffffff; } :root[data-theme="light"] .sidebar { - border-right-color: rgba(28, 29, 43, 0.1); - background: #ffffff; + border-right: 1px solid #e5e7eb; + background: #f9fafb; } :root[data-theme="light"] .brand, :root[data-theme="light"] .thread-item.active, @@ -926,34 +775,35 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { :root[data-theme="light"] .message, :root[data-theme="light"] textarea, :root[data-theme="light"] .model-pill { - color: #1c1d2b; + color: #000000; } :root[data-theme="light"] .icon-button { - background: rgba(28, 29, 43, 0.06); - color: #1c1d2b; + background: rgba(0, 0, 0, 0.05); + color: #000000; } :root[data-theme="light"] .icon-button:hover, :root[data-theme="light"] .icon-button:focus-visible { - background: rgba(28, 29, 43, 0.1); + background: rgba(0, 0, 0, 0.1); } :root[data-theme="light"] .new-chat { - border-color: rgba(124, 92, 214, 0.4); - background: rgba(124, 92, 214, 0.1); - color: #5c3fb8; + border-color: #0000ee; + background: #0000ee; + color: #ffffff; } :root[data-theme="light"] .new-chat:hover { - background: rgba(124, 92, 214, 0.18); + background: #0000cc; } :root[data-theme="light"] .search-box { - border-color: rgba(28, 29, 43, 0.1); - color: #5b6178; + border-color: #e5e7eb; + color: #6b7280; + background: #ffffff; } :root[data-theme="light"] .search-box:focus-within { - border-color: rgba(46, 134, 193, 0.4); - background: rgba(46, 134, 193, 0.06); + border-color: #0000ee; + background: #ffffff; } :root[data-theme="light"] .search-box input { - color: #1c1d2b; + color: #000000; } :root[data-theme="light"] .search-box input::placeholder, :root[data-theme="light"] .thread-item, @@ -963,30 +813,30 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { :root[data-theme="light"] p, :root[data-theme="light"] .empty-state span, :root[data-theme="light"] .note { - color: #5b6178; + color: #6b7280; } :root[data-theme="light"] .thread-list::-webkit-scrollbar-thumb, :root[data-theme="light"] .messages::-webkit-scrollbar-thumb { - background: rgba(28, 29, 43, 0.16); + background: rgba(0, 0, 0, 0.15); } :root[data-theme="light"] .thread-list::-webkit-scrollbar-thumb:hover, :root[data-theme="light"] .messages::-webkit-scrollbar-thumb:hover { - background: rgba(28, 29, 43, 0.28); + background: rgba(0, 0, 0, 0.25); } :root[data-theme="light"] .thread-list, :root[data-theme="light"] .messages { - scrollbar-color: rgba(28, 29, 43, 0.16) transparent; + scrollbar-color: rgba(0, 0, 0, 0.15) transparent; } :root[data-theme="light"] .thread-item.active, :root[data-theme="light"] .thread-item:hover, :root[data-theme="light"] .thread-item:focus-visible, :root[data-theme="light"] .mode-pill, :root[data-theme="light"] .prompt-button:hover { - background: rgba(46, 134, 193, 0.1); + background: rgba(0, 0, 238, 0.08); + color: #0000ee; } :root[data-theme="light"] .workspace { - background: - radial-gradient(circle at 50% 16%, rgba(124, 92, 214, 0.08), transparent 28rem), + background: #ffffff; linear-gradient(180deg, #f6f6fb 0%, #f2f2f8 52%, #ececf4 100%); } :root[data-theme="light"] .status { @@ -1143,6 +993,92 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { :root[data-theme="light"] .send-button.is-cancel:not(:disabled):focus-visible { background: #dd5b71; } + /* Modal Styles */ + .modal-overlay { + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + } + .modal-overlay.hidden { + display: none; + } + .modal-content { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: 8px; + width: 100%; + max-width: 500px; + box-shadow: 0 4px 12px rgba(0,0,0,0.2); + display: flex; + flex-direction: column; + } + .modal-header { + padding: 16px 20px; + border-bottom: 1px solid var(--border); + display: flex; + justify-content: space-between; + align-items: center; + } + .modal-header h2 { + margin: 0; + font-size: 1.1rem; + font-weight: 600; + color: var(--text); + } + .close-button { + background: transparent; + color: var(--text-muted); + font-size: 1.5rem; + padding: 0 4px; + line-height: 1; + } + .close-button:hover { + color: var(--text); + } + .modal-body { + padding: 20px; + color: var(--text-muted); + font-size: 0.95rem; + } + + /* Image Upload Preview */ + .composer-preview { + display: flex; + gap: 8px; + padding: 8px 12px 0; + flex-wrap: wrap; + } + .composer-preview img { + height: 48px; + border-radius: 4px; + border: 1px solid var(--border); + object-fit: contain; + } + .message-image { + max-width: 100%; + max-height: 300px; + border-radius: 6px; + margin-bottom: 8px; + display: block; + border: 1px solid var(--border); + } + .composer-inputs { + display: flex; + gap: 8px; + align-items: center; + flex: 1; + } + .upload-button { + padding: 4px 8px; + color: var(--text-muted); + } + .upload-button:hover { + color: var(--text); + } @@ -1153,7 +1089,7 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { Nanocoder logo Nanocoder - + @@ -1174,9 +1110,9 @@ export function renderWebModePage(nonce: string = createPageNonce()): string {
Starting

Localhost only. Private URL token required.

- - - + + +
@@ -1184,9 +1120,14 @@ export function renderWebModePage(nonce: string = createPageNonce()): string {
+
- - +
+ + + +
+
Nanocoder local session
@@ -1195,6 +1136,22 @@ export function renderWebModePage(nonce: string = createPageNonce()): string {
+ + + +
@@ -1205,18 +1151,32 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { let reconnectDelayMs = 1000; const maxReconnectDelayMs = 15000; + // Initial load animation + gsap.from('.sidebar', { opacity: 0, x: -20, duration: 0.6, ease: 'power2.out' }); + gsap.from('.topbar', { opacity: 0, y: -10, duration: 0.5, ease: 'power2.out', delay: 0.1 }); + gsap.from('.composer-wrap', { opacity: 0, y: 20, duration: 0.6, ease: 'power2.out', delay: 0.2 }); + gsap.from('.empty-state', { opacity: 0, y: 10, duration: 0.5, ease: 'power2.out', delay: 0.3 }); + + // Custom helper to animate elements in + function animateIn(element) { + gsap.fromTo(element, { opacity: 0, y: 15 }, { opacity: 1, y: 0, duration: 0.4, ease: 'power2.out' }); + } + + const themeStorageKey = 'nanocoder.webMode.theme.v1'; const sidebarStorageKey = 'nanocoder.webMode.sidebarCollapsed.v1'; function applyTheme(theme) { document.documentElement.dataset.theme = theme; const isLight = theme === 'light'; - themeToggleButton.textContent = isLight ? '◑' : '◐'; - themeToggleButton.setAttribute('aria-pressed', String(isLight)); - themeToggleButton.setAttribute( - 'aria-label', - isLight ? 'Switch to dark theme' : 'Switch to light theme', - ); + if (themeToggleButton) { + themeToggleButton.innerHTML = isLight ? '${IconMoon}' : '${IconSun}'; + themeToggleButton.setAttribute('aria-pressed', String(isLight)); + themeToggleButton.setAttribute( + 'aria-label', + isLight ? 'Switch to dark theme' : 'Switch to light theme', + ); + } window.localStorage.setItem(themeStorageKey, theme); } @@ -1230,9 +1190,9 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { function applySidebarCollapsed(isCollapsed) { appShell.classList.toggle('sidebar-collapsed', isCollapsed); - sidebarToggleButton.textContent = isCollapsed ? '❯' : '❮'; - sidebarToggleButton.setAttribute('aria-expanded', String(!isCollapsed)); - sidebarToggleButton.setAttribute( + + if (sidebarToggleButton) sidebarToggleButton.setAttribute('aria-expanded', String(!isCollapsed)); + if (sidebarToggleButton) sidebarToggleButton.setAttribute( 'aria-label', isCollapsed ? 'Expand sidebar' : 'Collapse sidebar', ); @@ -1532,6 +1492,8 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { messageList.append(messageElement); messageList.scrollTop = messageList.scrollHeight; + animateIn(messageElement); + if (shouldStore) { storedMessages.push({role, text, metaText: metaText ?? ''}); writeStoredMessages(); @@ -1619,10 +1581,10 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { button.type = 'button'; button.dataset.sessionId = session.id; button.dataset.threadLabel = session.title; - const marker = session.id === activeSessionId ? '●' : '○'; + const relative = formatRelativeTime(session.lastAccessedAt); button.textContent = - marker + ' ' + session.title + (relative ? ' · ' + relative : ''); + session.title + (relative ? ' · ' + relative : ''); threadList.append(button); } } @@ -2092,7 +2054,7 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { applyTheme(document.documentElement.dataset.theme === 'light' ? 'dark' : 'light'); }); - sidebarToggleButton.addEventListener('click', () => { + if (sidebarToggleButton) sidebarToggleButton.addEventListener('click', () => { applySidebarCollapsed(!appShell.classList.contains('sidebar-collapsed')); }); @@ -2103,7 +2065,7 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { ); }); - historyButton.addEventListener('click', () => { + if (historyButton) historyButton.addEventListener('click', () => { if (appShell.classList.contains('sidebar-collapsed')) { applySidebarCollapsed(false); } @@ -2111,10 +2073,10 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { type: 'list_sessions', id: 'browser-sessions-' + Date.now(), }); - threadSearchInput.focus(); + if (threadSearchInput) threadSearchInput.focus(); }); - settingsButton.addEventListener('click', () => { + if (settingsButton) settingsButton.addEventListener('click', () => { settingsModal.classList.remove('hidden'); }); closeSettingsButton.addEventListener('click', () => { @@ -2126,7 +2088,7 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { } }); - threadSearchInput.addEventListener('input', () => { + if (threadSearchInput) threadSearchInput.addEventListener('input', () => { const query = threadSearchInput.value.trim().toLowerCase(); for (const threadButton of threadList.querySelectorAll('.thread-item')) { const label = (threadButton.dataset.threadLabel || '').toLowerCase(); diff --git a/source/web/server.spec.ts b/source/web/server.spec.ts index 7dda8661e..334ecec60 100644 --- a/source/web/server.spec.ts +++ b/source/web/server.spec.ts @@ -62,7 +62,7 @@ test('local web server binds to localhost and serves token-protected browser cha t.true(response.body.includes('id="newChatButton"')); t.true(response.body.includes('id="threadSearchInput"')); t.true(response.body.includes('window.localStorage')); - t.true(response.body.includes('/assets/nanocoder-icon.svg')); + t.true(response.body.includes('/assets/nanocoder-icon.png')); t.true(response.body.includes("type: 'user_message'")); t.true(response.body.includes('submitUserMessage(prompt)')); t.true(response.body.includes('pointer-events: none;')); @@ -75,13 +75,12 @@ test('local web server serves Nanocoder icon asset', async t => { }); const response = await fetch( - `http://127.0.0.1:${webServer.port}/assets/nanocoder-icon.svg`, + `http://127.0.0.1:${webServer.port}/assets/nanocoder-icon.png`, ); const body = await response.text(); t.is(response.status, 200); - t.is(response.headers.get('content-type'), 'image/svg+xml; charset=utf-8'); - t.true(body.includes('aria-label="Nanocoder"')); + t.is(response.headers.get('content-type'), 'image/png'); }); test('local web server redirects /favicon.ico to the Nanocoder icon asset', async t => { @@ -96,7 +95,7 @@ test('local web server redirects /favicon.ico to the Nanocoder icon asset', asyn ); t.is(response.status, 302); - t.is(response.headers.get('location'), '/assets/nanocoder-icon.svg'); + t.is(response.headers.get('location'), '/assets/nanocoder-icon.png'); }); test('local web server sets security headers, including a CSP scoped to the page nonce', async t => { diff --git a/source/web/server.ts b/source/web/server.ts index 768c1f309..6980e2a71 100644 --- a/source/web/server.ts +++ b/source/web/server.ts @@ -3,7 +3,11 @@ import {randomBytes} from 'node:crypto'; import {createServer, type Server} from 'node:http'; import type {Duplex} from 'node:stream'; import {WebSocket, WebSocketServer} from 'ws'; -import {createPageNonce, nanocoderLogoSvg, renderWebModePage} from './page.js'; +import { + createPageNonce, + nanocoderLogoPngBuffer, + renderWebModePage, +} from './page.js'; import { parseWebClientEvent, serializeWebServerEvent, @@ -56,17 +60,17 @@ export async function startLocalWebServer( return; } - if (requestUrl.pathname === '/assets/nanocoder-icon.svg') { + if (requestUrl.pathname === '/assets/nanocoder-icon.png') { response.writeHead(200, { 'cache-control': 'public, max-age=3600', - 'content-type': 'image/svg+xml; charset=utf-8', + 'content-type': 'image/png', }); - response.end(nanocoderLogoSvg); + response.end(nanocoderLogoPngBuffer); return; } if (requestUrl.pathname === '/favicon.ico') { - response.writeHead(302, {location: '/assets/nanocoder-icon.svg'}); + response.writeHead(302, {location: '/assets/nanocoder-icon.png'}); response.end(); return; } From a9a8c1505d516ac2e84cc46111f4e42fa9114114 Mon Sep 17 00:00:00 2001 From: Sk Akram Date: Sun, 30 Aug 2026 21:15:06 +0530 Subject: [PATCH 07/13] feat: add web interface and dev server support (#1018) --- package.json | 3 +- source/web/page.ts | 818 ++++++++++++++++++-------------------- source/web/server.spec.ts | 9 +- source/web/server.ts | 14 +- 4 files changed, 405 insertions(+), 439 deletions(-) diff --git a/package.json b/package.json index 00383808e..f3d45c6d5 100644 --- a/package.json +++ b/package.json @@ -34,8 +34,9 @@ }, "scripts": { "build": "tsc && tsc-alias && cp source/commands/contributors.json dist/commands/contributors.json && chmod +x dist/cli.js", + "web": "node dist/cli.js --web", "build:credits": "./scripts/generate-credits.sh", - "dev": "tsc --watch", + "dev": "tsc --watch & tsc-alias --watch", "start": "node dist/cli.js", "test:all": "./scripts/test.sh", "test:ava": "ava", diff --git a/source/web/page.ts b/source/web/page.ts index fd716bb5c..9f1bc9288 100644 --- a/source/web/page.ts +++ b/source/web/page.ts @@ -1,12 +1,9 @@ import {randomBytes} from 'node:crypto'; -export const nanocoderLogoSvg = ` - - - - - -`; +export const nanocoderLogoPngBuffer = Buffer.from( + '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', + 'base64', +); export function createPageNonce(): string { return randomBytes(16).toString('base64'); @@ -18,7 +15,10 @@ export const IconSettings = ``; export const IconUpload = ``; export const IconClose = ``; -export const IconTheme = ``; +export const IconNewChat = ``; +export const IconSun = ``; +export const IconMoon = ``; +export const IconTheme = IconSun; export const IconCreate = ``; export const IconExplore = ``; export const IconCode = ``; @@ -31,22 +31,23 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { Nanocoder Web Mode - + +
@@ -1205,18 +1151,32 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { let reconnectDelayMs = 1000; const maxReconnectDelayMs = 15000; + // Initial load animation + gsap.from('.sidebar', { opacity: 0, x: -20, duration: 0.6, ease: 'power2.out' }); + gsap.from('.topbar', { opacity: 0, y: -10, duration: 0.5, ease: 'power2.out', delay: 0.1 }); + gsap.from('.composer-wrap', { opacity: 0, y: 20, duration: 0.6, ease: 'power2.out', delay: 0.2 }); + gsap.from('.empty-state', { opacity: 0, y: 10, duration: 0.5, ease: 'power2.out', delay: 0.3 }); + + // Custom helper to animate elements in + function animateIn(element) { + gsap.fromTo(element, { opacity: 0, y: 15 }, { opacity: 1, y: 0, duration: 0.4, ease: 'power2.out' }); + } + + const themeStorageKey = 'nanocoder.webMode.theme.v1'; const sidebarStorageKey = 'nanocoder.webMode.sidebarCollapsed.v1'; function applyTheme(theme) { document.documentElement.dataset.theme = theme; const isLight = theme === 'light'; - themeToggleButton.textContent = isLight ? '◑' : '◐'; - themeToggleButton.setAttribute('aria-pressed', String(isLight)); - themeToggleButton.setAttribute( - 'aria-label', - isLight ? 'Switch to dark theme' : 'Switch to light theme', - ); + if (themeToggleButton) { + themeToggleButton.innerHTML = isLight ? '${IconMoon}' : '${IconSun}'; + themeToggleButton.setAttribute('aria-pressed', String(isLight)); + themeToggleButton.setAttribute( + 'aria-label', + isLight ? 'Switch to dark theme' : 'Switch to light theme', + ); + } window.localStorage.setItem(themeStorageKey, theme); } @@ -1230,9 +1190,9 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { function applySidebarCollapsed(isCollapsed) { appShell.classList.toggle('sidebar-collapsed', isCollapsed); - sidebarToggleButton.textContent = isCollapsed ? '❯' : '❮'; - sidebarToggleButton.setAttribute('aria-expanded', String(!isCollapsed)); - sidebarToggleButton.setAttribute( + + if (sidebarToggleButton) sidebarToggleButton.setAttribute('aria-expanded', String(!isCollapsed)); + if (sidebarToggleButton) sidebarToggleButton.setAttribute( 'aria-label', isCollapsed ? 'Expand sidebar' : 'Collapse sidebar', ); @@ -1532,6 +1492,8 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { messageList.append(messageElement); messageList.scrollTop = messageList.scrollHeight; + animateIn(messageElement); + if (shouldStore) { storedMessages.push({role, text, metaText: metaText ?? ''}); writeStoredMessages(); @@ -1619,10 +1581,10 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { button.type = 'button'; button.dataset.sessionId = session.id; button.dataset.threadLabel = session.title; - const marker = session.id === activeSessionId ? '●' : '○'; + const relative = formatRelativeTime(session.lastAccessedAt); button.textContent = - marker + ' ' + session.title + (relative ? ' · ' + relative : ''); + session.title + (relative ? ' · ' + relative : ''); threadList.append(button); } } @@ -2092,7 +2054,7 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { applyTheme(document.documentElement.dataset.theme === 'light' ? 'dark' : 'light'); }); - sidebarToggleButton.addEventListener('click', () => { + if (sidebarToggleButton) sidebarToggleButton.addEventListener('click', () => { applySidebarCollapsed(!appShell.classList.contains('sidebar-collapsed')); }); @@ -2103,7 +2065,7 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { ); }); - historyButton.addEventListener('click', () => { + if (historyButton) historyButton.addEventListener('click', () => { if (appShell.classList.contains('sidebar-collapsed')) { applySidebarCollapsed(false); } @@ -2111,10 +2073,10 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { type: 'list_sessions', id: 'browser-sessions-' + Date.now(), }); - threadSearchInput.focus(); + if (threadSearchInput) threadSearchInput.focus(); }); - settingsButton.addEventListener('click', () => { + if (settingsButton) settingsButton.addEventListener('click', () => { settingsModal.classList.remove('hidden'); }); closeSettingsButton.addEventListener('click', () => { @@ -2126,7 +2088,7 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { } }); - threadSearchInput.addEventListener('input', () => { + if (threadSearchInput) threadSearchInput.addEventListener('input', () => { const query = threadSearchInput.value.trim().toLowerCase(); for (const threadButton of threadList.querySelectorAll('.thread-item')) { const label = (threadButton.dataset.threadLabel || '').toLowerCase(); diff --git a/source/web/server.spec.ts b/source/web/server.spec.ts index 7dda8661e..334ecec60 100644 --- a/source/web/server.spec.ts +++ b/source/web/server.spec.ts @@ -62,7 +62,7 @@ test('local web server binds to localhost and serves token-protected browser cha t.true(response.body.includes('id="newChatButton"')); t.true(response.body.includes('id="threadSearchInput"')); t.true(response.body.includes('window.localStorage')); - t.true(response.body.includes('/assets/nanocoder-icon.svg')); + t.true(response.body.includes('/assets/nanocoder-icon.png')); t.true(response.body.includes("type: 'user_message'")); t.true(response.body.includes('submitUserMessage(prompt)')); t.true(response.body.includes('pointer-events: none;')); @@ -75,13 +75,12 @@ test('local web server serves Nanocoder icon asset', async t => { }); const response = await fetch( - `http://127.0.0.1:${webServer.port}/assets/nanocoder-icon.svg`, + `http://127.0.0.1:${webServer.port}/assets/nanocoder-icon.png`, ); const body = await response.text(); t.is(response.status, 200); - t.is(response.headers.get('content-type'), 'image/svg+xml; charset=utf-8'); - t.true(body.includes('aria-label="Nanocoder"')); + t.is(response.headers.get('content-type'), 'image/png'); }); test('local web server redirects /favicon.ico to the Nanocoder icon asset', async t => { @@ -96,7 +95,7 @@ test('local web server redirects /favicon.ico to the Nanocoder icon asset', asyn ); t.is(response.status, 302); - t.is(response.headers.get('location'), '/assets/nanocoder-icon.svg'); + t.is(response.headers.get('location'), '/assets/nanocoder-icon.png'); }); test('local web server sets security headers, including a CSP scoped to the page nonce', async t => { diff --git a/source/web/server.ts b/source/web/server.ts index 768c1f309..6980e2a71 100644 --- a/source/web/server.ts +++ b/source/web/server.ts @@ -3,7 +3,11 @@ import {randomBytes} from 'node:crypto'; import {createServer, type Server} from 'node:http'; import type {Duplex} from 'node:stream'; import {WebSocket, WebSocketServer} from 'ws'; -import {createPageNonce, nanocoderLogoSvg, renderWebModePage} from './page.js'; +import { + createPageNonce, + nanocoderLogoPngBuffer, + renderWebModePage, +} from './page.js'; import { parseWebClientEvent, serializeWebServerEvent, @@ -56,17 +60,17 @@ export async function startLocalWebServer( return; } - if (requestUrl.pathname === '/assets/nanocoder-icon.svg') { + if (requestUrl.pathname === '/assets/nanocoder-icon.png') { response.writeHead(200, { 'cache-control': 'public, max-age=3600', - 'content-type': 'image/svg+xml; charset=utf-8', + 'content-type': 'image/png', }); - response.end(nanocoderLogoSvg); + response.end(nanocoderLogoPngBuffer); return; } if (requestUrl.pathname === '/favicon.ico') { - response.writeHead(302, {location: '/assets/nanocoder-icon.svg'}); + response.writeHead(302, {location: '/assets/nanocoder-icon.png'}); response.end(); return; } From a196637095a799dccf9fc76c9bffc734768326a0 Mon Sep 17 00:00:00 2001 From: SK Akram Date: Tue, 1 Sep 2026 10:15:47 +0000 Subject: [PATCH 08/13] feat: add session deletion support with UI improvements and composer animations --- source/app/App.tsx | 4 + source/web/page.ts | 208 ++++++++++++++++++++--------------- source/web/protocol.ts | 20 +++- source/web/runtime-bridge.ts | 12 ++ 4 files changed, 155 insertions(+), 89 deletions(-) diff --git a/source/app/App.tsx b/source/app/App.tsx index c87019a7b..f517e6cae 100644 --- a/source/app/App.tsx +++ b/source/app/App.tsx @@ -609,6 +609,10 @@ export default function App({ messageCount: session.messageCount, })); }, + deleteSession: async sessionId => { + await sessionManager.initialize(); + await sessionManager.deleteSession(sessionId); + }, loadSession: async sessionId => { if (webRuntimeStateRef.current.isGenerating) { throw new Error( diff --git a/source/web/page.ts b/source/web/page.ts index 9f1bc9288..947dce8c4 100644 --- a/source/web/page.ts +++ b/source/web/page.ts @@ -14,6 +14,7 @@ export const IconHistory = ``; export const IconSend = ``; export const IconUpload = ``; +export const IconTrash = ``; export const IconClose = ``; export const IconNewChat = ``; export const IconSun = ``; @@ -270,30 +271,56 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { .thread-item { display: flex; align-items: center; - gap: 10px; - min-height: 36px; + justify-content: space-between; width: 100%; - padding: 8px 12px; + padding: 12px 14px; + background: transparent; border: 0; + color: var(--foreground); + font-family: inherit; + font-size: 15px; + text-align: left; + cursor: pointer; border-radius: 8px; + transition: all 0.2s; + white-space: nowrap; + overflow: hidden; + flex-shrink: 0; + } + .thread-item-text { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: left; + } + .thread-delete-btn { + display: none; background: transparent; color: var(--muted-foreground); - font-size: 13px; - text-align: left; cursor: pointer; - transition: all 150ms ease; + padding: 4px; + border-radius: 4px; + flex-shrink: 0; + margin-left: 8px; + } + .thread-item:hover .thread-delete-btn { + display: flex; + align-items: center; + justify-content: center; + } + .thread-delete-btn:hover { + color: #ef4444; + } + .thread-item:hover, + .thread-item:focus-visible { + background: rgba(128, 128, 128, 0.12); } .thread-item.active { background: rgba(128, 128, 128, 0.12); color: var(--foreground); font-weight: 500; } - .thread-item:hover, - .thread-item:focus-visible { - background: rgba(128, 128, 128, 0.08); - color: var(--primary); - outline: 0; - } .sidebar-footer { margin-top: auto; padding-top: 12px; @@ -581,17 +608,25 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { } .empty-state { position: absolute; - z-index: 2; - top: 40%; + bottom: 100%; left: 50%; - transform: translate(-50%, -50%); + transform: translateX(-50%); width: min(760px, calc(100vw - 40px)); + margin-bottom: 16px; text-align: center; color: var(--foreground); + opacity: 0; + visibility: hidden; + transition: all 0.4s ease; + } + .composer-wrap.is-empty .empty-state { + opacity: 1; + visibility: visible; + transform: translateX(-50%) translateY(0); } .empty-state strong { display: block; - margin-bottom: 22px; + margin-bottom: 8px; font-size: clamp(34px, 5vw, 48px); font-weight: 780; line-height: 1.05; @@ -688,17 +723,18 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { color: #ffc4c4; } .meta { - color: rgba(245, 242, 235, 0.5); + color: var(--muted-foreground); font-size: 11px; } - .message.user .meta { - color: rgba(17, 20, 24, 0.62); - } .composer-wrap { position: relative; z-index: 2; width: min(680px, calc(100vw - 32px)); margin: 0 auto 24px; + transition: transform 0.6s cubic-bezier(0.2, 1, 0.2, 1); + } + .composer-wrap.is-empty { + transform: translateY(-42vh); } .composer { display: grid; @@ -830,7 +866,7 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; /* High Contrast Dark Theme */ - --background: var(--background); + --background: #09090b; --foreground: #fafafa; --card: #18181b; --card-foreground: #fafafa; @@ -1062,10 +1098,10 @@ export function renderWebModePage(nonce: string = createPageNonce()): string {
-
+
@@ -1130,18 +1166,6 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { const storageKey = 'nanocoder.webMode.localSession.v1'; const pendingMessages = new Map(); const assistantMessages = new Map(); - const modePrompts = [ - ['${IconCreate} Create', 'Draft a clean implementation plan for the next Nanocoder web mode step'], - ['${IconExplore} Explore', 'Explore this repository and summarize the web mode architecture'], - ['${IconCode} Code', 'Help me implement the next small, tested web mode change'], - ['${IconLearn} Learn', 'Teach me how this browser session connects to the local CLI runtime'], - ]; - const promptSuggestions = [ - 'Summarize this repository and suggest the next clean change', - 'Find the safest place to wire browser chat into the CLI', - 'Review the current web mode implementation for edge cases', - 'Explain how this local session sends messages to Nanocoder', - ]; let messageCounter = 0; let storedMessages = []; let activeTurnId = null; @@ -1154,8 +1178,8 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { // Initial load animation gsap.from('.sidebar', { opacity: 0, x: -20, duration: 0.6, ease: 'power2.out' }); gsap.from('.topbar', { opacity: 0, y: -10, duration: 0.5, ease: 'power2.out', delay: 0.1 }); - gsap.from('.composer-wrap', { opacity: 0, y: 20, duration: 0.6, ease: 'power2.out', delay: 0.2 }); - gsap.from('.empty-state', { opacity: 0, y: 10, duration: 0.5, ease: 'power2.out', delay: 0.3 }); + gsap.from('.composer-wrap', { opacity: 0, duration: 0.6, ease: 'power2.out', delay: 0.2 }); + // Custom helper to animate elements in function animateIn(element) { @@ -1254,50 +1278,22 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { window.localStorage.setItem(storageKey, JSON.stringify(storedMessages)); } - function setEmptyState(title, detail, includePrompts = false) { + function setEmptyState(title, detail) { emptyState.innerHTML = ''; const titleElement = document.createElement('strong'); titleElement.textContent = title; emptyState.append(titleElement); - if (includePrompts) { - const modePills = document.createElement('div'); - modePills.className = 'mode-pills'; - for (const [label, prompt] of modePrompts) { - const pill = document.createElement('button'); - pill.className = 'mode-pill'; - pill.type = 'button'; - pill.innerHTML = label; - pill.dataset.action = 'fill'; - pill.dataset.prompt = prompt; - modePills.append(pill); - } - emptyState.append(modePills); - - const promptList = document.createElement('div'); - promptList.className = 'prompt-list'; - for (const prompt of promptSuggestions) { - const promptButton = document.createElement('button'); - promptButton.className = 'prompt-button'; - promptButton.type = 'button'; - promptButton.textContent = prompt; - promptButton.dataset.action = 'submit'; - promptButton.dataset.prompt = prompt; - promptList.append(promptButton); - } - emptyState.append(promptList); - emptyState.hidden = false; - return; + if (detail) { + const detailElement = document.createElement('span'); + detailElement.textContent = detail; + emptyState.append(detailElement); } - - const detailElement = document.createElement('span'); - detailElement.textContent = detail; - emptyState.append(detailElement); - emptyState.hidden = false; + messageForm.classList.add('is-empty'); } function hideEmptyState() { - emptyState.hidden = true; + messageForm.classList.remove('is-empty'); } const inlineCodeMarker = String.fromCharCode(96); @@ -1529,7 +1525,7 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { pendingMessages.clear(); assistantMessages.clear(); messageList.replaceChildren(); - setEmptyState('How can I help you?', '', true); + setEmptyState('How can I help you?', ''); messageInput.value = ''; messageInput.focus(); activeSessionId = null; @@ -1575,17 +1571,45 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { } for (const session of sessions) { - const button = document.createElement('button'); - button.className = - 'thread-item' + (session.id === activeSessionId ? ' active' : ''); - button.type = 'button'; - button.dataset.sessionId = session.id; - button.dataset.threadLabel = session.title; + const item = document.createElement('button'); + item.className = 'thread-item' + (session.id === activeSessionId ? ' active' : ''); + item.type = 'button'; + item.dataset.sessionId = session.id; + item.dataset.threadLabel = session.title; + const textSpan = document.createElement('span'); + textSpan.className = 'thread-item-text'; const relative = formatRelativeTime(session.lastAccessedAt); - button.textContent = - session.title + (relative ? ' · ' + relative : ''); - threadList.append(button); + textSpan.textContent = session.title + (relative ? ' · ' + relative : ''); + + const deleteBtn = document.createElement('div'); + deleteBtn.className = 'thread-delete-btn'; + deleteBtn.title = 'Delete session'; + deleteBtn.innerHTML = '${IconTrash}'; + + deleteBtn.addEventListener('click', (event) => { + event.stopPropagation(); + if (activeTurnId) { + addSystemNotice('Cannot delete a session while a turn is active.', 'Session switch'); + return; + } + + sendClientEvent({ + type: 'delete_session', + id: 'browser-delete-' + Date.now(), + sessionId: session.id, + }); + + const updatedSessions = currentSessions.filter(s => s.id !== session.id); + renderThreadList(updatedSessions); + + if (session.id === activeSessionId) { + newChatButton.click(); + } + }); + + item.append(textSpan, deleteBtn); + threadList.append(item); } } @@ -1597,7 +1621,7 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { messageList.replaceChildren(); if (messages.length === 0) { - setEmptyState('How can I help you?', '', true); + setEmptyState('How can I help you?', ''); } else { hideEmptyState(); for (const message of messages) { @@ -1797,7 +1821,7 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { setStatus('Connected', 'connected'); setComposerEnabled(true); if (storedMessages.length === 0) { - setEmptyState('How can I help you?', '', true); + setEmptyState('How can I help you?', ''); } messageInput.focus(); sendClientEvent({type: 'list_sessions', id: 'browser-sessions-' + Date.now()}); @@ -1972,10 +1996,6 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { setActiveTurn(null); setComposerEnabled(false); setStatus('Reconnecting…', ''); - setEmptyState( - 'Reconnecting…', - 'Trying to reach the local Nanocoder server again.', - ); scheduleReconnect(); }); socket.addEventListener('error', () => { @@ -1998,7 +2018,7 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { applyTheme(initialTheme()); applySidebarCollapsed(window.localStorage.getItem(sidebarStorageKey) === 'true'); - setEmptyState('How can I help you?', '', true); + setEmptyState('How can I help you?', ''); restoreStoredMessages(); connectSocket(); @@ -2014,6 +2034,18 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { submitUserMessage(messageInput.value); }); + function adjustMessageInputHeight() { + messageInput.style.height = 'auto'; + messageInput.style.height = Math.min(messageInput.scrollHeight, 180) + 'px'; + } + + messageInput.addEventListener('input', () => { + adjustMessageInputHeight(); + if (!activeTurnId) { + sendButton.disabled = messageInput.value.trim().length === 0 && pendingImages.length === 0; + } + }); + messageInput.addEventListener('keydown', event => { if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); diff --git a/source/web/protocol.ts b/source/web/protocol.ts index 2671bc038..52f3c4ddc 100644 --- a/source/web/protocol.ts +++ b/source/web/protocol.ts @@ -26,7 +26,8 @@ export type WebClientEvent = | {type: 'question_response'; id: string; answer: string} | {type: 'reset_session'; id: string} | {type: 'list_sessions'; id: string} - | {type: 'load_session'; id: string; sessionId: string}; + | {type: 'load_session'; id: string; sessionId: string} + | {type: 'delete_session'; id: string; sessionId: string}; export type WebServerEvent = | {type: 'ready'; protocolVersion: typeof WEB_PROTOCOL_VERSION} @@ -184,6 +185,23 @@ export function parseWebClientEvent(rawMessage: string): WebClientEvent { id: parsed.id, sessionId: parsed.sessionId, }; + case 'delete_session': + if (typeof parsed.id !== 'string' || parsed.id.length === 0) { + throw new Error('Delete session id is required.'); + } + + if ( + typeof parsed.sessionId !== 'string' || + parsed.sessionId.length === 0 + ) { + throw new Error('Delete session sessionId is required.'); + } + + return { + type: 'delete_session', + id: parsed.id, + sessionId: parsed.sessionId, + }; default: throw new Error(`Unsupported web event type: ${parsed.type}.`); } diff --git a/source/web/runtime-bridge.ts b/source/web/runtime-bridge.ts index f2c6eb7c0..91e51a8cc 100644 --- a/source/web/runtime-bridge.ts +++ b/source/web/runtime-bridge.ts @@ -19,6 +19,7 @@ export interface WebRuntimeHandlers { resetSession: () => void | Promise; listSessions: () => Promise; loadSession: (sessionId: string) => Promise; + deleteSession: (sessionId: string) => Promise; } export interface WebApprovalRequest { @@ -249,6 +250,17 @@ export function createWebRuntimeBridge( return; } + if (event.type === 'delete_session') { + if (activeTurnId) { + throw new Error( + 'Cannot delete session while a browser turn is active.', + ); + } + + await runtimeHandlers.deleteSession(event.sessionId); + return; + } + if (activeTurnId) { throw new Error('Nanocoder is already processing a browser turn.'); } From 2ac383326da3a95e1986e5fe60dd17cc91f38e0c Mon Sep 17 00:00:00 2001 From: SK Akram Date: Tue, 1 Sep 2026 11:39:44 +0000 Subject: [PATCH 09/13] fixed hover problem --- source/web/page.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/source/web/page.ts b/source/web/page.ts index 947dce8c4..2cdf19e63 100644 --- a/source/web/page.ts +++ b/source/web/page.ts @@ -295,7 +295,9 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { text-align: left; } .thread-delete-btn { - display: none; + display: flex; + align-items: center; + justify-content: center; background: transparent; color: var(--muted-foreground); cursor: pointer; @@ -303,11 +305,14 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { border-radius: 4px; flex-shrink: 0; margin-left: 8px; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s; + border: 0; } .thread-item:hover .thread-delete-btn { - display: flex; - align-items: center; - justify-content: center; + opacity: 1; + pointer-events: auto; } .thread-delete-btn:hover { color: #ef4444; From 5214569fa71df6326168963ddd0c5f284083efd9 Mon Sep 17 00:00:00 2001 From: SK Akram Date: Wed, 7 Oct 2026 11:52:54 +0000 Subject: [PATCH 10/13] fix(web): improve runtime reliability and browser UI --- .changeset/web-mode-runtime-reliability.md | 5 + source/app/App.tsx | 12 +- .../app/hooks/useGlobalHandlerQueues.spec.tsx | 14 + source/app/hooks/useGlobalHandlerQueues.tsx | 47 +-- source/cli.tsx | 48 ++- .../conversation/conversation-loop.tsx | 5 + .../conversation/tool-executor.spec.ts | 12 +- .../conversation/tool-executor.tsx | 5 +- source/hooks/chat-handler/types.ts | 1 + .../chat-handler/useChatHandler.spec.tsx | 32 ++ source/hooks/chat-handler/useChatHandler.tsx | 3 + source/web/browser.spec.ts | 95 ++++++ source/web/page.spec.ts | 32 +- source/web/page.ts | 211 ++++++++----- source/web/protocol.ts | 11 + source/web/runtime-bridge.spec.ts | 111 ++++++- source/web/runtime-bridge.ts | 293 +++++++++++++----- source/web/server.spec.ts | 4 +- source/web/server.ts | 4 + 19 files changed, 706 insertions(+), 239 deletions(-) create mode 100644 .changeset/web-mode-runtime-reliability.md create mode 100644 source/web/browser.spec.ts diff --git a/.changeset/web-mode-runtime-reliability.md b/.changeset/web-mode-runtime-reliability.md new file mode 100644 index 000000000..cfc52989d --- /dev/null +++ b/.changeset/web-mode-runtime-reliability.md @@ -0,0 +1,5 @@ +--- +"@nanocollective/nanocoder": patch +--- + +Fix web-mode reply delivery, offline startup, interaction queues and cancellation, reconnect and multi-tab state, session-operation races, image handling, and tool error status. diff --git a/source/app/App.tsx b/source/app/App.tsx index ebcf33f1f..e79b89eff 100644 --- a/source/app/App.tsx +++ b/source/app/App.tsx @@ -289,11 +289,13 @@ export default function App({ appState.setCompactToolCounts(null); appState.compactToolCountsRef.current = {}; appState.setLiveTaskList(null); - webRuntimeBridge?.completeTurn(); }, onError: error => { webRuntimeBridge?.failTurn(error); }, + onAssistantContent: content => { + webRuntimeBridge?.publishAssistantContent(content); + }, // A turn that started in plan mode finished uninterrupted — a plan was // produced. Flag it so the interactive UI can show the plan review bar. onPlanTurnComplete: () => { @@ -675,11 +677,13 @@ export default function App({ .filter( message => (message.role === 'user' || message.role === 'assistant') && - message.content.trim().length > 0, + (message.content.trim().length > 0 || + (message.images?.length ?? 0) > 0), ) .map(message => ({ role: message.role as 'user' | 'assistant', content: message.content, + images: message.images, })), }; }, @@ -691,10 +695,6 @@ export default function App({ appState.toolManager, ]); - React.useEffect(() => { - webRuntimeBridge?.publishAssistantContent(chatHandler.streamingContent); - }, [webRuntimeBridge, chatHandler.streamingContent]); - React.useEffect(() => { if (!webRuntimeBridge) { return; diff --git a/source/app/hooks/useGlobalHandlerQueues.spec.tsx b/source/app/hooks/useGlobalHandlerQueues.spec.tsx index 35e17129d..ecd5f8a56 100644 --- a/source/app/hooks/useGlobalHandlerQueues.spec.tsx +++ b/source/app/hooks/useGlobalHandlerQueues.spec.tsx @@ -418,3 +418,17 @@ test('browser turns route questions and both approval slots to the browser, then captured!.handleQuestionAnswer('terminal'); t.is(await terminalAnswer, 'terminal'); }); + +test('browser routing forwards cancellation to the pending approval', async t => { + const events: WebServerEvent[] = []; + const bridge = createWebRuntimeBridge(event => events.push(event)); + bridge.bindRuntimeHandlers({submitMessage: () => new Promise(() => {}), cancel: () => {}, resetSession: () => {}}); + setup(bridge); + await bridge.handleClientEvent({type: 'user_message', id: 'turn', text: 'hello'}); + const controller = new AbortController(); + const approval = signalToolApproval(approvalFrom('agent'), controller.signal); + const event = events.at(-1)!; + controller.abort(); + t.false(await approval); + await t.throwsAsync(bridge.handleClientEvent({type: 'approval_response', id: event.id!, approved: true})); +}); diff --git a/source/app/hooks/useGlobalHandlerQueues.tsx b/source/app/hooks/useGlobalHandlerQueues.tsx index 60f878796..cbbd1ca7c 100644 --- a/source/app/hooks/useGlobalHandlerQueues.tsx +++ b/source/app/hooks/useGlobalHandlerQueues.tsx @@ -75,7 +75,7 @@ function useHandlerQueue( * approval is a denial. */ abandoned: (input: TInput) => TResult, - route?: (input: TInput) => Promise | undefined, + route?: (input: TInput, signal?: AbortSignal) => Promise | undefined, ): (result: TResult) => void { const queueRef = useRef[]>([]); @@ -97,7 +97,7 @@ function useHandlerQueue( useEffect(() => { install((input: TInput, abortSignal?: AbortSignal) => { - const routed = routeRef.current?.(input); + const routed = routeRef.current?.(input, abortSignal); if (routed) return routed; return new Promise(resolve => { const entry: QueuedRequest = {input, resolve}; @@ -169,13 +169,16 @@ export function useGlobalHandlerQueues({ setGlobalQuestionHandler, presentQuestion, ABANDONED_QUESTION, - question => { + (question, signal) => { if (webRuntimeBridge?.hasActiveBrowserTurn()) { - return webRuntimeBridge.requestQuestion({ - question: question.question, - options: question.options, - allowFreeform: question.allowFreeform, - }); + return webRuntimeBridge.requestQuestion( + { + question: question.question, + options: question.options, + allowFreeform: question.allowFreeform, + }, + signal, + ); } return undefined; }, @@ -191,13 +194,16 @@ export function useGlobalHandlerQueues({ setGlobalToolApprovalHandler, setPendingSubagentApproval, ABANDONED_APPROVAL, - approval => { + (approval, signal) => { if (webRuntimeBridge?.hasActiveBrowserTurn()) { - return webRuntimeBridge.requestApproval({ - toolName: approval.toolCall.function.name, - arguments: approval.toolCall.function.arguments, - context: `Subagent: ${approval.subagentName}`, - }); + return webRuntimeBridge.requestApproval( + { + toolName: approval.toolCall.function.name, + arguments: approval.toolCall.function.arguments, + context: `Subagent: ${approval.subagentName}`, + }, + signal, + ); } return undefined; }, @@ -209,12 +215,15 @@ export function useGlobalHandlerQueues({ setGlobalToolConfirmHandler, setPendingToolConfirmation, ABANDONED_APPROVAL, - confirmation => { + (confirmation, signal) => { if (webRuntimeBridge?.hasActiveBrowserTurn()) { - return webRuntimeBridge.requestApproval({ - toolName: confirmation.toolCall.function.name, - arguments: confirmation.toolCall.function.arguments, - }); + return webRuntimeBridge.requestApproval( + { + toolName: confirmation.toolCall.function.name, + arguments: confirmation.toolCall.function.arguments, + }, + signal, + ); } return undefined; }, diff --git a/source/cli.tsx b/source/cli.tsx index f3d038740..b720ac20c 100644 --- a/source/cli.tsx +++ b/source/cli.tsx @@ -728,7 +728,7 @@ async function main(): Promise { }); } else { // Interactive TUI — load Ink + App only now. - const [{render}, {default: App}] = await Promise.all([ + const [{render, Box, Text}, {default: App}] = await Promise.all([ import('ink'), import('@/app'), ]); @@ -790,7 +790,10 @@ async function main(): Promise { !args.includes('--no-alt-screen') && (args.includes('--alt-screen') || getAlternateScreen()); const useAltScreen = - process.stdout.isTTY && !nonInteractiveMode && altScreenAllowed; + process.stdout.isTTY && + !nonInteractiveMode && + !webMode && + altScreenAllowed; const mouseReportingAllowed = !args.includes('--no-mouse') && (args.includes('--mouse') || getMouseReporting()); @@ -933,6 +936,7 @@ async function main(): Promise { } let webRuntimeBridge: WebRuntimeBridge | undefined; + let webUrl: string | undefined; if (webMode) { const [ {createWebRuntimeBridge}, @@ -950,11 +954,13 @@ async function main(): Promise { }); const webServer = await startLocalWebServer({ onClientEvent: webRuntimeBridge.handleClientEvent, + getStateEvents: webRuntimeBridge.getStateEvents, onAllClientsDisconnected: () => { webRuntimeBridge?.handleDisconnect(); }, }); broadcastEvent = webServer.broadcastEvent; + webUrl = webServer.url; getShutdownManager().register({ name: 'web-server', @@ -968,20 +974,30 @@ async function main(): Promise { } const result = render( - , + + + {webUrl && ( + + Web mode: {webUrl} + + Open this URL in your browser. Ctrl+C stops the server. + + + )} + , { // Ctrl+C is handled inside App (routed through the shutdown // manager) so the exit-render handler below can paint the diff --git a/source/hooks/chat-handler/conversation/conversation-loop.tsx b/source/hooks/chat-handler/conversation/conversation-loop.tsx index a200e83c8..088cdbb64 100644 --- a/source/hooks/chat-handler/conversation/conversation-loop.tsx +++ b/source/hooks/chat-handler/conversation/conversation-loop.tsx @@ -128,6 +128,7 @@ interface ProcessAssistantResponseParams { setIsGenerating: (generating: boolean) => void; setStreamingReasoning: (content: string) => void; setStreamingContent: (content: string) => void; + onAssistantContent?: (content: string) => void; setTokenCount: (count: number) => void; setMessages: (messages: Message[]) => void; addToChatQueue: (component: React.ReactNode) => void; @@ -243,6 +244,7 @@ export const processAssistantResponse = async ( setIsGenerating, setStreamingReasoning, setStreamingContent, + onAssistantContent, setTokenCount, setMessages, addToChatQueue, @@ -344,6 +346,7 @@ export const processAssistantResponse = async ( // Use streaming with callbacks setIsGenerating(true); setStreamingContent(''); + onAssistantContent?.(''); setStreamingReasoning(''); setTokenCount(0); // Drop any prior empty-response retry counter from the live area so the @@ -402,6 +405,7 @@ export const processAssistantResponse = async ( onToken: (token: string) => { streamedContent += token; setStreamingContent(streamedContent); + onAssistantContent?.(stripThinkTags(streamedContent)); // Feed the in-flight reply into the context-usage estimate so the // `~%` indicator climbs as the model writes, instead of only // stepping up once the finished message is committed to history. @@ -540,6 +544,7 @@ export const processAssistantResponse = async ( const parsedToolCalls = parseResult.toolCalls; const cleanedContent = parseResult.cleanedContent; + onAssistantContent?.(cleanedContent); // Combine native tool calls with any parsed from content (XML fallback path) // Native and parsed are mutually exclusive: native comes from tool-calling models, diff --git a/source/hooks/chat-handler/conversation/tool-executor.spec.ts b/source/hooks/chat-handler/conversation/tool-executor.spec.ts index 797012c28..00adcbfbb 100644 --- a/source/hooks/chat-handler/conversation/tool-executor.spec.ts +++ b/source/hooks/chat-handler/conversation/tool-executor.spec.ts @@ -1,5 +1,6 @@ import test from 'ava'; -import {displayExecutedTool, executeToolsDirectly} from './tool-executor.js'; +import {displayExecutedTool, executeToolsDirectly, executeApprovedTool} from './tool-executor.js'; +import {setWebToolLifecyclePublisher} from '@/web/tool-lifecycle'; import type {ToolCall, ToolResult} from '@/types/core'; // ============================================================================ @@ -11,6 +12,15 @@ import {ToolValidationError} from '@/utils/tool-validation'; const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +test('web lifecycle reports structured tool failures as failed', async t => { + const outcomes: boolean[] = []; + setWebToolLifecyclePublisher({started: () => {}, finished: (_id, _name, ok) => outcomes.push(ok)}); + t.teardown(() => setWebToolLifecyclePublisher(null)); + const toolCall = {id: 'failed', function: {name: 'write_file', arguments: {}}}; + await executeApprovedTool(toolCall, null, async () => ({tool_call_id: toolCall.id, role: 'tool', name: 'write_file', content: 'Rejected', isError: true})); + t.deepEqual(outcomes, [false]); +}); + // Mock tool registry for tests const mockToolHandler: ToolCall['function']['name'] extends infer T ? Record) => Promise> diff --git a/source/hooks/chat-handler/conversation/tool-executor.tsx b/source/hooks/chat-handler/conversation/tool-executor.tsx index afc1b3ab2..9d74f19f6 100644 --- a/source/hooks/chat-handler/conversation/tool-executor.tsx +++ b/source/hooks/chat-handler/conversation/tool-executor.tsx @@ -52,7 +52,7 @@ const executeOne = async ( publishWebToolFinished( toolCall.id, toolCall.function.name, - !result.content.startsWith('Error: '), + !isToolResultError(result), ); return {toolCall, result}; } catch (error) { @@ -127,8 +127,7 @@ export const executeApprovedTool = async ( publishWebToolFinished( toolCall.id, toolCall.function.name, - !execution.result.content.startsWith('Error: ') && - !execution.result.content.startsWith('⚒ Validation failed'), + !isToolResultError(execution.result), ); return execution; } catch (error) { diff --git a/source/hooks/chat-handler/types.ts b/source/hooks/chat-handler/types.ts index fa072fe84..598044f4a 100644 --- a/source/hooks/chat-handler/types.ts +++ b/source/hooks/chat-handler/types.ts @@ -42,6 +42,7 @@ export interface UseChatHandlerProps { nonInteractiveMode?: boolean; onConversationComplete?: () => void; onError?: (error: unknown) => void; + onAssistantContent?: (content: string) => void; // Fired when a turn that STARTED in plan mode runs to completion without // being interrupted — i.e. a plan was actually produced. Decided here rather // than inferred from ambient state (isConversationComplete + current mode), diff --git a/source/hooks/chat-handler/useChatHandler.spec.tsx b/source/hooks/chat-handler/useChatHandler.spec.tsx index 158aea2e8..df0b7ed47 100644 --- a/source/hooks/chat-handler/useChatHandler.spec.tsx +++ b/source/hooks/chat-handler/useChatHandler.spec.tsx @@ -14,6 +14,8 @@ import {getBaseSystemPrompt, useChatHandler} from './useChatHandler'; import type {UseChatHandlerProps, ChatHandlerReturn} from './types'; import type {LLMClient, Message} from '../../types/core'; import {useUserMessageQueue} from '../useUserMessageQueue'; +import {createWebRuntimeBridge} from '@/web/runtime-bridge'; +import type {WebServerEvent} from '@/web/protocol'; import { resetSessionCwd, setProjectRoot, @@ -117,6 +119,36 @@ test('useChatHandler - returns correct interface', t => { t.true('tokenCount' in hookResult!); }); +for (const timing of ['burst', 'last-token', 'nonstream'] as const) { + test(`browser observer delivers the complete ${timing} reply before completion`, async t => { + const events: WebServerEvent[] = []; + const bridge = createWebRuntimeBridge(event => events.push(event)); + let hook: ChatHandlerReturn | null = null; + const client: LLMClient = { + ...createMockClient(), + chat: async (_messages, _tools, callbacks) => { + if (timing !== 'nonstream') { + callbacks.onToken('Hello'); + if (timing === 'last-token') await new Promise(resolve => setTimeout(resolve, 25)); + callbacks.onToken(' world'); + } + return {choices: [{message: {role: 'assistant', content: 'Hello world'}}]}; + }, + }; + const instance = render( {hook = result;}} />); + await waitForCondition(() => hook !== null); + bridge.bindRuntimeHandlers({submitMessage: text => hook!.handleChatMessage(text), cancel: () => {}, resetSession: () => {}}); + await bridge.handleClientEvent({type: 'user_message', id: timing, text: 'hello'}); + await waitForCondition(() => !bridge.hasActiveBrowserTurn()); + const text = events.filter(event => event.type === 'assistant_delta').map(event => event.text).join(''); + t.is(text, 'Hello world'); + t.true(events.findIndex(event => event.type === 'turn_completed') > events.findIndex(event => event.type === 'assistant_delta')); + instance.unmount(); + }); +} + test('useChatHandler - returns correct function types', t => { let hookResult: ChatHandlerReturn | null = null; diff --git a/source/hooks/chat-handler/useChatHandler.tsx b/source/hooks/chat-handler/useChatHandler.tsx index 94f408712..f7c6349a9 100644 --- a/source/hooks/chat-handler/useChatHandler.tsx +++ b/source/hooks/chat-handler/useChatHandler.tsx @@ -88,6 +88,7 @@ export function useChatHandler({ nonInteractiveMode = false, onConversationComplete, onError, + onAssistantContent, onPlanTurnComplete, onArchitectTurnComplete, reasoningExpandedRef, @@ -269,6 +270,7 @@ export function useChatHandler({ setIsGenerating, setStreamingReasoning, setStreamingContent, + onAssistantContent, setTokenCount, setMessages, addToChatQueue, @@ -330,6 +332,7 @@ export function useChatHandler({ nonInteractiveMode, onConversationComplete, onError, + onAssistantContent, reasoningExpandedRef, compactToolDisplayRef, compactToolCountsRef, diff --git a/source/web/browser.spec.ts b/source/web/browser.spec.ts new file mode 100644 index 000000000..d5ba184a7 --- /dev/null +++ b/source/web/browser.spec.ts @@ -0,0 +1,95 @@ +import {createContext, runInContext} from 'node:vm'; +import test from 'ava'; +import {createElement} from '@/vscode/chat-panel-harness'; +import {renderWebModePage} from './page.js'; + +function bootBrowser() { + const elements = new Map>(); + const storage = new Map(); + const root = createElement('html'); + const get = (selector: string) => { + if (!elements.has(selector)) elements.set(selector, element('div')); + return elements.get(selector)!; + }; + function element(tag: string) { + const node = createElement(tag); + node.append = (...children: ReturnType[]) => children.forEach(child => node.appendChild(child)); + node.replaceChildren = (...children: ReturnType[]) => {node.innerHTML = ''; node.append(...children);}; + node.scrollIntoView = () => {}; + return node; + } + const sent: unknown[] = []; + class Socket { + static OPEN = 1; + readyState = 1; + addEventListener() {} + send(raw: string) {sent.push(JSON.parse(raw));} + } + const context = createContext({ + document: {querySelector: get, createElement: element, createTextNode: (text: string) => {const node = element('text'); node.textContent = text; return node;}, documentElement: root, addEventListener() {}}, + window: {location: {search: '?token=test', href: 'http://localhost/?token=test'}, localStorage: {getItem: (key: string) => storage.get(key) ?? null, setItem: (key: string, value: string) => storage.set(key, value), removeItem: (key: string) => storage.delete(key)}, setTimeout: () => 1, matchMedia: () => ({matches: false})}, + URL, URLSearchParams, WebSocket: Socket, + }); + const script = renderWebModePage('test').match(/
@@ -1084,6 +1060,8 @@ export function renderWebModePage(nonce: string = createPageNonce()): string {
+ +
@@ -1155,7 +1133,6 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { const historyButton = document.querySelector('#historyButton'); const settingsButton = document.querySelector('#settingsButton'); const composerNote = document.querySelector('#composerNote'); - const threadSearchInput = document.querySelector('#threadSearchInput'); const threadList = document.querySelector('#threadList'); const settingsModal = document.querySelector('#settingsModal'); @@ -1170,10 +1147,14 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { eventsUrl.searchParams.set('token', token ?? ''); const storageKey = 'nanocoder.webMode.localSession.v1'; const pendingMessages = new Map(); + const pendingDrafts = new Map(); const assistantMessages = new Map(); let messageCounter = 0; let storedMessages = []; let activeTurnId = null; + let sessionBusy = false; + let sessionRevision = null; + const pendingSessionActions = new Map(); let isConnected = false; let socket = null; let reconnectTimer = null; @@ -1181,14 +1162,13 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { const maxReconnectDelayMs = 15000; // Initial load animation - gsap.from('.sidebar', { opacity: 0, x: -20, duration: 0.6, ease: 'power2.out' }); - gsap.from('.topbar', { opacity: 0, y: -10, duration: 0.5, ease: 'power2.out', delay: 0.1 }); - gsap.from('.composer-wrap', { opacity: 0, duration: 0.6, ease: 'power2.out', delay: 0.2 }); // Custom helper to animate elements in function animateIn(element) { - gsap.fromTo(element, { opacity: 0, y: 15 }, { opacity: 1, y: 0, duration: 0.4, ease: 'power2.out' }); + if (element.animate && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + element.animate([{opacity: 0, transform: 'translateY(15px)'}, {opacity: 1, transform: 'translateY(0)'}], {duration: 250, easing: 'ease-out'}); + } } @@ -1235,15 +1215,19 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { function setComposerEnabled(isEnabled) { isConnected = isEnabled; - messageInput.disabled = !isEnabled || activeTurnId !== null; - sendButton.disabled = !isEnabled; + updateComposer(); + } + + function updateComposer() { + messageInput.disabled = !isConnected || sessionBusy || activeTurnId !== null; + sendButton.disabled = !isConnected || sessionBusy || (activeTurnId === null && messageInput.value.trim().length === 0 && pendingImages.length === 0); + newChatButton.disabled = !isConnected || sessionBusy || activeTurnId !== null; + uploadImageButton.disabled = !isConnected || sessionBusy || activeTurnId !== null; } function setActiveTurn(id) { activeTurnId = id; const isActive = id !== null; - messageInput.disabled = !isConnected || isActive; - sendButton.disabled = !isConnected; sendButton.classList.toggle('is-cancel', isActive); sendButton.textContent = isActive ? '■' : '↑'; sendButton.setAttribute( @@ -1253,7 +1237,7 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { composerNote.textContent = isActive ? 'Nanocoder is working. Use the stop button to cancel.' : 'Enter sends. Shift+Enter creates a new line.'; - newChatButton.disabled = isActive; + updateComposer(); } function readStoredMessages() { @@ -1280,7 +1264,11 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { } function writeStoredMessages() { - window.localStorage.setItem(storageKey, JSON.stringify(storedMessages)); + try { + window.localStorage.setItem(storageKey, JSON.stringify(storedMessages)); + } catch { + // Browser storage is optional; a full quota must not stop chat. + } } function setEmptyState(title, detail) { @@ -1455,10 +1443,11 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { } } - function appendMessage(role, text, metaText, shouldStore = true, images = []) { + function appendMessage(role, text, metaText, shouldStore = true, images = [], id) { hideEmptyState(); const messageElement = document.createElement('div'); messageElement.className = 'message ' + role; + if (id) messageElement.dataset.messageId = id; if (images && images.length > 0) { const imageContainer = document.createElement('div'); @@ -1496,7 +1485,7 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { animateIn(messageElement); if (shouldStore) { - storedMessages.push({role, text, metaText: metaText ?? ''}); + storedMessages.push({id, role, text, metaText: metaText ?? '', images}); writeStoredMessages(); } @@ -1504,6 +1493,11 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { } function updateMessageMeta(messageElement, metaText) { + const stored = storedMessages.find(message => message.id === messageElement.dataset.messageId); + if (stored) { + stored.metaText = metaText; + writeStoredMessages(); + } let metaElement = messageElement.querySelector('.meta'); if (!metaElement) { metaElement = document.createElement('div'); @@ -1520,7 +1514,8 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { } for (const message of storedMessages) { - appendMessage(message.role, message.text, message.metaText, false); + const element = appendMessage(message.role, message.text, message.metaText, false, message.images ?? [], message.id); + if (message.role === 'assistant' && message.id) assistantMessages.set(message.id, element); } } @@ -1534,6 +1529,8 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { messageInput.value = ''; messageInput.focus(); activeSessionId = null; + pendingImages = []; + renderImagePreviews(); renderThreadList(currentSessions); } @@ -1605,12 +1602,6 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { sessionId: session.id, }); - const updatedSessions = currentSessions.filter(s => s.id !== session.id); - renderThreadList(updatedSessions); - - if (session.id === activeSessionId) { - newChatButton.click(); - } }); item.append(textSpan, deleteBtn); @@ -1624,19 +1615,21 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { pendingMessages.clear(); assistantMessages.clear(); messageList.replaceChildren(); + pendingImages = []; + renderImagePreviews(); if (messages.length === 0) { setEmptyState('How can I help you?', ''); } else { hideEmptyState(); for (const message of messages) { - appendMessage(message.role, message.content); + const element = appendMessage(message.role, message.content, '', true, message.images ?? [], message.id); + if (message.role === 'assistant' && message.id) assistantMessages.set(message.id, element); } } messageInput.value = ''; renderThreadList(currentSessions); - addSystemNotice('Resumed session: ' + sessionSummary.title, 'Session switch'); } function setPromptText(text) { @@ -1653,22 +1646,25 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { appendMessage('system', text, metaText); } - function appendAssistantDelta(id, text) { + function appendAssistantDelta(id, text, replace = false) { let messageElement = assistantMessages.get(id); if (!messageElement) { - messageElement = appendMessage('assistant', ''); + messageElement = appendMessage('assistant', '', '', true, [], id); assistantMessages.set(id, messageElement); } - const textElement = messageElement.firstElementChild; - const nextText = (textElement.dataset.rawText ?? '') + text; + const textElement = messageElement.querySelector('.message-content'); + const nextText = replace ? text : (textElement.dataset.rawText ?? '') + text; textElement.dataset.rawText = nextText; renderAssistantText(textElement, nextText); + const stored = storedMessages.find(message => message.id === id && message.role === 'assistant'); + if (stored) stored.text = nextText; + writeStoredMessages(); messageList.scrollTop = messageList.scrollHeight; } function sendClientEvent(event) { - if (socket.readyState !== WebSocket.OPEN) { + if (!socket || socket.readyState !== WebSocket.OPEN) { appendMessage('system error', 'The local session is not connected.'); return false; } @@ -1692,11 +1688,13 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { } function renderApprovalCard(message) { + if (document.querySelector('[data-interaction-id="' + message.id + '"]')) return; hideEmptyState(); const messageElement = document.createElement('div'); messageElement.className = 'message system interaction'; const card = document.createElement('div'); card.className = 'interaction-card'; + card.dataset.interactionId = message.id; const title = document.createElement('strong'); title.textContent = 'Approve tool: ' + message.toolName; @@ -1747,11 +1745,13 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { } function renderQuestionCard(message) { + if (document.querySelector('[data-interaction-id="' + message.id + '"]')) return; hideEmptyState(); const messageElement = document.createElement('div'); messageElement.className = 'message system interaction'; const card = document.createElement('div'); card.className = 'interaction-card'; + card.dataset.interactionId = message.id; const title = document.createElement('strong'); title.textContent = message.question; @@ -1822,6 +1822,35 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { } function handleServerEvent(message) { + if (message.type === 'state') { + const previousSession = activeSessionId; + const sessionChanged = sessionRevision !== null && sessionRevision !== message.sessionRevision; + sessionRevision = message.sessionRevision; + activeSessionId = message.session?.id ?? null; + sessionBusy = message.busy; + storedMessages = []; + assistantMessages.clear(); + messageList.replaceChildren(); + for (const item of message.messages) { + const element = appendMessage(item.role, item.content, item.role === 'user' ? 'Delivered to local session' : '', true, item.images ?? [], item.id); + if (item.role === 'assistant' && item.id) assistantMessages.set(item.id, element); + } + writeStoredMessages(); + if (message.messages.length === 0) setEmptyState('How can I help you?', ''); + if (sessionChanged || previousSession !== activeSessionId) { + pendingImages = []; + messageInput.value = ''; + renderImagePreviews(); + } + setActiveTurn(message.activeTurnId); + renderThreadList(currentSessions); + return; + } + if (message.type === 'interaction_closed') { + const card = document.querySelector('[data-interaction-id="' + message.id + '"]'); + if (card) disableInteractionCard(card); + return; + } if (message.type === 'ready') { setStatus('Connected', 'connected'); setComposerEnabled(true); @@ -1834,6 +1863,14 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { } if (message.type === 'ack') { + pendingDrafts.delete(message.id); + const action = pendingSessionActions.get(message.id); + if (action) { + pendingSessionActions.delete(message.id); + pendingImages = []; + messageInput.value = ''; + renderImagePreviews(); + } const messageElement = pendingMessages.get(message.id); if (messageElement) { updateMessageMeta(messageElement, 'Delivered to local session'); @@ -1846,6 +1883,10 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { appendAssistantDelta(message.id, message.text); return; } + if (message.type === 'assistant_content') { + appendAssistantDelta(message.id, message.text, true); + return; + } if (message.type === 'tool_started') { appendMessage('system tool-status', 'Running tool: ' + message.name, 'In progress'); @@ -1880,7 +1921,15 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { } if (message.type === 'error') { - setActiveTurn(null); + if (message.id === activeTurnId) setActiveTurn(null); + pendingSessionActions.delete(message.id); + const draft = pendingDrafts.get(message.id); + if (draft) { + setPromptText(draft.text); + pendingImages = draft.images; + pendingDrafts.delete(message.id); + renderImagePreviews(); + } const pendingMessageElement = message.id ? pendingMessages.get(message.id) : undefined; @@ -1890,6 +1939,8 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { updateMessageMeta(pendingMessageElement, 'Not sent — ' + message.message); pendingMessages.delete(message.id); setPromptText(failedText); + pendingImages = storedMessages.find(item => item.id === message.id)?.images ?? []; + renderImagePreviews(); } else { appendMessage('system error', message.message); } @@ -1910,7 +1961,7 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { } function submitUserMessage(text) { - if (activeTurnId) { + if (!isConnected || sessionBusy || activeTurnId) { return; } @@ -1920,8 +1971,9 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { } const id = 'browser-message-' + Date.now() + '-' + messageCounter++; - const messageElement = appendMessage('user', trimmedText, 'Sending...', true, pendingImages); + const messageElement = appendMessage('user', trimmedText, 'Sending...', true, pendingImages, id); pendingMessages.set(id, messageElement); + pendingDrafts.set(id, {text: trimmedText, images: pendingImages}); messageInput.value = ''; setActiveTurn(id); @@ -1952,18 +2004,22 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { } else { imagePreviewContainer.hidden = true; } + updateComposer(); } function handleFiles(files) { + if (activeTurnId || sessionBusy || !isConnected) return; for (const file of files) { if (!file.type.startsWith('image/')) continue; const reader = new FileReader(); reader.onload = e => { + if (activeTurnId || sessionBusy || !isConnected) return; pendingImages.push({ data: e.target.result, mediaType: file.type }); renderImagePreviews(); }; reader.readAsDataURL(file); } + imageUploadInput.value = ''; } emptyState.addEventListener('click', event => { @@ -2046,9 +2102,7 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { messageInput.addEventListener('input', () => { adjustMessageInputHeight(); - if (!activeTurnId) { - sendButton.disabled = messageInput.value.trim().length === 0 && pendingImages.length === 0; - } + updateComposer(); }); messageInput.addEventListener('keydown', event => { @@ -2082,9 +2136,9 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { }); newChatButton.addEventListener('click', () => { - sendClientEvent({type: 'reset_session', id: 'browser-reset-' + Date.now()}); - clearLocalSession(); - addSystemNotice('Started a fresh local browser session.', 'Stored only in this browser'); + if (!isConnected || activeTurnId || sessionBusy) return; + const id = 'browser-reset-' + Date.now(); + if (sendClientEvent({type: 'reset_session', id})) pendingSessionActions.set(id, 'reset'); }); themeToggleButton.addEventListener('click', () => { @@ -2110,7 +2164,6 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { type: 'list_sessions', id: 'browser-sessions-' + Date.now(), }); - if (threadSearchInput) threadSearchInput.focus(); }); if (settingsButton) settingsButton.addEventListener('click', () => { @@ -2125,14 +2178,6 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { } }); - if (threadSearchInput) threadSearchInput.addEventListener('input', () => { - const query = threadSearchInput.value.trim().toLowerCase(); - for (const threadButton of threadList.querySelectorAll('.thread-item')) { - const label = (threadButton.dataset.threadLabel || '').toLowerCase(); - threadButton.hidden = query.length > 0 && !label.includes(query); - } - }); - threadList.addEventListener('click', event => { const target = event.target.closest('.thread-item'); if (!target || !target.dataset.sessionId) { diff --git a/source/web/protocol.ts b/source/web/protocol.ts index 52f3c4ddc..55231fed0 100644 --- a/source/web/protocol.ts +++ b/source/web/protocol.ts @@ -8,6 +8,7 @@ export interface WebSessionSummary { } export interface WebSessionMessage { + id?: string; role: 'user' | 'assistant'; content: string; images?: {data: string; mediaType: string}[]; @@ -30,9 +31,19 @@ export type WebClientEvent = | {type: 'delete_session'; id: string; sessionId: string}; export type WebServerEvent = + | { + type: 'state'; + activeTurnId: string | null; + messages: WebSessionMessage[]; + session: WebSessionSummary | null; + busy: boolean; + sessionRevision: number; + } + | {type: 'interaction_closed'; id: string} | {type: 'ready'; protocolVersion: typeof WEB_PROTOCOL_VERSION} | {type: 'ack'; id: string} | {type: 'assistant_delta'; id: string; text: string} + | {type: 'assistant_content'; id: string; text: string} | {type: 'tool_started'; id: string; name: string} | {type: 'tool_finished'; id: string; name: string; ok: boolean} | { diff --git a/source/web/runtime-bridge.spec.ts b/source/web/runtime-bridge.spec.ts index 8a756c205..ff4ff09af 100644 --- a/source/web/runtime-bridge.spec.ts +++ b/source/web/runtime-bridge.spec.ts @@ -1,6 +1,13 @@ import test from 'ava'; import type {WebServerEvent} from './protocol.js'; import {createWebRuntimeBridge} from './runtime-bridge.js'; +import type {WebRuntimeHandlers} from './runtime-bridge.js'; + +const handlers = (overrides: Partial = {}): WebRuntimeHandlers => ({ + submitMessage: () => new Promise(() => {}), + cancel: () => {}, resetSession: () => {}, listSessions: async () => [], + loadSession: async () => null, deleteSession: async () => {}, ...overrides, +}); const userMessage = (id: string, text = 'hello') => ({ type: 'user_message' as const, @@ -65,16 +72,16 @@ test('web runtime bridge publishes assistant deltas and completion for the activ bridge.publishAssistantContent('Again'); bridge.completeTurn(); - t.deepEqual(events, [ + t.deepEqual(events.filter(event => event.type !== 'state'), [ {type: 'assistant_delta', id: 'turn-1', text: 'Hel'}, {type: 'assistant_delta', id: 'turn-1', text: 'lo'}, - {type: 'assistant_delta', id: 'turn-1', text: 'Again'}, + {type: 'assistant_delta', id: 'turn-1:response:2', text: 'Again'}, {type: 'turn_completed', id: 'turn-1'}, ]); resolveSubmission?.(); await submission; - t.is(events.length, 4); + t.is(events.filter(event => event.type !== 'state').length, 4); }); test('web runtime bridge cancels only the matching active browser turn', async t => { @@ -121,8 +128,8 @@ test('web runtime bridge reports asynchronous submission failures and clears the await new Promise(resolve => setTimeout(resolve, 0)); t.deepEqual(submittedMessages, ['fail', 'retry']); - t.deepEqual(events, [ - {type: 'error', message: 'Model request failed.'}, + t.deepEqual(events.filter(event => event.type !== 'state'), [ + {type: 'error', id: 'turn-1', message: 'Model request failed.'}, {type: 'turn_completed', id: 'turn-2'}, ]); }); @@ -261,7 +268,7 @@ test('web runtime bridge publishes tool lifecycle only during an active browser bridge.publishToolStarted('tool-1', 'read_file'); bridge.publishToolFinished('tool-1', 'read_file', true); - t.deepEqual(events, [ + t.deepEqual(events.filter(event => event.type !== 'state'), [ {type: 'tool_started', id: 'tool-1', name: 'read_file'}, {type: 'tool_finished', id: 'tool-1', name: 'read_file', ok: true}, ]); @@ -372,7 +379,7 @@ test('web runtime bridge broadcasts the loaded session on load_session', async t }); t.deepEqual(loadedIds, ['session-1']); - t.deepEqual(events, [ + t.deepEqual(events.filter(event => event.type !== 'state'), [ { type: 'session_loaded', id: 'load-1', @@ -454,3 +461,93 @@ test('web runtime bridge denies pending approval on disconnect', async t => { t.false(await approvalPromise); }); + +test('concurrent approvals and questions are presented FIFO across slots', async t => { + const events: WebServerEvent[] = []; + const bridge = createWebRuntimeBridge(event => events.push(event)); + bridge.bindRuntimeHandlers(handlers()); + await bridge.handleClientEvent(userMessage('turn')); + const first = bridge.requestApproval({toolName: 'first', arguments: {}}); + const second = bridge.requestApproval({toolName: 'second', arguments: {}}); + const question = bridge.requestQuestion({question: 'Which?', options: ['A', 'B'], allowFreeform: false}); + t.is(events.filter(event => event.type === 'approval_required').length, 1); + const firstEvent = events.at(-1)!; + await bridge.handleClientEvent({type: 'approval_response', id: firstEvent.id!, approved: true}); + t.true(await first); + t.like(events.at(-1), {type: 'approval_required', toolName: 'second'}); + await bridge.handleClientEvent({type: 'approval_response', id: events.at(-1)!.id!, approved: false}); + t.false(await second); + t.like(events.at(-1), {type: 'question_required', question: 'Which?'}); + await bridge.handleClientEvent({type: 'question_response', id: events.at(-1)!.id!, answer: 'B'}); + t.is(await question, 'B'); +}); + +test('aborting browser interactions denies them and removes stale responses', async t => { + const events: WebServerEvent[] = []; + const bridge = createWebRuntimeBridge(event => events.push(event)); + bridge.bindRuntimeHandlers(handlers()); + await bridge.handleClientEvent(userMessage('turn')); + const controller = new AbortController(); + const approval = bridge.requestApproval({toolName: 'first', arguments: {}}, controller.signal); + const id = events.at(-1)!.id!; + const queuedController = new AbortController(); + const queued = bridge.requestQuestion({question: 'queued', options: [], allowFreeform: true}, queuedController.signal); + queuedController.abort(); + t.regex(await queued, /cancelled/); + controller.abort(); + t.false(await approval); + await t.throwsAsync(bridge.handleClientEvent({type: 'approval_response', id, approved: true})); + t.is(bridge.getStateEvents().length, 1); +}); + +test('reconnect snapshots include the active prompt, full replies, images and pending interaction', async t => { + const bridge = createWebRuntimeBridge(() => {}); + bridge.bindRuntimeHandlers(handlers()); + const images = [{data: 'data:image/png;base64,AA==', mediaType: 'image/png'}]; + await bridge.handleClientEvent({...userMessage('turn'), images}); + bridge.publishAssistantContent('Hello'); + bridge.publishAssistantContent('Hello world'); + const approval = bridge.requestApproval({toolName: 'write_file', arguments: {}}); + const snapshot = bridge.getStateEvents(); + t.like(snapshot[0], {type: 'state', activeTurnId: 'turn', messages: [ + {id: 'turn', role: 'user', content: 'hello', images}, + {id: 'turn', role: 'assistant', content: 'Hello world'}, + ]}); + t.like(snapshot[1], {type: 'approval_required', toolName: 'write_file'}); + bridge.publishAssistantContent('Corrected'); + t.like(snapshot[0], {messages: [{content: 'hello'}, {content: 'Hello world'}]}); + bridge.completeTurn(); + t.false(await approval); + t.like(bridge.getStateEvents()[0], {activeTurnId: null, messages: [{content: 'hello'}, {content: 'Corrected'}]}); +}); + +test('session operations lock out prompts and release the lock on failure', async t => { + let release!: () => void; + const loading = new Promise(resolve => {release = resolve;}); + const bridge = createWebRuntimeBridge(() => {}); + bridge.bindRuntimeHandlers(handlers({loadSession: async () => {await loading; throw new Error('load failed');}})); + const load = bridge.handleClientEvent({type: 'load_session', id: 'load', sessionId: 'saved'}); + await t.throwsAsync(bridge.handleClientEvent(userMessage('turn')), {message: 'A session operation is already in progress.'}); + t.like(bridge.getStateEvents()[0], {busy: true}); + release(); + await t.throwsAsync(load, {message: 'load failed'}); + t.like(bridge.getStateEvents()[0], {busy: false}); + await t.notThrowsAsync(bridge.handleClientEvent(userMessage('retry'))); +}); + +test('failed reset preserves the transcript and deleting the active session resets it first', async t => { + const bridge = createWebRuntimeBridge(() => {}); + const sequence: string[] = []; + const session = {id: 'saved', title: 'Saved', lastAccessedAt: '', messageCount: 1}; + bridge.bindRuntimeHandlers(handlers({ + loadSession: async () => ({session, messages: [{role: 'user', content: 'keep me'}]}), + resetSession: async () => {throw new Error('reset failed');}, + })); + await bridge.handleClientEvent({type: 'load_session', id: 'load', sessionId: 'saved'}); + await t.throwsAsync(bridge.handleClientEvent({type: 'reset_session', id: 'reset'})); + t.like(bridge.getStateEvents()[0], {session, messages: [{content: 'keep me'}]}); + bridge.bindRuntimeHandlers(handlers({resetSession: () => {sequence.push('reset');}, deleteSession: async () => {sequence.push('delete');}})); + await bridge.handleClientEvent({type: 'delete_session', id: 'delete', sessionId: 'saved'}); + t.deepEqual(sequence, ['reset', 'delete']); + t.like(bridge.getStateEvents()[0], {session: null, messages: []}); +}); diff --git a/source/web/runtime-bridge.ts b/source/web/runtime-bridge.ts index 91e51a8cc..9b5eff68b 100644 --- a/source/web/runtime-bridge.ts +++ b/source/web/runtime-bridge.ts @@ -41,8 +41,15 @@ export interface WebRuntimeBridge { publishToolStarted: (id: string, name: string) => void; publishToolFinished: (id: string, name: string, ok: boolean) => void; hasActiveBrowserTurn: () => boolean; - requestApproval: (request: WebApprovalRequest) => Promise; - requestQuestion: (request: WebQuestionRequest) => Promise; + requestApproval: ( + request: WebApprovalRequest, + signal?: AbortSignal, + ) => Promise; + requestQuestion: ( + request: WebQuestionRequest, + signal?: AbortSignal, + ) => Promise; + getStateEvents: () => WebServerEvent[]; completeTurn: () => void; failTurn: (error: unknown) => void; handleDisconnect: () => void; @@ -53,12 +60,16 @@ type PendingInteraction = id: string; kind: 'approval'; resolve: (approved: boolean) => void; + event: WebServerEvent; + cleanup: () => void; } | { id: string; kind: 'question'; resolve: (answer: string) => void; reject: (error: Error) => void; + event: WebServerEvent; + cleanup: () => void; }; export function createWebRuntimeBridge( @@ -69,10 +80,62 @@ export function createWebRuntimeBridge( let previousAssistantContent = ''; let pendingInteraction: PendingInteraction | null = null; let interactionCounter = 0; + const interactions: PendingInteraction[] = []; + let messages: WebSessionMessage[] = []; + let session: WebSessionSummary | null = null; + let sessionBusy = false; + let sessionRevision = 0; + let assistantId: string | null = null; + let responseCounter = 0; + const stateEvent = (): WebServerEvent => ({ + type: 'state', + activeTurnId, + messages: structuredClone(messages), + session, + busy: sessionBusy, + sessionRevision, + }); + const publishState = () => { + broadcastEvent(stateEvent()); + if (pendingInteraction) broadcastEvent(pendingInteraction.event); + }; + const advanceInteraction = () => { + pendingInteraction = interactions[0] ?? null; + if (pendingInteraction) broadcastEvent(pendingInteraction.event); + }; + const removeInteraction = (pending: PendingInteraction) => { + const index = interactions.indexOf(pending); + if (index < 0) return; + interactions.splice(index, 1); + pending.cleanup(); + broadcastEvent({type: 'interaction_closed', id: pending.id}); + if (index === 0) advanceInteraction(); + }; + const enqueueInteraction = ( + pending: PendingInteraction, + signal?: AbortSignal, + ) => { + const abort = () => { + removeInteraction(pending); + if (pending.kind === 'approval') pending.resolve(false); + else + pending.resolve( + 'Error: The question was cancelled before it was answered.', + ); + }; + pending.cleanup = () => signal?.removeEventListener('abort', abort); + interactions.push(pending); + if (signal?.aborted) abort(); + else { + signal?.addEventListener('abort', abort, {once: true}); + if (interactions.length === 1) advanceInteraction(); + } + }; const clearActiveTurn = () => { activeTurnId = null; previousAssistantContent = ''; + assistantId = null; }; const settlePendingInteraction = (options: { @@ -80,28 +143,21 @@ export function createWebRuntimeBridge( rejectQuestions: boolean; questionMessage?: string; }) => { - const pending = pendingInteraction; + const waiting = interactions.splice(0); pendingInteraction = null; - if (!pending) { - return; - } - - if (pending.kind === 'approval') { - pending.resolve(false); - return; - } - - if (options.rejectQuestions) { - pending.reject( - new Error( - options.questionMessage ?? - 'The browser question was cancelled before an answer arrived.', - ), - ); - return; + for (const pending of waiting) { + pending.cleanup(); + broadcastEvent({type: 'interaction_closed', id: pending.id}); + if (pending.kind === 'approval') pending.resolve(false); + else if (options.rejectQuestions) + pending.reject( + new Error( + options.questionMessage ?? + 'The browser question was cancelled before an answer arrived.', + ), + ); + else pending.resolve(''); } - - pending.resolve(''); }; const completeActiveTurn = (expectedTurnId?: string) => { @@ -117,6 +173,7 @@ export function createWebRuntimeBridge( }); broadcastEvent({type: 'turn_completed', id: activeTurnId}); clearActiveTurn(); + publishState(); }; const failActiveTurn = (error: unknown, expectedTurnId?: string) => { @@ -132,12 +189,14 @@ export function createWebRuntimeBridge( }); broadcastEvent({ type: 'error', + id: activeTurnId, message: error instanceof Error ? error.message : 'Nanocoder could not complete this turn.', }); clearActiveTurn(); + publishState(); }; const nextInteractionId = (kind: 'approval' | 'question') => { @@ -171,7 +230,7 @@ export function createWebRuntimeBridge( } const pending = pendingInteraction; - pendingInteraction = null; + removeInteraction(pending); pending.resolve(event.approved); return; } @@ -192,7 +251,7 @@ export function createWebRuntimeBridge( } const pending = pendingInteraction; - pendingInteraction = null; + removeInteraction(pending); pending.resolve(event.answer); return; } @@ -212,6 +271,10 @@ export function createWebRuntimeBridge( return; } + if (sessionBusy && event.type !== 'list_sessions') { + throw new Error('A session operation is already in progress.'); + } + if (event.type === 'reset_session') { if (activeTurnId) { throw new Error( @@ -219,7 +282,17 @@ export function createWebRuntimeBridge( ); } - await runtimeHandlers.resetSession(); + sessionBusy = true; + publishState(); + try { + await runtimeHandlers.resetSession(); + messages = []; + session = null; + sessionRevision++; + } finally { + sessionBusy = false; + publishState(); + } return; } @@ -236,17 +309,30 @@ export function createWebRuntimeBridge( ); } - const result = await runtimeHandlers.loadSession(event.sessionId); - if (!result) { - throw new Error('Session not found.'); + sessionBusy = true; + publishState(); + try { + const result = await runtimeHandlers.loadSession(event.sessionId); + if (!result) { + throw new Error('Session not found.'); + } + + broadcastEvent({ + type: 'session_loaded', + id: event.id, + session: result.session, + messages: result.messages, + }); + messages = result.messages.map((message, index) => ({ + ...message, + id: message.id ?? `session-${result.session.id}-${index}`, + })); + session = result.session; + sessionRevision++; + } finally { + sessionBusy = false; + publishState(); } - - broadcastEvent({ - type: 'session_loaded', - id: event.id, - session: result.session, - messages: result.messages, - }); return; } @@ -257,7 +343,27 @@ export function createWebRuntimeBridge( ); } - await runtimeHandlers.deleteSession(event.sessionId); + sessionBusy = true; + publishState(); + try { + // Clear the active session before deleting it so later autosaves + // cannot recreate the deleted conversation. + if (session?.id === event.sessionId) { + await runtimeHandlers.resetSession(); + messages = []; + session = null; + sessionRevision++; + } + await runtimeHandlers.deleteSession(event.sessionId); + broadcastEvent({ + type: 'sessions', + id: event.id, + sessions: await runtimeHandlers.listSessions(), + }); + } finally { + sessionBusy = false; + publishState(); + } return; } @@ -267,6 +373,15 @@ export function createWebRuntimeBridge( activeTurnId = event.id; previousAssistantContent = ''; + assistantId = null; + responseCounter = 0; + messages.push({ + id: event.id, + role: 'user', + content: event.text, + images: event.images, + }); + publishState(); try { const submission = runtimeHandlers.submitMessage( @@ -278,7 +393,9 @@ export function createWebRuntimeBridge( error => failActiveTurn(error, event.id), ); } catch (error) { + messages.pop(); clearActiveTurn(); + publishState(); throw error; } }, @@ -288,12 +405,6 @@ export function createWebRuntimeBridge( return () => { if (runtimeHandlers === handlers) { - settlePendingInteraction({ - denyApprovals: true, - rejectQuestions: true, - questionMessage: - 'The browser runtime was unbound before the question was answered.', - }); runtimeHandlers = null; } }; @@ -306,18 +417,31 @@ export function createWebRuntimeBridge( if (content.length === 0) { previousAssistantContent = ''; + assistantId = null; return; } - const delta = content.startsWith(previousAssistantContent) + if (!assistantId) { + assistantId = + responseCounter++ === 0 + ? activeTurnId + : `${activeTurnId}:response:${responseCounter}`; + messages.push({id: assistantId, role: 'assistant', content: ''}); + } + const extendsContent = content.startsWith(previousAssistantContent); + const delta = extendsContent ? content.slice(previousAssistantContent.length) : content; previousAssistantContent = content; + const assistant = messages.find( + message => message.role === 'assistant' && message.id === assistantId, + ); + if (assistant) assistant.content = content; if (delta.length > 0) { broadcastEvent({ - type: 'assistant_delta', - id: activeTurnId, + type: extendsContent ? 'assistant_delta' : 'assistant_content', + id: assistantId, text: delta, }); } @@ -343,60 +467,65 @@ export function createWebRuntimeBridge( return activeTurnId !== null; }, - requestApproval(request) { - if (!activeTurnId) { - return Promise.resolve(false); - } + getStateEvents() { + return [ + stateEvent(), + ...(pendingInteraction ? [pendingInteraction.event] : []), + ]; + }, - if (pendingInteraction) { + requestApproval(request, signal) { + if (!activeTurnId) { return Promise.resolve(false); } const id = nextInteractionId('approval'); return new Promise(resolve => { - pendingInteraction = { - id, - kind: 'approval', - resolve, - }; - broadcastEvent({ - type: 'approval_required', - id, - toolName: request.toolName, - arguments: sanitizeJsonRecord(request.arguments), - ...(request.context ? {context: request.context} : {}), - }); + enqueueInteraction( + { + id, + kind: 'approval', + resolve, + cleanup: () => {}, + event: { + type: 'approval_required', + id, + toolName: request.toolName, + arguments: sanitizeJsonRecord(request.arguments), + ...(request.context ? {context: request.context} : {}), + }, + }, + signal, + ); }); }, - requestQuestion(request) { + requestQuestion(request, signal) { if (!activeTurnId) { return Promise.reject( new Error('No browser turn is active for this question.'), ); } - if (pendingInteraction) { - return Promise.reject( - new Error('Another browser interaction is already pending.'), - ); - } - const id = nextInteractionId('question'); return new Promise((resolve, reject) => { - pendingInteraction = { - id, - kind: 'question', - resolve, - reject, - }; - broadcastEvent({ - type: 'question_required', - id, - question: request.question, - options: [...request.options], - allowFreeform: request.allowFreeform, - }); + enqueueInteraction( + { + id, + kind: 'question', + resolve, + reject, + cleanup: () => {}, + event: { + type: 'question_required', + id, + question: request.question, + options: [...request.options], + allowFreeform: request.allowFreeform, + }, + }, + signal, + ); }); }, diff --git a/source/web/server.spec.ts b/source/web/server.spec.ts index 334ecec60..3996cc07f 100644 --- a/source/web/server.spec.ts +++ b/source/web/server.spec.ts @@ -56,11 +56,11 @@ test('local web server binds to localhost and serves token-protected browser cha const response = await readText(webServer.url); t.is(response.status, 200); - t.true(response.body.includes('Nanocoder web mode')); + t.true(response.body.includes('Nanocoder Web Mode')); t.true(response.body.includes('id="messageForm"')); t.true(response.body.includes('id="messageInput"')); t.true(response.body.includes('id="newChatButton"')); - t.true(response.body.includes('id="threadSearchInput"')); + t.true(response.body.includes('id="threadList"')); t.true(response.body.includes('window.localStorage')); t.true(response.body.includes('/assets/nanocoder-icon.png')); t.true(response.body.includes("type: 'user_message'")); diff --git a/source/web/server.ts b/source/web/server.ts index 6980e2a71..c6dd4f5c6 100644 --- a/source/web/server.ts +++ b/source/web/server.ts @@ -23,6 +23,7 @@ export interface LocalWebServerOptions { openBrowser?: boolean; onClientEvent?: (event: WebClientEvent) => void | Promise; onAllClientsDisconnected?: () => void; + getStateEvents?: () => WebServerEvent[]; } export interface LocalWebServer { @@ -118,6 +119,9 @@ export async function startLocalWebServer( type: 'ready', protocolVersion: WEB_PROTOCOL_VERSION, }); + for (const event of options.getStateEvents?.() ?? []) { + sendServerEvent(clientSocket, event); + } clientSocket.on('message', message => { void handleClientMessage( From ae55f0f58386ab188514f28a4b1e2f354cdfd32c Mon Sep 17 00:00:00 2001 From: SK Akram Date: Thu, 8 Oct 2026 11:31:47 +0000 Subject: [PATCH 11/13] feat(web): polish chat flow and sync sessions with fullscreen TUI --- source/acp/acp-types.ts | 2 + .../converters/message-converter.spec.ts | 6 + .../converters/message-converter.ts | 4 +- source/app/App.tsx | 218 ++++++- source/app/components/web-mode-footer.tsx | 26 + source/app/hooks/useGlobalHandlerQueues.tsx | 2 + source/app/sections/interactive-app.spec.tsx | 30 + source/app/sections/interactive-app.tsx | 4 + source/app/types.ts | 2 + source/cli.tsx | 54 +- .../conversation/conversation-loop.tsx | 5 + .../conversation/tool-executor.tsx | 14 +- source/hooks/chat-handler/types.ts | 1 + source/hooks/chat-handler/useChatHandler.tsx | 3 + source/hooks/useAppHandlers.tsx | 2 + .../hooks/useSessionAutosave-titles.spec.tsx | 33 + source/hooks/useSessionAutosave.ts | 23 +- source/plain/initialize.ts | 4 + source/session/session-manager.spec.ts | 10 + source/session/session-manager.ts | 28 + source/web/browser.spec.ts | 206 ++++++- source/web/commands.spec.ts | 17 + source/web/commands.ts | 36 ++ source/web/page.spec.ts | 10 +- source/web/page.ts | 577 +++++++++++++++--- source/web/protocol.spec.ts | 13 + source/web/protocol.ts | 91 ++- source/web/runtime-bridge.spec.ts | 73 ++- source/web/runtime-bridge.ts | 276 ++++++++- source/web/server.spec.ts | 14 + source/web/server.ts | 3 + source/web/tool-lifecycle.ts | 15 +- 32 files changed, 1628 insertions(+), 174 deletions(-) create mode 100644 source/app/components/web-mode-footer.tsx create mode 100644 source/hooks/useSessionAutosave-titles.spec.tsx create mode 100644 source/web/commands.spec.ts create mode 100644 source/web/commands.ts diff --git a/source/acp/acp-types.ts b/source/acp/acp-types.ts index 6311edfa8..f2a0d26e2 100644 --- a/source/acp/acp-types.ts +++ b/source/acp/acp-types.ts @@ -1,3 +1,4 @@ +import type {CustomCommandExecutor} from '@/custom-commands/executor'; import type {CustomCommandLoader} from '@/custom-commands/loader'; import type {ToolManager} from '@/tools/tool-manager'; import type {LLMClient} from '@/types/index'; @@ -6,6 +7,7 @@ export interface AcpInitContext { client: LLMClient; toolManager: ToolManager; customCommandLoader: CustomCommandLoader; + customCommandExecutor: CustomCommandExecutor; provider: string; model: string; } diff --git a/source/ai-sdk-client/converters/message-converter.spec.ts b/source/ai-sdk-client/converters/message-converter.spec.ts index cb02b0a2b..dcde03f29 100644 --- a/source/ai-sdk-client/converters/message-converter.spec.ts +++ b/source/ai-sdk-client/converters/message-converter.spec.ts @@ -123,6 +123,12 @@ test('convertToModelMessages keeps image-only user messages without a text part' t.is(content[0].type, 'image'); }); +test('saved browser data URLs are not prefixed twice', t => { + const result = convertToModelMessages([{role: 'user', content: 'describe', images: [{data: 'data:image/png;base64,AA==', mediaType: 'image/png'}]}]); + const content = result[0].content as Array>; + t.is(content[1].image, 'data:image/png;base64,AA=='); +}); + test('convertToModelMessages leaves text-only user messages as plain strings', t => { const messages: Message[] = [{role: 'user', content: 'plain text'}]; const result = convertToModelMessages(messages); diff --git a/source/ai-sdk-client/converters/message-converter.ts b/source/ai-sdk-client/converters/message-converter.ts index 5bdd75b83..bfd566c79 100644 --- a/source/ai-sdk-client/converters/message-converter.ts +++ b/source/ai-sdk-client/converters/message-converter.ts @@ -209,7 +209,9 @@ export function convertToModelMessages(messages: Message[]): ModelMessage[] { for (const image of msg.images) { content.push({ type: 'image', - image: `data:${image.mediaType};base64,${image.data}`, + image: image.data.startsWith('data:') + ? image.data + : `data:${image.mediaType};base64,${image.data}`, mediaType: image.mediaType, } as ImagePart); } diff --git a/source/app/App.tsx b/source/app/App.tsx index e79b89eff..d9a034d80 100644 --- a/source/app/App.tsx +++ b/source/app/App.tsx @@ -3,6 +3,7 @@ import Spinner from 'ink-spinner'; import React, {useMemo} from 'react'; import {createStaticComponents} from '@/app/components/app-container'; import {NonInteractiveShell} from '@/app/components/non-interactive-shell'; +import {getWebModeFooterRows} from '@/app/components/web-mode-footer'; import {useAppLogging} from '@/app/hooks/useAppLogging'; import {useGlobalHandlerQueues} from '@/app/hooks/useGlobalHandlerQueues'; import { @@ -37,7 +38,7 @@ import {useModeHandlers} from '@/hooks/useModeHandlers'; import {useNonInteractiveMode} from '@/hooks/useNonInteractiveMode'; import {useNotifications} from '@/hooks/useNotifications'; import {useSessionAutosave} from '@/hooks/useSessionAutosave'; -import {useTerminalRows} from '@/hooks/useTerminalWidth'; +import {useResponsiveTerminal, useTerminalRows} from '@/hooks/useTerminalWidth'; import {ThemeContext} from '@/hooks/useTheme'; import {TitleShapeContext, updateTitleShape} from '@/hooks/useTitleShape'; import {UIStateProvider} from '@/hooks/useUIState'; @@ -55,6 +56,7 @@ import {setGlobalMessageQueue} from '@/utils/message-queue'; import {setNotificationsConfig} from '@/utils/notifications'; import {getShutdownManager} from '@/utils/shutdown'; import {isExtensionInstalled} from '@/vscode/extension-installer'; +import {handleWebCommand} from '@/web/commands'; import {setWebToolLifecyclePublisher} from '@/web/tool-lifecycle'; // Rows the interactive frame keeps for itself in fullscreen: the root box's @@ -75,6 +77,7 @@ export default function App({ initialSession, openSessionSelectorOnStart = false, webRuntimeBridge, + webUrl, }: AppProps) { // Resolve the initial development mode with this precedence: // 1. --mode CLI flag (highest priority) @@ -296,6 +299,7 @@ export default function App({ onAssistantContent: content => { webRuntimeBridge?.publishAssistantContent(content); }, + onReasoningContent: content => webRuntimeBridge?.publishReasoning(content), // A turn that started in plan mode finished uninterrupted — a plan was // produced. Flag it so the interactive UI can show the plan review bar. onPlanTurnComplete: () => { @@ -581,12 +585,65 @@ export default function App({ handleChatMessage: chatHandler.handleChatMessage, dismissActiveEditor: vscodeServer.dismissActiveEditor, }); + const {isSaving, flush: flushSession} = useSessionAutosave({ + messages: appState.messages, + currentProvider: appState.currentProvider, + currentModel: appState.currentModel, + currentSessionId: appState.currentSessionId, + setCurrentSessionId: appState.setCurrentSessionId, + client: appState.client, + isConversationComplete: appState.isConversationComplete, + }); + React.useEffect(() => { + if (appState.isConversationComplete && appState.messages.length > 0) + void flushSession(); + }, [appState.isConversationComplete, appState.messages.length, flushSession]); + const getWebSessionState = React.useCallback( + () => ({ + session: appState.currentSessionId + ? { + id: appState.currentSessionId, + title: + appState.sessionName || + appState.messages + .find(message => message.role === 'user') + ?.content.slice(0, 80) || + 'New chat', + lastAccessedAt: new Date().toISOString(), + messageCount: appState.messages.length, + } + : null, + messages: appState.messages + .filter( + message => + (message.role === 'user' || message.role === 'assistant') && + (message.content.trim().length > 0 || + (message.images?.length ?? 0) > 0), + ) + .map(message => ({ + role: message.role as 'user' | 'assistant', + content: message.content, + images: message.images, + })), + }), + [appState.currentSessionId, appState.sessionName, appState.messages], + ); const webRuntimeStateRef = React.useRef({ isGenerating: chatHandler.isGenerating, submitMessage: appHandlers.handleMessageSubmit, cancel: appHandlers.handleCancel, resetSession: appHandlers.clearMessages, applySession: appHandlers.applySession, + getSessionState: getWebSessionState, + flushSession, + selectModel: modeHandlers.handleModelSelect, + setMode: appState.setDevelopmentMode, + ensureSessionId: appState.ensureCurrentSessionId, + settings: { + provider: appState.currentProvider, + model: appState.currentModel, + mode: appState.developmentMode, + }, }); webRuntimeStateRef.current = { isGenerating: chatHandler.isGenerating, @@ -594,7 +651,75 @@ export default function App({ cancel: appHandlers.handleCancel, resetSession: appHandlers.clearMessages, applySession: appHandlers.applySession, + getSessionState: getWebSessionState, + flushSession, + selectModel: modeHandlers.handleModelSelect, + setMode: appState.setDevelopmentMode, + ensureSessionId: appState.ensureCurrentSessionId, + settings: { + provider: appState.currentProvider, + model: appState.currentModel, + mode: appState.developmentMode, + }, }; + React.useEffect( + () => + sessionManager.subscribeToSaves(session => { + if ( + session.id === + webRuntimeStateRef.current.getSessionState().session?.id + ) { + appState.setSessionName(session.title); + } + if (webRuntimeBridge && session.workingDirectory === process.cwd()) { + void webRuntimeBridge + .handleClientEvent({ + type: 'list_sessions', + id: `saved-${session.id}`, + }) + .catch(() => {}); + } + }), + [webRuntimeBridge, appState.setSessionName], + ); + React.useEffect(() => { + webRuntimeBridge?.setRuntimeStatus( + isTrustedError + ? `Directory trust check failed: ${isTrustedError}` + : !isEffectivelyTrusted + ? 'Approve directory trust in the terminal to start web mode.' + : !appState.client || !appState.toolManager + ? 'Configure a provider and model in the terminal to start web mode.' + : 'Ready', + ); + }, [ + webRuntimeBridge, + isTrustedError, + isEffectivelyTrusted, + appState.client, + appState.toolManager, + ]); + React.useEffect(() => { + const snapshot = getWebSessionState(); + webRuntimeBridge?.syncSession(snapshot.session, snapshot.messages); + }, [webRuntimeBridge, getWebSessionState]); + React.useEffect(() => { + webRuntimeBridge?.setSettings({ + provider: appState.currentProvider, + model: appState.currentModel, + mode: appState.developmentMode, + providers: (getAppConfig().providers ?? []).map(provider => ({ + name: provider.name, + models: provider.models, + })), + modes: ['normal', 'auto-accept', 'yolo', 'plan', 'architect'], + }); + }, [ + webRuntimeBridge, + appState.currentProvider, + appState.currentModel, + appState.developmentMode, + ]); React.useEffect(() => { if ( @@ -607,26 +732,83 @@ export default function App({ } return webRuntimeBridge.bindRuntimeHandlers({ - submitMessage: (message, images) => { + submitMessage: async (message, images) => { if (webRuntimeStateRef.current.isGenerating) { throw new Error('Nanocoder is already processing a turn.'); } - return webRuntimeStateRef.current.submitMessage( + if ( + await handleWebCommand(message, { + resetSession: async () => { + await webRuntimeStateRef.current.flushSession(); + await webRuntimeStateRef.current.resetSession(); + }, + getSettings: () => webRuntimeStateRef.current.settings, + notice: text => webRuntimeBridge.publishNotice(text), + }) + ) { + await new Promise(resolve => setImmediate(resolve)); + return; + } + const sessionId = webRuntimeStateRef.current.ensureSessionId(); + await sessionManager.initialize(); + if (!(await sessionManager.readSession(sessionId))) { + await sessionManager.createSession({ + id: sessionId, + title: message.trim().slice(0, 50) || 'Image conversation', + provider: webRuntimeStateRef.current.settings.provider, + model: webRuntimeStateRef.current.settings.model, + workingDirectory: process.cwd(), + messageCount: 0, + messages: [], + }); + } + await webRuntimeStateRef.current.submitMessage( message, undefined, images, ); + await new Promise(resolve => setImmediate(resolve)); + }, + getSessionState: () => webRuntimeStateRef.current.getSessionState(), + updateSettings: async settings => { + const provider = getAppConfig().providers?.find( + provider => provider.name === settings.provider, + ); + if (!provider?.models.includes(settings.model)) + throw new Error('Select a configured provider and model.'); + const modes = [ + 'normal', + 'auto-accept', + 'yolo', + 'plan', + 'architect', + ] as const; + const mode = modes.find(mode => mode === settings.mode); + if (!mode) throw new Error('Unsupported development mode.'); + const switched = await webRuntimeStateRef.current.selectModel( + settings.provider, + settings.model, + true, + ); + if (!switched) + throw new Error( + `Unable to switch to ${settings.provider}. Check the provider configuration.`, + ); + webRuntimeStateRef.current.setMode(mode); + await new Promise(resolve => setImmediate(resolve)); }, cancel: () => webRuntimeStateRef.current.cancel(), - resetSession: () => { + resetSession: async () => { if (webRuntimeStateRef.current.isGenerating) { throw new Error( 'Cannot start a new chat while Nanocoder is processing a turn.', ); } - return webRuntimeStateRef.current.resetSession(); + await webRuntimeStateRef.current.flushSession(); + await webRuntimeStateRef.current.resetSession(); + await new Promise(resolve => setImmediate(resolve)); }, listSessions: async () => { await sessionManager.initialize(); @@ -658,6 +840,7 @@ export default function App({ ); } + await webRuntimeStateRef.current.flushSession(); await sessionManager.initialize(); const session = await sessionManager.loadSession(sessionId); if (!session) { @@ -701,9 +884,10 @@ export default function App({ } setWebToolLifecyclePublisher({ - started: (id, name) => webRuntimeBridge.publishToolStarted(id, name), - finished: (id, name, ok) => - webRuntimeBridge.publishToolFinished(id, name, ok), + started: (id, name, args) => + webRuntimeBridge.publishToolStarted(id, name, args), + finished: (id, name, ok, output) => + webRuntimeBridge.publishToolFinished(id, name, ok, output), }); return () => { @@ -762,15 +946,6 @@ export default function App({ developmentMode: initialDevelopmentMode, }); - // Setup session autosave - const {isSaving} = useSessionAutosave({ - messages: appState.messages, - currentProvider: appState.currentProvider, - currentModel: appState.currentModel, - currentSessionId: appState.currentSessionId, - setCurrentSessionId: appState.setCurrentSessionId, - }); - // Memoize static components. We pin the run-mode header to the // initial development mode so it never changes during the run — the // boot line represents what the agent *started* under, not a live @@ -779,8 +954,14 @@ export default function App({ // minus the interactive frame: the root box's padding rows plus the input // footer beneath it. Inline mode prints into scrollback and clips nothing. const terminalRows = useTerminalRows(); + const {actualWidth} = useResponsiveTerminal(); const welcomeRows = altScreenActive - ? Math.max(0, terminalRows - FULLSCREEN_CHROME_ROWS) + ? Math.max( + 0, + terminalRows - + FULLSCREEN_CHROME_ROWS - + getWebModeFooterRows(webUrl, actualWidth - 2), + ) : terminalRows; // Pin the provider/model the run started under, but only once @@ -967,6 +1148,7 @@ export default function App({ ) : ( + Web mode: {url} + {browserHint} + + ); +} diff --git a/source/app/hooks/useGlobalHandlerQueues.tsx b/source/app/hooks/useGlobalHandlerQueues.tsx index cbbd1ca7c..766419347 100644 --- a/source/app/hooks/useGlobalHandlerQueues.tsx +++ b/source/app/hooks/useGlobalHandlerQueues.tsx @@ -199,6 +199,7 @@ export function useGlobalHandlerQueues({ return webRuntimeBridge.requestApproval( { toolName: approval.toolCall.function.name, + toolCallId: approval.toolCall.id, arguments: approval.toolCall.function.arguments, context: `Subagent: ${approval.subagentName}`, }, @@ -220,6 +221,7 @@ export function useGlobalHandlerQueues({ return webRuntimeBridge.requestApproval( { toolName: confirmation.toolCall.function.name, + toolCallId: confirmation.toolCall.id, arguments: confirmation.toolCall.function.arguments, }, signal, diff --git a/source/app/sections/interactive-app.spec.tsx b/source/app/sections/interactive-app.spec.tsx index 870d52b44..f10c6bd54 100644 --- a/source/app/sections/interactive-app.spec.tsx +++ b/source/app/sections/interactive-app.spec.tsx @@ -10,6 +10,36 @@ import {InteractiveApp} from './interactive-app.js'; console.log(`\ninteractive-app.spec.tsx – ${React.version}`); +test('web URL stays inside the fullscreen frame and welcome disappears when a prompt arrives', async t => { + const previousRows = process.stdout.rows; + const previousColumns = process.stdout.columns; + process.stdout.rows = 24; + process.stdout.columns = 120; + t.teardown(() => { + process.stdout.rows = previousRows; + process.stdout.columns = previousColumns; + }); + const url = `http://127.0.0.1:12345/?token=${'a'.repeat(64)}`; + let addPrompt = () => {}; + function WebTuiHarness() { + const [chatComponents, setChatComponents] = React.useState([]); + addPrompt = () => setChatComponents([hello prompt]); + return welcome-marker]} />; + } + const view = renderWithTheme(); + t.teardown(() => view.unmount()); + await new Promise(resolve => setTimeout(resolve, 30)); + t.true(view.lastFrame()!.includes('welcome-marker')); + t.true(view.lastFrame()!.includes(url)); + t.true(view.lastFrame()!.split('\n').length <= 24); + addPrompt(); + await new Promise(resolve => setTimeout(resolve, 30)); + t.false(view.lastFrame()!.includes('welcome-marker')); + t.true(view.lastFrame()!.includes('hello prompt')); + t.true(view.lastFrame()!.includes(url)); + t.true(view.lastFrame()!.split('\n').length <= 24); +}); + interface Overrides { isExplorerMode?: boolean; isIdeSelectionMode?: boolean; diff --git a/source/app/sections/interactive-app.tsx b/source/app/sections/interactive-app.tsx index 929a46d84..68d6d5148 100644 --- a/source/app/sections/interactive-app.tsx +++ b/source/app/sections/interactive-app.tsx @@ -4,6 +4,7 @@ import {ChatHistory} from '@/app/components/chat-history'; import {ChatInput} from '@/app/components/chat-input'; import {ModalSelectors} from '@/app/components/modal-selectors'; import type {SettingsTabId} from '@/app/components/settings-constants'; +import {WebModeFooter} from '@/app/components/web-mode-footer'; import {artifactManager} from '@/artifacts/artifact-manager'; import ArchitectReviewPrompt from '@/components/architect-review-prompt'; import {SessionArtifactLinks} from '@/components/artifact-links-display'; @@ -62,6 +63,7 @@ interface InteractiveAppProps { */ altScreenActive?: boolean; isSaving?: boolean; + webUrl?: string; } /** @@ -91,6 +93,7 @@ export function InteractiveApp({ clearKey, altScreenActive = false, isSaving, + webUrl, }: InteractiveAppProps): React.ReactElement { const nextRestoredDraftIdRef = React.useRef(1); // Tune / IDE are launched by closing settings first, so their exit has no way @@ -711,6 +714,7 @@ export function InteractiveApp({ refreshKey={artifactRefreshKey} /> + {webUrl && } {appState.startChat && diff --git a/source/app/types.ts b/source/app/types.ts index b48ea6652..adb2a90b2 100644 --- a/source/app/types.ts +++ b/source/app/types.ts @@ -66,6 +66,8 @@ export interface AppProps { * runtime remains the owner of providers, tools, and conversation state. */ webRuntimeBridge?: WebRuntimeBridge; + /** Local browser URL displayed inside the terminal's fixed-height footer. */ + webUrl?: string; } /** diff --git a/source/cli.tsx b/source/cli.tsx index b720ac20c..5a4ce933e 100644 --- a/source/cli.tsx +++ b/source/cli.tsx @@ -728,7 +728,7 @@ async function main(): Promise { }); } else { // Interactive TUI — load Ink + App only now. - const [{render, Box, Text}, {default: App}] = await Promise.all([ + const [{render}, {default: App}] = await Promise.all([ import('ink'), import('@/app'), ]); @@ -790,10 +790,7 @@ async function main(): Promise { !args.includes('--no-alt-screen') && (args.includes('--alt-screen') || getAlternateScreen()); const useAltScreen = - process.stdout.isTTY && - !nonInteractiveMode && - !webMode && - altScreenAllowed; + process.stdout.isTTY && !nonInteractiveMode && altScreenAllowed; const mouseReportingAllowed = !args.includes('--no-mouse') && (args.includes('--mouse') || getMouseReporting()); @@ -968,36 +965,29 @@ async function main(): Promise { handler: webServer.close, }); - console.log('Nanocoder web mode started.'); - console.log(`Local URL: ${webServer.url}`); - console.log('Press Ctrl+C to stop.'); + if (!useAltScreen) { + console.log('Nanocoder web mode started.'); + console.log(`Local URL: ${webServer.url}`); + console.log('Press Ctrl+C to stop.'); + } } const result = render( - - - {webUrl && ( - - Web mode: {webUrl} - - Open this URL in your browser. Ctrl+C stops the server. - - - )} - , + , { // Ctrl+C is handled inside App (routed through the shutdown // manager) so the exit-render handler below can paint the diff --git a/source/hooks/chat-handler/conversation/conversation-loop.tsx b/source/hooks/chat-handler/conversation/conversation-loop.tsx index 088cdbb64..ff3f07d44 100644 --- a/source/hooks/chat-handler/conversation/conversation-loop.tsx +++ b/source/hooks/chat-handler/conversation/conversation-loop.tsx @@ -129,6 +129,7 @@ interface ProcessAssistantResponseParams { setStreamingReasoning: (content: string) => void; setStreamingContent: (content: string) => void; onAssistantContent?: (content: string) => void; + onReasoningContent?: (content: string) => void; setTokenCount: (count: number) => void; setMessages: (messages: Message[]) => void; addToChatQueue: (component: React.ReactNode) => void; @@ -245,6 +246,7 @@ export const processAssistantResponse = async ( setStreamingReasoning, setStreamingContent, onAssistantContent, + onReasoningContent, setTokenCount, setMessages, addToChatQueue, @@ -348,6 +350,7 @@ export const processAssistantResponse = async ( setStreamingContent(''); onAssistantContent?.(''); setStreamingReasoning(''); + onReasoningContent?.(''); setTokenCount(0); // Drop any prior empty-response retry counter from the live area so the // streaming UI for this turn renders unobstructed. The counter is only @@ -414,6 +417,7 @@ export const processAssistantResponse = async ( onReasoningToken: (token: string) => { streamedReasoning += token; setStreamingReasoning(streamedReasoning); + onReasoningContent?.(streamedReasoning); }, onPrivacyEvent, }, @@ -434,6 +438,7 @@ export const processAssistantResponse = async ( // leak the tokens into the assistant message and conversation history. const fullContent = stripThinkTags(message.content || ''); const fullReasoning = message.reasoning; + if (fullReasoning) onReasoningContent?.(fullReasoning); // Tool extraction is layered: // - XML fallback path (toolsDisabled): parse text for XML/JSON tool calls. diff --git a/source/hooks/chat-handler/conversation/tool-executor.tsx b/source/hooks/chat-handler/conversation/tool-executor.tsx index 9d74f19f6..7fec8e44c 100644 --- a/source/hooks/chat-handler/conversation/tool-executor.tsx +++ b/source/hooks/chat-handler/conversation/tool-executor.tsx @@ -46,13 +46,18 @@ const executeOne = async ( const {publishWebToolFinished, publishWebToolStarted} = await import( '@/web/tool-lifecycle' ); - publishWebToolStarted(toolCall.id, toolCall.function.name); + publishWebToolStarted( + toolCall.id, + toolCall.function.name, + toolCall.function.arguments, + ); try { const result = await processToolUse(toolCall); publishWebToolFinished( toolCall.id, toolCall.function.name, !isToolResultError(result), + result.content, ); return {toolCall, result}; } catch (error) { @@ -116,7 +121,11 @@ export const executeApprovedTool = async ( const {publishWebToolFinished, publishWebToolStarted} = await import( '@/web/tool-lifecycle' ); - publishWebToolStarted(toolCall.id, toolCall.function.name); + publishWebToolStarted( + toolCall.id, + toolCall.function.name, + toolCall.function.arguments, + ); try { const execution = await executeBashStreaming( toolCall, @@ -128,6 +137,7 @@ export const executeApprovedTool = async ( toolCall.id, toolCall.function.name, !isToolResultError(execution.result), + execution.result.content, ); return execution; } catch (error) { diff --git a/source/hooks/chat-handler/types.ts b/source/hooks/chat-handler/types.ts index 598044f4a..be3ec6855 100644 --- a/source/hooks/chat-handler/types.ts +++ b/source/hooks/chat-handler/types.ts @@ -43,6 +43,7 @@ export interface UseChatHandlerProps { onConversationComplete?: () => void; onError?: (error: unknown) => void; onAssistantContent?: (content: string) => void; + onReasoningContent?: (content: string) => void; // Fired when a turn that STARTED in plan mode runs to completion without // being interrupted — i.e. a plan was actually produced. Decided here rather // than inferred from ambient state (isConversationComplete + current mode), diff --git a/source/hooks/chat-handler/useChatHandler.tsx b/source/hooks/chat-handler/useChatHandler.tsx index f7c6349a9..99c617fa1 100644 --- a/source/hooks/chat-handler/useChatHandler.tsx +++ b/source/hooks/chat-handler/useChatHandler.tsx @@ -89,6 +89,7 @@ export function useChatHandler({ onConversationComplete, onError, onAssistantContent, + onReasoningContent, onPlanTurnComplete, onArchitectTurnComplete, reasoningExpandedRef, @@ -271,6 +272,7 @@ export function useChatHandler({ setStreamingReasoning, setStreamingContent, onAssistantContent, + onReasoningContent, setTokenCount, setMessages, addToChatQueue, @@ -333,6 +335,7 @@ export function useChatHandler({ onConversationComplete, onError, onAssistantContent, + onReasoningContent, reasoningExpandedRef, compactToolDisplayRef, compactToolCountsRef, diff --git a/source/hooks/useAppHandlers.tsx b/source/hooks/useAppHandlers.tsx index c79f90a6a..7b8b5dd56 100644 --- a/source/hooks/useAppHandlers.tsx +++ b/source/hooks/useAppHandlers.tsx @@ -204,6 +204,7 @@ export function useAppHandlers(props: UseAppHandlersProps): AppHandlers { await baseClear(); props.setChatComponents([]); props.setCurrentSessionId(null); + props.setSessionName(''); // Reset the key-generator session ID so keys in the new conversation // are not prefixed with the cleared session's ID. A fresh random ID // will be lazily generated on the next generateKey() call. @@ -569,6 +570,7 @@ export function useAppHandlers(props: UseAppHandlersProps): AppHandlers { props.setCurrentProvider(session.provider); props.setCurrentModel(session.model); props.setCurrentSessionId(session.id); + props.setSessionName(session.title); setKeyGeneratorSessionId(session.id); void loadTasks(session.id).then(tasks => { props.setLiveTaskList(tasks.length > 0 ? tasks : null); diff --git a/source/hooks/useSessionAutosave-titles.spec.tsx b/source/hooks/useSessionAutosave-titles.spec.tsx new file mode 100644 index 000000000..50f90e081 --- /dev/null +++ b/source/hooks/useSessionAutosave-titles.spec.tsx @@ -0,0 +1,33 @@ +import test from 'ava'; +import {render} from 'ink-testing-library'; +import React from 'react'; +import {sessionManager} from '@/session/session-manager'; +import type {LLMClient, Message} from '@/types/core'; +import {useSessionAutosave} from './useSessionAutosave'; + +test('TUI autosave generates the same smart title as ACP and publishes the saved name', async t => { + await sessionManager.initialize(); + const messages: Message[] = [ + {role: 'user', content: 'fix this'}, + {role: 'assistant', content: 'Fixed the login redirect.', tool_calls: [{id: 'read', function: {name: 'read_file', arguments: {path: 'login.ts'}}}]}, + ]; + const session = await sessionManager.createSession({title: 'fix this', provider: 'test', model: 'test', workingDirectory: process.cwd(), messageCount: messages.length, messages}); + t.teardown(() => sessionManager.deleteSession(session.id)); + let flush: (() => Promise) | undefined; + const client = {chat: async () => ({choices: [{message: {role: 'assistant', content: 'Fix Login Redirect'}}]})} as unknown as LLMClient; + let resolveTitle: (() => void) | undefined; + const titled = new Promise(resolve => {resolveTitle = resolve;}); + const unsubscribe = sessionManager.subscribeToSaves(saved => {if (saved.id === session.id && saved.titleGenerated) resolveTitle?.();}); + t.teardown(unsubscribe); + function Probe() { + const autosave = useSessionAutosave({messages, currentProvider: 'test', currentModel: 'test', currentSessionId: session.id, setCurrentSessionId: () => {}, client, isConversationComplete: true}); + flush = autosave.flush; + return null; + } + const view = render(); + t.teardown(() => view.unmount()); + await new Promise(resolve => setTimeout(resolve, 20)); + await flush!(); + await titled; + t.is((await sessionManager.readSession(session.id))?.title, 'Fix Login Redirect'); +}); diff --git a/source/hooks/useSessionAutosave.ts b/source/hooks/useSessionAutosave.ts index bdc2e2d27..2dda2ae21 100644 --- a/source/hooks/useSessionAutosave.ts +++ b/source/hooks/useSessionAutosave.ts @@ -3,9 +3,10 @@ import {isApprovedPlanMessage} from '@/artifacts/approved-plan'; import {isInternalWalkthroughMessage} from '@/artifacts/walkthrough-lifecycle'; import {getAppConfig} from '@/config/index'; import {BASH_OUTPUT_PREFIX} from '@/constants'; +import {maybeGenerateTitle} from '@/session/maybe-generate-title'; import {sessionManager} from '@/session/session-manager'; import {deriveTitleFromFirstMessage} from '@/session/title-generator'; -import type {Message} from '@/types/core'; +import type {LLMClient, Message} from '@/types/core'; import {formatError} from '@/utils/error-formatter'; import {logWarning} from '@/utils/message-queue'; import {getShutdownManager} from '@/utils/shutdown'; @@ -16,6 +17,8 @@ interface UseSessionAutosaveProps { currentModel: string; currentSessionId: string | null; setCurrentSessionId: (id: string | null) => void; + client?: LLMClient | null; + isConversationComplete?: boolean; } const SHUTDOWN_HANDLER_NAME = 'session-autosave-flush'; @@ -96,12 +99,16 @@ export function useSessionAutosave({ currentModel, currentSessionId, setCurrentSessionId, + client, + isConversationComplete = false, }: UseSessionAutosaveProps) { const [isSaving, setIsSaving] = useState(false); const initPromiseRef = useRef | null>(null); const timeoutRef = useRef(null); const hideTimerRef = useRef(null); const lastSaveRef = useRef(0); + const titleContextRef = useRef({client, isConversationComplete}); + titleContextRef.current = {client, isConversationComplete}; // Serialises saves: each new save is chained onto the tail of this promise. const saveChainRef = useRef>(Promise.resolve()); @@ -287,6 +294,18 @@ export function useSessionAutosave({ } lastSaveRef.current = Date.now(); + const titleContext = titleContextRef.current; + if ( + titleContext.client && + titleContext.isConversationComplete && + currentSessionIdRef.current + ) { + void maybeGenerateTitle({ + sessionId: currentSessionIdRef.current, + messages: persistedMessages, + client: titleContext.client, + }); + } } catch (error) { console.warn('Failed to auto-save session:', error); } finally { @@ -380,5 +399,5 @@ export function useSessionAutosave({ return () => manager.unregister(SHUTDOWN_HANDLER_NAME); }, [flush]); - return {isSaving}; + return {isSaving, flush}; } diff --git a/source/plain/initialize.ts b/source/plain/initialize.ts index 57f8a880f..48586290f 100644 --- a/source/plain/initialize.ts +++ b/source/plain/initialize.ts @@ -9,6 +9,7 @@ import { updateLastUsed, } from '@/config/preferences'; import {validateProjectConfigSecurity} from '@/config/validation'; +import {CustomCommandExecutor} from '@/custom-commands/executor'; import {CustomCommandLoader} from '@/custom-commands/loader'; import {resolveStartupProvider} from '@/hooks/startup-provider'; import { @@ -32,6 +33,7 @@ export interface PlainInitResult { client: LLMClient; toolManager: ToolManager; customCommandLoader: CustomCommandLoader; + customCommandExecutor: CustomCommandExecutor; provider: string; model: string; } @@ -52,6 +54,7 @@ export async function initializePlain( ): Promise { const toolManager = new ToolManager(); const customCommandLoader = new CustomCommandLoader(); + const customCommandExecutor = new CustomCommandExecutor(); const preferences = loadPreferences(); setToolRegistryGetter(() => toolManager.getToolRegistry()); @@ -168,6 +171,7 @@ export async function initializePlain( client, toolManager, customCommandLoader, + customCommandExecutor, provider: actualProvider, model: finalModel, }; diff --git a/source/session/session-manager.spec.ts b/source/session/session-manager.spec.ts index a16a47339..d2df18325 100644 --- a/source/session/session-manager.spec.ts +++ b/source/session/session-manager.spec.ts @@ -31,6 +31,16 @@ test.afterEach(async () => { // --- initialize --- +test.serial('a stale autosave preserves a title generated after its read', async t => { + const session = await manager.createSession({title: 'hi', provider: 'test', model: 'test', workingDirectory: '/tmp', messageCount: 1, messages: [{role: 'user', content: 'hi'}]}); + await manager.saveSession({...session, title: 'Fix Login Redirect', titleGenerated: true}); + await manager.saveSession({...session, messageCount: 2, messages: [...session.messages, {role: 'assistant', content: 'done'}]}); + const saved = await manager.readSession(session.id); + t.is(saved?.title, 'Fix Login Redirect'); + t.true(saved?.titleGenerated); + t.is(saved?.messageCount, 2); +}); + test.serial('initialize creates sessions directory and index', async t => { const indexPath = join(sessionsDir, 'sessions.json'); const data = await readFile(indexPath, 'utf-8'); diff --git a/source/session/session-manager.ts b/source/session/session-manager.ts index 41b957235..504d76ea2 100644 --- a/source/session/session-manager.ts +++ b/source/session/session-manager.ts @@ -79,6 +79,14 @@ export class SessionManager { private indexWriteLock: Promise = Promise.resolve(); /** Optional explicit directory override (used by tests). */ private readonly overrideDir?: string; + private readonly saveListeners = new Set< + (session: SessionMetadata) => void + >(); + + subscribeToSaves(listener: (session: SessionMetadata) => void): () => void { + this.saveListeners.add(listener); + return () => this.saveListeners.delete(listener); + } constructor( sessionsDir?: string, @@ -166,6 +174,19 @@ export class SessionManager { // to prevent orphaned files if the process dies between them. await this.withIndexLock(async () => { const sessionFilePath = path.join(this.sessionsDir, `${session.id}.json`); + // A save may have read the heuristic title before the background + // generator or a manual rename committed. Preserve the newer title + // while holding the same lock as the write, not at call-site read time. + const existing = await this.readSession(session.id); + if (existing?.titleManuallySet && !session.titleManuallySet) { + session = {...session, title: existing.title, titleManuallySet: true}; + } else if ( + existing?.titleGenerated && + !session.titleGenerated && + !session.titleManuallySet + ) { + session = {...session, title: existing.title, titleGenerated: true}; + } // Write session file atomically await atomicWriteFile( @@ -203,6 +224,13 @@ export class SessionManager { 0o600, ); }); + for (const listener of this.saveListeners) { + try { + listener(session); + } catch { + /* UI updates must not fail a completed save. */ + } + } } /** Read the index file (internal helper — not locked). */ diff --git a/source/web/browser.spec.ts b/source/web/browser.spec.ts index d5ba184a7..ffadbbf9d 100644 --- a/source/web/browser.spec.ts +++ b/source/web/browser.spec.ts @@ -3,7 +3,7 @@ import test from 'ava'; import {createElement} from '@/vscode/chat-panel-harness'; import {renderWebModePage} from './page.js'; -function bootBrowser() { +function bootBrowser(storageBlocked = false) { const elements = new Map>(); const storage = new Map(); const root = createElement('html'); @@ -19,6 +19,9 @@ function bootBrowser() { return node; } const sent: unknown[] = []; + const copied: string[] = []; + const timers = new Map void>(); + let nextTimer = 0; class Socket { static OPEN = 1; readyState = 1; @@ -27,12 +30,14 @@ function bootBrowser() { } const context = createContext({ document: {querySelector: get, createElement: element, createTextNode: (text: string) => {const node = element('text'); node.textContent = text; return node;}, documentElement: root, addEventListener() {}}, - window: {location: {search: '?token=test', href: 'http://localhost/?token=test'}, localStorage: {getItem: (key: string) => storage.get(key) ?? null, setItem: (key: string, value: string) => storage.set(key, value), removeItem: (key: string) => storage.delete(key)}, setTimeout: () => 1, matchMedia: () => ({matches: false})}, - URL, URLSearchParams, WebSocket: Socket, + window: {location: {search: '?token=test', href: 'http://localhost/?token=test'}, localStorage: {getItem: (key: string) => {if (storageBlocked) throw new Error('Storage blocked'); return storage.get(key) ?? null;}, setItem: (key: string, value: string) => {if (storageBlocked) throw new Error('Storage blocked'); storage.set(key, value);}, removeItem: (key: string) => {if (storageBlocked) throw new Error('Storage blocked'); return storage.delete(key);}}, setTimeout: (callback: () => void) => {const id = ++nextTimer; timers.set(id, callback); return id;}, clearTimeout: (id: number) => timers.delete(id), matchMedia: () => ({matches: false})}, + URL, URLSearchParams, Event, WebSocket: Socket, + navigator: {clipboard: {writeText: async (text: string) => {copied.push(text);}}}, }); const script = renderWebModePage('test').match(/