From 50a6a67b89f2e2bd901b903fe0f2affabc1cb449 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Thu, 1 Oct 2026 20:24:06 +0200 Subject: [PATCH] Streamline local agent controls and preserve proactive chat Separate agent configuration from chat visibility, keep chat compact, and resume selection explanations after manual questions. Restore project conversations locally for 24 hours with bounded storage and clear history. Move daemon configuration into System and report persisted index metadata and live watcher registration alongside its configuration control. Validated with 303 focused frontend tests, TypeScript/Vite production build, 160 native watcher/HTTP tests, regression checks, and local browser checks. Signed-off-by: Martin Vogel --- graph-ui/src/App.tsx | 26 +- graph-ui/src/app/AtlasChrome.test.tsx | 65 ++- graph-ui/src/app/AtlasChrome.tsx | 18 +- graph-ui/src/app/agent-status.css | 44 +++ graph-ui/src/app/atlas-api.test.ts | 4 +- .../src/app/workspace-navigation.test.tsx | 14 +- .../src/browser-ai/AgentSettingsDialog.tsx | 26 ++ .../BrowserChatDock.history.test.tsx | 79 ++++ .../src/browser-ai/BrowserChatDock.test.tsx | 371 +++++++++++++++--- graph-ui/src/browser-ai/BrowserChatDock.tsx | 295 +++++++++----- graph-ui/src/browser-ai/agent-settings.css | 28 ++ .../src/browser-ai/browser-ai-controller.ts | 6 +- .../src/browser-ai/browser-ai-runtime.test.ts | 23 +- graph-ui/src/browser-ai/browser-ai-runtime.ts | 9 +- .../src/browser-ai/browser-ai.worker.test.ts | 43 ++ graph-ui/src/browser-ai/browser-ai.worker.ts | 25 +- graph-ui/src/browser-ai/browser-chat.css | 47 ++- .../src/browser-ai/chat-history-cache.test.ts | 237 +++++++++++ graph-ui/src/browser-ai/chat-history-cache.ts | 268 +++++++++++++ .../browser-ai/explanation-response.test.ts | 65 ++- .../src/browser-ai/explanation-response.ts | 38 +- .../src/browser-ai/use-chat-history.test.tsx | 188 +++++++++ graph-ui/src/browser-ai/use-chat-history.ts | 151 +++++++ graph-ui/src/projects/projects-model.test.ts | 12 + graph-ui/src/projects/projects-model.ts | 6 + graph-ui/src/provider/rpc-client.test.ts | 37 ++ graph-ui/src/provider/rpc-client.ts | 30 +- graph-ui/src/provider/rpc-schemas.ts | 24 +- graph-ui/src/settings/ConfigPanel.test.tsx | 61 ++- graph-ui/src/settings/ConfigPanel.tsx | 27 +- graph-ui/src/settings/configuration.css | 5 + graph-ui/src/styles/workspace.css | 4 +- graph-ui/src/system/IndexesPanel.test.tsx | 115 ++++++ graph-ui/src/system/IndexesPanel.tsx | 114 ++++++ graph-ui/src/system/SystemWorkspace.test.tsx | 63 ++- graph-ui/src/system/SystemWorkspace.tsx | 53 ++- graph-ui/src/system/system.css | 24 +- src/ui/http_server.c | 64 ++- src/watcher/watcher.c | 19 + src/watcher/watcher.h | 13 +- tests/test_httpd.c | 121 ++++++ tests/test_watcher.c | 62 +++ 42 files changed, 2629 insertions(+), 295 deletions(-) create mode 100644 graph-ui/src/app/agent-status.css create mode 100644 graph-ui/src/browser-ai/AgentSettingsDialog.tsx create mode 100644 graph-ui/src/browser-ai/BrowserChatDock.history.test.tsx create mode 100644 graph-ui/src/browser-ai/agent-settings.css create mode 100644 graph-ui/src/browser-ai/chat-history-cache.test.ts create mode 100644 graph-ui/src/browser-ai/chat-history-cache.ts create mode 100644 graph-ui/src/browser-ai/use-chat-history.test.tsx create mode 100644 graph-ui/src/browser-ai/use-chat-history.ts create mode 100644 graph-ui/src/system/IndexesPanel.test.tsx create mode 100644 graph-ui/src/system/IndexesPanel.tsx diff --git a/graph-ui/src/App.tsx b/graph-ui/src/App.tsx index cdcad0d5f..33e236c5a 100644 --- a/graph-ui/src/App.tsx +++ b/graph-ui/src/App.tsx @@ -130,6 +130,7 @@ import { resolveRepositorySelection, useRepositorySnapshot } from './architectur import ActivityPanel from './agents/ActivityPanel'; import WelcomePanel from './app/WelcomePanel'; import BrowserChatDock from './browser-ai/BrowserChatDock'; +import { browserChatHistoryProjectKey } from './browser-ai/chat-history-cache'; import type { BrowserChatAttachment } from './browser-ai/BrowserChatDock'; import { browserGraphContext } from './browser-ai/graph-context'; import { readerChatContext, type ReaderSourceOrigin } from './browser-ai/reader-chat-context'; @@ -770,6 +771,7 @@ export default function App(): JSX.Element { const [browserAiOpen, setBrowserAiOpen] = useState(false); const [localAgentState, setLocalAgentState] = useState<'off' | 'loading' | 'active' | 'busy' | 'error'>('off'); const [agentSettingsRequest, setAgentSettingsRequest] = useState(0); + const [agentModelName, setAgentModelName] = useState(''); const agentHasOpened = useRef(false); const onAgentStateChange = useCallback((state: 'off' | 'loading' | 'active' | 'busy' | 'error') => { setLocalAgentState(state); @@ -777,7 +779,7 @@ export default function App(): JSX.Element { agentHasOpened.current = true; setBrowserAiOpen(true); } }, []); - const openAgentSettings = () => { setAgentSettingsRequest(value => value + 1); setBrowserAiOpen(true); }; + const openAgentSettings = () => { setAgentSettingsRequest(value => value + 1); }; const [codeDetailsOpen, setCodeDetailsOpen] = useState(false); const [readerSelection, setReaderSelection] = useState(); const [pinnedCode, setPinnedCode] = useState(); @@ -963,7 +965,6 @@ export default function App(): JSX.Element { * die Hilfe: eine Flaeche ueber dem Editor geht nicht ungefragt auf. */ const [settingsOpen, setSettingsOpen] = useState(false); - const [configOpen, setConfigOpen] = useState(false); const [projectsOpen, setProjectsOpen] = useState(false); const [indexActivity, setIndexActivity] = useState<{ name: string; status: 'indexing' | 'done' | 'error' }>(); /* @@ -2682,7 +2683,7 @@ export default function App(): JSX.Element { * eigenes Eingabefeld. Ohne diesen Eintrag fielen Buchstaben, die * jemand vor dem offenen Panel tippt, in die Kommandozeile dahinter. */ - overlayOpen: helpOpen || entryOpen || settingsOpen || configOpen || projectsOpen, + overlayOpen: helpOpen || entryOpen || settingsOpen || projectsOpen, walkRunning: tour !== undefined, }; @@ -4601,7 +4602,7 @@ export default function App(): JSX.Element { * braucht. Dieselbe Reihenfolge wie ueberall in dieser Datei: was * ueber dem Panel liegt, hat den Vortritt. */ - escapeTaken={helpOpen || entryOpen || overlayOpen || settingsOpen || configOpen || projectsOpen} + escapeTaken={helpOpen || entryOpen || overlayOpen || settingsOpen || projectsOpen} /> ); @@ -4766,20 +4767,19 @@ export default function App(): JSX.Element { return ( setConfigOpen(true)} configOpen={configOpen} projectSwitcher={ setProjectsOpen(true)} indexActivity={indexActivity} />} onWorkspaceChange={value => { setDiagnosticsPath(undefined); changeWorkspace(value); }} onOpenBrowserAi={openAgentSettings} - onExpandBrowserAi={() => setBrowserAiOpen(true)} - agentState={localAgentState} + onToggleBrowserAi={() => setBrowserAiOpen(open => !open)} + agentState={localAgentState} agentModelName={agentModelName} onOpenSystem={() => changeWorkspace('system')} daemonState={serverOk === undefined ? 'checking' : serverOk ? 'connected' : 'disconnected'} chatOpen={browserAiOpen} chatDock={ setBrowserAiOpen(false)} - showCollapsed onOpen={() => setBrowserAiOpen(true)} + historyKey={project ? browserChatHistoryProjectKey(window.location.origin, project) : undefined} proactiveSelection={proactiveSelection?.scope === selectionScope ? proactiveSelection.context : undefined} - selectionScope={selectionScope} onAgentStateChange={onAgentStateChange} settingsRequest={agentSettingsRequest} + selectionScope={selectionScope} onAgentStateChange={onAgentStateChange} onAgentModelChange={setAgentModelName} settingsRequest={agentSettingsRequest} readerContext={workspace === 'explore' ? currentReaderContext : undefined} pendingContext={chatGraphSelection} onContextConsumed={clearChatGraphSelection} onContextRemoved={clearChatGraphSelection} context={browserGraphContext(inspector.ir, project, inspector.filePath, inspector.symbol ? workspacePathOf(inspector.symbol.uri) : '')} @@ -4794,10 +4794,6 @@ export default function App(): JSX.Element { onOpen={target => exploreEvidence({ filePath: target.filePath, line: target.line, name: target.name })} /> : undefined} readerDetails={codeDetailsOpen ? selectedCode : undefined} globalOverlay={<> - {configOpen && { setConfigOpen(false); openAgentSettings(); }} - onOpenDisplay={() => { setConfigOpen(false); setSettingsOpen(true); }} - onClose={() => setConfigOpen(false)} />} {sourceEvidence?.project === project && setSourceEvidence(undefined)} onExplore={exploreEvidence} />} @@ -4843,7 +4839,9 @@ export default function App(): JSX.Element { {props.projectSwitcher} - {props.onOpenBrowserAi !== undefined && } - {props.onOpenConfig && } + {(props.onOpenBrowserAi !== undefined || props.onToggleBrowserAi !== undefined) &&
+ {props.onOpenBrowserAi !== undefined && } + {props.onToggleBrowserAi !== undefined && } +
} {props.workspaceStatus}
- {!showChatColumn && props.onExpandBrowserAi && }
} - onOpenBrowserAi={onOpenBrowserAi} chatOpen />)); + onOpenBrowserAi={onOpenBrowserAi} onToggleBrowserAi={onToggleBrowserAi} chatOpen />)); const project = host.querySelector('[data-testid="project-selector"]')!; - const chat = project.nextElementSibling as HTMLButtonElement; - expect(chat.textContent).toBe('Chat'); + const controls = project.nextElementSibling!; + expect(controls.getAttribute('aria-label')).toBe('Local agent'); + const settings = controls.querySelector('.atlas-browser-ai-action')!; + const chat = controls.querySelector('[aria-label="Hide chat"]')!; expect(chat.getAttribute('aria-expanded')).toBe('true'); await act(async () => chat.click()); + expect(onToggleBrowserAi).toHaveBeenCalledOnce(); + expect(onOpenBrowserAi).not.toHaveBeenCalled(); + await act(async () => settings.click()); expect(onOpenBrowserAi).toHaveBeenCalledOnce(); }); diff --git a/graph-ui/src/browser-ai/AgentSettingsDialog.tsx b/graph-ui/src/browser-ai/AgentSettingsDialog.tsx new file mode 100644 index 000000000..3a659c4c0 --- /dev/null +++ b/graph-ui/src/browser-ai/AgentSettingsDialog.tsx @@ -0,0 +1,26 @@ +import { useEffect, useRef, type ReactNode } from 'react'; +import { createPortal } from 'react-dom'; +import './agent-settings.css'; + +export default function AgentSettingsDialog({ children, onClose }: { children: ReactNode; onClose: () => void }) { + const dialog = useRef(null); + useEffect(() => { + const previous = document.activeElement; + const element = dialog.current; + if (element && !element.open) { + if (typeof element.showModal === 'function') element.showModal(); + else element.setAttribute('open', ''); + } + return () => { if (previous instanceof HTMLElement && previous.isConnected) previous.focus(); }; + }, []); + return createPortal( event.stopPropagation()} onCancel={event => { event.preventDefault(); onClose(); }} onClick={event => { + if (event.target !== event.currentTarget) return; + const bounds = event.currentTarget.getBoundingClientRect(); + if (event.clientX < bounds.left || event.clientX > bounds.right || event.clientY < bounds.top || event.clientY > bounds.bottom) onClose(); + }}> +

Agent configuration

Runs locally in this browser.

+
+
{children}
+
, document.body); +} diff --git a/graph-ui/src/browser-ai/BrowserChatDock.history.test.tsx b/graph-ui/src/browser-ai/BrowserChatDock.history.test.tsx new file mode 100644 index 000000000..0aa45fea7 --- /dev/null +++ b/graph-ui/src/browser-ai/BrowserChatDock.history.test.tsx @@ -0,0 +1,79 @@ +// @vitest-environment jsdom +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import BrowserChatDock from './BrowserChatDock'; +import { browserChatHistoryCache, type BrowserChatHistorySnapshot } from './chat-history-cache'; + +let host: HTMLDivElement; +let root: Root; +beforeEach(() => { + (globalThis as unknown as Record).IS_REACT_ACT_ENVIRONMENT = true; + host = document.createElement('div'); document.body.appendChild(host); root = createRoot(host); + vi.spyOn(browserChatHistoryCache, 'save').mockResolvedValue({ saved: true, trimmed: false, droppedTurns: 0, evictedProjects: 0 }); + vi.spyOn(browserChatHistoryCache, 'delete').mockResolvedValue(); +}); +afterEach(async () => { await act(async () => root.unmount()); host.remove(); vi.restoreAllMocks(); }); + +function history(answer: string): BrowserChatHistorySnapshot { + return { turns: [{ id: 'local-turn-1', prompt: 'What does it do?', modelId: 'cached-model', + request: [{ role: 'user', content: 'What does it do?' }], answer, status: 'complete', + readerContext: { project: 'sample', path: 'sum.ts', status: 'ready', source: { + id: 'source', project: 'sample', path: 'sum.ts', sourceVersion: 'v1', text: 'return a + b;', + kind: 'selection', startLine: 1, endLine: 1, startColumn: 1, endColumn: 14, + } } }], draft: 'Follow-up draft', savedAt: Date.now(), expiresAt: Date.now() + 86400000, trimmed: false }; +} +const base = { open: true, proactive: false, onClose: () => {}, onAttachmentConsumed: () => {} }; +const click = async (name: string) => { + const button = [...document.querySelectorAll('button')].find(item => item.textContent === name || item.getAttribute('aria-label') === name); + expect(button).toBeDefined(); await act(async () => button!.click()); +}; + +it('restores conversation, draft and source without enabling a model or resaving on read', async () => { + vi.spyOn(browserChatHistoryCache, 'load').mockResolvedValue(history('Cached explanation.')); + const createRuntime = vi.fn(); + await act(async () => root.render()); + expect(host.textContent).toContain('Cached explanation.'); + expect(host.querySelector('textarea')?.value).toBe('Follow-up draft'); + expect(host.querySelector('.cbm-chat-source-content pre')?.textContent).toBe('return a + b;'); + expect(createRuntime).not.toHaveBeenCalled(); + expect(browserChatHistoryCache.save).not.toHaveBeenCalled(); + expect(host.textContent).not.toContain('cached-model'); +}); + +it('clears this project through agent configuration and does not restore it on folding', async () => { + vi.spyOn(browserChatHistoryCache, 'load').mockResolvedValue(history('Remove this history.')); + await act(async () => root.render()); + await act(async () => root.render()); + expect(document.querySelector('dialog')?.textContent).toContain('24 hours'); + vi.spyOn(window, 'confirm').mockReturnValue(true); + await click('Clear history'); + expect(browserChatHistoryCache.delete).toHaveBeenCalledWith('project-a'); + expect(host.textContent).not.toContain('Remove this history.'); + await click('Close agent configuration'); + await act(async () => root.render()); + await act(async () => root.render()); + expect(host.textContent).not.toContain('Remove this history.'); +}); + +it('does not display the previous project while another history is loading', async () => { + let finish!: (value: BrowserChatHistorySnapshot) => void; + const pending = new Promise(resolve => { finish = resolve; }); + vi.spyOn(browserChatHistoryCache, 'load').mockImplementation(key => key === 'project-a' ? Promise.resolve(history('Project A answer')) : pending); + await act(async () => root.render()); + expect(host.textContent).toContain('Project A answer'); + await act(async () => root.render()); + expect(host.textContent).not.toContain('Project A answer'); + await act(async () => finish(history('Project B answer'))); + expect(host.textContent).toContain('Project B answer'); + expect(host.textContent).not.toContain('Project A answer'); +}); + +it('keeps storage diagnostics in agent settings instead of the conversation', async () => { + vi.spyOn(browserChatHistoryCache, 'load').mockRejectedValue(new Error('Storage blocked')); + await act(async () => root.render()); + expect(host.textContent).not.toContain('Storage blocked'); + await act(async () => root.render()); + expect(document.querySelector('dialog')?.textContent).toMatch(/history|storage/i); + expect(host.textContent).not.toContain('Storage blocked'); +}); diff --git a/graph-ui/src/browser-ai/BrowserChatDock.test.tsx b/graph-ui/src/browser-ai/BrowserChatDock.test.tsx index c80387cca..2cdf1ffca 100644 --- a/graph-ui/src/browser-ai/BrowserChatDock.test.tsx +++ b/graph-ui/src/browser-ai/BrowserChatDock.test.tsx @@ -8,6 +8,7 @@ import { BROWSER_MODELS } from './model-policy'; let container: HTMLDivElement; let root: Root; +let renderedProps: BrowserChatDockProps; beforeEach(() => { (globalThis as unknown as Record).IS_REACT_ACT_ENVIRONMENT = true; container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); @@ -27,12 +28,15 @@ function fixture() { const props = { proactive: false, open: true, onClose: vi.fn(), onAttachmentConsumed: vi.fn(), onAttachmentRemoved: vi.fn(), createRuntime: vi.fn(() => runtime), removeCache: vi.fn(async () => {}) }; return { runtime, props }; } -async function render(props: BrowserChatDockProps): Promise { await act(async () => root.render()); } +async function render(props: BrowserChatDockProps): Promise { renderedProps = props; await act(async () => root.render()); } function button(label: string): HTMLButtonElement { - const target = [...container.querySelectorAll('button')].find(item => item.textContent === label || item.getAttribute('aria-label') === label); + const target = [...document.body.querySelectorAll('button')].find(item => item.textContent === label || item.getAttribute('aria-label') === label); expect(target, `button ${label}`).toBeDefined(); return target!; } -async function click(label: string): Promise { await act(async () => button(label).click()); } +async function click(label: string): Promise { + if (['Download & load', 'Load model', 'Reload model'].includes(label) && !document.querySelector('#cbm-chat-model')) await models(); + await act(async () => button(label).click()); +} async function type(value: string): Promise { const input = container.querySelector('textarea')!; await act(async () => { @@ -41,7 +45,7 @@ async function type(value: string): Promise { }); } async function models(): Promise { - if (!container.querySelector('#cbm-chat-model')) await act(async () => (container.querySelector('.cbm-chat-modelbar button') as HTMLButtonElement).click()); + if (!document.querySelector('#cbm-chat-model')) await render({ ...renderedProps, settingsRequest: (renderedProps.settingsRequest ?? 0) + 1 }); } function deferred() { let resolve!: (value: T) => void; @@ -52,32 +56,46 @@ function deferred() { const attachmentData = (message: BrowserChatMessage) => JSON.parse(message.content.slice(message.content.indexOf('\n{') + 1).split('\n')[0]); describe('persistent local browser chat', () => { - it('opens without creating a worker or downloading a model and exposes explicit settings', async () => { + it('keeps configuration and model names out of chat until agent configuration is requested', async () => { const { props } = fixture(); await render({ ...props, attachment: selection }); expect(props.createRuntime).not.toHaveBeenCalled(); - expect(button('Download & load').disabled).toBe(false); + expect(container.querySelector('select')).toBeNull(); + expect(container.textContent).not.toContain(BROWSER_MODELS[0].displayName); + expect(container.textContent).not.toContain('Explain selections automatically'); expect(container.querySelector('pre')?.textContent).toBe(selection.text); - expect(container.querySelectorAll('option')).toHaveLength(BROWSER_MODELS.length); - expect(container.textContent).toContain('No model downloads automatically'); - expect(container.querySelector('.cbm-chat-settings h3')?.textContent).toBe('Enable chat'); - expect(container.querySelector('[role="log"]')).toBeNull(); - expect(container.querySelector('textarea')).toBeNull(); + await click('Enable agent'); + const dialog = document.querySelector('dialog')!; + expect(dialog.textContent).toContain('Agent configuration'); + expect(dialog.querySelectorAll('option')).toHaveLength(BROWSER_MODELS.length); + expect(dialog.textContent).toContain('No model downloads automatically'); + expect(container.querySelector('select')).toBeNull(); + expect(button('Download & load').disabled).toBe(false); + expect(props.createRuntime).not.toHaveBeenCalled(); + await click('Close agent configuration'); + expect(document.querySelector('dialog')).toBeNull(); }); - it('offers a compact enable action without starting a download or consuming selected code', async () => { + it('opens configuration centrally while chat stays collapsed without consuming the selection', async () => { const { props } = fixture(); const onOpen = vi.fn(); await render({ ...props, open: false, showCollapsed: true, onOpen, attachment: selection }); - expect(container.querySelector('aside')?.hidden).toBe(false); expect(container.querySelector('form')).toBeNull(); + await click('Open chat'); expect(onOpen).toHaveBeenCalledOnce(); + await render({ ...props, open: false, showCollapsed: true, onOpen, attachment: selection, settingsRequest: 1 }); + expect(document.querySelector('dialog[open]')).not.toBeNull(); expect(container.querySelector('select')).toBeNull(); - await click('Enable chat'); - expect(onOpen).toHaveBeenCalledOnce(); expect(props.createRuntime).not.toHaveBeenCalled(); expect(props.onAttachmentConsumed).not.toHaveBeenCalled(); - await render({ ...props, showCollapsed: true, onOpen, attachment: selection }); - expect(button('Download & load').disabled).toBe(false); - expect(container.querySelector('pre')?.textContent).toBe(selection.text); - expect(props.createRuntime).not.toHaveBeenCalled(); + }); + + it('preserves the conversation and draft across agent configuration and reports the model only to the toolbar', async () => { + const { props } = fixture(); const onAgentModelChange = vi.fn(); + await render({ ...props, onAgentModelChange }); + await click('Download & load'); await type('Explain'); await click('Send ↑'); await type('Follow up'); + await models(); await click('Close agent configuration'); + expect(container.querySelector('textarea')?.value).toBe('Follow up'); + expect(container.textContent).toContain('Adds the two values.'); + expect(container.textContent).not.toContain(BROWSER_MODELS[0].displayName); + expect(onAgentModelChange).toHaveBeenLastCalledWith(BROWSER_MODELS[0].displayName); }); it('keeps an active answer and draft when only the collapsed header is visible', async () => { @@ -123,7 +141,7 @@ describe('persistent local browser chat', () => { expect(second.at(-1)?.content).toBe('Why?'); expect(second[1].content).toContain(selection.text); expect(props.onAttachmentConsumed).toHaveBeenCalledOnce(); - expect(container.querySelector('.cbm-chat-question pre')?.textContent).toBe(selection.text); + expect(container.querySelector('.cbm-chat-answer .cbm-chat-source-content pre')?.textContent).toBe(selection.text); }); it('retains the draft and attachment when the actual tokenizer rejects context size', async () => { @@ -185,7 +203,7 @@ describe('persistent local browser chat', () => { const { props, runtime } = fixture(); const prepare = deferred(); runtime.prepare.mockReturnValueOnce(prepare.promise); await render(props); await click('Download & load'); await click('Stop download'); await act(async () => prepare.resolve()); - expect(runtime.dispose).toHaveBeenCalledOnce(); expect(container.querySelector('.cbm-chat-status')?.textContent).toBe('Off'); + expect(runtime.dispose).toHaveBeenCalledOnce(); expect(document.querySelector('.cbm-chat-status')?.textContent).toBe('Off'); expect(container.querySelector('textarea')).toBeNull(); expect(button('Download & load').disabled).toBe(false); }); @@ -202,10 +220,10 @@ describe('persistent local browser chat', () => { it('changes models without automatic download or losing conversation', async () => { const { props, runtime } = fixture(); await render(props); await click('Download & load'); await type('Explain'); await click('Send ↑'); await models(); - await act(async () => { const select = container.querySelector('select')!; select.value = BROWSER_MODELS[1].id; select.dispatchEvent(new Event('change', { bubbles: true })); }); + await act(async () => { const select = document.querySelector('#cbm-chat-model') as HTMLSelectElement; select.value = BROWSER_MODELS[1].id; select.dispatchEvent(new Event('change', { bubbles: true })); }); expect(runtime.dispose).toHaveBeenCalledOnce(); expect(props.createRuntime).toHaveBeenCalledOnce(); expect(container.textContent).toContain('Adds the two values.'); expect(button('Send ↑').disabled).toBe(true); - expect(container.querySelector('select')?.value).toBe(BROWSER_MODELS[1].id); + expect((document.querySelector('#cbm-chat-model') as HTMLSelectElement)?.value).toBe(BROWSER_MODELS[1].id); }); it('does not move scroll position while the reader is inspecting earlier output', async () => { @@ -237,7 +255,7 @@ describe('persistent local browser chat', () => { await act(async () => { stream('Ignored stale chunk'); answer.resolve('Ignored stale final'); }); expect(runtime.dispose).toHaveBeenCalledOnce(); expect(container.textContent).toContain('Partial answer'); expect(container.textContent).not.toContain('Ignored stale'); expect(container.textContent).toContain('Stopped · partial answer'); - expect(container.querySelector('.cbm-chat-status')?.textContent).toBe('Off'); + expect(document.querySelector('.cbm-chat-status')?.textContent).toBe('Off'); }); it('includes graph evidence only after explicit choice, freezes it, and clears it after send', async () => { @@ -252,7 +270,7 @@ describe('persistent local browser chat', () => { expect(runtime.chat.mock.calls[1][0].at(-1)?.content).toContain('entry → sum'); expect(runtime.chat.mock.calls[1][0].at(-1)?.content).not.toContain('changed graph'); expect(container.querySelector('.cbm-chat-pending')).toBeNull(); - expect(container.querySelector('.cbm-chat-turn:last-child .cbm-chat-question pre')?.textContent).toBe('entry → sum'); + expect(container.querySelector('.cbm-chat-turn:last-child .cbm-chat-source-content pre')?.textContent).toBe('entry → sum'); await click('Retry'); expect(runtime.chat.mock.calls[2][0]).toEqual(runtime.chat.mock.calls[1][0]); }); @@ -307,20 +325,20 @@ describe('persistent local browser chat', () => { expect(container.querySelector('.cbm-chat-answer h2')?.textContent).toBe('Result'); expect(container.querySelector('.cbm-chat-answer li strong')?.textContent).toBe('clear'); expect(container.querySelector('.cbm-chat-answer pre code')?.textContent).toBe('return value;\n'); - expect(container.querySelector('.cbm-chat-question .cbm-chat-attachment pre')?.textContent).toBe(attachedCode.text); - expect(container.querySelector('.cbm-chat-question .cbm-chat-attachment b')).toBeNull(); + expect(container.querySelector('.cbm-chat-source-content .cbm-chat-attachment pre')?.textContent).toBe(attachedCode.text); + expect(container.querySelector('.cbm-chat-source-content .cbm-chat-attachment b')).toBeNull(); expect(runtime.chat.mock.calls[0][0].at(-1)!.content).toContain(prompt); expect(runtime.chat.mock.calls[0][0].at(-1)!.content).toContain(attachedCode.text); }); - it('shows automatic source before model setup without downloading or offering removal', async () => { + it('keeps ready reader source out of the composer without downloading or offering removal', async () => { const { props } = fixture(); const context = reader('Loaded excerpt'); context.source!.partial = 'Only lines 20–80 are loaded.'; await render({ ...props, readerContext: context, attachment: { ...selection, text: 'IGNORED_MANUAL_CODE' } }); expect(props.createRuntime).not.toHaveBeenCalled(); - expect(container.querySelector('[aria-label="Source for next message"]')?.textContent).toContain('Current file'); - expect(container.querySelector('.cbm-chat-source-partial')?.textContent).toBe(context.source!.partial); - expect(container.querySelector('pre')?.textContent).toBe('Loaded excerpt'); + expect(container.querySelector('[aria-label="Source for next message"]')).toBeNull(); + expect(container.querySelector('.cbm-chat-composer pre')).toBeNull(); + expect(container.textContent).not.toContain('Current file'); expect(container.textContent).not.toContain('IGNORED_MANUAL_CODE'); expect(container.querySelector('[aria-label="Remove code attachment"]')).toBeNull(); expect(container.querySelector('textarea')).toBeNull(); @@ -343,7 +361,7 @@ describe('persistent local browser chat', () => { expect(requests[2].map(message => message.content).join('\n')).not.toContain(selection.text); expect(requests[2].filter(message => message.role === 'user').map(message => message.content)).toEqual(['Explain the file', 'Explain the marked code', 'Explain this other file']); expect(props.onAttachmentConsumed).not.toHaveBeenCalled(); - expect(container.querySelectorAll('.cbm-chat-question .cbm-chat-attachment')).toHaveLength(3); + expect(container.querySelectorAll('.cbm-chat-answer .cbm-chat-attachment')).toHaveLength(3); await render(props); await type('Ask from another workspace'); await click('Send ↑'); expect(runtime.chat.mock.calls[3][0].map(message => message.content).join('\n')).not.toMatch(/WHOLE_FIRST_FILE|WHOLE_SECOND_FILE/); }); @@ -359,8 +377,9 @@ describe('persistent local browser chat', () => { await act(async () => count.resolve(100)); expect(runtime.chat.mock.calls[0][0]).toEqual(counted); expect(runtime.chat.mock.calls[0][0][0].content).toContain('ORIGINAL_LITERAL\r\n\t '); - expect(container.querySelector('.cbm-chat-question pre')?.textContent).toBe('ORIGINAL_LITERAL\r\n\t '); - expect(container.querySelector('.cbm-chat-reader-source pre')?.textContent).toBe('NEW_CURRENT_FILE'); + expect(container.querySelector('.cbm-chat-answer .cbm-chat-source-content pre')?.textContent).toBe('ORIGINAL_LITERAL\r\n\t '); + expect(container.querySelector('.cbm-chat-source-content')?.textContent).not.toContain('NEW_CURRENT_FILE'); + expect(container.querySelector('.cbm-chat-composer pre')).toBeNull(); expect(runtime.chat.mock.calls[0][0][0].content).not.toContain('MUTATED_AFTER_COUNT'); }); @@ -386,7 +405,7 @@ describe('persistent local browser chat', () => { await act(async () => container.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))); expect(runtime.countTokens).not.toHaveBeenCalled(); expect(runtime.chat).not.toHaveBeenCalled(); - expect(container.querySelector('.cbm-chat-reader-source pre')).toBeNull(); + expect(container.querySelector('.cbm-chat-composer pre')).toBeNull(); expect(container.textContent).toContain('Loading current file'); await render({ ...props, attachment: selection, readerContext: { project: 'sample', path: 'new.ts', status: 'unavailable' } }); await click('Send ↑'); @@ -394,7 +413,7 @@ describe('persistent local browser chat', () => { expect(runtime.chat.mock.calls[0][0].map(message => message.content).join('\n')).not.toContain(selection.text); expect(runtime.chat.mock.calls[0][0].map(message => message.content).join('\n')).not.toMatch(/OLD_CODE|STALE_CODE/); await render({ ...props, readerContext: { project: 'sample', status: 'empty' } }); - expect(container.querySelector('.cbm-chat-reader-source')?.textContent).toContain('Open a file'); + expect(container.querySelector('.cbm-chat-source-state')?.textContent).toContain('Open a file'); }); it('preserves explicitly chosen graph evidence alongside automatic system source', async () => { @@ -428,7 +447,7 @@ describe('persistent local browser chat', () => { expect(runtime.countTokens.mock.calls[0][0].at(-1)!.content).not.toContain(text); expect(runtime.chat).not.toHaveBeenCalled(); expect(container.querySelector('textarea')?.value).toBe('Explain exactly'); - expect(container.querySelector('.cbm-chat-reader-source pre')?.textContent).toBe(text); + expect(container.querySelector('.cbm-chat-composer pre')).toBeNull(); expect(container.textContent).toContain('Nothing was sent or shortened'); }); }); @@ -451,7 +470,7 @@ describe('proactive selection explanations', () => { expect(runtime.chat.mock.calls[0][0].at(-1)!.content).toContain('latest'); expect(runtime.chat.mock.calls[0][0].at(-1)!.content).not.toContain('first'); expect(container.querySelectorAll('.cbm-chat-turn')).toHaveLength(0); - expect(container.querySelector('[aria-label="Current selection explanation"]')?.textContent).toContain('Source evidence'); + expect(container.querySelector('[aria-label="Current selection explanation"]')?.textContent).toContain('Adds the two values.'); await render({ ...props, proactive: true, proactiveSelection: graph('latest', 'new-event-id') }); await settleSelection(); expect(runtime.chat).toHaveBeenCalledOnce(); await render({ ...props, proactive: true }); @@ -473,20 +492,157 @@ describe('proactive selection explanations', () => { expect(runtime.chat.mock.calls[1][0].at(-1)!.content).toContain('new'); }); - it('preserves drafts, sends current evidence on each manual question, and does not schedule while typing', async () => { + it('keeps explaining new selections while preserving drafts and manual history', async () => { vi.useFakeTimers(); const { props, runtime } = fixture(); await render({ ...props, proactive: true, proactiveSelection: graph('one') }); await click('Download & load'); await type('What does it do?'); - await settleSelection(); expect(runtime.chat).not.toHaveBeenCalled(); + await settleSelection(); expect(runtime.chat).toHaveBeenCalledOnce(); + expect(container.querySelector('textarea')?.value).toBe('What does it do?'); await click('Send ↑'); - expect(runtime.chat.mock.calls[0][0].at(-1)!.content).toContain('one'); + expect(runtime.chat.mock.calls[1][0].at(-1)!.content).toContain('one'); await type('And this?'); await render({ ...props, proactive: true, proactiveSelection: graph('two') }); - await settleSelection(); expect(runtime.chat).toHaveBeenCalledOnce(); + await settleSelection(); expect(runtime.chat).toHaveBeenCalledTimes(3); + expect(container.querySelector('.cbm-chat-question')?.textContent).toContain('What does it do?'); + expect(runtime.chat.mock.calls[2][0].at(-1)!.content).toContain('two'); expect(container.querySelector('textarea')?.value).toBe('And this?'); await click('Send ↑'); - expect(runtime.chat.mock.calls[1][0].at(-1)!.content).toContain('two'); - expect(runtime.chat.mock.calls[1][0].at(-1)!.content).not.toContain('one'); + expect(runtime.chat.mock.calls[3][0].at(-1)!.content).toContain('two'); + expect(runtime.chat.mock.calls[3][0].at(-1)!.content).not.toContain('one'); + }); + + it('prioritizes a frozen question only after automatic generation settles, then explains the latest selection', async () => { + vi.useFakeTimers(); const { props, runtime } = fixture(); + const automatic = deferred(); const manual = deferred(); + runtime.chat.mockReturnValueOnce(automatic.promise).mockReturnValueOnce(manual.promise); + const original = reader('ORIGINAL_SOURCE', 'selection'); + await render({ ...props, proactive: true, readerContext: original }); + await click('Download & load'); await settleSelection(); + await type('Explain this exact code'); await click('Send ↑'); + expect(runtime.stop).toHaveBeenCalledOnce(); + expect(container.textContent).toContain('Your question is next'); + expect(runtime.countTokens).toHaveBeenCalledOnce(); + original.source!.text = 'MUTATED_ORIGINAL'; + await render({ ...renderedProps, readerContext: reader('INTERMEDIATE_SOURCE') }); + await render({ ...renderedProps, readerContext: reader('LATEST_SOURCE') }); + await type('Next draft'); await settleSelection(); + expect(runtime.chat).toHaveBeenCalledOnce(); + await act(async () => automatic.resolve('STALE_AUTOMATIC_ANSWER')); + expect(runtime.chat).toHaveBeenCalledTimes(2); + const question = runtime.chat.mock.calls[1][0]; + expect(question[0].content).toContain('ORIGINAL_SOURCE'); + expect(question[0].content).not.toMatch(/MUTATED_ORIGINAL|INTERMEDIATE_SOURCE|LATEST_SOURCE/); + expect(question.at(-1)?.content).toBe('Explain this exact code'); + expect(container.querySelector('textarea')?.value).toBe('Next draft'); + expect(container.textContent).not.toContain('STALE_AUTOMATIC_ANSWER'); + await settleSelection(); expect(runtime.chat).toHaveBeenCalledTimes(2); + await act(async () => manual.resolve('Manual answer.')); + await settleSelection(); expect(runtime.chat).toHaveBeenCalledTimes(3); + expect(runtime.chat.mock.calls[2][0].at(-1)?.content).toContain('LATEST_SOURCE'); + expect(runtime.chat.mock.calls[2][0].at(-1)?.content).not.toContain('INTERMEDIATE_SOURCE'); + expect(container.querySelector('.cbm-chat-answer .cbm-chat-source-content pre')?.textContent).toBe('ORIGINAL_SOURCE'); + expect(container.textContent).toContain('Current selection ↑'); + }); + + it('waits for cancelled automatic token counting before counting a manual question', async () => { + vi.useFakeTimers(); const { props, runtime } = fixture(); const counting = deferred(); + runtime.countTokens.mockReturnValueOnce(counting.promise); + await render({ ...props, proactive: true, readerContext: reader('SOURCE') }); + await click('Download & load'); await settleSelection(); + await type('Question'); await click('Send ↑'); + expect(runtime.stop).toHaveBeenCalledOnce(); + expect(runtime.countTokens).toHaveBeenCalledOnce(); + expect(runtime.chat).not.toHaveBeenCalled(); + await act(async () => counting.resolve(100)); + expect(runtime.countTokens).toHaveBeenCalledTimes(2); + expect(runtime.chat).toHaveBeenCalledOnce(); + expect(runtime.chat.mock.calls[0][0].at(-1)?.content).toBe('Question'); + }); + + it('cancels a queued question on Stop without silently sending or restarting it', async () => { + vi.useFakeTimers(); const { props, runtime } = fixture(); const automatic = deferred(); + runtime.chat.mockReturnValueOnce(automatic.promise); + await render({ ...props, proactive: true, readerContext: reader('SOURCE') }); + await click('Download & load'); await settleSelection(); + await type('Keep my unsent question'); await click('Send ↑'); await click('Stop'); + await act(async () => automatic.resolve('CANCELLED')); + await settleSelection(); + expect(runtime.chat).toHaveBeenCalledOnce(); + expect(runtime.countTokens).toHaveBeenCalledOnce(); + expect(container.querySelector('textarea')?.value).toBe('Keep my unsent question'); + expect(container.querySelector('.cbm-chat-turn')).toBeNull(); + await click('Send ↑'); expect(runtime.chat).toHaveBeenCalledTimes(2); + }); + + it('discards a queued question after unload even when the cancelled worker later resolves', async () => { + vi.useFakeTimers(); const { props, runtime } = fixture(); const automatic = deferred(); + runtime.chat.mockReturnValueOnce(automatic.promise); + await render({ ...props, proactive: true, readerContext: reader('SOURCE') }); + await click('Download & load'); await settleSelection(); + await type('Do not send after unload'); await click('Send ↑'); + await models(); await click('Unload model'); + await act(async () => automatic.resolve('LATE_ANSWER')); + await settleSelection(); + expect(runtime.dispose).toHaveBeenCalledOnce(); + expect(runtime.countTokens).toHaveBeenCalledOnce(); + expect(runtime.chat).toHaveBeenCalledOnce(); + expect(container.textContent).not.toContain('LATE_ANSWER'); + expect(container.querySelector('.cbm-chat-turn')).toBeNull(); + await click('Load model'); await click('Send ↑'); + expect(runtime.chat).toHaveBeenCalledTimes(2); + expect(runtime.chat.mock.calls[1][0].at(-1)?.content).toBe('Do not send after unload'); + }); + + it('drains old project work and retains the model without sending its queued question into the new project', async () => { + vi.useFakeTimers(); const { props, runtime } = fixture(); const automatic = deferred(); + runtime.chat.mockReturnValueOnce(automatic.promise); + await render({ ...props, historyKey: 'project-a', proactive: true, readerContext: reader('PROJECT_A') }); + await click('Download & load'); await settleSelection(); + await type('Old project question'); await click('Send ↑'); + await render({ ...renderedProps, historyKey: 'project-b', readerContext: reader('PROJECT_B') }); + await settleSelection(); expect(runtime.chat).toHaveBeenCalledOnce(); + await act(async () => automatic.resolve('OLD_PROJECT_RESULT')); + await settleSelection(); + expect(runtime.dispose).not.toHaveBeenCalled(); + expect(props.createRuntime).toHaveBeenCalledOnce(); + expect(runtime.chat).toHaveBeenCalledTimes(2); + expect(runtime.chat.mock.calls[1][0].at(-1)?.content).toContain('PROJECT_B'); + expect(runtime.chat.mock.calls[1][0].at(-1)?.content).not.toContain('Old project question'); + expect(container.querySelector('.cbm-chat-turn')).toBeNull(); + expect(container.textContent).not.toContain('OLD_PROJECT_RESULT'); + }); + + it('drains a manual answer on project change before reusing the loaded model for new source', async () => { + vi.useFakeTimers(); const { props, runtime } = fixture(); const manual = deferred(); + await render({ ...props, historyKey: 'project-a', proactive: true, readerContext: reader('PROJECT_A') }); + await click('Download & load'); await settleSelection(); + runtime.chat.mockReturnValueOnce(manual.promise); + await type('Old project question'); await click('Send ↑'); + await render({ ...renderedProps, historyKey: 'project-b', readerContext: reader('PROJECT_B') }); + await settleSelection(); expect(runtime.chat).toHaveBeenCalledTimes(2); + await act(async () => { runtime.chat.mock.calls[1][1]('STALE_CHUNK'); manual.resolve('STALE_MANUAL_ANSWER'); }); + await settleSelection(); + expect(runtime.dispose).not.toHaveBeenCalled(); + expect(runtime.chat).toHaveBeenCalledTimes(3); + expect(runtime.chat.mock.calls[2][0].at(-1)?.content).toContain('PROJECT_B'); + expect(container.textContent).not.toMatch(/STALE_CHUNK|STALE_MANUAL_ANSWER/); + }); + + it('keeps the manual answer visible when navigation queues a new explanation', async () => { + vi.useFakeTimers(); const { props, runtime } = fixture(); const answer = deferred(); + await render({ ...props, proactive: true, readerContext: reader('FIRST') }); + await click('Download & load'); await settleSelection(); + runtime.chat.mockReturnValueOnce(answer.promise); + await type('Manual question'); await click('Send ↑'); + const log = container.querySelector('.cbm-chat-transcript') as HTMLDivElement; + Object.defineProperties(log, { scrollHeight: { configurable: true, value: 1000 }, clientHeight: { configurable: true, value: 100 } }); + log.scrollTop = 900; + await render({ ...renderedProps, readerContext: reader('SECOND') }); + expect(log.scrollTop).toBe(900); + await act(async () => answer.resolve('Manual answer')); + await settleSelection(); + expect(log.scrollTop).not.toBe(0); + await click('Current selection ↑'); expect(log.scrollTop).toBe(0); }); it('uses exact reader source, never falls back while source is loading, and cancels pending work on unload', async () => { @@ -507,8 +663,8 @@ describe('proactive selection explanations', () => { await render({ ...props, onAgentStateChange }); expect(onAgentStateChange).toHaveBeenLastCalledWith('off'); await click('Download & load'); expect(onAgentStateChange).toHaveBeenLastCalledWith('active'); expect(container.querySelector('#cbm-chat-model')).toBeNull(); - await render({ ...props, onAgentStateChange, settingsRequest: 1 }); - expect(container.querySelector('#cbm-chat-model')).not.toBeNull(); + await render({ ...props, onAgentStateChange, settingsRequest: (renderedProps.settingsRequest ?? 0) + 1 }); + expect(document.querySelector('#cbm-chat-model')).not.toBeNull(); await click('Unload model'); expect(onAgentStateChange).toHaveBeenLastCalledWith('off'); }); }); @@ -541,7 +697,7 @@ describe('selection explainer controls', () => { await act(async () => { await vi.advanceTimersByTimeAsync(650); }); expect(runtime.chat).toHaveBeenCalledTimes(2); await models(); - const automatic = [...container.querySelectorAll('label')].find(label => label.textContent?.includes('Explain selections automatically'))!.querySelector('input')!; + const automatic = [...document.querySelectorAll('label')].find(label => label.textContent?.includes('Explain selections automatically'))!.querySelector('input')!; await act(async () => automatic.click()); await render({ ...props, proactive: true, readerContext: reader('different source') }); await act(async () => { await vi.advanceTimersByTimeAsync(650); }); @@ -571,16 +727,126 @@ it('does not generate from a superseded token count and can return to that selec describe('live-browser regressions', () => { afterEach(() => vi.useRealTimers()); - it('keeps unsupported automatic prose out of the explanation and exposes source evidence', async () => { + it('keeps a compact composer and places frozen source beside the manual answer speaker', async () => { + const { props, runtime } = fixture(); + const original = reader('ORIGINAL_SOURCE'); original.source!.partial = 'Only lines 3–4 were loaded.'; + const pendingContext = { id: 'graph-source', label: 'Callers', text: 'entry calls original' }; + await render({ ...props, readerContext: original, pendingContext }); await click('Download & load'); + const textarea = container.querySelector('textarea')!; + expect(textarea.rows).toBe(1); + expect(textarea.parentElement?.querySelector('[aria-label="Send ↑"]')).not.toBeNull(); + expect(container.querySelector('.cbm-chat-clear')).toBeNull(); + expect(container.querySelector('.cbm-chat-reader-source')).toBeNull(); + expect(container.querySelector('[aria-label="Remove graph selection"]')).not.toBeNull(); + await type('Explain the source'); await click('Send ↑'); + const response = container.querySelector('.cbm-chat-answer')!; + const source = response.querySelector('.cbm-chat-response-source') as HTMLDetailsElement; + expect(response.firstElementChild).toBe(source); + expect(source.open).toBe(false); + expect(source.querySelector('summary')?.textContent).toContain('Agentⓘ Source'); + expect(source.textContent).toContain('Only lines 3–4 were loaded.'); + expect(source.textContent).toContain('entry calls original'); + await act(async () => source.querySelector('summary')!.click()); + expect(source.open).toBe(true); + await render({ ...renderedProps, readerContext: reader('NEW_SOURCE', 'file', 'new.ts') }); + expect(source.querySelector('pre')?.textContent).toBe('ORIGINAL_SOURCE'); + expect(source.textContent).not.toContain('NEW_SOURCE'); + expect(container.querySelector('.cbm-chat-composer pre')).toBeNull(); + expect(container.querySelector('.cbm-chat-clear')).not.toBeNull(); + expect(runtime.chat.mock.calls[0][0][0].content).toContain('ORIGINAL_SOURCE'); + }); + + it('shows automatic explanations from their start with older manual turns and on reopening', async () => { vi.useFakeTimers(); const { props, runtime } = fixture(); - runtime.chat.mockResolvedValueOnce('Invented destructor behavior.'); + const answer = deferred(); + await render(props); await click('Download & load'); + await type('Earlier manual question'); await click('Send ↑'); + const log = container.querySelector('.cbm-chat-transcript') as HTMLDivElement; + Object.defineProperties(log, { scrollHeight: { configurable: true, value: 1000 }, clientHeight: { configurable: true, value: 100 } }); + log.scrollTop = 900; + await act(async () => log.dispatchEvent(new Event('scroll', { bubbles: true }))); + runtime.chat.mockReturnValueOnce(answer.promise); + await render({ ...renderedProps, proactive: true, readerContext: reader('const first = 1;') }); + expect(log.scrollTop).toBe(0); + await act(async () => { await vi.advanceTimersByTimeAsync(650); }); + expect(log.scrollTop).toBe(0); + await act(async () => answer.resolve('Declares first with the value 1.')); + expect(log.scrollTop).toBe(0); + expect(log.querySelector('[aria-label="Current selection explanation"]')?.textContent).toContain('Declares first'); + expect(log.querySelector('.cbm-chat-question')?.textContent).toContain('Earlier manual question'); + log.scrollTop = 900; + await act(async () => log.dispatchEvent(new Event('scroll', { bubbles: true }))); + await render({ ...renderedProps, readerContext: reader('const second = 2;') }); + expect(log.scrollTop).toBe(0); + await act(async () => { await vi.advanceTimersByTimeAsync(650); }); + expect(log.scrollTop).toBe(0); + log.scrollTop = 400; + await act(async () => log.dispatchEvent(new Event('scroll', { bubbles: true }))); + await render({ ...renderedProps, open: false }); + await render({ ...renderedProps, open: true }); + expect(log.scrollTop).toBe(0); + }); + + it('preserves a deliberate history scroll when an automatic explanation finishes', async () => { + vi.useFakeTimers(); const { props, runtime } = fixture(); const answer = deferred(); + runtime.chat.mockReturnValueOnce(answer.promise); + await render({ ...props, proactive: true, readerContext: reader('const value = 1;') }); + await click('Download & load'); + const log = container.querySelector('.cbm-chat-transcript') as HTMLDivElement; + Object.defineProperties(log, { scrollHeight: { configurable: true, value: 1000 }, clientHeight: { configurable: true, value: 100 } }); + await act(async () => { await vi.advanceTimersByTimeAsync(650); }); + log.scrollTop = 350; + await act(async () => log.dispatchEvent(new Event('scroll', { bubbles: true }))); + await act(async () => answer.resolve('Declares value with the value 1.')); + expect(log.scrollTop).toBe(350); + await type('A draft'); + await render({ ...renderedProps, readerContext: reader('const value = 1;') }); + expect(log.scrollTop).toBe(350); + expect(container.querySelector('.cbm-chat-jump')?.textContent).toBe('Current selection ↑'); + await click('Current selection ↑'); + expect(log.scrollTop).toBe(0); + }); + + it('follows manual replies after an explanation while respecting a later history scroll', async () => { + vi.useFakeTimers(); const { props, runtime } = fixture(); + await render({ ...props, proactive: true, readerContext: reader('const value = 1;') }); + await click('Download & load'); + await act(async () => { await vi.advanceTimersByTimeAsync(650); }); + const log = container.querySelector('.cbm-chat-transcript') as HTMLDivElement; + let height = 1000; + Object.defineProperties(log, { scrollHeight: { configurable: true, get: () => height }, clientHeight: { configurable: true, value: 100 } }); + log.scrollTop = 0; + await act(async () => log.dispatchEvent(new Event('scroll', { bubbles: true }))); + const answer = deferred(); let stream!: (chunk: string) => void; + runtime.chat.mockImplementationOnce((_messages, onToken) => { stream = onToken; return answer.promise; }); + await type('Explain more'); await click('Send ↑'); + expect(log.scrollTop).toBe(1000); + height = 1200; + await act(async () => stream('Manual response.')); + expect(log.scrollTop).toBe(1200); + log.scrollTop = 200; + await act(async () => log.dispatchEvent(new Event('scroll', { bubbles: true }))); + await act(async () => stream(' More detail.')); + expect(log.scrollTop).toBe(200); + expect(button('Latest answer ↓')).toBeDefined(); + await act(async () => answer.resolve('Manual response. More detail.')); + expect(log.scrollTop).toBe(200); + }); + + it('renders a useful generated explanation as Markdown without requiring citation JSON', async () => { + vi.useFakeTimers(); const { props, runtime } = fixture(); + runtime.chat.mockResolvedValueOnce('Casts `CBM_NOT_FOUND` to an unsigned integer and assigns it to `u.i`.'); await render({ ...props, proactive: true, readerContext: reader('u.i = (uintptr_t)CBM_NOT_FOUND;', 'selection') }); await click('Download & load'); await act(async () => { await vi.advanceTimersByTimeAsync(650); }); const card = container.querySelector('[aria-label="Current selection explanation"]'); - expect(card?.textContent).not.toContain('Invented destructor behavior.'); - expect(card?.textContent).toContain('Source evidence'); - expect(card?.textContent).toContain('CBM_NOT_FOUND'); + expect(card?.textContent).toContain('Casts CBM_NOT_FOUND'); + expect(card?.querySelector('code')?.textContent).toBe('CBM_NOT_FOUND'); + expect(card?.textContent).not.toContain('A generated explanation is unavailable'); + expect(card?.querySelector('details')?.open).toBe(false); + expect(card?.firstElementChild).toBe(card?.querySelector('.cbm-chat-response-source')); + expect(card?.querySelector('.cbm-chat-source-content pre')?.textContent).toContain('u.i = (uintptr_t)CBM_NOT_FOUND;'); + expect(container.querySelector('.cbm-chat-composer pre')).toBeNull(); }); it('does not promote an automatic model guess into follow-up instructions', async () => { @@ -601,6 +867,7 @@ describe('live-browser regressions', () => { expect(runtime.dispose).toHaveBeenCalledOnce(); expect(onAgentStateChange).toHaveBeenLastCalledWith('error'); expect(container.textContent).toContain('Reload'); + expect(document.querySelector('dialog')).toBeNull(); }); it('prepares a bounded, disclosed excerpt for an oversized file before inference', async () => { diff --git a/graph-ui/src/browser-ai/BrowserChatDock.tsx b/graph-ui/src/browser-ai/BrowserChatDock.tsx index cc2b2c9d5..b17771f1c 100644 --- a/graph-ui/src/browser-ai/BrowserChatDock.tsx +++ b/graph-ui/src/browser-ai/BrowserChatDock.tsx @@ -1,22 +1,26 @@ -import { useEffect, useMemo, useRef, useState, type FormEvent, type JSX } from 'react'; +import { useEffect, useMemo, useRef, useState, type FormEvent, type JSX, type ReactNode } from 'react'; import type { BrowserAiProgress } from './browser-ai-controller'; import { createBrowserChatRuntime, type BrowserChatRuntime } from './browser-ai-runtime'; import { BROWSER_MODELS, removeBrowserModelCache } from './model-policy'; import { buildChatMessages, selectionLocation, snapshotAttachment, snapshotReaderContext, type BrowserChatAttachment, type BrowserChatContext, type BrowserChatReaderContext, type BrowserChatTurn } from './chat-model'; import { explanationInput, EXPLANATION_DELAY_MS, type ExplanationInput } from './proactive-selection'; import { prepareExplanationContext, type PreparedExplanationContext } from './explanation-context'; -import { AUTO_INPUT_TOKENS, AUTO_OUTPUT_TOKENS, CHAT_INPUT_TOKENS, citedInterpretation, explanationMessages, formatExplanationEvidence } from './explanation-response'; +import { AUTO_INPUT_TOKENS, AUTO_OUTPUT_TOKENS, CHAT_INPUT_TOKENS, citedInterpretation, parseExplanationResponse, explanationMessages, formatExplanationEvidence } from './explanation-response'; import { isGpuRuntimeFailure, BrowserRuntimeFatalError } from './runtime-fault'; import ChatMarkdown from './ChatMarkdown'; +import AgentSettingsDialog from './AgentSettingsDialog'; +import { useChatHistory } from './use-chat-history'; import './browser-chat.css'; export type { BrowserChatAttachment, BrowserChatContext, BrowserChatReaderContext, BrowserChatSource } from './chat-model'; export interface BrowserChatDockProps { proactiveSelection?: BrowserChatContext; selectionScope?: string; + historyKey?: string; proactive?: boolean; onAgentStateChange?: (state: 'off' | 'loading' | 'active' | 'busy' | 'error') => void; settingsRequest?: number; + onAgentModelChange?: (name: string) => void; open: boolean; onClose: () => void; showCollapsed?: boolean; @@ -34,6 +38,7 @@ export interface BrowserChatDockProps { } type Phase = 'off' | 'preparing' | 'ready' | 'counting' | 'generating' | 'removing'; +type ChatTurn = BrowserChatTurn & { evidence?: PreparedExplanationContext }; const initialModel = BROWSER_MODELS.find(model => model.availability === 'available')!; const messageOf = (error: unknown): string => error instanceof Error ? error.message : String(error); const sizeLabel = (bytes: number): string => bytes >= 1_000_000_000 ? `${(bytes / 1_000_000_000).toFixed(2)} GB` : `${Math.ceil(bytes / 1_000_000)} MB`; @@ -46,16 +51,20 @@ function Attachment({ attachment, label }: { attachment: BrowserChatAttachment; ; } -function ReaderContext({ context }: { context: BrowserChatReaderContext }): JSX.Element { - const current = snapshotReaderContext(context)!; - const source = current.source; - return
- {source ? <>
- {source.kind === 'selection' ? 'Selected code' : 'Current file'}{source.kind === 'selection' ? selectionLocation(source) : source.path} -
{source.text}
{source.project} · Source {source.sourceVersion} -
{source.partial &&

{source.partial}

} - :
{current.status === 'loading' ? 'Loading current file…' : current.status === 'empty' ? 'Open a file to include its code.' : 'Current file source is unavailable.'}{current.path && {current.path}}
} -
; +function SourceDisclosure({ children, title }: { children?: ReactNode; title?: string }): JSX.Element { + return children ?
+ Agentⓘ Source +
{children}
+
: Agent; +} + +function PacketSource({ packet, citation }: { packet: PreparedExplanationContext; citation?: ReturnType }): JSX.Element { + return <> + {packet.limitations.map((limit, index) =>

{limit}

)} + {citation ?
{citation.quote}
: packet.evidence.slice(0, 3).map(item =>
+ {item.location && {item.location.path}:{item.location.startLine}–{item.location.endLine}}
{item.text}
+
)} + ; } function ContextSnapshot({ context }: { context: BrowserChatContext }): JSX.Element { @@ -63,11 +72,16 @@ function ContextSnapshot({ context }: { context: BrowserChatContext }): JSX.Elem } /** Keep mounted when collapsed: state and worker lifetime are independent of visibility. */ -export default function BrowserChatDock({ proactiveSelection, selectionScope = "", proactive = true, onAgentStateChange, settingsRequest = 0, open, onClose, showCollapsed = false, onOpen, attachment, readerContext, context = [], pendingContext, onContextConsumed, onContextRemoved, onAttachmentConsumed, onAttachmentRemoved, createRuntime = createBrowserChatRuntime, removeCache = removeBrowserModelCache }: BrowserChatDockProps): JSX.Element { +export default function BrowserChatDock({ proactiveSelection, selectionScope = "", historyKey, proactive = true, onAgentStateChange, onAgentModelChange, settingsRequest = 0, open, onClose, showCollapsed = false, onOpen, attachment, readerContext, context = [], pendingContext, onContextConsumed, onContextRemoved, onAttachmentConsumed, onAttachmentRemoved, createRuntime = createBrowserChatRuntime, removeCache = removeBrowserModelCache }: BrowserChatDockProps): JSX.Element { const [automatic, setAutomatic] = useState(true); - const [explanation, setExplanation] = useState<{ key: string; label: string; answer: string; status: string; error?: string; packet?: PreparedExplanationContext; citation?: ReturnType; mode?: 'interpretation' | 'evidence' }>(); + const [explanation, setExplanation] = useState<{ key: string; label: string; answer: string; status: string; error?: string; packet?: PreparedExplanationContext; citation?: ReturnType; mode?: 'interpretation' }>(); const [retryExplanation, setRetryExplanation] = useState(0); - const autoRun = useRef<{ key: string; cancelled: boolean } | undefined>(undefined); + const autoRun = useRef<{ key: string; cancelled: boolean; settled: Promise } | undefined>(undefined); + const manualRequest = useRef<{ cancelled: boolean } | undefined>(undefined); + const operationSettled = useRef | undefined>(undefined); + const historyProject = useRef(historyKey); + const [questionQueued, setQuestionQueued] = useState(false); + const [newExplanation, setNewExplanation] = useState(false); const lastAttempt = useRef(undefined); const selected = useMemo(() => explanationInput(selectionScope, readerContext, proactiveSelection), [selectionScope, readerContext, proactiveSelection]); const selectionRef = useRef(selected); selectionRef.current = selected; @@ -75,46 +89,71 @@ export default function BrowserChatDock({ proactiveSelection, selectionScope = " const [modelId, setModelId] = useState(initialModel.id); const model = BROWSER_MODELS.find(candidate => candidate.id === modelId) ?? initialModel; const [phase, setPhase] = useState('off'); - const [draft, setDraft] = useState(''); - const [turns, setTurns] = useState([]); + const { draft, setDraft, turns, setTurns, ready: historyReady, historyNotice, clearHistory } = useChatHistory(historyKey); const [error, setError] = useState(); const [runtimeFailed, setRuntimeFailed] = useState(false); - const [contextNotice, setContextNotice] = useState(); const [notice, setNotice] = useState(); const [progress, setProgress] = useState(); const [downloaded, setDownloaded] = useState>(() => new Set()); - const [tokenCount, setTokenCount] = useState(); - const [showModels, setShowModels] = useState(true); + const [settingsOpen, setSettingsOpen] = useState(false); const [newOutput, setNewOutput] = useState(false); const [stopping, setStopping] = useState(false); const [selectedContext, setSelectedContext] = useState([]); const [handledContextId, setHandledContextId] = useState(); const graphSelection = pendingContext?.id !== handledContextId ? pendingContext : undefined; const manualAttachment = readerContext === undefined ? attachment : undefined; + const currentReader = snapshotReaderContext(readerContext); const runtime = useRef(undefined); const epoch = useRef(0); const pending = useRef(false); const stopRequested = useRef(false); - const nextTurn = useRef(0); const activeTurn = useRef(undefined); const transcript = useRef(null); const followOutput = useRef(true); + const followExplanation = useRef(false); const input = useRef(null); const busy = phase === 'preparing' || phase === 'counting' || phase === 'generating' || phase === 'removing'; useEffect(() => () => { epoch.current += 1; runtime.current?.dispose(); runtime.current = undefined; }, []); + useEffect(() => { + if (historyProject.current === historyKey) return; + historyProject.current = historyKey; + resetProject(); + setSelectedContext([]); setHandledContextId(undefined); setNewExplanation(false); + }, [historyKey]); + useEffect(() => { + // Navigation must not pull the reader away from a question being answered. + if (manualRequest.current) { setNewExplanation(!!selected); return; } + followExplanation.current = open && proactive && automatic && !!selected; + if (followExplanation.current) { + if (transcript.current) transcript.current.scrollTop = 0; + setNewOutput(false); setNewExplanation(false); + } + }, [selected?.key, open, proactive, automatic]); + useEffect(() => { + // The current explanation precedes manual turns, so its beginning is at the top. + // Keep it there as it completes unless the reader has deliberately scrolled away. + if (open && followExplanation.current && transcript.current) transcript.current.scrollTop = 0; + }, [explanation, open, phase]); useEffect(() => { const element = transcript.current; - if (!element || !open) return; + if (!element || !open || followExplanation.current) return; if (followOutput.current) { element.scrollTop = element.scrollHeight; setNewOutput(false); } - else if (phase === 'generating') setNewOutput(true); - }, [turns, open, phase]); + else if (turns.some(turn => turn.status === 'generating')) setNewOutput(true); + }, [turns, open]); useEffect(() => { if (open && (manualAttachment || graphSelection)) input.current?.focus(); }, [open, manualAttachment?.id, graphSelection?.id]); + useEffect(() => { + const element = input.current; + if (!element || !open) return; + element.style.height = 'auto'; + element.style.height = `${Math.min(104, Math.max(32, element.scrollHeight))}px`; + }, [draft, open, phase]); const agentState = phase === 'off' ? error ? 'error' : 'off' : phase === 'preparing' || phase === 'removing' ? 'loading' : phase === 'ready' ? 'active' : 'busy'; useEffect(() => { onAgentStateChange?.(agentState); }, [agentState, onAgentStateChange]); + useEffect(() => { onAgentModelChange?.(model.displayName); }, [model.displayName, onAgentModelChange]); useEffect(() => { - if (settingsSeen.current !== settingsRequest) { settingsSeen.current = settingsRequest; setShowModels(true); } + if (settingsSeen.current !== settingsRequest) { settingsSeen.current = settingsRequest; setSettingsOpen(true); } }, [settingsRequest]); useEffect(() => { const run = autoRun.current; @@ -123,18 +162,21 @@ export default function BrowserChatDock({ proactiveSelection, selectionScope = " } }, [selected?.key, automatic, proactive]); useEffect(() => { - if (!proactive || !automatic || !selected || phase !== 'ready' || draft.trim() || pending.current || lastAttempt.current === selected.key) return; + if (!historyReady || !proactive || !automatic || !selected || phase !== 'ready' || manualRequest.current || pending.current || lastAttempt.current === selected.key) return; const snapshot = selected; const timer = setTimeout(() => { void explain(snapshot); }, EXPLANATION_DELAY_MS); return () => clearTimeout(timer); - }, [selected?.key, phase, automatic, proactive, draft, retryExplanation]); + }, [selected?.key, phase, automatic, proactive, retryExplanation, historyReady]); const explain = async (snapshot: ExplanationInput): Promise => { const currentRuntime = runtime.current; - if (!currentRuntime || pending.current || selectionRef.current?.key !== snapshot.key) return; + if (!historyReady || !currentRuntime || pending.current || manualRequest.current || selectionRef.current?.key !== snapshot.key) return; pending.current = true; stopRequested.current = false; setStopping(false); const ticket = ++epoch.current; - const run = { key: snapshot.key, cancelled: false }; autoRun.current = run; lastAttempt.current = snapshot.key; + let settle!: () => void; + const settled = new Promise(resolve => { settle = resolve; }); + operationSettled.current = settled; + const run = { key: snapshot.key, cancelled: false, settled }; autoRun.current = run; lastAttempt.current = snapshot.key; const valid = () => epoch.current === ticket && !run.cancelled && !stopRequested.current && selectionRef.current?.key === snapshot.key; setExplanation({ key: snapshot.key, label: snapshot.label, answer: '', status: 'generating' }); setPhase('counting'); @@ -150,17 +192,19 @@ export default function BrowserChatDock({ proactiveSelection, selectionScope = " if (!valid()) return; } if (!packet.evidence.length || count > Math.min(AUTO_INPUT_TOKENS, model.contextTokens - AUTO_OUTPUT_TOKENS)) { - setExplanation({ key: snapshot.key, label: snapshot.label, answer: packet.fallback, status: 'complete', mode: 'evidence', packet }); + setExplanation({ key: snapshot.key, label: snapshot.label, answer: '', status: 'error', packet, error: !packet.evidence.length ? 'No source or graph evidence is available for this selection.' : 'This selection is too large for the local agent. Select a smaller code range and try again.' }); return; } setExplanation({ key: snapshot.key, label: snapshot.label, answer: '', status: 'generating', packet }); setPhase('generating'); - // Buffer structured automatic output: do not flash unchecked model guesses while streaming. - const answer = await currentRuntime.chat(request, () => {}, { maxOutputTokens: AUTO_OUTPUT_TOKENS }); + // Keep an explanation together and ignore output from superseded selections. + const answer = await currentRuntime.chat(request, () => {}, { maxOutputTokens: AUTO_OUTPUT_TOKENS, generationProfile: 'automatic-explanation' }); if (valid()) { - const citation = citedInterpretation(answer, packet); - setExplanation({ key: snapshot.key, label: snapshot.label, answer: citation?.claim ?? packet.fallback, - status: 'complete', mode: citation ? 'interpretation' : 'evidence', packet, citation }); + const result = parseExplanationResponse(answer, packet); + if (!followExplanation.current) setNewExplanation(true); + setExplanation(result.status === 'generated' + ? { key: snapshot.key, label: snapshot.label, answer: result.markdown, status: 'complete', mode: 'interpretation', packet, citation: result.citation } + : { key: snapshot.key, label: snapshot.label, answer: '', status: 'error', packet, error: result.reason }); } } catch (failure) { if (epoch.current === ticket && isGpuRuntimeFailure(failure)) { invalidateRuntime(failure); return; } @@ -170,9 +214,35 @@ export default function BrowserChatDock({ proactiveSelection, selectionScope = " if (run.cancelled || stopRequested.current) setExplanation(previous => previous?.key === snapshot.key ? { ...previous, status: 'stopped' } : previous); autoRun.current = undefined; pending.current = false; setStopping(false); setPhase('ready'); } + // Cancellation only asks the worker to stop. A waiting question must not + // use the worker until token counting / generation has actually settled. + if (operationSettled.current === settled) operationSettled.current = undefined; + settle(); } }; + const resetProject = (): void => { + if (!runtime.current || phase === 'preparing' || phase === 'removing') { release(); return; } + const retainedRuntime = runtime.current; + const settled = operationSettled.current; + if (autoRun.current) autoRun.current.cancelled = true; + if (manualRequest.current) manualRequest.current.cancelled = true; + autoRun.current = undefined; manualRequest.current = undefined; + lastAttempt.current = undefined; activeTurn.current = undefined; + setQuestionQueued(false); setExplanation(undefined); setError(undefined); + const ticket = ++epoch.current; + pending.current = !!settled; stopRequested.current = !!settled; setStopping(!!settled); + if (!settled) { setPhase('ready'); return; } + // Retain downloaded weights across projects, but drain the old worker request + // before making the new project's context eligible for inference. + setPhase('counting'); retainedRuntime.stop(); + void settled.then(() => { + if (epoch.current !== ticket || runtime.current !== retainedRuntime) return; + pending.current = false; stopRequested.current = false; setStopping(false); setPhase('ready'); + }); + }; const release = (): void => { + if (manualRequest.current) manualRequest.current.cancelled = true; + manualRequest.current = undefined; operationSettled.current = undefined; setQuestionQueued(false); if (autoRun.current) autoRun.current.cancelled = true; autoRun.current = undefined; lastAttempt.current = undefined; setExplanation(undefined); const id = activeTurn.current; @@ -180,12 +250,12 @@ export default function BrowserChatDock({ proactiveSelection, selectionScope = " epoch.current += 1; pending.current = false; activeTurn.current = undefined; stopRequested.current = false; setStopping(false); runtime.current?.dispose(); runtime.current = undefined; - setProgress(undefined); setTokenCount(undefined); setPhase('off'); setRuntimeFailed(false); + setProgress(undefined); setPhase('off'); setRuntimeFailed(false); }; const invalidateRuntime = (failure: unknown): void => { const id = activeTurn.current; const message = new BrowserRuntimeFatalError(messageOf(failure)).message; - release(); setRuntimeFailed(true); setError(message); setShowModels(true); + release(); setRuntimeFailed(true); setError(message); if (id) setTurns(previous => previous.map(turn => turn.id === id ? { ...turn, status: 'error', error: message } : turn)); }; const prepare = async (): Promise => { @@ -200,7 +270,7 @@ export default function BrowserChatDock({ proactiveSelection, selectionScope = " await nextRuntime.prepare(value => { if (epoch.current === ticket) setProgress(value); }); if (epoch.current !== ticket) return; setDownloaded(previous => new Set(previous).add(model.id)); - setPhase('ready'); setProgress(undefined); setShowModels(false); pending.current = false; + setPhase('ready'); setProgress(undefined); setSettingsOpen(false); pending.current = false; } catch (failure) { if (epoch.current !== ticket) return; release(); setError(messageOf(failure)); @@ -208,29 +278,52 @@ export default function BrowserChatDock({ proactiveSelection, selectionScope = " }; const stop = (): void => { if (phase === 'preparing') { release(); setNotice('Download stopped. Cached files can be reused or deleted.'); return; } + if (manualRequest.current && autoRun.current) { + manualRequest.current.cancelled = true; manualRequest.current = undefined; setQuestionQueued(false); + lastAttempt.current = selectionRef.current?.key; + } if ((phase !== 'generating' && phase !== 'counting') || stopRequested.current) return; stopRequested.current = true; setStopping(true); if (phase === 'generating') runtime.current?.stop(); // Stay busy until the in-flight GPU operation settles; a second send must not overlap it. }; - const send = async (retry?: BrowserChatTurn): Promise => { + const send = async (retry?: ChatTurn): Promise => { const currentRuntime = runtime.current; - if (!currentRuntime || phase !== 'ready' || pending.current || (!retry && !draft.trim())) return; + const automaticRun = autoRun.current; + if (!historyReady || !currentRuntime || manualRequest.current || stopping || (!automaticRun && (phase !== 'ready' || pending.current)) || (!retry && !draft.trim())) return; if (!retry && readerContext?.status === 'loading') return; - pending.current = true; stopRequested.current = false; setStopping(false); - const ticket = ++epoch.current; + // A manual question switches from reading the current explanation to its reply. + // Preserve a deliberate history scroll; only automatic explanation following resets it. + if (followExplanation.current) followOutput.current = true; + followExplanation.current = false; + // Capture the question and every source before yielding to cancellation. + // Later navigation changes the live explainer, never this request. const prompt = retry?.prompt ?? draft; const source = snapshotAttachment(retry ? retry.attachment : manualAttachment); const reader = snapshotReaderContext(retry ? retry.readerContext : readerContext); const selectedGraph = !retry && graphSelection ? { ...graphSelection } : undefined; const nextContext = selectedGraph ? [...selectedContext.filter(item => item.id !== selectedGraph.id), selectedGraph] : selectedContext; const extra = (retry ? retry.context ?? [] : nextContext).map(item => ({ ...item })); - let packet: PreparedExplanationContext | undefined; - const currentGraph = !reader && proactiveSelection ? [proactiveSelection] : []; + let packet = retry?.evidence; + const currentGraph = !reader && proactiveSelection ? [{ ...proactiveSelection }] : []; const makeRequest = () => buildChatMessages(turns, prompt, source, extra, reader, currentGraph, packet ? formatExplanationEvidence(packet) : undefined); if (!retry && ((reader?.source?.text.length ?? 0) > 5000 || currentGraph.length)) packet = prepareExplanationContext(reader, currentGraph, 3200); let request = retry ? retry.request.map(message => ({ ...message })) : makeRequest(); - setError(undefined); setNotice(undefined); setContextNotice(undefined); setPhase('counting'); + const queued = { cancelled: false }; manualRequest.current = queued; + const waitingEpoch = epoch.current; + if (automaticRun) { + automaticRun.cancelled = true; lastAttempt.current = undefined; + setQuestionQueued(true); currentRuntime.stop(); + await automaticRun.settled; + if (queued.cancelled || manualRequest.current !== queued || epoch.current !== waitingEpoch || runtime.current !== currentRuntime) return; + } + setQuestionQueued(false); + pending.current = true; stopRequested.current = false; setStopping(false); + const ticket = ++epoch.current; + let settle!: () => void; + const settled = new Promise(resolve => { settle = resolve; }); + operationSettled.current = settled; + setError(undefined); setNotice(undefined); setPhase('counting'); try { let count = await currentRuntime.countTokens(request); if (epoch.current !== ticket || stopRequested.current) return; @@ -240,14 +333,12 @@ export default function BrowserChatDock({ proactiveSelection, selectionScope = " request = makeRequest(); count = await currentRuntime.countTokens(request); if (epoch.current !== ticket || stopRequested.current) return; } - setTokenCount(count); if (count > limit) { setError(`This prompt needs ${count.toLocaleString()} input tokens; the local working limit is ${limit.toLocaleString()}. Select less code or start a new conversation. Nothing was sent or shortened without disclosure.`); return; } - if (packet) setContextNotice(packet.limitations.length ? packet.limitations.join(' ') : 'Using compact current-selection evidence.'); - const id = retry?.id ?? `local-turn-${++nextTurn.current}`; - const turn: BrowserChatTurn = { id, prompt, attachment: source, readerContext: reader, context: extra, modelId: model.id, request, answer: '', status: 'generating' }; + const id = retry?.id ?? `local-turn-${crypto.randomUUID()}`; + const turn: ChatTurn = { id, prompt, attachment: source, readerContext: reader, context: extra, evidence: packet, modelId: model.id, request, answer: '', status: 'generating' }; activeTurn.current = id; if (retry) setTurns(previous => previous.map(item => item.id === id ? turn : item)); else { @@ -272,7 +363,10 @@ export default function BrowserChatDock({ proactiveSelection, selectionScope = " if (id) setTurns(previous => previous.map(turn => turn.id === id ? { ...turn, status: stopRequested.current ? 'stopped' : 'error', error: stopRequested.current ? undefined : explanation } : turn)); else if (!stopRequested.current) setError(explanation); } finally { + if (manualRequest.current === queued) manualRequest.current = undefined; if (epoch.current === ticket) { activeTurn.current = undefined; pending.current = false; setStopping(false); setPhase('ready'); } + if (operationSettled.current === settled) operationSettled.current = undefined; + settle(); } }; const deleteCache = async (): Promise => { @@ -289,33 +383,19 @@ export default function BrowserChatDock({ proactiveSelection, selectionScope = " finally { if (epoch.current === ticket) { pending.current = false; setPhase('off'); } } }; const clear = (): void => { - if (busy || !turns.length || !window.confirm('Clear this local conversation? This removes its messages and attached code snapshots.')) return; - setTurns([]); setTokenCount(undefined); setError(undefined); setNewOutput(false); followOutput.current = true; + if (busy || !historyReady || (!turns.length && !draft) || !window.confirm('Clear this project’s local conversation and saved history? This removes its messages, draft and attached code snapshots.')) return; + void clearHistory(); setError(undefined); setNewOutput(false); followOutput.current = true; }; const submit = (event: FormEvent): void => { event.preventDefault(); void send(); }; const status = stopping ? 'Stopping' : phase === 'preparing' ? 'Loading' : phase === 'counting' ? 'Checking context' : phase === 'generating' ? 'Answering' : phase === 'removing' ? 'Deleting cache' : phase === 'ready' ? 'Loaded' : 'Off'; const needsEnable = phase === 'off'; - const showConversation = turns.length > 0 || phase === 'ready' || phase === 'counting' || phase === 'generating'; + const showConversation = turns.length > 0 || draft.length > 0 || phase === 'ready' || phase === 'counting' || phase === 'generating'; - if (!open && showCollapsed) return ; - - return