diff --git a/.storybook/main.ts b/.storybook/main.ts index 4c3b21c..87fb1bb 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -1,10 +1,10 @@ -import type { StorybookConfig } from '@storybook/react-vite' +import type { StorybookConfig } from '@storybook/svelte-vite' const config: StorybookConfig = { - stories: ['../frontend/src/**/*.stories.@(ts|tsx)'], + stories: ['../frontend/src/**/*.stories.@(ts|svelte.ts)'], addons: ['@storybook/addon-a11y', '@storybook/addon-vitest'], framework: { - name: '@storybook/react-vite', + name: '@storybook/svelte-vite', options: {}, }, } diff --git a/.storybook/preview.ts b/.storybook/preview.ts index 12d6f95..7357551 100644 --- a/.storybook/preview.ts +++ b/.storybook/preview.ts @@ -1,4 +1,4 @@ -import type { Preview } from '@storybook/react-vite' +import type { Preview } from '@storybook/svelte-vite' import '../frontend/src/index.css' const preview: Preview = { diff --git a/backend/src/routes/agents.test.ts b/backend/src/routes/agents.test.ts index 9eb8238..616f9c8 100644 --- a/backend/src/routes/agents.test.ts +++ b/backend/src/routes/agents.test.ts @@ -6,6 +6,14 @@ import { Hono } from 'hono' import { agentsRoutes } from './agents.js' import type { AgentRegistry } from '../agents/registry.js' +const { getHistorySourceDescriptorsMock } = vi.hoisted(() => ({ + getHistorySourceDescriptorsMock: vi.fn<(...args: unknown[]) => unknown[]>(() => []), +})) + +vi.mock('../history/index.js', () => ({ + getHistorySourceDescriptors: getHistorySourceDescriptorsMock, +})) + function makeTempDir(): string { const dir = join(tmpdir(), `acp-agents-routes-test-${Date.now()}-${Math.random()}`) mkdirSync(dir, { recursive: true }) @@ -105,6 +113,8 @@ describe('agents routes', () => { let origEnv: string | undefined beforeEach(() => { + getHistorySourceDescriptorsMock.mockReset() + getHistorySourceDescriptorsMock.mockReturnValue([]) tempDir = makeTempDir() origEnv = process.env['ACP_HISTORY_SOURCES_CONFIG_PATH'] process.env['ACP_HISTORY_SOURCES_CONFIG_PATH'] = join(tempDir, 'history-sources.json') @@ -223,5 +233,82 @@ describe('agents routes', () => { expect(res.status).toBe(404) }) + + it('GET /history-sources/status returns provider discovery summaries', async () => { + getHistorySourceDescriptorsMock.mockImplementation((agentIdRaw: unknown) => { + const agentId = String(agentIdRaw) + if (agentId === 'copilot') { + return [ + { + id: 'copilot:vscode_workspace_db:/x/state.vscdb', + backendId: 'copilot', + providerId: 'copilot', + kind: 'vscode_workspace_db', + path: '/x/state.vscdb', + platform: 'linux', + access: 'readable', + signal: 'contains_history', + discoveredBy: 'manual', + sessionCount: 7, + }, + ] + } + + if (agentId === 'gemini-cli') { + return [ + { + id: 'gemini:tmp:/tmp/google-generative-ai-cli', + backendId: 'gemini-cli', + providerId: 'gemini-cli', + kind: 'gemini_tmp_dir', + path: '/tmp/google-generative-ai-cli', + platform: 'linux', + access: 'missing', + signal: 'unknown', + discoveredBy: 'auto', + }, + ] + } + + return [] + }) + + const registry = createRegistryStub() + const app = new Hono().route('/api', agentsRoutes(registry)) + + const res = await app.request('/api/history-sources/status') + expect(res.status).toBe(200) + + const body = (await res.json()) as Array<{ + provider: string + discoveredSources: Array<{ id: string }> + summary: { + readable: number + missing: number + invalid: number + containsHistory: number + totalSessions: number + } + }> + + const copilot = body.find((item) => item.provider === 'copilot') + expect(copilot?.discoveredSources).toHaveLength(1) + expect(copilot?.summary).toMatchObject({ + readable: 1, + missing: 0, + invalid: 0, + containsHistory: 1, + totalSessions: 7, + }) + + const gemini = body.find((item) => item.provider === 'gemini') + expect(gemini?.summary).toMatchObject({ + readable: 0, + missing: 1, + invalid: 0, + containsHistory: 0, + totalSessions: 0, + }) + }) }) }) diff --git a/backend/src/routes/agents.ts b/backend/src/routes/agents.ts index d3ae80a..b77affc 100644 --- a/backend/src/routes/agents.ts +++ b/backend/src/routes/agents.ts @@ -5,9 +5,29 @@ import { updateHistorySource, type HistoryProvider, } from '../history/sources-config.js' +import { getHistorySourceDescriptors } from '../history/index.js' +import type { HistorySourceDescriptor } from '../agents/types.js' const VALID_PROVIDERS = new Set(['gemini', 'copilot', 'opencode']) +const PROVIDER_AGENT_ID: Record = { + copilot: 'copilot', + gemini: 'gemini-cli', + opencode: 'opencode', +} + +interface HistorySourceStatus { + provider: HistoryProvider + discoveredSources: HistorySourceDescriptor[] + summary: { + readable: number + missing: number + invalid: number + containsHistory: number + totalSessions: number + } +} + export function agentsRoutes(registry: AgentRegistry): Hono { const app = new Hono() @@ -67,6 +87,38 @@ export function agentsRoutes(registry: AgentRegistry): Hono { return c.json(sources) }) + app.get('/history-sources/status', (c) => { + const sources = readHistorySourcesConfig() + const status: HistorySourceStatus[] = sources.map((source) => { + const agentId = PROVIDER_AGENT_ID[source.provider] + const discoveredSources = getHistorySourceDescriptors( + agentId, + source.paths, + source.cliPaths ?? [] + ) + + const summary = discoveredSources.reduce( + (acc, descriptor) => { + if (descriptor.access === 'readable') acc.readable += 1 + if (descriptor.access === 'missing') acc.missing += 1 + if (descriptor.access === 'invalid') acc.invalid += 1 + if (descriptor.signal === 'contains_history') acc.containsHistory += 1 + acc.totalSessions += descriptor.sessionCount ?? 0 + return acc + }, + { readable: 0, missing: 0, invalid: 0, containsHistory: 0, totalSessions: 0 } + ) + + return { + provider: source.provider, + discoveredSources, + summary, + } + }) + + return c.json(status) + }) + app.patch('/history-sources/:provider', async (c) => { const provider = c.req.param('provider') diff --git a/eslint.config.mjs b/eslint.config.mjs index 03df307..bae362e 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,8 +1,8 @@ import js from '@eslint/js' import globals from 'globals' -import reactHooks from 'eslint-plugin-react-hooks' -import reactRefresh from 'eslint-plugin-react-refresh' import tseslint from 'typescript-eslint' +import sveltePlugin from 'eslint-plugin-svelte' +import svelteParser from 'svelte-eslint-parser' export default tseslint.config( { @@ -18,7 +18,7 @@ export default tseslint.config( }, { extends: [js.configs.recommended, ...tseslint.configs.recommended], - files: ['**/*.{ts,tsx}'], + files: ['**/*.{ts,mts,cts}'], languageOptions: { ecmaVersion: 2022, globals: { @@ -26,13 +26,25 @@ export default tseslint.config( ...globals.node, }, }, - plugins: { - 'react-hooks': reactHooks, - 'react-refresh': reactRefresh, - }, - rules: { - ...reactHooks.configs.recommended.rules, - 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], + }, + { + extends: [ + js.configs.recommended, + ...tseslint.configs.recommended, + ...sveltePlugin.configs['flat/recommended'], + ], + files: ['**/*.svelte', '**/*.svelte.ts'], + languageOptions: { + ecmaVersion: 2022, + globals: { + ...globals.browser, + ...globals.node, + }, + parser: svelteParser, + parserOptions: { + parser: tseslint.parser, + extraFileExtensions: ['.svelte'], + }, }, } ) diff --git a/frontend/index.html b/frontend/index.html index a463892..6c14b4a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -7,6 +7,6 @@
- + diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte new file mode 100644 index 0000000..9eadd50 --- /dev/null +++ b/frontend/src/App.svelte @@ -0,0 +1,33 @@ + + +{#if route === 'settings'} + +{:else} + +{/if} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx deleted file mode 100644 index a92f8c3..0000000 --- a/frontend/src/App.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { RouterProvider } from '@tanstack/react-router' -import { router, type AppRouter } from './router.js' - -interface AppProps { - routerInstance?: AppRouter -} - -export function App({ routerInstance = router }: AppProps) { - return -} diff --git a/frontend/src/components/chat/AttachmentGroup.svelte b/frontend/src/components/chat/AttachmentGroup.svelte new file mode 100644 index 0000000..ee73735 --- /dev/null +++ b/frontend/src/components/chat/AttachmentGroup.svelte @@ -0,0 +1,143 @@ + + +
+ {#each blocks as block, i (`${block.payload.filename}-${i}`)} + {@const isImage = block.payload.mime.startsWith('image/')} + {@const imageIndex = imageBlocks.findIndex((c) => c.payload.url === block.payload.url)} +
+ {#if isImage} + + {:else} +
+ File +
+ {/if} + +
+

{block.payload.filename}

+

{block.payload.mime}

+
+ + + Download + +
+ {/each} +
+ +{#if viewerIndex !== null && imageBlocks[viewerIndex]} + +{/if} diff --git a/frontend/src/components/chat/ChatComposer.stories.tsx b/frontend/src/components/chat/ChatComposer.stories.tsx deleted file mode 100644 index 0ab977b..0000000 --- a/frontend/src/components/chat/ChatComposer.stories.tsx +++ /dev/null @@ -1,178 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react-vite' -import { useState } from 'react' -import { ChatComposer } from './ChatComposer.js' -import type { ModelState } from '../../hooks/useAgUiChat.js' - -function ComposerStory(props: { - disabled: boolean - canSubmit: boolean - initialValue?: string - helperText?: string - modelState?: ModelState | null -}) { - const [value, setValue] = useState(props.initialValue ?? '') - const [modelState, setModelState] = useState(props.modelState ?? null) - - const handleModelChange = (modelId: string) => { - setModelState((prev) => (prev ? { ...prev, currentModelId: modelId } : prev)) - } - - return ( -
- event.preventDefault()} - disabled={props.disabled} - canSubmit={props.canSubmit} - helperText={props.helperText} - modelState={modelState} - onModelChange={handleModelChange} - /> -
- ) -} - -const meta = { - title: 'Chat/ChatComposer', - component: ComposerStory, - args: { - disabled: false, - canSubmit: true, - initialValue: 'Say hello in one short sentence.', - helperText: 'Streaming responses appear in the workspace as the agent thinks and replies.', - }, -} satisfies Meta - -export default meta -type Story = StoryObj - -export const Ready: Story = {} - -export const Disabled: Story = { - args: { - disabled: true, - canSubmit: false, - initialValue: '', - helperText: 'Choose a project context and start a new session before sending a message.', - }, -} - -// History-session panel stories (rendered directly, not via ComposerStory wrapper) -export const HistorySessionWithResumeAndFork: StoryObj = { - render: () => ( -
- {}} - onSubmit={(e) => e.preventDefault()} - disabled={false} - canSubmit={false} - isHistorySession - resumeAgent={{ id: 'opencode', name: 'opencode' }} - forkAgents={[ - { id: 'copilot', name: 'GitHub Copilot' }, - { id: 'gemini', name: 'Gemini CLI' }, - ]} - onResume={(id) => console.log('resume', id)} - onFork={(id) => console.log('fork', id)} - /> -
- ), -} - -export const HistorySessionResumeOnly: StoryObj = { - render: () => ( -
- {}} - onSubmit={(e) => e.preventDefault()} - disabled={false} - canSubmit={false} - isHistorySession - resumeAgent={{ id: 'opencode', name: 'opencode' }} - forkAgents={[]} - onResume={(id) => console.log('resume', id)} - /> -
- ), -} - -export const HistorySessionForkOnly: StoryObj = { - render: () => ( -
- {}} - onSubmit={(e) => e.preventDefault()} - disabled={false} - canSubmit={false} - isHistorySession - forkAgents={[{ id: 'copilot', name: 'GitHub Copilot' }]} - onFork={(id) => console.log('fork', id)} - /> -
- ), -} - -export const HistorySessionNoAgents: StoryObj = { - render: () => ( -
- {}} - onSubmit={(e) => e.preventDefault()} - disabled={false} - canSubmit={false} - isHistorySession - /> -
- ), -} - -export const HistorySessionLoading: StoryObj = { - render: () => ( -
- {}} - onSubmit={(e) => e.preventDefault()} - disabled={false} - canSubmit={false} - isHistorySession - historyLoading - /> -
- ), -} - -export const WithModelSelector: Story = { - args: { - disabled: false, - canSubmit: true, - initialValue: 'Summarise the latest changes in one paragraph.', - helperText: undefined, - modelState: { - availableModels: [ - { modelId: 'gpt-4o', name: 'GPT-4o' }, - { modelId: 'gpt-4o-mini', name: 'GPT-4o mini' }, - { modelId: 'o3', name: 'o3' }, - ], - currentModelId: 'gpt-4o', - }, - }, -} - -export const WithSingleModel: Story = { - args: { - disabled: false, - canSubmit: true, - initialValue: 'Say hello in one short sentence.', - helperText: undefined, - modelState: { - availableModels: [{ modelId: 'gpt-4o', name: 'GPT-4o' }], - currentModelId: 'gpt-4o', - }, - }, -} diff --git a/frontend/src/components/chat/ChatComposer.svelte b/frontend/src/components/chat/ChatComposer.svelte new file mode 100644 index 0000000..364f00c --- /dev/null +++ b/frontend/src/components/chat/ChatComposer.svelte @@ -0,0 +1,343 @@ + + +{#if isHistorySession} +
+
+ {#if historyLoading} +
+ + Loading session history… +
+ {:else} +

+ This is a read-only history session. Continue the conversation with an active agent: +

+ + {#if !hasAnyAction} +

+ No active agents available. Start an agent in Settings to continue this conversation. +

+ {:else} +
+ {#each resumableAgents as agent (agent.id)} + + {/each} +
+ {/if} + {/if} +
+
+{:else} +
+
+ + + {#if resumableAgents.length > 0} +
+ + + {#if switchOpen} + + {/if} +
+ {/if} + + +
+ + {#if showModelSelector} +
+
+ + + {#if modelOpen} +
+

+ Model +

+ {#each modelState!.availableModels as model, idx (model.modelId)} + {@const isSelected = model.modelId === modelState!.currentModelId} + {#if idx === 0} + + {:else} + + {/if} + {/each} +
+ {/if} +
+
+ {/if} + +

+ {helperText ?? 'Streaming responses appear in the workspace as the agent thinks and replies.'} +

+
+{/if} diff --git a/frontend/src/components/chat/ChatComposer.tsx b/frontend/src/components/chat/ChatComposer.tsx deleted file mode 100644 index 9927f40..0000000 --- a/frontend/src/components/chat/ChatComposer.tsx +++ /dev/null @@ -1,368 +0,0 @@ -import { useState, useEffect, useRef, type FormEvent } from 'react' -import type { ModelState } from '../../hooks/useAgUiChat.js' - -/** Minimal agent shape needed for the delegation panel. */ -interface ResumableAgent { - id: string - name: string -} - -interface ChatComposerProps { - value: string - onChange: (value: string) => void - onSubmit: (e: FormEvent) => void | Promise - disabled: boolean - canSubmit: boolean - helperText?: string - /** When true, renders a read-only history session delegation panel instead of the input. */ - isHistorySession?: boolean - /** True while the history session messages are being fetched. */ - historyLoading?: boolean - /** - * The agent that supports native session/load for this history session (same - * agent + canLoad). Rendered as the primary "Resume" action. Undefined when - * no agent supports load. - */ - resumeAgent?: ResumableAgent - /** Agents that can receive a handoff fork (all active agents except resumeAgent). */ - forkAgents?: ResumableAgent[] - /** Called when the user picks the primary resume agent (session/load path). */ - onResume?: (agentId: string) => void - /** Called when the user picks a fork agent (handoff path). */ - onFork?: (agentId: string) => void - /** - * @deprecated Use resumeAgent + forkAgents instead for history sessions. - * Still used for the live-session switch-agent popover. - */ - resumableAgents?: ResumableAgent[] - /** True while a resume/switch operation is in flight. */ - resuming?: boolean - /** Model selection state; null or undefined when the agent does not support model switching. */ - modelState?: ModelState | null - /** Called when the user selects a different model. */ - onModelChange?: (modelId: string) => void -} - -export function ChatComposer({ - value, - onChange, - onSubmit, - disabled, - canSubmit, - helperText, - isHistorySession = false, - historyLoading = false, - resumeAgent, - forkAgents = [], - onResume, - onFork, - resumableAgents = [], - resuming = false, - modelState, - onModelChange, -}: ChatComposerProps) { - const [switchOpen, setSwitchOpen] = useState(false) - const switchRef = useRef(null) - const firstMenuItemRef = useRef(null) - - const [modelOpen, setModelOpen] = useState(false) - const modelRef = useRef(null) - const firstModelItemRef = useRef(null) - - // Close the switch-agent popover when clicking outside or pressing Escape - useEffect(() => { - if (!switchOpen) return - const onMouse = (e: MouseEvent) => { - if (switchRef.current && !switchRef.current.contains(e.target as Node)) { - setSwitchOpen(false) - } - } - const onKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') setSwitchOpen(false) - } - document.addEventListener('mousedown', onMouse) - document.addEventListener('keydown', onKey) - return () => { - document.removeEventListener('mousedown', onMouse) - document.removeEventListener('keydown', onKey) - } - }, [switchOpen]) - - // Close the model popover when clicking outside or pressing Escape - useEffect(() => { - if (!modelOpen) return - const onMouse = (e: MouseEvent) => { - if (modelRef.current && !modelRef.current.contains(e.target as Node)) { - setModelOpen(false) - } - } - const onKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') setModelOpen(false) - } - document.addEventListener('mousedown', onMouse) - document.addEventListener('keydown', onKey) - return () => { - document.removeEventListener('mousedown', onMouse) - document.removeEventListener('keydown', onKey) - } - }, [modelOpen]) - - // Move focus to the first menu item when the switch-agent popover opens - useEffect(() => { - if (switchOpen) firstMenuItemRef.current?.focus() - }, [switchOpen]) - - // Move focus to the first model item when the model popover opens - useEffect(() => { - if (modelOpen) firstModelItemRef.current?.focus() - }, [modelOpen]) - - const hasAnyAction = resumeAgent != null || forkAgents.length > 0 - - if (isHistorySession) { - return ( -
-
- {historyLoading ? ( -
- - Loading session history… -
- ) : ( - <> -

- This is a read-only history session. Continue the conversation with an active agent: -

- - {!hasAnyAction ? ( -

- No active agents available. Enable and start an agent in Settings to import this - conversation. -

- ) : ( -
- {/* Primary action: native session/load — same agent, supports resume */} - {resumeAgent != null && ( - - )} - - {/* Secondary actions: handoff/fork — other active agents */} - {forkAgents.map((agent) => ( - - ))} -
- )} - - )} -
-
- ) - } - - const canSwitch = resumableAgents.length > 0 && !resuming && !disabled - const showModelSelector = - modelState != null && modelState.availableModels.length > 1 && !isHistorySession - const currentModel = modelState?.availableModels.find( - (m) => m.modelId === modelState.currentModelId - ) - - return ( -
-
- - - {/* Switch-agent popover — only shown in live sessions with other active agents */} - {resumableAgents.length > 0 && ( -
- - - {switchOpen && ( -
-

- Continue in… -

- {resumableAgents.map((agent, idx) => ( - - ))} -
- )} -
- )} - - -
- - {/* Model selector — shown when the agent advertises multiple models */} - {showModelSelector && ( -
-
- - - {modelOpen && ( -
-

- Model -

- {modelState!.availableModels.map((model, idx) => { - const isSelected = model.modelId === modelState!.currentModelId - return ( - - ) - })} -
- )} -
-
- )} - -

- {helperText ?? - 'Streaming responses appear in the workspace as the agent thinks and replies.'} -

-
- ) -} diff --git a/frontend/src/components/chat/ChatDiffView.svelte b/frontend/src/components/chat/ChatDiffView.svelte new file mode 100644 index 0000000..13c251e --- /dev/null +++ b/frontend/src/components/chat/ChatDiffView.svelte @@ -0,0 +1,163 @@ + + +{#if state === 'loading'} +
+

Diff

+

Loading project diff...

+
+{:else if state === 'error'} +
+

Diff

+

{message}

+
+{:else if state === 'git_not_found'} +
+

Diff

+

Git not available

+

+ {message ?? 'Git was not found on PATH for this backend process.'} +

+
+{:else if state === 'empty'} +
+

Diff

+

+ Working tree is clean +

+

+ No unstaged or staged changes are currently shown for this project. +

+
+{:else if parsedFiles.length === 0} +
+
+ Working Tree Diff +
+ {#if message} +
+ {message} +
+ {/if} +
{diff}
+
+{:else} +
+
+
+
+

Working Tree Diff

+

+ {parsedFiles.length} file{parsedFiles.length === 1 ? '' : 's'} changed +

+
+
+ + +{totalAdditions} + + + -{totalDeletions} + +
+
+ {#if message} +
+ {message} +
+ {/if} +
+ + {#each parsedFiles as file (file.header)} +
+
+
+
+

+ {file.displayPath} +

+

{file.header}

+
+
+ + +{file.additions} + + + -{file.deletions} + +
+
+ {#if file.metadata.length > 0} +
+ {#each file.metadata as entry (`${file.header}-${entry}`)} + + {entry} + + {/each} +
+ {/if} +
+ +
+ {#each file.hunks as hunk (`${file.header}-${hunk.header}`)} +
+
+

{hunk.header}

+ {#if hunk.context} +

{hunk.context}

+ {/if} +
+
+
+ {#each hunk.lines as line, index (`${hunk.header}-${index}-${line.oldLineNumber ?? 'x'}-${line.newLineNumber ?? 'y'}`)} +
+ + {line.oldLineNumber ?? ''} + + + {line.newLineNumber ?? ''} + + + {line.kind === 'addition' ? '+' : line.kind === 'deletion' ? '-' : line.kind === 'note' ? '\\' : ' '} + + + {line.content || ' '} + +
+ {/each} +
+
+
+ {/each} +
+
+ {/each} +
+{/if} diff --git a/frontend/src/components/chat/ChatDiffView.test.tsx b/frontend/src/components/chat/ChatDiffView.test.svelte.ts similarity index 83% rename from frontend/src/components/chat/ChatDiffView.test.tsx rename to frontend/src/components/chat/ChatDiffView.test.svelte.ts index fb2cb6f..f3da9b5 100644 --- a/frontend/src/components/chat/ChatDiffView.test.tsx +++ b/frontend/src/components/chat/ChatDiffView.test.svelte.ts @@ -1,7 +1,7 @@ // @vitest-environment happy-dom import { describe, it, expect } from 'vitest' -import { render, screen, within } from '@testing-library/react' -import { ChatDiffView } from './ChatDiffView.js' +import { render, screen, within } from '@testing-library/svelte' +import ChatDiffView from './ChatDiffView.svelte' import { parseUnifiedDiff } from './parseUnifiedDiff.js' const SAMPLE_DIFF = `diff --git a/src/app.tsx b/src/app.tsx @@ -46,7 +46,7 @@ describe('parseUnifiedDiff', () => { describe('ChatDiffView', () => { it('renders structured diff cards with file summaries', () => { - render() + render(ChatDiffView, { props: { state: 'ready', diff: SAMPLE_DIFF } }) expect(screen.getByTestId('chat-diff-view')).toBeDefined() expect(screen.getByText(/2 files changed/i)).toBeDefined() @@ -57,7 +57,7 @@ describe('ChatDiffView', () => { }) it('renders hunk lines with additions and deletions visible', () => { - render() + render(ChatDiffView, { props: { state: 'ready', diff: SAMPLE_DIFF } }) const diffView = screen.getByTestId('chat-diff-view') expect(within(diffView).getByText("import { ChatDiffView } from './chat'")).toBeDefined() @@ -67,18 +67,18 @@ describe('ChatDiffView', () => { }) it('renders the empty state when no diff text is present', () => { - render() + render(ChatDiffView, { props: { state: 'empty' } }) expect(screen.getByText('Working tree is clean')).toBeDefined() }) it('renders the git missing state message', () => { - render( - - ) + render(ChatDiffView, { + props: { + state: 'git_not_found', + message: 'Git was not found on PATH for this backend process.', + }, + }) expect(screen.getByText('Git not available')).toBeDefined() expect(screen.getByText('Git was not found on PATH for this backend process.')).toBeDefined() diff --git a/frontend/src/components/chat/ChatDiffView.tsx b/frontend/src/components/chat/ChatDiffView.tsx deleted file mode 100644 index fd815ef..0000000 --- a/frontend/src/components/chat/ChatDiffView.tsx +++ /dev/null @@ -1,200 +0,0 @@ -import { buildLineClassName, parseUnifiedDiff } from './parseUnifiedDiff.js' - -interface ChatDiffViewProps { - state: 'loading' | 'error' | 'git_not_found' | 'empty' | 'ready' - diff?: string - message?: string | null -} - -export function ChatDiffView({ state, diff = '', message = null }: ChatDiffViewProps) { - if (state === 'loading') { - return ( -
-

Diff

-

Loading project diff...

-
- ) - } - - if (state === 'error') { - return ( -
-

Diff

-

{message}

-
- ) - } - - if (state === 'git_not_found') { - return ( -
-

Diff

-

- Git not available -

-

- {message ?? 'Git was not found on PATH for this backend process.'} -

-
- ) - } - - if (state === 'empty') { - return ( -
-

Diff

-

- Working tree is clean -

-

- No unstaged or staged changes are currently shown for this project. -

-
- ) - } - - const parsedFiles = parseUnifiedDiff(diff) - if (parsedFiles.length === 0) { - return ( -
-
- Working Tree Diff -
- {message ? ( -
- {message} -
- ) : null} -
-          {diff}
-        
-
- ) - } - - const totalAdditions = parsedFiles.reduce((count, file) => count + file.additions, 0) - const totalDeletions = parsedFiles.reduce((count, file) => count + file.deletions, 0) - - return ( -
-
-
-
-

- Working Tree Diff -

-

- {parsedFiles.length} file{parsedFiles.length === 1 ? '' : 's'} changed -

-
-
- - +{totalAdditions} - - - -{totalDeletions} - -
-
- {message ? ( -
- {message} -
- ) : null} -
- - {parsedFiles.map((file) => ( -
-
-
-
-

- {file.displayPath} -

-

{file.header}

-
-
- - +{file.additions} - - - -{file.deletions} - -
-
- {file.metadata.length > 0 ? ( -
- {file.metadata.map((entry) => ( - - {entry} - - ))} -
- ) : null} -
- -
- {file.hunks.map((hunk) => ( -
-
-

{hunk.header}

- {hunk.context ?

{hunk.context}

: null} -
-
-
- {hunk.lines.map((line, index) => ( -
- - {line.oldLineNumber ?? ''} - - - {line.newLineNumber ?? ''} - - - {line.kind === 'addition' - ? '+' - : line.kind === 'deletion' - ? '-' - : line.kind === 'note' - ? '\\' - : ' '} - - - {line.content || ' '} - -
- ))} -
-
-
- ))} -
-
- ))} -
- ) -} diff --git a/frontend/src/components/chat/ChatHeader.stories.tsx b/frontend/src/components/chat/ChatHeader.stories.ts similarity index 76% rename from frontend/src/components/chat/ChatHeader.stories.tsx rename to frontend/src/components/chat/ChatHeader.stories.ts index 6e38278..a6fbd8f 100644 --- a/frontend/src/components/chat/ChatHeader.stories.tsx +++ b/frontend/src/components/chat/ChatHeader.stories.ts @@ -1,17 +1,16 @@ -import type { Meta, StoryObj } from '@storybook/react-vite' -import { ChatHeader } from './ChatHeader.js' +import type { Meta, StoryObj } from '@storybook/svelte' +import ChatHeader from './ChatHeader.svelte' const meta = { title: 'Chat/ChatHeader', component: ChatHeader, args: { - renderLink: ({ className, children }) => {children}, activeAgentName: 'GitHub Copilot', project: { id: 'acp-frontend', name: 'ACP Frontend', path: '/home/vries/projects/acp-frontend', - status: 'available', + status: 'available' as const, }, sessionId: '8bde315f-d2a3-4521-80e2-a55a0f2598d8', title: 'Agentic Coding Presentation Outline', diff --git a/frontend/src/components/chat/ChatHeader.svelte b/frontend/src/components/chat/ChatHeader.svelte new file mode 100644 index 0000000..bf3f7c7 --- /dev/null +++ b/frontend/src/components/chat/ChatHeader.svelte @@ -0,0 +1,206 @@ + + +{#snippet statusPill(label: string, tone: 'neutral' | 'ready' | 'error', detail?: string, compact?: boolean)} +
+
+ {label} + {#if detail}{detail}{/if} +
+
+{/snippet} + +
+ {#if isCompactViewport} +
+
+
+ ACP +
+
+

+ {headerTitle} +

+

{compactSubtitle}

+
+
+ +
+ {#if errorMessage} + + {:else} + {@render statusPill(statusLabel, ready ? 'ready' : 'neutral', thinking ? 'Reply in progress' : undefined, true)} + {/if} + + {#if errorMessage && showCompactErrorPopover} + + {/if} +
+
+ {:else} +
+
+
+ ACP +
+
+

+ {headerTitle} +

+

+ {#if isHistorySession} + {project ? `${project.name} · Imported history` : `${activeAgentName} · Imported history`} + {:else} + {project ? `${project.name} · ${activeAgentName}` : `${activeAgentName} · Local chat`} + {/if} +

+
+ + +
+ +
+ {@render statusPill(isHistorySession ? `${activeAgentName} history` : activeAgentName, 'neutral')} + {#if project}{@render statusPill(project.name, 'neutral')}{/if} + {@render statusPill(statusLabel, errorMessage ? 'error' : 'ready', statusDetail)} + {@render statusPill(`Session ${formatSessionLabel(sessionId, ready, isHistorySession)}`, 'neutral')} +
+
+ + + {/if} +
diff --git a/frontend/src/components/chat/ChatHeader.test.tsx b/frontend/src/components/chat/ChatHeader.test.svelte.ts similarity index 79% rename from frontend/src/components/chat/ChatHeader.test.tsx rename to frontend/src/components/chat/ChatHeader.test.svelte.ts index b7ea476..52357c8 100644 --- a/frontend/src/components/chat/ChatHeader.test.tsx +++ b/frontend/src/components/chat/ChatHeader.test.svelte.ts @@ -1,8 +1,7 @@ // @vitest-environment happy-dom import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { fireEvent, render, screen } from '@testing-library/react' -import type { ReactNode } from 'react' -import { ChatHeader } from './ChatHeader.js' +import { fireEvent, render, screen, cleanup } from '@testing-library/svelte' +import ChatHeader from './ChatHeader.svelte' const DEFAULT_PROPS = { activeAgentName: 'GitHub Copilot', @@ -17,9 +16,7 @@ const DEFAULT_PROPS = { errorMessage: null, ready: true, thinking: false, - renderLink: ({ children, className }: { children: ReactNode; className: string }) => ( - {children} - ), + isHistorySession: false, } describe('ChatHeader', () => { @@ -30,6 +27,7 @@ describe('ChatHeader', () => { }) afterEach(() => { + cleanup() window.innerWidth = originalWidth window.dispatchEvent(new Event('resize')) }) @@ -38,7 +36,7 @@ describe('ChatHeader', () => { window.innerWidth = 390 window.dispatchEvent(new Event('resize')) - render() + render(ChatHeader, { props: DEFAULT_PROPS }) expect(screen.getByTestId('chat-header-compact')).toBeDefined() expect(screen.queryByText('Backends')).toBeNull() @@ -51,7 +49,7 @@ describe('ChatHeader', () => { window.innerWidth = 1280 window.dispatchEvent(new Event('resize')) - render() + render(ChatHeader, { props: DEFAULT_PROPS }) expect(screen.queryByTestId('chat-header-compact')).toBeNull() expect(screen.getAllByText('Backends').length).toBeGreaterThan(0) @@ -63,13 +61,14 @@ describe('ChatHeader', () => { window.innerWidth = 390 window.dispatchEvent(new Event('resize')) - render( - - ) + render(ChatHeader, { + props: { + ...DEFAULT_PROPS, + errorMessage: + 'Unable to load that session right now. Pick another one or create a new chat.', + ready: false, + }, + }) expect( screen.queryByText( diff --git a/frontend/src/components/chat/ChatHeader.tsx b/frontend/src/components/chat/ChatHeader.tsx deleted file mode 100644 index 8d15881..0000000 --- a/frontend/src/components/chat/ChatHeader.tsx +++ /dev/null @@ -1,246 +0,0 @@ -import { useEffect, useState, type ReactNode } from 'react' -import { Link } from '@tanstack/react-router' -import type { ProjectSummary } from '../../hooks/useAgUiChat.js' - -interface HeaderLinkProps { - to: '/settings/backends' | '/settings/mcp' - className: string - children: ReactNode -} - -interface ChatHeaderProps { - renderLink?: (props: HeaderLinkProps) => ReactNode - project: ProjectSummary | null - sessionId: string | null - activeAgentName?: string - title?: string | null - errorMessage: string | null - ready: boolean - thinking: boolean - isHistorySession?: boolean -} - -function formatSessionLabel(sessionId: string | null, ready: boolean, isHistorySession: boolean) { - if (isHistorySession) return 'Read-only' - if (!ready) return 'Starting' - if (!sessionId) return 'Unavailable' - return sessionId.slice(0, 8) -} - -export function ChatHeader({ - renderLink, - project, - sessionId, - activeAgentName = 'Agent', - title, - errorMessage, - ready, - thinking, - isHistorySession = false, -}: ChatHeaderProps) { - const headerLink = renderLink ?? defaultHeaderLink - const [isCompactViewport, setIsCompactViewport] = useState(() => - typeof window !== 'undefined' ? window.innerWidth < 1024 : false - ) - const [showCompactErrorPopover, setShowCompactErrorPopover] = useState(false) - const statusLabel = errorMessage - ? 'Needs attention' - : thinking - ? 'Thinking' - : isHistorySession - ? 'History' - : ready - ? 'Ready' - : 'Connecting' - const statusDetail = - errorMessage ?? - (isHistorySession - ? 'Read-only session' - : !ready - ? 'Connecting to server' - : thinking - ? 'Reply in progress' - : 'Stream healthy') - const headerTitle = title?.trim() || 'Chat Workspace' - const compactSubtitle = project ? project.name : activeAgentName - - useEffect(() => { - if (typeof window === 'undefined') { - return - } - - const handleResize = () => { - setIsCompactViewport(window.innerWidth < 1024) - } - - handleResize() - window.addEventListener('resize', handleResize) - return () => window.removeEventListener('resize', handleResize) - }, []) - - useEffect(() => { - if (!errorMessage) { - setShowCompactErrorPopover(false) - } - }, [errorMessage]) - - return ( -
- {isCompactViewport ? ( -
-
-
- ACP -
-
-

- {headerTitle} -

-

{compactSubtitle}

-
-
- -
- {errorMessage ? ( - - ) : ( - - )} - - {errorMessage && showCompactErrorPopover ? ( -
-
-

- Chat warning -

-

{errorMessage}

-
- ) : null} -
-
- ) : ( - <> -
-
-
- ACP -
-
-

- {headerTitle} -

-

- {project - ? `${project.name} · ${activeAgentName}` - : `${activeAgentName} · Local chat`} -

-
- -
- {headerLink({ - to: '/settings/backends', - className: - 'inline-flex h-9 items-center justify-center rounded-full border border-white/10 bg-slate-900/70 px-3 text-sm font-medium text-slate-100 transition hover:border-white/15 hover:bg-slate-900', - children: 'Backends', - })} - {headerLink({ - to: '/settings/mcp', - className: - 'inline-flex h-9 items-center justify-center rounded-full border border-white/10 bg-slate-900/45 px-3 text-sm font-medium text-slate-300 transition hover:border-white/15 hover:bg-slate-900', - children: 'MCP', - })} -
-
- -
- - {project ? : null} - - -
-
- -
- {headerLink({ - to: '/settings/backends', - className: - 'inline-flex h-9 items-center justify-center rounded-full border border-white/10 bg-slate-900/70 px-3 text-sm font-medium text-slate-100 transition hover:border-white/15 hover:bg-slate-900', - children: 'Backends', - })} - {headerLink({ - to: '/settings/mcp', - className: - 'inline-flex h-9 items-center justify-center rounded-full border border-white/10 bg-slate-900/45 px-3 text-sm font-medium text-slate-300 transition hover:border-white/15 hover:bg-slate-900', - children: 'MCP', - })} -
- - )} -
- ) -} - -function StatusPill({ - label, - detail, - tone, - compact = false, -}: { - label: string - detail?: string - tone: 'neutral' | 'ready' | 'error' - compact?: boolean -}) { - const className = - tone === 'error' - ? 'border-rose-500/25 bg-rose-500/10 text-rose-100' - : tone === 'ready' - ? 'border-emerald-500/20 bg-emerald-500/10 text-slate-100' - : 'border-white/10 bg-slate-900/60 text-slate-200' - - return ( -
-
- {label} - {detail ? {detail} : null} -
-
- ) -} - -function defaultHeaderLink({ to, className, children }: HeaderLinkProps) { - return ( - - {children} - - ) -} diff --git a/frontend/src/components/chat/ChatTranscript.stories.ts b/frontend/src/components/chat/ChatTranscript.stories.ts new file mode 100644 index 0000000..a7a934c --- /dev/null +++ b/frontend/src/components/chat/ChatTranscript.stories.ts @@ -0,0 +1,66 @@ +import type { Meta, StoryObj } from '@storybook/svelte' +import ChatTranscript from './ChatTranscript.svelte' + +const meta = { + title: 'Chat/ChatTranscript', + component: ChatTranscript, + args: { + activeAgentName: 'GitHub Copilot', + messages: [], + hasSession: true, + loading: false, + ready: true, + thinking: false, + errorMessage: null, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Empty: Story = {} + +export const Loading: Story = { + args: { + hasSession: false, + loading: true, + ready: false, + }, +} + +export const Error: Story = { + args: { + hasSession: false, + errorMessage: 'Message failed to send. Check the agent connection and try again.', + }, +} + +export const LongTranscript: Story = { + args: { + hasSession: true, + messages: [ + { id: 'user-1', role: 'user', content: 'Please audit the chat layout for mobile spacing.' }, + { + id: 'assistant-1', + role: 'assistant', + content: + 'The main issue is vertical crowding in the header, especially once session metadata and status badges stack. I would reduce the copy, tighten the gaps, and keep the composer pinned visually to the transcript.', + }, + { id: 'user-2', role: 'user', content: 'Can you suggest a cleaner status treatment?' }, + { + id: 'assistant-2', + role: 'assistant', + content: + 'Use a single concise status block in the header and avoid repeating health diagnostics in the transcript pane. That keeps the chat area focused on conversation content.', + }, + ], + }, +} + +export const Thinking: Story = { + args: { + hasSession: true, + messages: [{ id: 'user-1', role: 'user', content: 'Say hello in one short sentence.' }], + thinking: true, + }, +} diff --git a/frontend/src/components/chat/ChatTranscript.stories.tsx b/frontend/src/components/chat/ChatTranscript.stories.tsx deleted file mode 100644 index 5961b5d..0000000 --- a/frontend/src/components/chat/ChatTranscript.stories.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react-vite' -import { ChatTranscript } from './ChatTranscript.js' - -const meta = { - title: 'Chat/ChatTranscript', - component: ChatTranscript, - args: { - activeAgentName: 'GitHub Copilot', - messages: [], - hasSession: true, - loading: false, - ready: true, - thinking: false, - errorMessage: null, - }, -} satisfies Meta - -export default meta -type Story = StoryObj - -export const Empty: Story = {} - -export const Loading: Story = { - args: { - hasSession: false, - loading: true, - ready: false, - }, -} - -export const Error: Story = { - args: { - hasSession: false, - errorMessage: 'Message failed to send. Check the agent connection and try again.', - }, -} - -export const LongTranscript: Story = { - args: { - hasSession: true, - messages: [ - { id: 'user-1', role: 'user', content: 'Please audit the chat layout for mobile spacing.' }, - { - id: 'assistant-1', - role: 'assistant', - content: - 'The main issue is vertical crowding in the header, especially once session metadata and status badges stack. I would reduce the copy, tighten the gaps, and keep the composer pinned visually to the transcript.', - }, - { id: 'user-2', role: 'user', content: 'Can you suggest a cleaner status treatment?' }, - { - id: 'assistant-2', - role: 'assistant', - content: - 'Use a single concise status block in the header and avoid repeating health diagnostics in the transcript pane. That keeps the chat area focused on conversation content.', - }, - ], - }, -} - -export const DenseDesktop: Story = { - args: { - hasSession: true, - messages: [ - { - id: 'assistant-1', - role: 'assistant', - content: - 'The transcript should become the clear hero of the page, with fewer decorative status treatments stealing attention from the conversation itself.', - }, - { - id: 'user-1', - role: 'user', - content: 'So the layout should feel more like a chat app than a dashboard?', - }, - { - id: 'assistant-2', - role: 'assistant', - content: - 'Exactly. Keep context available, but let the eye land on the exchange first and the controls second.', - }, - { - id: 'user-2', - role: 'user', - content: - 'And files or diff should stay in the same column rather than opening a separate workspace?', - }, - { - id: 'assistant-3', - role: 'assistant', - content: - 'Yes. The mental model is one conversation surface with contextual modes, not three competing panes.', - }, - ], - }, -} - -export const Thinking: Story = { - args: { - hasSession: true, - messages: [{ id: 'user-1', role: 'user', content: 'Say hello in one short sentence.' }], - thinking: true, - }, -} - -export const Welcome: Story = { - args: { - hasSession: false, - ready: false, - canManageProjects: true, - canStartSession: true, - hasAnyProject: true, - hasAvailableAgent: true, - hasAvailableProject: true, - onOpenProjectManager: () => {}, - onStartSession: () => {}, - }, -} - -export const NeedsProject: Story = { - args: { - hasSession: false, - ready: false, - canManageProjects: true, - canStartSession: false, - hasAnyProject: false, - hasAvailableAgent: true, - hasAvailableProject: false, - onOpenProjectManager: () => {}, - }, -} - -export const NeedsAgent: Story = { - args: { - hasSession: false, - ready: false, - canManageProjects: true, - canStartSession: false, - hasAnyProject: true, - hasAvailableAgent: false, - hasAvailableProject: true, - onOpenProjectManager: () => {}, - }, -} diff --git a/frontend/src/components/chat/ChatTranscript.svelte b/frontend/src/components/chat/ChatTranscript.svelte new file mode 100644 index 0000000..4fd20a0 --- /dev/null +++ b/frontend/src/components/chat/ChatTranscript.svelte @@ -0,0 +1,371 @@ + + +
+
+ {#if loading && !errorMessage} +
+
+

Loading

+

Opening your workspace

+

Fetching agents, projects, and your most recent session.

+
+ {/if} + + {#if historyLoading && hasSession && !errorMessage} +
+
+
+ +
+

Loading History

+

Restoring the full conversation while keeping the current transcript in view.

+
+
+
+ {/if} + + {#if streamReconnecting && hasSession && !loading && !errorMessage && !historyLoading} +
+
+
+ +
+

Reconnecting Stream

+

Live updates dropped for a moment. Rejoining the session stream now.

+
+
+
+ {/if} + + {#if errorMessage} + + {/if} + + {#if ready && messages.length === 0} +
+

Transcript

+

Start the conversation

+

+ Ask {activeAgentName} to inspect code, explain a failure, or sketch a next step for the current workspace. +

+
+ {/if} + + {#if !loading && messages.length === 0 && !hasSession} + + {/if} + + {#each transcriptRuns as run, index (run.kind === 'user' ? `user-${run.message.id}-${index}` : `assistant-${index}`)} + {#if run.kind === 'user'} +
+
+

You

+ {#if run.message.content} +

{run.message.content}

+ {/if} + {#if run.message.structuredBlocks?.length} +
+ +
+ {/if} + +
+
+ {:else} +
+
+ {#each buildAssistantNodes(run.messages) as node, nodeIndex (nodeIndex)} + {#if node.kind === 'structured'} +
+ +
+ {:else} +
+ + {@html renderAssistantMarkdown(node.content)} +
+ {/if} + {/each} + +
+
+ {/if} + {/each} + + {#if thinking} +
+
+ Thinking… +
+
+ {/if} + + {#if showJumpToLatest} +
+ +
+ {/if} +
+
+ + diff --git a/frontend/src/components/chat/ChatTranscript.test.tsx b/frontend/src/components/chat/ChatTranscript.test.svelte.ts similarity index 95% rename from frontend/src/components/chat/ChatTranscript.test.tsx rename to frontend/src/components/chat/ChatTranscript.test.svelte.ts index 365cbee..81b6c1d 100644 --- a/frontend/src/components/chat/ChatTranscript.test.tsx +++ b/frontend/src/components/chat/ChatTranscript.test.svelte.ts @@ -1,8 +1,8 @@ // @vitest-environment happy-dom import { describe, it, expect, vi, afterEach } from 'vitest' -import { render, screen, fireEvent, waitFor } from '@testing-library/react' -import { ChatTranscript } from './ChatTranscript.js' -import type { ChatMessage } from '../../hooks/useAgUiChat.js' +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte' +import ChatTranscript from './ChatTranscript.svelte' +import type { ChatMessage } from '../../store/chatStore.svelte.js' class MockResizeObserver { observe() {} @@ -28,27 +28,23 @@ if (!HTMLElement.prototype.scrollTo) { this.scrollTop = options return } - this.scrollTop = options?.top ?? this.scrollTop } } -function renderTranscript( - messages: ChatMessage[], - extra: Partial> = {} -) { - return render( - - ) +function renderTranscript(messages: ChatMessage[], extra: Record = {}) { + return render(ChatTranscript, { + props: { + activeAgentName: 'Test Agent', + messages, + hasSession: true, + loading: false, + ready: true, + thinking: false, + errorMessage: null, + ...extra, + }, + }) } function installTranscriptMetrics(transcript: HTMLDivElement, scrollTop: number) { @@ -401,6 +397,6 @@ describe('ChatTranscript', () => { }) await waitFor(() => expect(scrollSpy).toHaveBeenCalled()) - expect(scrollSpy).toHaveBeenCalledWith({ top: 0, behavior: 'auto' }) + expect(scrollSpy).toHaveBeenCalledWith({ top: expect.any(Number), behavior: 'auto' }) }) }) diff --git a/frontend/src/components/chat/ChatTranscript.tsx b/frontend/src/components/chat/ChatTranscript.tsx deleted file mode 100644 index f7c322b..0000000 --- a/frontend/src/components/chat/ChatTranscript.tsx +++ /dev/null @@ -1,829 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from 'react' -import ReactMarkdown from 'react-markdown' -import remarkGfm from 'remark-gfm' -import SyntaxHighlighter from 'react-syntax-highlighter' -import { atomOneDark } from 'react-syntax-highlighter/dist/esm/styles/hljs' -import type { ChatMessage } from '../../hooks/useAgUiChat.js' -import { parseUnifiedDiff } from './parseUnifiedDiff.js' -import { StructuredAssistantMessage } from './StructuredAssistantMessage.js' -import { ChatWelcomeState } from './ChatWelcomeState.js' - -const SCROLL_BOTTOM_THRESHOLD = 120 - -interface ChatTranscriptProps { - activeAgentName: string - canManageProjects?: boolean - canStartSession?: boolean - hasAnyProject?: boolean - hasAvailableAgent?: boolean - hasAvailableProject?: boolean - messages: ChatMessage[] - projectPath?: string | null - sessionId?: string | null - hasSession: boolean - loading: boolean - historyLoading?: boolean - streamReconnecting?: boolean - onOpenProjectManager?: () => void - onStartSession?: () => void - ready: boolean - thinking: boolean - errorMessage: string | null -} - -export function ChatTranscript({ - activeAgentName, - canManageProjects = false, - canStartSession = false, - hasAnyProject = true, - hasAvailableAgent = true, - hasAvailableProject = true, - messages, - projectPath = null, - sessionId = null, - hasSession, - loading, - historyLoading = false, - streamReconnecting = false, - onOpenProjectManager, - onStartSession, - ready, - thinking, - errorMessage, -}: ChatTranscriptProps) { - const transcriptRef = useRef(null) - const shouldStickToBottomRef = useRef(true) - const [showJumpToLatest, setShowJumpToLatest] = useState(false) - - const updateScrollState = useCallback(() => { - const element = transcriptRef.current - if (!element) return - - const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight - const nearBottom = distanceFromBottom <= SCROLL_BOTTOM_THRESHOLD - - shouldStickToBottomRef.current = nearBottom - setShowJumpToLatest(!nearBottom && messages.length > 0) - }, [messages.length]) - - const scrollToLatest = (behavior: ScrollBehavior = 'smooth') => { - const element = transcriptRef.current - if (!element) return - - element.scrollTo({ top: element.scrollHeight, behavior }) - shouldStickToBottomRef.current = true - setShowJumpToLatest(false) - } - - useEffect(() => { - if (!sessionId) return - - const frame = window.requestAnimationFrame(() => { - scrollToLatest('auto') - }) - - return () => window.cancelAnimationFrame(frame) - }, [sessionId]) - - useEffect(() => { - const frame = window.requestAnimationFrame(() => { - if (shouldStickToBottomRef.current) { - scrollToLatest(messages.length > 0 ? 'smooth' : 'auto') - } else { - updateScrollState() - } - }) - - return () => window.cancelAnimationFrame(frame) - }, [messages, thinking, updateScrollState]) - - return ( -
-
- {loading && !errorMessage && ( -
-
-

- Loading -

-

- Opening your workspace -

-

- Fetching agents, projects, and your most recent session. -

-
- )} - - {historyLoading && hasSession && !errorMessage ? ( -
-
-
- -
-

- Loading History -

-

- Restoring the full conversation while keeping the current transcript in view. -

-
-
-
- ) : null} - - {streamReconnecting && hasSession && !loading && !errorMessage && !historyLoading ? ( -
-
-
- -
-

- Reconnecting Stream -

-

- Live updates dropped for a moment. Rejoining the session stream now. -

-
-
-
- ) : null} - - {errorMessage && ( -
-

- Attention -

-

{errorMessage}

-
- )} - - {ready && messages.length === 0 && ( -
-

- Transcript -

-

- Start the conversation -

-

- Ask {activeAgentName} to inspect code, explain a failure, or sketch a next step for - the current workspace. -

-
- )} - - {!loading && messages.length === 0 && !hasSession && ( - - )} - - {buildTranscriptRuns(messages).map((run, index) => - run.kind === 'user' ? ( -
-
-

- You -

- {run.message.content ? ( -

- {run.message.content} -

- ) : null} - {run.message.structuredBlocks?.length ? ( -
- -
- ) : null} - -
-
- ) : ( -
-
- {buildAssistantNodes(run.messages).map((node, nodeIndex) => - node.kind === 'structured' ? ( -
- -
- ) : ( -
- -
- ) - )} - -
-
- ) - )} - - {thinking && ( -
-
- Thinking… -
-
- )} - - {showJumpToLatest ? ( -
- -
- ) : null} -
-
- ) -} - -function firstLine(value: string): string | null { - const line = value - .split('\n') - .map((part) => part.trim()) - .find(Boolean) - - return line ?? null -} - -type TranscriptRun = - | { kind: 'user'; message: ChatMessage } - | { kind: 'assistant'; messages: ChatMessage[] } - -type AssistantNode = - | { - kind: 'structured' - blocks: NonNullable - summaryTitle: string | null - } - | { kind: 'markdown'; content: string } - -function buildTranscriptRuns(messages: ChatMessage[]): TranscriptRun[] { - const runs: TranscriptRun[] = [] - - for (const message of messages) { - if (message.role === 'user') { - runs.push({ kind: 'user', message }) - continue - } - - const lastRun = runs.at(-1) - if (lastRun?.kind === 'assistant') { - lastRun.messages.push(message) - continue - } - - runs.push({ kind: 'assistant', messages: [message] }) - } - - return runs -} - -function aggregateAssistantTurnInfo(messages: ChatMessage[]): ChatMessage['turnInfo'] | undefined { - const infos = messages.map((message) => message.turnInfo).filter(Boolean) - if (infos.length === 0) { - return undefined - } - - const startedAtMs = infos.reduce((value, info) => { - if (info?.startedAtMs === undefined) { - return value - } - - return value === undefined ? info.startedAtMs : Math.min(value, info.startedAtMs) - }, undefined) - - const completedAtMs = infos.reduce((value, info) => { - if (info?.completedAtMs === undefined) { - return value - } - - return value === undefined ? info.completedAtMs : Math.max(value, info.completedAtMs) - }, undefined) - - const modifiedFiles = Array.from(new Set(infos.flatMap((info) => info?.modifiedFiles ?? []))) - const patches = infos.flatMap((info) => info?.patches ?? []) - const latest = infos.at(-1) - - return { - providerId: latest?.providerId, - modelId: latest?.modelId, - mode: latest?.mode, - startedAtMs, - completedAtMs, - durationMs: - startedAtMs !== undefined && completedAtMs !== undefined - ? completedAtMs - startedAtMs - : undefined, - modifiedFiles, - patches, - } -} - -function buildAssistantNodes(messages: ChatMessage[]): AssistantNode[] { - const nodes: AssistantNode[] = [] - - for (const message of messages) { - const blocks = message.structuredBlocks ?? [] - const summary = firstLine(message.content) - const contentWithoutSummary = - blocks.length > 0 && summary ? stripLeadingSummary(message.content, summary) : message.content - - if (blocks.length > 0) { - const previous = nodes.at(-1) - if (previous?.kind === 'structured') { - previous.blocks.push(...blocks) - if (!previous.summaryTitle && summary) { - previous.summaryTitle = summary - } - } else { - nodes.push({ kind: 'structured', blocks: [...blocks], summaryTitle: summary }) - } - } - - if (contentWithoutSummary.trim()) { - const previous = nodes.at(-1) - if (previous?.kind === 'markdown') { - previous.content = `${previous.content.trimEnd()}\n\n${contentWithoutSummary.trim()}` - } else { - nodes.push({ kind: 'markdown', content: contentWithoutSummary.trim() }) - } - } - } - - return nodes -} - -function stripLeadingSummary(content: string, summary: string): string { - const trimmed = content.trimStart() - if (!trimmed.startsWith(summary)) { - return content - } - - return trimmed.slice(summary.length).trimStart() -} - -function CodeBlock({ language, children }: { language: string | null; children: string }) { - const [copied, setCopied] = useState(false) - - const handleCopy = () => { - void navigator.clipboard.writeText(children).then(() => { - setCopied(true) - setTimeout(() => setCopied(false), 2000) - }) - } - - return ( -
-
- - {language ?? 'code'} - - -
- - {children} - -
- ) -} - -function AssistantMarkdown({ content }: { content: string }) { - return ( - ( - - ), - blockquote: ({ ...props }) => ( -
- ), - code: ({ className, children, ...props }) => { - const match = /language-(\w+)/.exec(className ?? '') - const isBlock = Boolean(className) - if (!isBlock) { - return ( - - {children} - - ) - } - return ( - - {String(children).replace(/\n$/, '')} - - ) - }, - h1: ({ ...props }) => ( -

- ), - h2: ({ ...props }) => ( -

- ), - h3: ({ ...props }) => ( -

- ), - li: ({ ...props }) => ( -
  • - ), - ol: ({ ...props }) =>
      , - p: ({ ...props }) =>

      , - pre: ({ children }) => <>{children}, - table: ({ ...props }) => ( -

      - - - ), - td: ({ ...props }) => ( -
      - ), - th: ({ ...props }) => ( - - ), - ul: ({ ...props }) =>
        , - }} - > - {content} - - ) -} - -function TurnFooter({ - message, - compact = false, - projectPath = null, - sessionId = null, - turnInfoOverride, -}: { - message: ChatMessage | null - compact?: boolean - projectPath?: string | null - sessionId?: string | null - turnInfoOverride?: ChatMessage['turnInfo'] -}) { - const [showFiles, setShowFiles] = useState(false) - const [diffByHash, setDiffByHash] = useState>({}) - const [diffErrorByHash, setDiffErrorByHash] = useState>({}) - const [visibleDiffHashes, setVisibleDiffHashes] = useState>({}) - const [loadingHash, setLoadingHash] = useState(null) - const turnInfo = turnInfoOverride ?? message?.turnInfo - const modifiedFiles = turnInfo?.modifiedFiles ?? [] - const patches = turnInfo?.patches ?? [] - const chipClass = compact - ? 'border border-teal-950/20 text-teal-950/80' - : 'border border-white/10 text-slate-200' - const showCopy = !compact && Boolean(message?.content) - const patchLabel = formatPatchLabel(patches.length, modifiedFiles.length) - - const loadPatchDiff = async (hash: string, nextHash: string) => { - if (!sessionId || loadingHash === hash) { - return - } - - if (diffByHash[hash] !== undefined) { - setVisibleDiffHashes((current) => ({ ...current, [hash]: true })) - return - } - - setLoadingHash(hash) - setDiffErrorByHash((current) => { - const next = { ...current } - delete next[hash] - return next - }) - try { - const response = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/patch-diff`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ fromHash: hash, toHash: nextHash }), - }) - if (!response.ok) { - throw new Error(`Patch diff request failed with status ${response.status}`) - } - - const payload = (await response.json()) as { diff: string } - setDiffByHash((current) => ({ ...current, [hash]: payload.diff })) - setVisibleDiffHashes((current) => ({ ...current, [hash]: true })) - } catch (error) { - console.error('[ChatTranscript] patch diff load failed:', error) - setDiffErrorByHash((current) => ({ ...current, [hash]: 'Unable to load this patch diff.' })) - } finally { - setLoadingHash((current) => (current === hash ? null : current)) - } - } - - const togglePatchDiff = (hash: string, nextHash: string) => { - if (visibleDiffHashes[hash]) { - setVisibleDiffHashes((current) => ({ ...current, [hash]: false })) - return - } - - void loadPatchDiff(hash, nextHash) - } - - if (!turnInfo && modifiedFiles.length === 0 && patches.length === 0) { - return null - } - - return ( -
        -

        - {compact ? 'Turn Input' : 'Turn Outcome'} -

        -
        -
        - {modifiedFiles.length > 0 || patches.length > 0 ? ( - - ) : null} - {turnInfo?.providerId ? {turnInfo.providerId} : null} - {turnInfo?.modelId ? {turnInfo.modelId} : null} - {turnInfo?.mode ? {turnInfo.mode} mode : null} - {turnInfo?.durationMs ? {formatDuration(turnInfo.durationMs)} : null} -
        - - {showCopy ? ( - - ) : null} -
        - - {showFiles && (modifiedFiles.length > 0 || patches.length > 0) ? ( -
        - {patches.length > 0 ? ( -
        -

        Patch Summary

        -
        - {patches.map((patch, index) => ( -
        -
        - Patch {index + 1} - {patch.hash ? ( - - {shortHash(patch.hash)} - - ) : null} - {patch.additions !== undefined || patch.deletions !== undefined ? ( - - {formatPatchDelta(patch.additions, patch.deletions)} - - ) : null} -
        -
        - {patch.files.map((file) => ( -
        - - - {relativizeFile(file, projectPath)} - -
        - ))} -
        - {patch.nextHash ? ( -
        - {diffErrorByHash[patch.hash] ? ( -

        - {diffErrorByHash[patch.hash]} -

        - ) : null} - - {visibleDiffHashes[patch.hash] && diffByHash[patch.hash] !== undefined ? ( -
        - -
        - ) : null} -
        - ) : null} -
        - ))} -
        -
        - ) : ( - <> -

        - Modified Files -

        -
        - {modifiedFiles.map((file) => ( -
        - - - {relativizeFile(file, projectPath)} - -
        - ))} -
        - - )} -
        - ) : null} -
        - ) -} - -function formatPatchLabel(patchCount: number, modifiedFileCount: number): string { - if (patchCount > 0) { - return `Modified ${modifiedFileCount} file${modifiedFileCount === 1 ? '' : 's'} across ${patchCount} patch${patchCount === 1 ? '' : 'es'}` - } - - return `Modified ${modifiedFileCount} file${modifiedFileCount === 1 ? '' : 's'}` -} - -function formatDuration(durationMs: number): string { - if (durationMs < 1000) { - return `${durationMs}ms` - } - - return `${(durationMs / 1000).toFixed(1)}s` -} - -function relativizeFile(file: string, projectPath?: string | null): string { - if (!projectPath || !file.startsWith(projectPath)) { - return file - } - - return `.${file.slice(projectPath.length)}` -} - -function shortHash(hash: string): string { - return hash.slice(0, 7) -} - -function formatPatchDelta(additions?: number, deletions?: number): string { - const added = additions ?? 0 - const removed = deletions ?? 0 - return `+${added} -${removed}` -} - -function InlinePatchDiff({ diff }: { diff: string }) { - const parsedFiles = parseUnifiedDiff(diff) - - if (parsedFiles.length === 0) { - return ( -
        -        {diff || 'Diff unavailable.'}
        -      
        - ) - } - - return ( -
        - {parsedFiles.map((file) => ( -
        -
        -

        {file.displayPath}

        -
        - - +{file.additions} - - - -{file.deletions} - -
        -
        -
        - {file.hunks.map((hunk) => ( -
        -
        - {hunk.header} -
        - {hunk.lines.map((line, index) => ( -
        - - {line.kind === 'addition' - ? '+' - : line.kind === 'deletion' - ? '-' - : line.kind === 'note' - ? '\\' - : ' '} - - {line.content || ' '} -
        - ))} -
        - ))} -
        -
        - ))} -
        - ) -} diff --git a/frontend/src/components/chat/ChatTranscriptA2UIDisabled.test.tsx b/frontend/src/components/chat/ChatTranscriptA2UIDisabled.test.svelte.ts similarity index 67% rename from frontend/src/components/chat/ChatTranscriptA2UIDisabled.test.tsx rename to frontend/src/components/chat/ChatTranscriptA2UIDisabled.test.svelte.ts index f081607..1f638c1 100644 --- a/frontend/src/components/chat/ChatTranscriptA2UIDisabled.test.tsx +++ b/frontend/src/components/chat/ChatTranscriptA2UIDisabled.test.svelte.ts @@ -2,13 +2,13 @@ // This file tests ChatTranscript with ENABLE_A2UI = false. // vi.mock is hoisted to module scope by Vitest, so it must live in its own file. import { describe, it, expect, vi } from 'vitest' -import { render, screen } from '@testing-library/react' -import type { ChatMessage } from '../../hooks/useAgUiChat.js' +import { render, screen } from '@testing-library/svelte' +import type { ChatMessage } from '../../store/chatStore.svelte.js' vi.mock('../../config/features.js', () => ({ ENABLE_A2UI: false })) // Import after mock registration so the module sees the mocked value -const { ChatTranscript } = await import('./ChatTranscript.js') +const { default: ChatTranscript } = await import('./ChatTranscript.svelte') describe('ChatTranscript with ENABLE_A2UI disabled', () => { it('does not render structured blocks when ENABLE_A2UI is false', () => { @@ -23,17 +23,17 @@ describe('ChatTranscript with ENABLE_A2UI disabled', () => { }, ] - render( - - ) + render(ChatTranscript, { + props: { + activeAgentName: 'Test Agent', + messages, + hasSession: true, + loading: false, + ready: true, + thinking: false, + errorMessage: null, + }, + }) expect(screen.queryByTestId('a2ui-tool-call-card')).toBeNull() expect(screen.getByText('plain text only')).toBeDefined() diff --git a/frontend/src/components/chat/ChatWelcomeState.svelte b/frontend/src/components/chat/ChatWelcomeState.svelte new file mode 100644 index 0000000..5a85cae --- /dev/null +++ b/frontend/src/components/chat/ChatWelcomeState.svelte @@ -0,0 +1,141 @@ + + +
        +
        +

        + {highlightLabel} +

        +

        + {title} +

        +

        + {description} +

        +
        + +
        +
        +
        +

        + Project scoped +

        +

        Sessions stay tied to a repository

        +

        + Every chat, file browse, and diff review follows the selected project instead of a global + workspace. +

        +
        +
        +

        + Agent per session +

        +

        + Pick the right backend when you start +

        +

        + New chats choose an agent first, so each session keeps the right execution context from the + beginning. +

        +
        +
        + +
        +

        Next step

        +
        + {#if onOpenProjectManager} + + {/if} + {#if canStartSession && onStartSession} + + {/if} + + Open settings + +
        + +
        + {canStartSession + ? 'Once the session is open, the composer stays visible while files and diff swap into the main workspace area.' + : 'The composer will unlock automatically once a usable project and agent are available for the current session.'} +
        +
        +
        +
        diff --git a/frontend/src/components/chat/ChatWelcomeState.test.tsx b/frontend/src/components/chat/ChatWelcomeState.test.svelte.ts similarity index 70% rename from frontend/src/components/chat/ChatWelcomeState.test.tsx rename to frontend/src/components/chat/ChatWelcomeState.test.svelte.ts index dea9493..0e8facc 100644 --- a/frontend/src/components/chat/ChatWelcomeState.test.tsx +++ b/frontend/src/components/chat/ChatWelcomeState.test.svelte.ts @@ -1,22 +1,21 @@ // @vitest-environment happy-dom import { describe, it, expect, vi } from 'vitest' -import { render, screen, fireEvent } from '@testing-library/react' - -import { ChatWelcomeState } from './ChatWelcomeState.js' - -function renderWelcomeState(props: Partial> = {}) { - return render( - - ) +import { render, screen, fireEvent } from '@testing-library/svelte' +import ChatWelcomeState from './ChatWelcomeState.svelte' + +function renderWelcomeState(props: Record = {}) { + return render(ChatWelcomeState, { + props: { + activeAgentName: 'GitHub Copilot', + canStartSession: true, + hasAnyProject: true, + hasAvailableAgent: true, + hasAvailableProject: true, + onStartSession: vi.fn(), + onOpenProjectManager: vi.fn(), + ...props, + }, + }) } describe('ChatWelcomeState', () => { @@ -49,7 +48,7 @@ describe('ChatWelcomeState', () => { expect(screen.getByText('Connect an agent to begin')).toBeDefined() }) - it('fires the start callback when the CTA is pressed', () => { + it('fires the start callback when the CTA is pressed', async () => { const onStartSession = vi.fn() renderWelcomeState({ onStartSession }) diff --git a/frontend/src/components/chat/ChatWelcomeState.tsx b/frontend/src/components/chat/ChatWelcomeState.tsx deleted file mode 100644 index 83d6189..0000000 --- a/frontend/src/components/chat/ChatWelcomeState.tsx +++ /dev/null @@ -1,135 +0,0 @@ -interface ChatWelcomeStateProps { - activeAgentName: string - canStartSession: boolean - hasAnyProject: boolean - hasAvailableProject: boolean - hasAvailableAgent: boolean - onStartSession?: () => void - onOpenProjectManager?: () => void -} - -export function ChatWelcomeState({ - activeAgentName, - canStartSession, - hasAnyProject, - hasAvailableProject, - hasAvailableAgent, - onStartSession, - onOpenProjectManager, -}: ChatWelcomeStateProps) { - const title = !hasAnyProject - ? 'Bring a project into the workspace' - : !hasAvailableProject - ? 'Choose a project that is available' - : !hasAvailableAgent - ? 'Connect an agent to begin' - : 'Open a fresh chat in this project' - - const description = !hasAnyProject - ? 'Projects organize every session, file view, and diff. Add one first so the chat rail has a workspace to target.' - : !hasAvailableProject - ? 'The current project entries are configured, but none are available on disk right now. Pick another path or fix the missing repository.' - : !hasAvailableAgent - ? 'An agent connection is required before the composer can stream replies. Check Settings, enable a backend, then come back to start the session.' - : `Create a session with ${activeAgentName} to keep the transcript, files, and diff scoped to the selected repository.` - - const highlightLabel = !hasAnyProject - ? 'Workspace setup' - : !hasAvailableProject - ? 'Project attention needed' - : !hasAvailableAgent - ? 'Agent connection' - : 'Ready for the first prompt' - - return ( -
        -
        -

        - {highlightLabel} -

        -

        - {title} -

        -

        - {description} -

        -
        - -
        -
        - - -
        - -
        -

        - Next step -

        -
        - {onOpenProjectManager ? ( - - ) : null} - {canStartSession && onStartSession ? ( - - ) : null} - - Open settings - -
        - -
        - {canStartSession - ? 'Once the session is open, the composer stays visible while files and diff swap into the main workspace area.' - : 'The composer will unlock automatically once a usable project and agent are available for the current session.'} -
        -
        -
        -
        - ) -} - -function FeatureCard({ - label, - title, - description, -}: { - label: string - title: string - description: string -}) { - return ( -
        -

        - {label} -

        -

        {title}

        -

        {description}

        -
        - ) -} diff --git a/frontend/src/components/chat/ProjectContextSwitcher.svelte b/frontend/src/components/chat/ProjectContextSwitcher.svelte new file mode 100644 index 0000000..f0b5321 --- /dev/null +++ b/frontend/src/components/chat/ProjectContextSwitcher.svelte @@ -0,0 +1,808 @@ + + + + +{#if managerOpen} + +
        +
        +
        +
        +

        + Projects +

        +

        + Manage Project Views +

        +

        + Show or hide projects in the chat rail, choose the current workspace, and add + new repositories. +

        +
        +
        + + +
        +
        + +
        +
        +
        +
        +

        + Session Rail +

        +

        + Choose which projects appear in chats. +

        +
        + + {visibleProjectCount}/{projects.length} + +
        + +
        + {#each projects as project (project.id)} + {@const visible = visibleProjectSet.has(project.id)} + {@const current = project.id === selectedProjectId} + {@const selectable = project.status === 'available'} +
        +
        +
        +
        +

        {project.name}

        + {#if current} + + Current + + {/if} + + {describeProjectStatus(project.status)} + +
        +

        {project.path}

        +
        + +
        + +
        +

        + {visible ? 'Visible in the session viewer.' : 'Hidden from the session viewer.'} +

        +
        + + +
        +
        +
        + {/each} +
        +
        + + {#if addFormOpen && !isCompactViewport} +
        + {#snippet addProjectFormContent()} +
        +

        + Add Project +

        +

        + Register another repository for chat and explorer context. +

        +
        + +
        + { addName = (e.target as HTMLInputElement).value }} + disabled={addSubmitting} + class="w-full rounded-lg border border-white/10 bg-slate-950 px-3 py-2 text-sm text-slate-100 placeholder-slate-500 outline-none focus:border-teal-500" + /> + { + addPath = (e.target as HTMLInputElement).value + pathSuggestionsOpen = true + }} + onfocus={() => { + if (pathSuggestions.length > 0 || pathSuggestionError) { + pathSuggestionsOpen = true + } + }} + onkeydown={handlePathKeydown} + disabled={addSubmitting} + class="w-full rounded-lg border border-white/10 bg-slate-950 px-3 py-2 text-sm text-slate-100 placeholder-slate-500 outline-none focus:border-teal-500" + /> +

        + Type an absolute path like /home/vries/projects or ~/code. Suggestions appear below when + folders match. +

        + {#if breadcrumbSegments.length > 0} +
        + {#each breadcrumbSegments as segment (segment.path)} + + {/each} +
        + {/if} + {#if pathQuickActions.length > 0} +
        + {#each pathQuickActions as group (group.label)} +
        +
        +

        + {group.label} +

        +
        +
        + {#each group.paths as path (`${group.label}-${path}`)} + + {/each} +
        +
        + {/each} +
        + {/if} + {#if pathSuggestionsLoading} +

        Loading suggestions...

        + {/if} + {#if showSuggestionPanel && !pathInputHasSearchablePrefix} +
        + Start with / or ~/ to browse folders. +
        + {/if} + {#if pathSuggestionsOpen && pathSuggestions.length > 0} +
        +
        +

        + Path suggestions +

        +

        Arrow keys · Enter

        +
        +
        + {#each pathSuggestions as suggestion, index (suggestion.path)} + + {/each} +
        +
        + {/if} + {#if pathSuggestionsOpen && pathSuggestionError} +
        + {pathSuggestionError} +
        + {/if} + {#if showSuggestionEmptyState} +
        + No matching folders found for {pathInputValue}. +
        + {/if} + {#if addError} + + {/if} + +
        + {/snippet} + {@render addProjectFormContent()} +
        + {/if} +
        +
        +
        +{/if} + +{#if managerOpen && addFormOpen && isCompactViewport} + +
        +
        +
        +
        +

        + Add Project +

        +

        + Use a focused form so the keyboard does not cover the inputs. +

        +
        + +
        + +
        +
        +

        + Add Project +

        +

        + Register another repository for chat and explorer context. +

        +
        + +
        + { addName = (e.target as HTMLInputElement).value }} + disabled={addSubmitting} + class="w-full rounded-lg border border-white/10 bg-slate-950 px-3 py-2 text-sm text-slate-100 placeholder-slate-500 outline-none focus:border-teal-500" + /> + { + addPath = (e.target as HTMLInputElement).value + pathSuggestionsOpen = true + }} + onfocus={() => { + if (pathSuggestions.length > 0 || pathSuggestionError) { + pathSuggestionsOpen = true + } + }} + onkeydown={handlePathKeydown} + disabled={addSubmitting} + class="w-full rounded-lg border border-white/10 bg-slate-950 px-3 py-2 text-sm text-slate-100 placeholder-slate-500 outline-none focus:border-teal-500" + /> +

        + Type an absolute path like /home/vries/projects or ~/code. Suggestions appear below when + folders match. +

        + {#if pathSuggestionsLoading} +

        Loading suggestions...

        + {/if} + {#if showSuggestionPanel && !pathInputHasSearchablePrefix} +
        + Start with / or ~/ to browse folders. +
        + {/if} + {#if pathSuggestionsOpen && pathSuggestions.length > 0} +
        +
        + {#each pathSuggestions as suggestion, index (suggestion.path)} + + {/each} +
        +
        + {/if} + {#if addError} + + {/if} + +
        +
        +
        +
        +{/if} + + diff --git a/frontend/src/components/chat/ProjectContextSwitcher.test.tsx b/frontend/src/components/chat/ProjectContextSwitcher.test.svelte.ts similarity index 86% rename from frontend/src/components/chat/ProjectContextSwitcher.test.tsx rename to frontend/src/components/chat/ProjectContextSwitcher.test.svelte.ts index a2fe3f6..d981b2f 100644 --- a/frontend/src/components/chat/ProjectContextSwitcher.test.tsx +++ b/frontend/src/components/chat/ProjectContextSwitcher.test.svelte.ts @@ -1,8 +1,8 @@ // @vitest-environment happy-dom import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react' -import { ProjectContextSwitcher } from './ProjectContextSwitcher.js' -import type { ProjectSummary } from '../../hooks/useAgUiChat.js' +import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/svelte' +import ProjectContextSwitcher from './ProjectContextSwitcher.svelte' +import type { ProjectSummary } from '../../store/chatStore.svelte.js' const AVAILABLE_PROJECT: ProjectSummary = { id: 'repo-1', @@ -24,7 +24,7 @@ const DEFAULT_PROPS = { visibleProjectIds: [AVAILABLE_PROJECT.id] as string[], onProjectSelect: vi.fn<(id: string) => void>(), onProjectVisibilityChange: vi.fn<(id: string, visible: boolean) => void>(), - onRemoveProject: vi.fn<(id: string) => Promise>().mockResolvedValue(), + onRemoveProject: vi.fn<(id: string) => Promise>().mockResolvedValue(undefined), onAddProject: vi .fn<(name: string, path: string) => Promise>() .mockResolvedValue(AVAILABLE_PROJECT), @@ -34,7 +34,7 @@ const DEFAULT_PROPS = { } function renderSwitcher(overrides: Partial = {}) { - return render() + return render(ProjectContextSwitcher, { props: { ...DEFAULT_PROPS, ...overrides } }) } describe('ProjectContextSwitcher', () => { @@ -46,6 +46,8 @@ describe('ProjectContextSwitcher', () => { afterEach(() => { vi.useRealTimers() + window.innerWidth = 1024 + window.dispatchEvent(new Event('resize')) }) it('renders a button with current project summary', () => { @@ -118,15 +120,18 @@ describe('ProjectContextSwitcher', () => { fireEvent.click(screen.getByRole('button', { name: /Open/i })) fireEvent.click(await screen.findByRole('button', { name: /Add Project/i })) - fireEvent.change(screen.getByRole('textbox', { name: /Project name/i }), { + await waitFor(() => screen.getByRole('textbox', { name: /Project name/i })) + fireEvent.input(screen.getByRole('textbox', { name: /Project name/i }), { target: { value: 'Draft Name' }, }) - fireEvent.change(screen.getByRole('combobox', { name: /Project path/i }), { + fireEvent.input(screen.getByRole('combobox', { name: /Project path/i }), { target: { value: '/tmp/project-draft' }, }) fireEvent.click(screen.getByRole('button', { name: /Hide Add Project/i })) - fireEvent.click(screen.getByRole('button', { name: /Add Project/i })) + await waitFor(() => screen.getByRole('button', { name: /^Add Project$/i })) + fireEvent.click(screen.getByRole('button', { name: /^Add Project$/i })) + await waitFor(() => screen.getByRole('textbox', { name: /Project name/i })) expect((screen.getByRole('textbox', { name: /Project name/i }) as HTMLInputElement).value).toBe( 'Draft Name' @@ -161,7 +166,8 @@ describe('ProjectContextSwitcher', () => { fireEvent.click(screen.getByRole('button', { name: /Open/i })) fireEvent.click(await screen.findByRole('button', { name: /Add Project/i })) - fireEvent.change(screen.getByRole('combobox', { name: /Project path/i }), { + await waitFor(() => screen.getByRole('combobox', { name: /Project path/i })) + fireEvent.input(screen.getByRole('combobox', { name: /Project path/i }), { target: { value: '/work' }, }) diff --git a/frontend/src/components/chat/ProjectContextSwitcher.tsx b/frontend/src/components/chat/ProjectContextSwitcher.tsx deleted file mode 100644 index 01a983f..0000000 --- a/frontend/src/components/chat/ProjectContextSwitcher.tsx +++ /dev/null @@ -1,802 +0,0 @@ -import { useEffect, useMemo, useState } from 'react' -import { createPortal } from 'react-dom' -import type { ProjectSummary } from '../../hooks/useAgUiChat.js' - -const RECENT_PATHS_STORAGE_KEY = 'acp.project-paths.recent' -const DRAFT_PATH_STORAGE_KEY = 'acp.project-paths.draft-path' -const DRAFT_NAME_STORAGE_KEY = 'acp.project-paths.draft-name' -const MAX_RECENT_PATHS = 6 - -export interface ProjectPathSuggestion { - name: string - path: string -} - -interface ProjectContextSwitcherProps { - projects: ProjectSummary[] - selectedProjectId: string | null - visibleProjectIds: string[] - open?: boolean - onProjectSelect: (projectId: string) => void | Promise - onOpenChange?: (open: boolean) => void - onProjectVisibilityChange: (projectId: string, visible: boolean) => void - onAddProject: (name: string, path: string) => Promise - onRemoveProject: (projectId: string) => Promise - onSuggestProjectPaths: (path: string) => Promise -} - -export function ProjectContextSwitcher({ - projects, - selectedProjectId, - visibleProjectIds, - open, - onProjectSelect, - onOpenChange, - onProjectVisibilityChange, - onAddProject, - onRemoveProject, - onSuggestProjectPaths, -}: ProjectContextSwitcherProps) { - const [uncontrolledManagerOpen, setUncontrolledManagerOpen] = useState(false) - const [addFormOpen, setAddFormOpen] = useState(false) - const [addName, setAddName] = useState(() => readStoredValue(DRAFT_NAME_STORAGE_KEY)) - const [addPath, setAddPath] = useState(() => readStoredValue(DRAFT_PATH_STORAGE_KEY)) - const [addError, setAddError] = useState(null) - const [addSubmitting, setAddSubmitting] = useState(false) - const [removingProjectId, setRemovingProjectId] = useState(null) - const [pathSuggestions, setPathSuggestions] = useState([]) - const [pathSuggestionsLoading, setPathSuggestionsLoading] = useState(false) - const [pathSuggestionsOpen, setPathSuggestionsOpen] = useState(false) - const [activeSuggestionIndex, setActiveSuggestionIndex] = useState(-1) - const [pathSuggestionError, setPathSuggestionError] = useState(null) - const [isCompactViewport, setIsCompactViewport] = useState(() => - typeof window !== 'undefined' ? window.innerWidth < 1024 : false - ) - const [recentPaths, setRecentPaths] = useState(() => - readStoredPaths(RECENT_PATHS_STORAGE_KEY) - ) - const suggestionListId = 'project-path-suggestions' - const visibleProjectSet = useMemo(() => new Set(visibleProjectIds), [visibleProjectIds]) - const visibleProjectCount = visibleProjectIds.length - const selectedProject = useMemo( - () => projects.find((project) => project.id === selectedProjectId) ?? null, - [projects, selectedProjectId] - ) - const activeSuggestion = - activeSuggestionIndex >= 0 ? (pathSuggestions[activeSuggestionIndex] ?? null) : null - const breadcrumbSegments = useMemo(() => buildBreadcrumbSegments(addPath), [addPath]) - const pathQuickActions = useMemo( - () => buildPathQuickActions(addPath, recentPaths), - [addPath, recentPaths] - ) - const pathInputValue = addPath.trim() - const pathInputHasSearchablePrefix = looksLikeSuggestionPathInput(pathInputValue) - const showSuggestionPanel = addFormOpen && pathInputValue.length > 0 - const showSuggestionEmptyState = - showSuggestionPanel && - !pathSuggestionsLoading && - !pathSuggestionError && - pathSuggestions.length === 0 && - pathInputHasSearchablePrefix - const managerOpen = open ?? uncontrolledManagerOpen - - const setManagerOpen = (nextOpen: boolean) => { - if (open === undefined) { - setUncontrolledManagerOpen(nextOpen) - } - - onOpenChange?.(nextOpen) - } - - const applySuggestion = (suggestion: ProjectPathSuggestion) => { - setAddPath(toTraversableSuggestionPath(suggestion.path)) - setPathSuggestionsOpen(true) - setActiveSuggestionIndex(-1) - } - - const applyPath = (path: string) => { - setAddPath(toTraversableSuggestionPath(path)) - setPathSuggestionsOpen(true) - setActiveSuggestionIndex(-1) - } - - useEffect(() => { - if (typeof window === 'undefined') { - return - } - - const handleResize = () => { - setIsCompactViewport(window.innerWidth < 1024) - } - - handleResize() - window.addEventListener('resize', handleResize) - return () => window.removeEventListener('resize', handleResize) - }, []) - - useEffect(() => { - writeStoredValue(DRAFT_NAME_STORAGE_KEY, addName) - }, [addName]) - - useEffect(() => { - writeStoredValue(DRAFT_PATH_STORAGE_KEY, addPath) - }, [addPath]) - - useEffect(() => { - if (!addFormOpen) { - setPathSuggestions([]) - setPathSuggestionsLoading(false) - setPathSuggestionsOpen(false) - setActiveSuggestionIndex(-1) - setPathSuggestionError(null) - return - } - - const trimmedPath = addPath.trim() - if (!trimmedPath || !looksLikeSuggestionPathInput(trimmedPath)) { - setPathSuggestions([]) - setPathSuggestionsLoading(false) - setPathSuggestionsOpen(false) - setActiveSuggestionIndex(-1) - setPathSuggestionError(null) - return - } - - let cancelled = false - const timer = window.setTimeout(() => { - setPathSuggestionsLoading(true) - setPathSuggestionError(null) - - void onSuggestProjectPaths(trimmedPath) - .then((nextSuggestions) => { - if (cancelled) { - return - } - - setPathSuggestions(nextSuggestions) - setPathSuggestionsOpen(true) - setActiveSuggestionIndex(nextSuggestions.length > 0 ? 0 : -1) - }) - .catch(() => { - if (cancelled) { - return - } - - setPathSuggestions([]) - setPathSuggestionsOpen(true) - setActiveSuggestionIndex(-1) - setPathSuggestionError('Unable to load folder suggestions right now.') - }) - .finally(() => { - if (!cancelled) { - setPathSuggestionsLoading(false) - } - }) - }, 140) - - return () => { - cancelled = true - window.clearTimeout(timer) - } - }, [addFormOpen, addPath, onSuggestProjectPaths]) - - useEffect(() => { - if (activeSuggestionIndex >= pathSuggestions.length) { - setActiveSuggestionIndex(pathSuggestions.length > 0 ? 0 : -1) - } - }, [activeSuggestionIndex, pathSuggestions]) - - const handleAddSubmit = async (e: React.FormEvent) => { - e.preventDefault() - const trimmedPath = addPath.trim() - - if (!trimmedPath) { - setAddError('Path is required.') - return - } - - const effectiveName = - addName.trim() || (trimmedPath.split('/').filter(Boolean).pop() ?? trimmedPath) - - setAddError(null) - setAddSubmitting(true) - - try { - const newProject = await onAddProject(effectiveName, trimmedPath) - const normalizedStoredPath = normalizeStoredPath(trimmedPath) - if (normalizedStoredPath) { - setRecentPaths((current) => { - const next = [ - normalizedStoredPath, - ...current.filter((item) => item !== normalizedStoredPath), - ].slice(0, MAX_RECENT_PATHS) - writeStoredPaths(RECENT_PATHS_STORAGE_KEY, next) - return next - }) - } - - setAddName('') - setAddPath('') - setAddFormOpen(false) - setPathSuggestions([]) - setPathSuggestionsOpen(false) - setActiveSuggestionIndex(-1) - onProjectVisibilityChange(newProject.id, true) - await onProjectSelect(newProject.id) - } catch { - setAddError('Failed to add project. Check the name and path and try again.') - } finally { - setAddSubmitting(false) - } - } - - const addProjectForm = ( - <> -
        -

        - Add Project -

        -

        - Register another repository for chat and explorer context. -

        -
        - -
        void handleAddSubmit(e)} - className="mt-4 space-y-3" - > - setAddName(e.target.value)} - disabled={addSubmitting} - className="w-full rounded-lg border border-white/10 bg-slate-950 px-3 py-2 text-sm text-slate-100 placeholder-slate-500 outline-none focus:border-teal-500" - /> - { - setAddPath(e.target.value) - setPathSuggestionsOpen(true) - }} - onFocus={() => { - if (pathSuggestions.length > 0 || pathSuggestionError) { - setPathSuggestionsOpen(true) - } - }} - onKeyDown={(e) => { - if (e.key === 'Escape') { - setPathSuggestionsOpen(false) - setActiveSuggestionIndex(-1) - return - } - - if (pathSuggestions.length === 0) { - return - } - - if (e.key === 'ArrowDown') { - e.preventDefault() - setPathSuggestionsOpen(true) - setActiveSuggestionIndex((current) => - current < pathSuggestions.length - 1 ? current + 1 : 0 - ) - return - } - - if (e.key === 'ArrowUp') { - e.preventDefault() - setPathSuggestionsOpen(true) - setActiveSuggestionIndex((current) => - current > 0 ? current - 1 : pathSuggestions.length - 1 - ) - return - } - - if (e.key === 'Enter' && pathSuggestionsOpen && activeSuggestion) { - e.preventDefault() - applySuggestion(activeSuggestion) - } - }} - disabled={addSubmitting} - className="w-full rounded-lg border border-white/10 bg-slate-950 px-3 py-2 text-sm text-slate-100 placeholder-slate-500 outline-none focus:border-teal-500" - /> -

        - Type an absolute path like /home/vries/projects or ~/code. Suggestions appear below when - folders match. -

        - {breadcrumbSegments.length > 0 ? ( -
        - {breadcrumbSegments.map((segment) => ( - - ))} -
        - ) : null} - {pathQuickActions.length > 0 ? ( -
        - {pathQuickActions.map((group) => ( -
        -
        -

        - {group.label} -

        -
        -
        - {group.paths.map((path) => ( - - ))} -
        -
        - ))} -
        - ) : null} - {pathSuggestionsLoading ? ( -

        Loading suggestions...

        - ) : null} - {showSuggestionPanel && !pathInputHasSearchablePrefix ? ( -
        - Start with / or ~/ to browse folders. -
        - ) : null} - {pathSuggestionsOpen && pathSuggestions.length > 0 ? ( -
        -
        -

        - Path suggestions -

        -

        Arrow keys · Enter

        -
        -
        - {pathSuggestions.map((suggestion, index) => ( - - ))} -
        -
        - ) : null} - {pathSuggestionsOpen && pathSuggestionError ? ( -
        - {pathSuggestionError} -
        - ) : null} - {showSuggestionEmptyState ? ( -
        - No matching folders found for {pathInputValue}. -
        - ) : null} - {addError ? ( -

        - {addError} -

        - ) : null} - -
        - - ) - - return ( - <> - - - {managerOpen && typeof document !== 'undefined' - ? createPortal( -
        -
        -
        -
        -

        - Projects -

        -

        - Manage Project Views -

        -

        - Show or hide projects in the chat rail, choose the current workspace, and add - new repositories. -

        -
        -
        - - -
        -
        - -
        -
        -
        -
        -

        - Session Rail -

        -

        - Choose which projects appear in chats. -

        -
        - - {visibleProjectCount}/{projects.length} - -
        - -
        - {projects.map((project) => { - const visible = visibleProjectSet.has(project.id) - const current = project.id === selectedProjectId - const selectable = project.status === 'available' - - return ( -
        -
        -
        -
        -

        - {project.name} -

        - {current ? ( - - Current - - ) : null} - - {describeProjectStatus(project.status)} - -
        -

        - {project.path} -

        -
        - -
        - -
        -

        - {visible - ? 'Visible in the session viewer.' - : 'Hidden from the session viewer.'} -

        -
        - - -
        -
        -
        - ) - })} -
        -
        - - {addFormOpen && !isCompactViewport ? ( -
        - {addProjectForm} -
        - ) : null} -
        -
        -
        , - document.body - ) - : null} - - {managerOpen && addFormOpen && isCompactViewport && typeof document !== 'undefined' - ? createPortal( -
        -
        -
        -
        -

        - Add Project -

        -

        - Use a focused form so the keyboard does not cover the inputs. -

        -
        - -
        - -
        - {addProjectForm} -
        -
        -
        , - document.body - ) - : null} - - ) -} - -function buildProjectStatusClassName(status: ProjectSummary['status']): string { - if (status === 'available') { - return 'rounded-full border border-emerald-500/20 bg-emerald-500/10 px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-emerald-200' - } - - return 'rounded-full border border-white/10 bg-slate-900 px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-400' -} - -function describeProjectStatus(status: ProjectSummary['status']): string { - if (status === 'available') { - return status - } - - return status === 'missing' ? 'path not found' : 'path is invalid' -} - -function toTraversableSuggestionPath(path: string): string { - return path === '/' ? path : `${path}/` -} - -function buildSuggestionId(path: string): string { - return `project-path-suggestion-${path.replace(/[^a-zA-Z0-9_-]+/g, '-')}` -} - -function buildBreadcrumbSegments(path: string): Array<{ label: string; path: string }> { - const trimmed = path.trim() - if (!trimmed.startsWith('/')) { - return [] - } - - const normalized = trimmed === '/' ? '/' : trimmed.replace(/\/+$/, '') - if (normalized === '/') { - return [{ label: '/', path: '/' }] - } - - const parts = normalized.split('/').filter(Boolean) - const segments = [{ label: '/', path: '/' }] - let current = '' - - for (const part of parts) { - current = `${current}/${part}` - segments.push({ label: part, path: current }) - } - - return segments -} - -function buildPathQuickActions( - currentPath: string, - recentPaths: string[] -): Array<{ label: string; paths: string[] }> { - const current = normalizeStoredPath(currentPath) - const groups = [{ label: 'Recent', paths: recentPaths.filter((path) => path !== current) }] - - return groups.filter((group) => group.paths.length > 0) -} - -function normalizeStoredPath(path: string): string { - const trimmed = path.trim() - if (!looksLikeSuggestionPathInput(trimmed)) { - return '' - } - - if (trimmed === '~') { - return '~' - } - - return trimmed === '/' ? '/' : trimmed.replace(/\/+$/, '') -} - -function looksLikeSuggestionPathInput(path: string): boolean { - return path.startsWith('/') || path === '~' || path.startsWith('~/') -} - -function readStoredValue(storageKey: string): string { - if (typeof window === 'undefined' || !window.localStorage) { - return '' - } - - return window.localStorage.getItem(storageKey) ?? '' -} - -function writeStoredValue(storageKey: string, value: string): void { - if (typeof window === 'undefined' || !window.localStorage) { - return - } - - if (!value) { - window.localStorage.removeItem(storageKey) - return - } - - window.localStorage.setItem(storageKey, value) -} - -function compactPathLabel(path: string): string { - if (path === '/' || path === '~') { - return path - } - - const trimmed = path.replace(/\/+$/, '') - const parts = trimmed.split('/').filter(Boolean) - if (path.startsWith('~/')) { - const homeParts = path.slice(2).replace(/\/+$/, '').split('/').filter(Boolean) - if (homeParts.length <= 2) { - return `~/${homeParts.join('/')}` - } - - return `~/${homeParts[0]}/.../${homeParts.at(-1)}` - } - - if (parts.length <= 3) { - return path - } - - return `/${parts[0]}/${parts[1]}/.../${parts.at(-1)}` -} - -function readStoredPaths(storageKey: string): string[] { - if (typeof window === 'undefined' || !window.localStorage) { - return [] - } - - try { - const value = window.localStorage.getItem(storageKey) - if (!value) { - return [] - } - - const parsed = JSON.parse(value) as unknown - return Array.isArray(parsed) - ? parsed.filter((item): item is string => typeof item === 'string').slice(0, MAX_RECENT_PATHS) - : [] - } catch { - return [] - } -} - -function writeStoredPaths(storageKey: string, paths: string[]): void { - if (typeof window === 'undefined' || !window.localStorage) { - return - } - - window.localStorage.setItem(storageKey, JSON.stringify(paths)) -} diff --git a/frontend/src/components/chat/ProjectWorkspacePanel.svelte b/frontend/src/components/chat/ProjectWorkspacePanel.svelte new file mode 100644 index 0000000..63486d0 --- /dev/null +++ b/frontend/src/components/chat/ProjectWorkspacePanel.svelte @@ -0,0 +1,217 @@ + + +{#snippet emptyPanel(title: string, description: string, tone: 'default' | 'error' = 'default')} +
        +

        {title}

        +

        {description}

        +
        +{/snippet} + + + + diff --git a/frontend/src/components/chat/ProjectWorkspacePanel.test.tsx b/frontend/src/components/chat/ProjectWorkspacePanel.test.svelte.ts similarity index 89% rename from frontend/src/components/chat/ProjectWorkspacePanel.test.tsx rename to frontend/src/components/chat/ProjectWorkspacePanel.test.svelte.ts index 1196f2e..77fa14b 100644 --- a/frontend/src/components/chat/ProjectWorkspacePanel.test.tsx +++ b/frontend/src/components/chat/ProjectWorkspacePanel.test.svelte.ts @@ -1,9 +1,8 @@ // @vitest-environment happy-dom -import type React from 'react' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { render, screen, fireEvent, cleanup, within } from '@testing-library/react' -import { ProjectWorkspacePanel } from './ProjectWorkspacePanel.js' -import type { ProjectSummary } from '../../hooks/useAgUiChat.js' +import { render, screen, fireEvent, cleanup, within } from '@testing-library/svelte' +import ProjectWorkspacePanel from './ProjectWorkspacePanel.svelte' +import type { ProjectSummary } from '../../store/chatStore.svelte.js' const AVAILABLE_PROJECT: ProjectSummary = { id: 'repo-1', @@ -23,7 +22,7 @@ const DEFAULT_PROPS = { projects: [AVAILABLE_PROJECT, MISSING_PROJECT] as ProjectSummary[], selectedProjectId: AVAILABLE_PROJECT.id as string | null, onProjectSelect: vi.fn<(id: string) => void>(), - tree: [] as [], + tree: [] as unknown[], treePath: null as string | null, treeLoading: false, treeError: null as string | null, @@ -33,8 +32,10 @@ const DEFAULT_PROPS = { onSelectEntry: vi.fn<(path: string | null) => void>(), } -function renderPanel(overrides: Partial> = {}) { - return render() +function renderPanel( + overrides: Partial & { showProjectPicker?: boolean } = {} +) { + return render(ProjectWorkspacePanel, { props: { ...DEFAULT_PROPS, ...overrides } }) } describe('ProjectWorkspacePanel', () => { @@ -89,7 +90,7 @@ describe('ProjectWorkspacePanel', () => { it('renders tree entries and toggles folders', () => { renderPanel({ - tree: [{ name: 'src', path: 'src', type: 'directory', hasChildren: true }], + tree: [{ name: 'src', path: 'src', type: 'directory', hasChildren: true }] as unknown[], }) fireEvent.click(screen.getByRole('button', { name: /src/i })) diff --git a/frontend/src/components/chat/ProjectWorkspacePanel.tsx b/frontend/src/components/chat/ProjectWorkspacePanel.tsx deleted file mode 100644 index 10136cc..0000000 --- a/frontend/src/components/chat/ProjectWorkspacePanel.tsx +++ /dev/null @@ -1,271 +0,0 @@ -import { useMemo, useState } from 'react' -import type { ProjectSummary } from '../../hooks/useAgUiChat.js' - -export interface ProjectTreeEntry { - name: string - path: string - type: 'file' | 'directory' - hasChildren: boolean -} - -interface ProjectWorkspacePanelProps { - projects: ProjectSummary[] - selectedProjectId: string | null - onProjectSelect: (projectId: string) => void | Promise - activeAgentCount?: number - showProjectPicker?: boolean - tree: ProjectTreeEntry[] - treePath: string | null - treeLoading: boolean - treeError: string | null - expandedPaths: string[] - onToggleFolder: (path: string) => void | Promise - selectedEntryPath: string | null - onSelectEntry: (path: string | null) => void -} - -export function ProjectWorkspacePanel({ - projects, - selectedProjectId, - onProjectSelect, - activeAgentCount = 0, - showProjectPicker = true, - tree, - treePath, - treeLoading, - treeError, - expandedPaths, - onToggleFolder, - selectedEntryPath, - onSelectEntry, -}: ProjectWorkspacePanelProps) { - const [mobileOpen, setMobileOpen] = useState(false) - const panelId = 'project-workspace-panel' - const selectedProject = useMemo( - () => projects.find((project) => project.id === selectedProjectId) ?? null, - [projects, selectedProjectId] - ) - const expanded = new Set(expandedPaths) - - return ( - <> - - - - - ) -} - -function buildProjectOptionLabel(project: ProjectSummary, activeAgentCount: number): string { - if (project.status !== 'available') { - return `${project.name} — ${project.status}` - } - - if (activeAgentCount === 0) { - return `${project.name} — no agent online` - } - - return `${project.name} - ${project.path}` -} - -function buildProjectStatusClassName(status: ProjectSummary['status']): string { - if (status === 'available') { - return 'rounded-full border border-emerald-500/20 bg-emerald-500/10 px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-emerald-200' - } - - return 'rounded-full border border-white/10 bg-slate-900 px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-400' -} - -interface EmptyPanelProps { - title: string - description: string - tone?: 'default' | 'error' -} - -function EmptyPanel({ title, description, tone = 'default' }: EmptyPanelProps) { - return ( -
        -

        {title}

        -

        {description}

        -
        - ) -} diff --git a/frontend/src/components/chat/ReasoningGroup.svelte b/frontend/src/components/chat/ReasoningGroup.svelte new file mode 100644 index 0000000..a67264c --- /dev/null +++ b/frontend/src/components/chat/ReasoningGroup.svelte @@ -0,0 +1,76 @@ + + +
        + + + {#if open} +
        + {#each blocks as block, i (`${block.payload.title ?? 'reasoning'}-${i}`)} +
        + {#if blocks.length > 1 && block.payload.title && block.payload.title !== latestTitle} +

        {block.payload.title}

        + {/if} +
        + + {@html renderMarkdown(stripReasoningHeading(block.payload.text, block.payload.title))} +
        +
        + {/each} +
        + {/if} +
        diff --git a/frontend/src/components/chat/SessionList.stories.tsx b/frontend/src/components/chat/SessionList.stories.tsx deleted file mode 100644 index 0d1f1d4..0000000 --- a/frontend/src/components/chat/SessionList.stories.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react-vite' -import { SessionList } from './SessionList.js' - -const denseSessions = Array.from({ length: 8 }, (_, index) => ({ - id: `session-${index + 1}`, - title: index === 0 ? 'Inspect auth bug' : `Conversation ${index + 1}`, - updatedAt: `2026-03-1${Math.min(index + 1, 8)}T08:0${index}:00.000Z`, - agentId: 'copilot', - source: 'history' as const, - project: { - id: 'acp-frontend', - name: 'ACP Frontend', - path: '/home/vries/projects/acp-frontend', - }, -})) - -const meta = { - title: 'Chat/SessionList', - component: SessionList, - args: { - agents: [ - { - id: 'copilot', - name: 'GitHub Copilot', - status: 'active', - command: 'copilot', - canResume: true, - canLoad: false, - }, - { - id: 'gemini-cli', - name: 'Gemini CLI', - status: 'active', - command: 'gemini', - canResume: true, - canLoad: false, - }, - { - id: 'claude-code', - name: 'Claude Code', - status: 'unavailable', - command: null, - canResume: false, - canLoad: false, - }, - ], - sessions: denseSessions, - activeSessionId: 'session-1', - creatingSession: false, - onCreate: () => {}, - onSelect: () => {}, - }, -} satisfies Meta - -export default meta -type Story = StoryObj - -export const DenseList: Story = {} - -export const Empty: Story = { - args: { - sessions: [], - activeSessionId: null, - }, -} - -export const Creating: Story = { - args: { - creatingSession: true, - }, -} - -export const MixedAgents: Story = { - args: { - sessions: [ - ...denseSessions, - { - id: 'gemini-session-1', - title: 'Gemini compatibility notes', - updatedAt: '2026-03-18T11:20:00.000Z', - agentId: 'gemini-cli', - source: 'history' as const, - project: { - id: 'docs-site', - name: 'Docs Site', - path: '/home/vries/projects/docs-site', - }, - }, - ], - }, -} diff --git a/frontend/src/components/chat/SessionList.svelte b/frontend/src/components/chat/SessionList.svelte new file mode 100644 index 0000000..a516982 --- /dev/null +++ b/frontend/src/components/chat/SessionList.svelte @@ -0,0 +1,320 @@ + + + diff --git a/frontend/src/components/chat/SessionList.tsx b/frontend/src/components/chat/SessionList.tsx deleted file mode 100644 index 255736d..0000000 --- a/frontend/src/components/chat/SessionList.tsx +++ /dev/null @@ -1,361 +0,0 @@ -import { useEffect, useMemo, useState } from 'react' -import type { AgentSummary, SessionSummary } from '../../hooks/useAgUiChat.js' - -interface SessionListProps { - agents: AgentSummary[] - sessions: SessionSummary[] - selectedProjectId?: string | null - activeSessionId: string | null - creatingSession: boolean - loading?: boolean - onCreate: (agentId: string) => void | Promise - onSelect: (sessionId: string) => void | Promise -} - -export function SessionList({ - agents, - sessions, - selectedProjectId = null, - activeSessionId, - creatingSession, - loading = false, - onCreate, - onSelect, -}: SessionListProps) { - const [pickerOpen, setPickerOpen] = useState(false) - const [collapsedProjects, setCollapsedProjects] = useState([]) - - const agentById = useMemo(() => new Map(agents.map((a) => [a.id, a])), [agents]) - const activeAgents = useMemo(() => agents.filter((agent) => agent.status === 'active'), [agents]) - const projectGroups = useMemo( - () => buildProjectGroups({ agentById, sessions, activeSessionId, selectedProjectId }), - [activeSessionId, agentById, selectedProjectId, sessions] - ) - const selectedProjectHasSessions = useMemo( - () => - selectedProjectId ? projectGroups.some((group) => group.id === selectedProjectId) : false, - [projectGroups, selectedProjectId] - ) - - useEffect(() => { - if (!selectedProjectId) return - - setCollapsedProjects((current) => - current.filter((projectId) => projectId !== selectedProjectId) - ) - }, [selectedProjectId]) - - function handleAgentPick(agentId: string) { - setPickerOpen(false) - void onCreate(agentId) - } - - function handleNewChat() { - if (activeAgents.length === 1) { - void onCreate(activeAgents[0]!.id) - } else { - setPickerOpen((open) => !open) - } - } - - return ( - - ) -} - -interface AgentDotProps { - status: AgentSummary['status'] -} - -function AgentDot({ status }: AgentDotProps) { - const className = - status === 'active' - ? 'h-2 w-2 rounded-full bg-emerald-400 shadow-[0_0_4px_rgba(52,211,153,0.6)] flex-shrink-0' - : status === 'detected' - ? 'h-2 w-2 rounded-full bg-amber-400 flex-shrink-0' - : 'h-2 w-2 rounded-full bg-slate-600 flex-shrink-0' - - const label = status === 'active' ? 'online' : status === 'detected' ? 'detected' : 'offline' - - return -} - -function formatUpdatedAt(updatedAt: string): string { - const date = new Date(updatedAt) - if (Number.isNaN(date.valueOf())) return 'just now' - - return new Intl.DateTimeFormat('en', { - month: 'short', - day: 'numeric', - hour: 'numeric', - minute: '2-digit', - }).format(date) -} - -interface ProjectGroup { - id: string - name: string - pathLabel: string - sessions: SessionSummary[] -} - -function buildProjectGroups({ - agentById, - sessions, - activeSessionId, - selectedProjectId, -}: { - agentById: Map - sessions: SessionSummary[] - activeSessionId: string | null - selectedProjectId: string | null -}): ProjectGroup[] { - const grouped = new Map() - - const visibleSessions = sessions - .filter((session) => agentById.get(session.agentId)?.status !== 'disabled') - .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)) - - for (const session of visibleSessions) { - const projectId = session.project?.id ?? '__no_project__' - const group = grouped.get(projectId) - - if (group) { - group.sessions.push(session) - continue - } - - grouped.set(projectId, { - id: projectId, - name: session.project?.name ?? 'No project', - pathLabel: compactProjectPath(session.project?.path ?? 'No project path'), - sessions: [session], - }) - } - - return Array.from(grouped.values()).sort((left, right) => { - const leftSelected = left.id === selectedProjectId ? 1 : 0 - const rightSelected = right.id === selectedProjectId ? 1 : 0 - if (leftSelected !== rightSelected) { - return rightSelected - leftSelected - } - - const leftActive = left.sessions.some((session) => session.id === activeSessionId) ? 1 : 0 - const rightActive = right.sessions.some((session) => session.id === activeSessionId) ? 1 : 0 - if (leftActive !== rightActive) { - return rightActive - leftActive - } - - return left.name.localeCompare(right.name) - }) -} - -function compactProjectPath(path: string): string { - const trimmed = path.trim() - if (trimmed.length <= 36) { - return trimmed - } - - const parts = trimmed.split('/').filter(Boolean) - if (parts.length <= 3) { - return trimmed - } - - return `/${parts[0]}/${parts[1]}/.../${parts.at(-1)}` -} diff --git a/frontend/src/components/chat/StructuredAssistantMessage.svelte b/frontend/src/components/chat/StructuredAssistantMessage.svelte new file mode 100644 index 0000000..f008bdb --- /dev/null +++ b/frontend/src/components/chat/StructuredAssistantMessage.svelte @@ -0,0 +1,165 @@ + + +
        + {#each groups as group, index (index)} + {#if group.kind === 'reasoning_group'} + + {:else if group.kind === 'attachment_group'} + + {:else if group.kind === 'compaction_group'} + {@render CompactionGroup(group.blocks)} + {:else if group.kind === 'truncation_group'} + {@render TruncationGroup(group.blocks)} + {:else if group.kind === 'tool_group'} + + {/if} + {/each} +
        + +{#snippet CompactionGroup(cblocks: Array>)} +
        + {#each cblocks as block, i (`compaction-${(block.payload as A2UICompactionNoticePayload).auto}-${(block.payload as A2UICompactionNoticePayload).overflow}-${i}`)} +
        +

        Compaction

        +

        + {(block.payload as A2UICompactionNoticePayload).auto ? 'Session compacted automatically' : 'Session compacted'} +

        +

        + {(block.payload as A2UICompactionNoticePayload).overflow + ? 'Context overflow triggered a history compaction.' + : 'Earlier context was condensed into a compact history snapshot.'} +

        +
        + {/each} +
        +{/snippet} + +{#snippet TruncationGroup(tblocks: Array>)} +
        + {#each tblocks as block, i (`truncation-${(block.payload as A2UITruncationNoticePayload).tokenLimit}-${(block.payload as A2UITruncationNoticePayload).tokensRemoved}-${i}`)} + {@const p = block.payload as A2UITruncationNoticePayload} + {@const details = [ + p.tokenLimit !== undefined ? `Limit ${p.tokenLimit.toLocaleString()} tokens` : null, + p.tokensRemoved !== undefined ? `Removed ${p.tokensRemoved.toLocaleString()} tokens` : null, + p.messagesRemoved !== undefined ? `Dropped ${p.messagesRemoved.toLocaleString()} messages` : null, + ].filter(Boolean).join(' - ')} +
        +

        Truncation

        +

        Session truncated to stay within context limits.

        + {#if details} +

        {details}

        + {/if} +
        + {/each} +
        +{/snippet} + + diff --git a/frontend/src/components/chat/StructuredAssistantMessage.test.tsx b/frontend/src/components/chat/StructuredAssistantMessage.test.svelte.ts similarity index 86% rename from frontend/src/components/chat/StructuredAssistantMessage.test.tsx rename to frontend/src/components/chat/StructuredAssistantMessage.test.svelte.ts index d6a6c75..8f0696a 100644 --- a/frontend/src/components/chat/StructuredAssistantMessage.test.tsx +++ b/frontend/src/components/chat/StructuredAssistantMessage.test.svelte.ts @@ -1,7 +1,7 @@ // @vitest-environment happy-dom import { describe, it, expect } from 'vitest' -import { fireEvent, render, screen } from '@testing-library/react' -import { StructuredAssistantMessage } from './StructuredAssistantMessage.js' +import { fireEvent, render, screen } from '@testing-library/svelte' +import StructuredAssistantMessage from './StructuredAssistantMessage.svelte' import type { StructuredBlock } from './a2ui-types.js' describe('StructuredAssistantMessage', () => { @@ -12,7 +12,7 @@ describe('StructuredAssistantMessage', () => { payload: { callId: 'c-1', toolName: 'read_file', done: false }, }, ] - render() + render(StructuredAssistantMessage, { props: { blocks } }) expect(screen.getByText('read_file')).toBeDefined() expect(screen.queryByTestId('a2ui-tool-call-card')).toBeNull() @@ -30,7 +30,7 @@ describe('StructuredAssistantMessage', () => { payload: { callId: 'c-1', toolName: 'bash', result: 'exit 0', done: true }, }, ] - render() + render(StructuredAssistantMessage, { props: { blocks } }) fireEvent.click(screen.getByRole('button', { name: /bash/i })) fireEvent.click(screen.getByRole('button', { name: /Completed/i })) @@ -45,7 +45,7 @@ describe('StructuredAssistantMessage', () => { payload: { callId: 'c-1', toolName: 'bash', args: 'ls -la', done: false }, }, ] - render() + render(StructuredAssistantMessage, { props: { blocks } }) fireEvent.click(screen.getByRole('button', { name: /bash/i })) expect(screen.getByText('ls -la')).toBeDefined() @@ -55,8 +55,7 @@ describe('StructuredAssistantMessage', () => { // Cast to simulate an unrecognised kind arriving at runtime const blocks = [{ kind: 'unknown_future_widget', payload: {} }] as unknown as StructuredBlock[] - const { container } = render() - // The wrapper div exists but has no children + const { container } = render(StructuredAssistantMessage, { props: { blocks } }) expect(container.querySelector('[data-testid="a2ui-tool-call-card"]')).toBeNull() }) @@ -65,7 +64,7 @@ describe('StructuredAssistantMessage', () => { { kind: 'tool_call', payload: { callId: 'c-1', toolName: 'read_file', done: false } }, { kind: 'tool_call', payload: { callId: 'c-2', toolName: 'bash', done: true, result: 'ok' } }, ] - render() + render(StructuredAssistantMessage, { props: { blocks } }) expect(screen.getByText('2 tool calls')).toBeDefined() fireEvent.click(screen.getByRole('button', { name: /2 tool calls/i })) @@ -104,7 +103,7 @@ describe('StructuredAssistantMessage', () => { }, ] - render() + render(StructuredAssistantMessage, { props: { blocks } }) expect(screen.getByText('Refining output')).toBeDefined() expect(screen.queryByTestId('a2ui-skill-card')).toBeNull() @@ -119,9 +118,9 @@ describe('StructuredAssistantMessage', () => { }) it('renders attachment blocks with inline images', () => { - render( - { url: 'data:image/png;base64,AAAA', }, }, - ]} - /> - ) + ], + }, + }) expect(screen.getByTestId('a2ui-attachment-card')).toBeDefined() expect(screen.getByAltText('image.png')).toBeDefined() }) it('renders compaction notices as their own block group', () => { - render( - { overflow: false, }, }, - ]} - /> - ) + ], + }, + }) expect(screen.getByTestId('a2ui-compaction-card')).toBeDefined() expect(screen.getByText('Session compacted automatically')).toBeDefined() }) it('renders truncation notices distinctly from compaction notices', () => { - render( - { messagesRemoved: 4, }, }, - ]} - /> - ) + ], + }, + }) expect(screen.getByTestId('a2ui-truncation-card')).toBeDefined() expect(screen.getByText('Session truncated to stay within context limits.')).toBeDefined() }) it('renders file operations, model switches, and approval notices inside actions', () => { - render( - { state: 'pending', }, }, - ]} - /> - ) + ], + }, + }) fireEvent.click(screen.getByRole('button', { name: /3 tool calls/i })) expect(screen.getByTestId('a2ui-file-operation-card')).toBeDefined() diff --git a/frontend/src/components/chat/StructuredAssistantMessage.tsx b/frontend/src/components/chat/StructuredAssistantMessage.tsx deleted file mode 100644 index 302c89c..0000000 --- a/frontend/src/components/chat/StructuredAssistantMessage.tsx +++ /dev/null @@ -1,778 +0,0 @@ -import { Component, useMemo, useState, type ErrorInfo, type ReactNode } from 'react' -import ReactMarkdown from 'react-markdown' -import remarkGfm from 'remark-gfm' -import type { - A2UIApprovalNoticePayload, - A2UICompactionNoticePayload, - A2UIFileOperationPayload, - A2UIModelSwitchPayload, - A2UISkillInvocationPayload, - A2UISubagentInvocationPayload, - A2UIToolCallPayload, - A2UITruncationNoticePayload, - StructuredBlock, -} from './a2ui-types.js' - -// --------------------------------------------------------------------------- -// Error boundary — wraps each renderer so a throw can't crash the conversation -// --------------------------------------------------------------------------- - -interface ErrorBoundaryState { - caught: boolean -} - -class BlockErrorBoundary extends Component<{ children: ReactNode }, ErrorBoundaryState> { - state: ErrorBoundaryState = { caught: false } - - static getDerivedStateFromError(): ErrorBoundaryState { - return { caught: true } - } - - componentDidCatch(error: Error, info: ErrorInfo): void { - console.error('[A2UI] renderer threw:', error, info) - } - - render(): ReactNode { - if (this.state.caught) return null - return this.props.children - } -} - -// --------------------------------------------------------------------------- -// Individual widget renderers -// --------------------------------------------------------------------------- - -function ToolCallCard({ - payload, - projectPath, -}: { - payload: A2UIToolCallPayload - projectPath?: string | null -}) { - const [open, setOpen] = useState(false) - const argsText = - payload.args !== undefined - ? typeof payload.args === 'string' - ? relativizeText(payload.args, projectPath) - : JSON.stringify(relativizeValue(payload.args, projectPath), null, 2) - : null - const summary = summarizeToolCall(payload, projectPath) - - return ( -
        - - - {open ? ( -
        - {argsText ? ( -
        -

        Input

        -
        -                {argsText}
        -              
        -
        - ) : null} - {payload.done && payload.result !== undefined ? ( -
        -

        Result

        -
        -                {payload.result}
        -              
        -
        - ) : null} - {!payload.done ?

        Running…

        : null} -
        - ) : null} -
        - ) -} - -function SkillInvocationCard({ payload }: { payload: A2UISkillInvocationPayload }) { - return ( -
        -
        -

        Skill: {payload.skillName}

        - - {payload.status} - -
        - {payload.result ? ( -
        -          {payload.result}
        -        
        - ) : null} -
        - ) -} - -function SubagentInvocationCard({ payload }: { payload: A2UISubagentInvocationPayload }) { - return ( -
        -
        -

        Subagent: {payload.agentName}

        - - {payload.status} - -
        - {payload.prompt ? ( -

        {payload.prompt}

        - ) : null} - {payload.result ? ( -
        -          {payload.result}
        -        
        - ) : null} -
        - ) -} - -function CompactionNoticeCard({ payload }: { payload: A2UICompactionNoticePayload }) { - const label = payload.auto ? 'Session compacted automatically' : 'Session compacted' - const detail = payload.overflow - ? 'Context overflow triggered a history compaction.' - : 'Earlier context was condensed into a compact history snapshot.' - - return ( -
        -

        - Compaction -

        -

        {label}

        -

        {detail}

        -
        - ) -} - -function FileOperationCard({ payload }: { payload: A2UIFileOperationPayload }) { - return ( -
        -

        - File {payload.operation} -

        -

        {payload.path}

        - {payload.source ?

        {payload.source}

        : null} -
        - ) -} - -function ModelSwitchCard({ payload }: { payload: A2UIModelSwitchPayload }) { - return ( -
        -

        - Model Switch -

        -

        - {payload.fromModelId ? `${payload.fromModelId} -> ${payload.toModelId}` : payload.toModelId} -

        -
        - ) -} - -function ApprovalNoticeCard({ payload }: { payload: A2UIApprovalNoticePayload }) { - return ( -
        -

        - Approval -

        -

        {payload.title}

        - {payload.message ?

        {payload.message}

        : null} -

        {payload.state}

        -
        - ) -} - -function TruncationNoticeCard({ payload }: { payload: A2UITruncationNoticePayload }) { - const details = [ - payload.tokenLimit !== undefined ? `Limit ${payload.tokenLimit.toLocaleString()} tokens` : null, - payload.tokensRemoved !== undefined - ? `Removed ${payload.tokensRemoved.toLocaleString()} tokens` - : null, - payload.messagesRemoved !== undefined - ? `Dropped ${payload.messagesRemoved.toLocaleString()} messages` - : null, - ] - .filter(Boolean) - .join(' - ') - - return ( -
        -

        - Truncation -

        -

        Session truncated to stay within context limits.

        - {details ?

        {details}

        : null} -
        - ) -} - -// --------------------------------------------------------------------------- -// Component registry -// --------------------------------------------------------------------------- - -type BlockRenderer = React.ComponentType<{ - payload: Extract['payload'] - projectPath?: string | null -}> - -const REGISTRY: { [K in StructuredBlock['kind']]: BlockRenderer } = { - approval_notice: ApprovalNoticeCard, - attachment: () => null, - compaction_notice: CompactionNoticeCard, - file_operation: FileOperationCard, - model_switch: ModelSwitchCard, - truncation_notice: TruncationNoticeCard, - skill_invocation: SkillInvocationCard, - subagent_invocation: SubagentInvocationCard, - tool_call: ToolCallCard, - reasoning: () => null, -} - -// --------------------------------------------------------------------------- -// Public component -// --------------------------------------------------------------------------- - -interface StructuredAssistantMessageProps { - blocks: StructuredBlock[] - summaryTitle?: string | null - projectPath?: string | null -} - -export function StructuredAssistantMessage({ - blocks, - summaryTitle = null, - projectPath = null, -}: StructuredAssistantMessageProps) { - const groups = useMemo(() => groupStructuredBlocks(blocks), [blocks]) - - return ( -
        - {groups.map((group, index) => - group.kind === 'reasoning_group' ? ( - - ) : group.kind === 'attachment_group' ? ( - - ) : group.kind === 'compaction_group' ? ( - - ) : group.kind === 'truncation_group' ? ( - - ) : group.kind === 'tool_group' ? ( - - ) : null - )} -
        - ) -} - -function ReasoningGroup({ - blocks, -}: { - blocks: Array> -}) { - const [open, setOpen] = useState(false) - const latestTitle = [...blocks] - .reverse() - .find((block) => block.payload.title?.trim()) - ?.payload.title?.trim() - - return ( -
        - - - {open ? ( -
        - {blocks.map((block, index) => ( -
        - {blocks.length > 1 && block.payload.title && block.payload.title !== latestTitle ? ( -

        {block.payload.title}

        - ) : null} -
        - - {stripReasoningHeading(block.payload.text, block.payload.title)} - -
        -
        - ))} -
        - ) : null} -
        - ) -} - -function ToolGroup({ - blocks, - summaryTitle, - projectPath, -}: { - blocks: Array< - Exclude< - StructuredBlock, - { - kind: 'reasoning' | 'attachment' | 'compaction_notice' | 'truncation_notice' - } - > - > - summaryTitle?: string | null - projectPath?: string | null -}) { - const [open, setOpen] = useState(false) - const summary = - summaryTitle?.trim() || - (blocks.length === 1 ? describeToolBlock(blocks[0]) : `${blocks.length} tool calls`) - - return ( -
        - - - {open ? ( -
        - {blocks.map((block, index) => { - const Renderer = REGISTRY[block.kind] as BlockRenderer | undefined - if (!Renderer) return null - - const stableKey = - 'callId' in block.payload - ? `${block.kind}-${block.payload.callId}` - : `${block.kind}-${index}` - - return ( - - - - ) - })} -
        - ) : null} -
        - ) -} - -type StructuredGroup = - | { kind: 'reasoning_group'; blocks: Array> } - | { kind: 'attachment_group'; blocks: Array> } - | { - kind: 'compaction_group' - blocks: Array> - } - | { - kind: 'truncation_group' - blocks: Array> - } - | { - kind: 'tool_group' - blocks: Array< - Exclude< - StructuredBlock, - { - kind: 'reasoning' | 'attachment' | 'compaction_notice' | 'truncation_notice' - } - > - > - } - -function groupStructuredBlocks(blocks: StructuredBlock[]): StructuredGroup[] { - const groups: StructuredGroup[] = [] - - for (const block of blocks) { - const nextKind = - block.kind === 'reasoning' - ? 'reasoning_group' - : block.kind === 'attachment' - ? 'attachment_group' - : block.kind === 'compaction_notice' - ? 'compaction_group' - : block.kind === 'truncation_notice' - ? 'truncation_group' - : block.kind === 'approval_notice' - ? 'tool_group' - : 'tool_group' - const current = groups.at(-1) - - if (current && current.kind === nextKind) { - if (current.kind === 'reasoning_group' && block.kind === 'reasoning') { - current.blocks.push(block) - continue - } - - if ( - current.kind === 'tool_group' && - block.kind !== 'reasoning' && - block.kind !== 'attachment' && - block.kind !== 'compaction_notice' && - block.kind !== 'truncation_notice' - ) { - current.blocks.push(block) - continue - } - - if (current.kind === 'attachment_group' && block.kind === 'attachment') { - current.blocks.push(block) - continue - } - - if (current.kind === 'compaction_group' && block.kind === 'compaction_notice') { - current.blocks.push(block) - continue - } - - if (current.kind === 'truncation_group' && block.kind === 'truncation_notice') { - current.blocks.push(block) - continue - } - - continue - } - - if (nextKind === 'reasoning_group') { - groups.push({ - kind: nextKind, - blocks: [block as Extract], - }) - } else if (nextKind === 'attachment_group') { - groups.push({ - kind: nextKind, - blocks: [block as Extract], - }) - } else if (nextKind === 'compaction_group') { - groups.push({ - kind: nextKind, - blocks: [block as Extract], - }) - } else if (nextKind === 'truncation_group') { - groups.push({ - kind: nextKind, - blocks: [block as Extract], - }) - } else { - groups.push({ - kind: nextKind, - blocks: [ - block as Exclude< - StructuredBlock, - { - kind: 'reasoning' | 'attachment' | 'compaction_notice' | 'truncation_notice' - } - >, - ], - }) - } - } - - return groups -} - -function describeToolBlock( - block: Exclude< - StructuredBlock, - { - kind: 'reasoning' | 'attachment' | 'compaction_notice' | 'truncation_notice' - } - > -): string { - switch (block.kind) { - case 'tool_call': - return block.payload.toolName - case 'skill_invocation': - return `Skill: ${block.payload.skillName}` - case 'subagent_invocation': - return `Subagent: ${block.payload.agentName}` - default: - return 'Action' - } -} - -function AttachmentGroup({ - blocks, -}: { - blocks: Array> -}) { - const [viewerIndex, setViewerIndex] = useState(null) - const imageBlocks = blocks.filter((block) => block.payload.mime.startsWith('image/')) - - return ( - <> -
        - {blocks.map((block, index) => { - const isImage = block.payload.mime.startsWith('image/') - const imageIndex = imageBlocks.findIndex( - (candidate) => candidate.payload.url === block.payload.url - ) - - return ( -
        - {isImage ? ( - - ) : ( -
        - File -
        - )} - -
        -

        {block.payload.filename}

        -

        {block.payload.mime}

        -
        - - - Download - -
        - ) - })} -
        - - {viewerIndex !== null && imageBlocks[viewerIndex] ? ( -
        -
        -
        -
        -

        - {imageBlocks[viewerIndex].payload.filename} -

        -

        - {viewerIndex + 1} / {imageBlocks.length} -

        -
        - -
        - - Download - - -
        -
        - -
        - - -
        - {imageBlocks[viewerIndex].payload.filename} -
        - - -
        -
        -
        - ) : null} - - ) -} - -function CompactionGroup({ - blocks, -}: { - blocks: Array> -}) { - return ( -
        - {blocks.map((block, index) => ( - - ))} -
        - ) -} - -function TruncationGroup({ - blocks, -}: { - blocks: Array> -}) { - return ( -
        - {blocks.map((block, index) => ( - - ))} -
        - ) -} - -function summarizeToolCall(payload: A2UIToolCallPayload, projectPath?: string | null): string { - if (typeof payload.args === 'string' && payload.args.trim()) { - return relativizeText(payload.args.trim(), projectPath) - } - - if (payload.args && typeof payload.args === 'object') { - const record = payload.args as Record - for (const key of [ - 'description', - 'command', - 'filePath', - 'pattern', - 'question', - 'prompt', - 'url', - ]) { - if (typeof record[key] === 'string' && record[key].trim()) { - return relativizeText(record[key].trim(), projectPath) - } - } - } - - return payload.done ? 'Completed' : 'Running…' -} - -function relativizeValue(value: unknown, projectPath?: string | null): unknown { - if (!projectPath) return value - if (typeof value === 'string') return relativizeText(value, projectPath) - if (Array.isArray(value)) return value.map((item) => relativizeValue(item, projectPath)) - if (typeof value === 'object' && value !== null) { - return Object.fromEntries( - Object.entries(value as Record).map(([key, entry]) => [ - key, - relativizeValue(entry, projectPath), - ]) - ) - } - - return value -} - -function relativizeText(value: string, projectPath?: string | null): string { - if (!projectPath) return value - return value.replaceAll(projectPath, '.') -} - -function stripReasoningHeading(text: string, title?: string): string { - if (!title) { - return text - } - - const normalizedTitle = title.trim() - const withBoldHeading = new RegExp(`^\\*\\*${escapeRegExp(normalizedTitle)}\\*\\*\\s*\n*`, 'i') - return text.replace(withBoldHeading, '').trimStart() -} - -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') -} diff --git a/frontend/src/components/chat/ToolCallCard.svelte b/frontend/src/components/chat/ToolCallCard.svelte new file mode 100644 index 0000000..45cdc3e --- /dev/null +++ b/frontend/src/components/chat/ToolCallCard.svelte @@ -0,0 +1,96 @@ + + +
        + + + {#if open} +
        + {#if argsText} +
        +

        Input

        +
        {argsText}
        +
        + {/if} + {#if payload.done && payload.result !== undefined} +
        +

        Result

        +
        {payload.result}
        +
        + {/if} + {#if !payload.done} +

        Running…

        + {/if} +
        + {/if} +
        diff --git a/frontend/src/components/chat/ToolGroup.svelte b/frontend/src/components/chat/ToolGroup.svelte new file mode 100644 index 0000000..f36a589 --- /dev/null +++ b/frontend/src/components/chat/ToolGroup.svelte @@ -0,0 +1,145 @@ + + +
        + + + {#if open} +
        + {#each blocks as block, i (('callId' in block.payload) ? `${block.kind}-${block.payload.callId}` : `${block.kind}-${i}`)} + {#if block.kind === 'tool_call'} + + {:else if block.kind === 'skill_invocation'} + {@const p = block.payload as A2UISkillInvocationPayload} +
        +
        +

        Skill: {p.skillName}

        + + {p.status} + +
        + {#if p.result} +
        {p.result}
        + {/if} +
        + {:else if block.kind === 'subagent_invocation'} + {@const p = block.payload as A2UISubagentInvocationPayload} +
        +
        +

        Subagent: {p.agentName}

        + + {p.status} + +
        + {#if p.prompt} +

        {p.prompt}

        + {/if} + {#if p.result} +
        {p.result}
        + {/if} +
        + {:else if block.kind === 'model_switch'} + {@const p = block.payload as A2UIModelSwitchPayload} +
        +

        Model Switch

        +

        + {p.fromModelId ? `${p.fromModelId} -> ${p.toModelId}` : p.toModelId} +

        +
        + {:else if block.kind === 'file_operation'} + {@const p = block.payload as A2UIFileOperationPayload} +
        +
        +

        {p.operation}

        + {#if p.source} + + {p.source} + + {/if} +
        +

        {p.path}

        +
        + {:else if block.kind === 'approval_notice'} + {@const p = block.payload as A2UIApprovalNoticePayload} +
        +

        Approval

        +

        {p.title}

        + {#if p.message} +

        {p.message}

        + {/if} +

        {p.state}

        +
        + {/if} + {/each} +
        + {/if} +
        diff --git a/frontend/src/components/chat/TurnFooter.svelte b/frontend/src/components/chat/TurnFooter.svelte new file mode 100644 index 0000000..daea162 --- /dev/null +++ b/frontend/src/components/chat/TurnFooter.svelte @@ -0,0 +1,246 @@ + + +{#if turnInfo || modifiedFiles.length > 0 || patches.length > 0} +
        +

        + {compact ? 'Turn Input' : 'Turn Outcome'} +

        +
        +
        + {#if modifiedFiles.length > 0 || patches.length > 0} + + {/if} + {#if turnInfo?.providerId}{turnInfo.providerId}{/if} + {#if turnInfo?.modelId}{turnInfo.modelId}{/if} + {#if turnInfo?.mode}{turnInfo.mode} mode{/if} + {#if turnInfo?.durationMs}{formatDuration(turnInfo.durationMs)}{/if} +
        + + {#if showCopy} + + {/if} +
        + + {#if showFiles && (modifiedFiles.length > 0 || patches.length > 0)} +
        + {#if patches.length > 0} +
        +

        Patch Summary

        +
        + {#each patches as patch, patchIndex (patch.hash || `patch-${patchIndex}`)} +
        +
        + Patch {patchIndex + 1} + {#if patch.hash} + {shortHash(patch.hash)} + {/if} + {#if patch.additions !== undefined || patch.deletions !== undefined} + {formatPatchDelta(patch.additions, patch.deletions)} + {/if} +
        +
        + {#each patch.files as file (`${patch.hash}-${file}`)} +
        + + + {relativizeFile(file, projectPath)} + +
        + {/each} +
        + {#if patch.nextHash} +
        + {#if diffErrorByHash[patch.hash]} +

        {diffErrorByHash[patch.hash]}

        + {/if} + + {#if visibleDiffHashes[patch.hash] && diffByHash[patch.hash] !== undefined} + {@const parsedFiles = parseUnifiedDiff(diffByHash[patch.hash])} +
        + {#if parsedFiles.length === 0} +
        {diffByHash[patch.hash] || 'Diff unavailable.'}
        + {:else} +
        + {#each parsedFiles as file (file.header)} +
        +
        +

        {file.displayPath}

        +
        + +{file.additions} + -{file.deletions} +
        +
        +
        + {#each file.hunks as hunk (`${file.header}-${hunk.header}`)} +
        +
        {hunk.header}
        + {#each hunk.lines as line, li (`${hunk.header}-${li}`)} +
        + + {line.kind === 'addition' ? '+' : line.kind === 'deletion' ? '-' : line.kind === 'note' ? '\\' : ' '} + + {line.content || ' '} +
        + {/each} +
        + {/each} +
        +
        + {/each} +
        + {/if} +
        + {/if} +
        + {/if} +
        + {/each} +
        +
        + {:else} +

        Modified Files

        +
        + {#each modifiedFiles as file (file)} +
        + + + {relativizeFile(file, projectPath)} + +
        + {/each} +
        + {/if} +
        + {/if} +
        +{/if} diff --git a/frontend/src/components/chat/icons/AgentIcon.svelte b/frontend/src/components/chat/icons/AgentIcon.svelte new file mode 100644 index 0000000..30de7ef --- /dev/null +++ b/frontend/src/components/chat/icons/AgentIcon.svelte @@ -0,0 +1,52 @@ + + +{#if id.includes('gemini')} + + + +{:else if id.includes('copilot') || id.includes('github')} + + + +{:else if id.includes('codex') || id.includes('openai')} + +{:else if id.includes('claude')} + +{:else if id.includes('opencode')} + +{:else} + + + +{/if} diff --git a/frontend/src/components/chat/icons/AgentIcon.tsx b/frontend/src/components/chat/icons/AgentIcon.tsx deleted file mode 100644 index ac7c9fa..0000000 --- a/frontend/src/components/chat/icons/AgentIcon.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import openCodeLogoUrl from '../../../assets/agents/opencode.png' - -interface AgentIconProps { - agentId?: string - className?: string -} - -export function AgentIcon({ agentId, className }: AgentIconProps) { - const id = agentId?.toLowerCase() || '' - const baseClasses = `shrink-0 ${className || ''}` - - if (id.includes('gemini')) { - return ( - - - - ) - } - - if (id.includes('copilot') || id.includes('github')) { - return ( - - - - ) - } - - if (id.includes('codex') || id.includes('openai')) { - // OpenAI logo: white mark on black rounded square background - return ( - - ) - } - - if (id.includes('claude')) { - // Anthropic brand logomark (official ray-burst mark, color #d97757) - return ( - - ) - } - - if (id.includes('opencode')) { - return - } - - return ( - - - - ) -} diff --git a/frontend/src/components/settings/BackendCard.svelte b/frontend/src/components/settings/BackendCard.svelte new file mode 100644 index 0000000..b798c68 --- /dev/null +++ b/frontend/src/components/settings/BackendCard.svelte @@ -0,0 +1,61 @@ + + +
        +
        +
        +

        {backend.name}

        +

        {backend.id}

        +
        + + + {statusLabel} + +
        + +
        +

        Runtime command

        +

        {backend.command ?? 'not detected'}

        +
        + +
        +

        Managed by acpx runtime configuration.

        +
        + + {backend.canResume ? 'Can resume sessions' : 'Cannot resume sessions'} +
        +
        +
        diff --git a/frontend/src/components/settings/HistorySourceCard.svelte b/frontend/src/components/settings/HistorySourceCard.svelte new file mode 100644 index 0000000..316c233 --- /dev/null +++ b/frontend/src/components/settings/HistorySourceCard.svelte @@ -0,0 +1,142 @@ + + +
        +

        {providerLabel[source.provider]}

        +

        {source.provider}

        + +
        + + + {#if isCopilot} + + {/if} +
        + +
        +

        Discovery status

        + {#if status} +
        + + {status.summary.readable} readable + + + {status.summary.missing} missing + + + {status.summary.invalid} invalid + + + {status.summary.containsHistory} with history + + + {status.summary.totalSessions} sessions found + +
        + + {#if status.discoveredSources.length > 0} +
        + {#each status.discoveredSources as descriptor (descriptor.id)} +
        +

        {descriptor.kind}

        +

        {descriptor.path}

        +
        + {/each} +
        + {:else} +

        No sources discovered from the current hints.

        + {/if} + {:else} +

        Discovery status is unavailable.

        + {/if} +
        + +
        + +
        +
        diff --git a/frontend/src/hooks/useAgUiChat.ts b/frontend/src/hooks/useAgUiChat.ts deleted file mode 100644 index 039474a..0000000 --- a/frontend/src/hooks/useAgUiChat.ts +++ /dev/null @@ -1,971 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { EventType } from '@ag-ui/core' -import type { StructuredBlock } from '../components/chat/a2ui-types.js' - -export interface ChatMessage { - id: string - role: 'user' | 'assistant' - content: string - structuredBlocks?: StructuredBlock[] - turnInfo?: { - providerId?: string - modelId?: string - mode?: string - startedAtMs?: number - completedAtMs?: number - durationMs?: number - modifiedFiles?: string[] - patches?: Array<{ - hash: string - nextHash?: string - files: string[] - additions?: number - deletions?: number - }> - } -} - -export interface AgentSummary { - id: string - name: string - status: 'active' | 'disabled' | 'detected' | 'unavailable' - command: string | null - /** True when the agent is active and can accept a resume/continuation request. */ - canResume: boolean - /** True when the agent supports ACP session/load (resume as the original session). */ - canLoad: boolean -} - -export interface HistorySourceDescriptor { - id: string - backendId: string - providerId: string - kind: - | 'cli_session_dir' - | 'cli_history_dir' - | 'vscode_workspace_db' - | 'vscode_chat_sessions' - | 'vscode_chat_editing_sessions' - | 'vscode_extension_resources' - | 'gemini_tmp_dir' - | 'opencode_db' - path: string - platform: 'linux' | 'mounted_host' | 'windows' | 'unknown' - access: 'readable' | 'missing' | 'permission_error' | 'invalid' - signal: 'contains_history' | 'empty' | 'unknown' - discoveredBy: 'auto' | 'manual' - lastModifiedMs?: number - sessionCount?: number -} - -export interface ProjectSummary { - id: string - name: string - path: string - status: 'available' | 'missing' | 'invalid' -} - -export interface SessionProjectContext { - id: string - name: string - path: string -} - -export interface SessionSummary { - id: string - title: string - updatedAt: string - agentId: string - project: SessionProjectContext | null - source: 'live' | 'history' -} - -interface ProjectPathSuggestion { - name: string - path: string -} - -interface SessionDetails extends SessionSummary { - messages: ChatMessage[] - /** Current model selection state; null when the agent does not support model selection. */ - modelState: ModelState | null -} - -/** A selectable model advertised by an agent via ACP session creation. */ -export interface ModelInfo { - modelId: string - name: string - description?: string | null -} - -/** Current model selection state for a live session. */ -export interface ModelState { - availableModels: ModelInfo[] - currentModelId: string -} - -interface UseAgUiChatOptions { - sessionId: string | null - projectId: string | null - onSessionCreated: (sessionId: string) => void - onSessionSelected: (sessionId: string) => void - onSessionCleared: () => void - onProjectSelected: (projectId: string | null) => void -} - -export function useAgUiChat({ - sessionId, - projectId, - onSessionCreated, - onSessionSelected, - onSessionCleared, - onProjectSelected, -}: UseAgUiChatOptions) { - const [currentSessionId, setCurrentSessionId] = useState(sessionId) - const [currentProjectId, setCurrentProjectId] = useState(projectId) - const [agents, setAgents] = useState([]) - const [projects, setProjects] = useState([]) - const [sessions, setSessions] = useState([]) - const sessionsRef = useRef([]) - const setSessionsAndRef = useCallback((nextSessions: SessionSummary[]) => { - sessionsRef.current = nextSessions - setSessions(nextSessions) - }, []) - const [messages, setMessagesRaw] = useState([]) - const messagesRef = useRef([]) - // Wraps the raw setter so that messagesRef always stays in sync. - // Used wherever we need to snapshot messages in a callback without stale-closure issues. - const setMessages = useCallback( - (next: ChatMessage[] | ((prev: ChatMessage[]) => ChatMessage[])) => { - if (typeof next === 'function') { - setMessagesRaw((prev) => { - const resolved = next(prev) - messagesRef.current = resolved - return resolved - }) - } else { - messagesRef.current = next - setMessagesRaw(next) - } - }, - [] - ) - const [thinking, setThinking] = useState(false) - const [errorMessage, setErrorMessage] = useState(null) - const [loading, setLoading] = useState(true) - const [historyLoadingSessionId, setHistoryLoadingSessionId] = useState(null) - const [streamReconnecting, setStreamReconnecting] = useState(false) - const [creatingSession, setCreatingSession] = useState(false) - const [modelState, setModelState] = useState(null) - const activeSessionRef = useRef(sessionId) - const routeSessionRef = useRef(sessionId) - // Stable refs so the one-shot bootstrap effect can call the latest version of - // these callbacks without listing them (and their transitive deps) in the dep - // array, which would cause the effect to re-run mid-flight and cancel itself. - const createSessionRef = useRef(null) - const loadSessionRef = useRef(null) - - useEffect(() => { - setCurrentProjectId(projectId) - }, [projectId]) - - const selectedProject = useMemo( - () => projects.find((candidate) => candidate.id === currentProjectId) ?? null, - [projects, currentProjectId] - ) - const availableProjects = useMemo( - () => projects.filter((candidate) => candidate.status === 'available'), - [projects] - ) - const activeAgents = useMemo( - () => agents.filter((candidate) => candidate.status === 'active'), - [agents] - ) - const currentSession = useMemo( - () => sessions.find((s) => s.id === currentSessionId) ?? null, - [sessions, currentSessionId] - ) - const currentSessionAgent = useMemo( - () => (currentSession ? (agents.find((a) => a.id === currentSession.agentId) ?? null) : null), - [agents, currentSession] - ) - const ready = useMemo( - () => - currentSessionId !== null && - currentSession?.source === 'live' && - currentSessionAgent?.status === 'active' && - selectedProject?.status === 'available' && - !creatingSession, - [creatingSession, currentSessionId, currentSession, currentSessionAgent, selectedProject] - ) - - const fetchJson = useCallback(async (url: string, init?: RequestInit): Promise => { - const response = await fetch(url, init) - if (!response.ok) { - throw new Error(`${url} failed with status ${response.status}`) - } - - return (await response.json()) as T - }, []) - - const refreshSessions = useCallback(async () => { - try { - const nextSessions = await fetchJson('/api/sessions') - setSessionsAndRef(nextSessions) - return nextSessions - } catch (error) { - console.error('[useAgUiChat] session list failed:', error) - setErrorMessage( - (current) => - current ?? 'Unable to load session history right now. Reload or try again in a moment.' - ) - return [] - } - }, [fetchJson, setSessionsAndRef]) - - const refreshProjects = useCallback(async () => { - try { - const nextProjects = await fetchJson('/api/projects') - setProjects(nextProjects) - return nextProjects - } catch (error) { - console.error('[useAgUiChat] project list failed:', error) - setErrorMessage( - (current) => - current ?? 'Unable to load projects right now. Reload or try again in a moment.' - ) - return [] - } - }, [fetchJson]) - - const loadSession = useCallback( - async (nextSessionId: string, syncRoute = true) => { - setHistoryLoadingSessionId(nextSessionId) - try { - const knownSession = sessionsRef.current.find((s) => s.id === nextSessionId) - const agentParam = knownSession?.agentId - ? `?agentId=${encodeURIComponent(knownSession.agentId)}` - : '' - const session = await fetchJson( - `/api/sessions/${encodeURIComponent(nextSessionId)}${agentParam}` - ) - - if (activeSessionRef.current !== nextSessionId) { - return null - } - - setMessages(session.messages) - setModelState(session.modelState ?? null) - - setCurrentSessionId(nextSessionId) - setCurrentProjectId(session.project?.id ?? null) - - if (syncRoute && session.project?.id !== projectId) { - onProjectSelected(session.project?.id ?? null) - } - - if (syncRoute && sessionId !== nextSessionId) { - onSessionSelected(nextSessionId) - } - - return session - } catch (error) { - console.error('[useAgUiChat] session load failed:', error) - setMessages([]) - setErrorMessage( - 'Unable to load that session right now. Pick another one or create a new chat.' - ) - return null - } finally { - setHistoryLoadingSessionId((current) => (current === nextSessionId ? null : current)) - } - }, - [fetchJson, onProjectSelected, onSessionSelected, projectId, sessionId, setMessages] - ) - - useEffect(() => { - if (sessionId === routeSessionRef.current) { - return - } - - routeSessionRef.current = sessionId - - if (!sessionId) { - activeSessionRef.current = null - setCurrentSessionId(null) - setMessages([]) - setThinking(false) - return - } - - if (sessionId === activeSessionRef.current) { - return - } - - activeSessionRef.current = sessionId - setCurrentSessionId(sessionId) - setErrorMessage(null) - setThinking(false) - void loadSession(sessionId, false) - }, [loadSession, sessionId, setMessages]) - - const createSession = useCallback( - async (agentId: string, nextProjectId?: string) => { - const effectiveProjectId = nextProjectId ?? currentProjectId ?? projectId - - if (!agentId) { - setErrorMessage('Select an available agent before starting a new chat.') - return null - } - - if (!effectiveProjectId) { - setErrorMessage('Select an available project before starting a new chat.') - return null - } - - setCreatingSession(true) - setThinking(false) - - try { - const session = await fetchJson('/api/sessions', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ agentId, projectId: effectiveProjectId }), - }) - - activeSessionRef.current = session.id - setCurrentSessionId(session.id) - setMessages(session.messages) - setCurrentProjectId(session.project?.id ?? effectiveProjectId) - onSessionCreated(session.id) - - if (session.project?.id !== projectId) { - onProjectSelected(session.project?.id ?? effectiveProjectId) - } - - void refreshSessions() - return session.id - } catch (error) { - console.error('[useAgUiChat] session create failed:', error) - setErrorMessage( - 'Unable to start a chat session right now. Reload or try again in a moment.' - ) - return null - } finally { - setCreatingSession(false) - } - }, - [ - currentProjectId, - fetchJson, - onProjectSelected, - onSessionCreated, - projectId, - refreshSessions, - setMessages, - ] - ) - - // Keep refs in sync so the one-shot bootstrap effect always calls the latest - // version without having them in its dependency array. - createSessionRef.current = createSession - loadSessionRef.current = loadSession - - useEffect(() => { - let cancelled = false - - void (async () => { - setLoading(true) - setErrorMessage(null) - - try { - const [nextAgents, nextProjects, nextSessions] = await Promise.all([ - fetchJson('/api/agents'), - fetchJson('/api/projects'), - fetchJson('/api/sessions'), - ]) - - if (cancelled) return - - setAgents(nextAgents) - setProjects(nextProjects) - setSessionsAndRef(nextSessions) - - const nextActiveAgents = nextAgents.filter((candidate) => candidate.status === 'active') - const nextActiveAgentIds = new Set(nextActiveAgents.map((agent) => agent.id)) - - const availableProjectIds = new Set( - nextProjects - .filter((project) => project.status === 'available') - .map((project) => project.id) - ) - const preferredProjectId = - projectId && availableProjectIds.has(projectId) - ? projectId - : currentProjectId && availableProjectIds.has(currentProjectId) - ? currentProjectId - : (nextSessions.find( - (session) => session.project && availableProjectIds.has(session.project.id) - )?.project?.id ?? - nextProjects.find((project) => project.status === 'available')?.id ?? - null) - - setCurrentProjectId(preferredProjectId) - - if (preferredProjectId !== projectId) { - onProjectSelected(preferredProjectId) - } - - const preferredSession = - (sessionId && nextSessions.find((session) => session.id === sessionId)) ?? - nextSessions.find( - (session) => - session.project?.id === preferredProjectId && nextActiveAgentIds.has(session.agentId) - ) ?? - null - - if (preferredSession) { - activeSessionRef.current = preferredSession.id - setCurrentSessionId(preferredSession.id) - // End the amber loading banner now — loadSession will show the - // history-shimmer pill for the session-fetch phase. - setLoading(false) - await loadSessionRef.current!( - preferredSession.id, - sessionId !== preferredSession.id || preferredSession.project?.id !== projectId - ) - return - } - - activeSessionRef.current = null - setCurrentSessionId(null) - - if (sessionId !== null) { - onSessionCleared() - } - - if (!preferredProjectId) { - setMessages([]) - setErrorMessage( - 'No projects are currently available. Check the generated workspace config and try again.' - ) - return - } - - if (nextActiveAgents.length === 0) { - setMessages([]) - setErrorMessage( - 'No agents are currently available. Start an adapter and reload to continue.' - ) - return - } - - if (cancelled) return - setMessages([]) - } catch (error) { - console.error('[useAgUiChat] bootstrap failed:', error) - setErrorMessage('Unable to load chat data right now. Reload or try again in a moment.') - } finally { - if (!cancelled) { - setLoading(false) - } - } - })() - - return () => { - cancelled = true - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - - useEffect(() => { - if (!currentSessionId || currentSession?.source !== 'live') { - setStreamReconnecting(false) - return - } - - activeSessionRef.current = currentSessionId - setStreamReconnecting(false) - const sse = new EventSource(`/api/stream?sessionId=${encodeURIComponent(currentSessionId)}`) - - sse.onopen = () => { - if (activeSessionRef.current !== currentSessionId) return - setStreamReconnecting(false) - } - - sse.addEventListener(EventType.RUN_STARTED, () => { - setStreamReconnecting(false) - setThinking(true) - }) - - sse.addEventListener(EventType.TEXT_MESSAGE_START, (event: MessageEvent) => { - if (activeSessionRef.current !== currentSessionId) return - - const { messageId } = JSON.parse(event.data) as { messageId: string } - setMessages((current) => - current.some((message) => message.id === messageId) - ? current - : [...current, { id: messageId, role: 'assistant', content: '', structuredBlocks: [] }] - ) - }) - - sse.addEventListener(EventType.TEXT_MESSAGE_CONTENT, (event: MessageEvent) => { - if (activeSessionRef.current !== currentSessionId) return - setStreamReconnecting(false) - - const { messageId, delta } = JSON.parse(event.data) as { messageId: string; delta: string } - - setMessages((current) => { - let found = false - const nextMessages = current.map((message) => { - if (message.id !== messageId) return message - - found = true - return { ...message, content: message.content + delta } - }) - - return found - ? nextMessages - : [ - ...nextMessages, - { id: messageId, role: 'assistant', content: delta, structuredBlocks: [] }, - ] - }) - }) - - sse.addEventListener(EventType.CUSTOM, (event: MessageEvent) => { - if (activeSessionRef.current !== currentSessionId) return - setStreamReconnecting(false) - - const customEvent = JSON.parse(event.data) as { - name?: string - value?: Record - } - - if (customEvent.name !== 'a2ui:tool_call') { - return - } - - const payload = customEvent.value ?? {} - const callId = typeof payload['callId'] === 'string' ? payload['callId'] : null - const toolName = typeof payload['toolName'] === 'string' ? payload['toolName'] : null - const done = payload['done'] === true - if (!callId || !toolName) { - return - } - - setMessages((current) => { - const nextMessages = [...current] - const lastAssistantIndex = [...nextMessages] - .reverse() - .findIndex((message) => message.role === 'assistant') - const targetIndex = - lastAssistantIndex === -1 ? -1 : nextMessages.length - 1 - lastAssistantIndex - - const fallbackMessage = { - id: `assistant-tool-${callId}`, - role: 'assistant' as const, - content: '', - structuredBlocks: [] as StructuredBlock[], - } - - const targetMessage = targetIndex >= 0 ? nextMessages[targetIndex] : fallbackMessage - - const existingBlocks = targetMessage.structuredBlocks ?? [] - const nextBlock: StructuredBlock = { - kind: 'tool_call', - payload: { - callId, - toolName, - args: payload['args'], - result: typeof payload['result'] === 'string' ? payload['result'] : undefined, - done, - }, - } - - const mergedBlocks = upsertStructuredBlock(existingBlocks, nextBlock) - const mergedMessage = { ...targetMessage, structuredBlocks: mergedBlocks } - - if (targetIndex >= 0) { - nextMessages[targetIndex] = mergedMessage - return nextMessages - } - - return [...nextMessages, mergedMessage] - }) - }) - - const finishRun = () => { - if (activeSessionRef.current !== currentSessionId) return - setThinking(false) - void refreshSessions() - } - - sse.addEventListener(EventType.RUN_FINISHED, finishRun) - sse.addEventListener(EventType.RUN_ERROR, finishRun) - - sse.onerror = () => { - if (activeSessionRef.current !== currentSessionId) return - setThinking(false) - setStreamReconnecting(true) - } - - return () => { - sse.close() - } - }, [currentSession?.source, currentSessionId, refreshSessions, setMessages]) - - const sendMessage = useCallback( - async (text: string) => { - if (!currentSessionId) return - - // Find which agent owns the current session so we can pass agentId in the payload - const currentSession = sessions.find((s) => s.id === currentSessionId) - const agentId = currentSession?.agentId ?? null - - setErrorMessage(null) - setMessages((current) => [ - ...current, - { id: `user-${Date.now()}`, role: 'user', content: text }, - ]) - - try { - const response = await fetch( - `/api/sessions/${encodeURIComponent(currentSessionId)}/message`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(buildSendMessagePayload(text, agentId)), - } - ) - - if (!response.ok) { - throw new Error(`Message send failed with status ${response.status}`) - } - - void refreshSessions() - } catch (error) { - console.error('[useAgUiChat] message send failed:', error) - setErrorMessage('Message failed to send. Check the agent connection and try again.') - throw error - } - }, - [currentSessionId, refreshSessions, sessions, setMessages] - ) - - const selectSession = useCallback( - async (nextSessionId: string) => { - if (nextSessionId === currentSessionId) return - activeSessionRef.current = nextSessionId - setCurrentSessionId(nextSessionId) - setErrorMessage(null) - setThinking(false) - await loadSession(nextSessionId, true) - }, - [currentSessionId, loadSession] - ) - - const selectProject = useCallback( - async (nextProjectId: string) => { - if (nextProjectId === currentProjectId) { - return - } - - const candidate = projects.find((project) => project.id === nextProjectId) - if (!candidate || candidate.status !== 'available') { - return - } - - setErrorMessage(null) - setMessages([]) - setThinking(false) - setCurrentProjectId(nextProjectId) - onProjectSelected(nextProjectId) - - // Pick the first active agent for the new project session - const firstActive = agents.find((agent) => agent.status === 'active') - if (!firstActive) { - return - } - - activeSessionRef.current = null - setCurrentSessionId(null) - await createSession(firstActive.id, nextProjectId) - }, - [agents, createSession, currentProjectId, onProjectSelected, projects, setMessages] - ) - - const startNewSession = useCallback( - async (agentId: string) => { - setErrorMessage(null) - await createSession(agentId) - }, - [createSession] - ) - - const resumeSession = useCallback( - async (agentId: string) => { - if (!currentSessionId) return - setErrorMessage(null) - setThinking(false) - setStreamReconnecting(false) - setCreatingSession(true) - - // Snapshot messages before the async call — we'll prepend them to the new session - // so the transcript shows the full prior conversation immediately. - const priorMessages = messagesRef.current - - try { - // Pass sourceAgentId so the backend can disambiguate cross-provider session lookups - const sourceAgentId = currentSession?.agentId ?? null - const session = await fetchJson( - `/api/sessions/${encodeURIComponent(currentSessionId)}/resume`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - agentId, - ...(sourceAgentId ? { sourceAgentId } : {}), - }), - } - ) - activeSessionRef.current = session.id - setCurrentSessionId(session.id) - // Prepend the source conversation so the transcript is continuous. - // The new session's own messages (the context-handoff exchange) are appended after. - setMessages([...priorMessages, ...session.messages]) - setCurrentProjectId(session.project?.id ?? null) - onSessionCreated(session.id) - if (session.project?.id !== projectId) { - onProjectSelected(session.project?.id ?? null) - } - void refreshSessions() - } catch (error) { - console.error('[useAgUiChat] session resume failed:', error) - setErrorMessage('Unable to continue this conversation right now. Try again in a moment.') - } finally { - setCreatingSession(false) - } - }, - [ - currentSession?.agentId, - currentSessionId, - fetchJson, - onProjectSelected, - onSessionCreated, - projectId, - refreshSessions, - setMessages, - ] - ) - - /** - * Load a history session as a live session via ACP `session/load`. - * This resumes the *original* session in the agent rather than creating a - * new session with a handoff. Only supported by agents that advertise the - * `loadSession` capability (e.g. opencode). - */ - const loadHistorySession = useCallback( - async (agentId: string) => { - if (!currentSessionId) return - setErrorMessage(null) - setThinking(false) - setStreamReconnecting(false) - setCreatingSession(true) - - // Snapshot messages before the async call — we'll prepend them to the - // loaded session so the transcript shows the full prior conversation. - const priorMessages = messagesRef.current - - try { - const session = await fetchJson( - `/api/sessions/${encodeURIComponent(currentSessionId)}/load`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ agentId }), - } - ) - activeSessionRef.current = session.id - setCurrentSessionId(session.id) - // Optimistically upsert the new live session into the sessions list so - // that currentSession immediately resolves to source:'live'. Without - // this, there is a race: if refreshSessions resolves before React - // commits the setCurrentSessionId update above, currentSession would - // still look up the old history ID and find source:'history', sending - // the UI back into history mode. - setSessionsAndRef([ - ...sessionsRef.current.filter((s) => s.id !== session.id), - { - id: session.id, - title: session.title, - updatedAt: session.updatedAt, - agentId: session.agentId, - project: session.project, - source: session.source, - }, - ]) - // Prepend the history messages so the transcript is continuous. - setMessages([...priorMessages, ...session.messages]) - setModelState(session.modelState ?? null) - setCurrentProjectId(session.project?.id ?? null) - onSessionCreated(session.id) - if (session.project?.id !== projectId) { - onProjectSelected(session.project?.id ?? null) - } - void refreshSessions() - } catch (error) { - console.error('[useAgUiChat] session load failed:', error) - setErrorMessage('Unable to load this session right now. Try again in a moment.') - } finally { - setCreatingSession(false) - } - }, - [ - currentSessionId, - fetchJson, - onProjectSelected, - onSessionCreated, - projectId, - refreshSessions, - sessionsRef, - setMessages, - setSessionsAndRef, - ] - ) - - const addProject = useCallback( - async (name: string, path: string) => { - const project = await fetchJson('/api/projects', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, path }), - }) - - const nextProjects = await refreshProjects() - const resolvedProject = - nextProjects.find((candidate) => candidate.id === project.id) ?? project - setCurrentProjectId(resolvedProject.id) - onProjectSelected(resolvedProject.id) - return resolvedProject - }, - [fetchJson, onProjectSelected, refreshProjects] - ) - - const removeProjectById = useCallback( - async (projectIdToRemove: string) => { - const response = await fetch(`/api/projects/${encodeURIComponent(projectIdToRemove)}`, { - method: 'DELETE', - }) - - if (!response.ok) { - throw new Error(`/api/projects/${projectIdToRemove} failed with status ${response.status}`) - } - - const nextProjects = await refreshProjects() - if (currentProjectId === projectIdToRemove) { - const fallbackProject = - nextProjects.find((project) => project.status === 'available') ?? null - activeSessionRef.current = null - setCurrentProjectId(fallbackProject?.id ?? null) - setCurrentSessionId(null) - setMessages([]) - setThinking(false) - onSessionCleared() - onProjectSelected(fallbackProject?.id ?? null) - } - }, - [currentProjectId, onProjectSelected, onSessionCleared, refreshProjects, setMessages] - ) - - const suggestProjectPaths = useCallback( - async (path: string) => { - return fetchJson( - `/api/projects/path-suggestions?path=${encodeURIComponent(path)}` - ) - }, - [fetchJson] - ) - - const setSessionModel = useCallback( - async (newModelId: string) => { - if (!currentSessionId) return - await fetchJson(`/api/sessions/${encodeURIComponent(currentSessionId)}/model`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ modelId: newModelId }), - }) - setModelState((prev) => (prev ? { ...prev, currentModelId: newModelId } : prev)) - }, - [currentSessionId, fetchJson] - ) - - return { - activeAgents, - agents, - creatingSession, - errorMessage, - historyLoading: historyLoadingSessionId !== null, - loading, - messages, - modelState, - projects, - ready, - selectedProject, - selectProject, - selectSession, - sendMessage, - sessionId: currentSessionId, - currentSession, - sessions, - startNewSession, - resumeSession, - loadHistorySession, - setSessionModel, - streamReconnecting, - thinking, - availableProjects, - addProject, - removeProject: removeProjectById, - suggestProjectPaths, - } -} - -function upsertStructuredBlock( - blocks: StructuredBlock[], - nextBlock: StructuredBlock -): StructuredBlock[] { - const nextId = readStructuredBlockId(nextBlock) - if (!nextId) { - return [...blocks, nextBlock] - } - - const existingIndex = blocks.findIndex((block) => readStructuredBlockId(block) === nextId) - if (existingIndex === -1) { - return [...blocks, nextBlock] - } - - return blocks.map((block, index) => (index === existingIndex ? nextBlock : block)) -} - -function readStructuredBlockId(block: StructuredBlock): string | null { - switch (block.kind) { - case 'tool_call': - return block.payload.callId - case 'skill_invocation': - return block.payload.callId - case 'subagent_invocation': - return block.payload.callId - case 'reasoning': - return block.payload.text - default: - return null - } -} - -export function buildSendMessagePayload(message: string, agentId: string | null) { - return { - ...(agentId ? { agentId } : {}), - message, - } -} diff --git a/frontend/src/hooks/useBackendSettings.ts b/frontend/src/hooks/useBackendSettings.ts deleted file mode 100644 index c4713c9..0000000 --- a/frontend/src/hooks/useBackendSettings.ts +++ /dev/null @@ -1,263 +0,0 @@ -import { useCallback, useEffect, useState } from 'react' - -export interface BackendEndpointSupport { - source: 'connection' | 'unknown' - implemented: string[] - unknown: string[] -} - -export interface HistorySourceDescriptor { - id: string - backendId: string - providerId: string - kind: - | 'cli_session_dir' - | 'cli_history_dir' - | 'vscode_workspace_db' - | 'vscode_chat_sessions' - | 'vscode_chat_editing_sessions' - | 'vscode_extension_resources' - | 'gemini_tmp_dir' - | 'opencode_db' - path: string - platform: 'linux' | 'mounted_host' | 'windows' | 'unknown' - access: 'readable' | 'missing' | 'permission_error' | 'invalid' - signal: 'contains_history' | 'empty' | 'unknown' - discoveredBy: 'auto' | 'manual' - lastModifiedMs?: number - sessionCount?: number - warnings?: string[] -} - -export interface HistorySourceDiscoverySummary { - family: string - readable: number - missing: number - invalid: number - containsHistory: number -} - -export interface HistorySupport { - source: 'none' | 'derived' | 'native' - supported: Array< - | 'text' - | 'markdown' - | 'reasoning' - | 'tool_calls' - | 'skills' - | 'subagents' - | 'attachments' - | 'rich_media' - | 'file_operations' - | 'patches' - | 'compaction' - | 'truncation' - > - discoveredSources: HistorySourceDescriptor[] - discoverySummary?: HistorySourceDiscoverySummary[] -} - -export interface BackendSummary { - id: string - name: string - status: 'active' | 'disabled' | 'detected' | 'unavailable' - command: string | null - detectedCommand: string | null - args: string[] - defaultArgs: string[] - enabled: boolean - usesCustomCommand: boolean - endpointSupport: BackendEndpointSupport - historySupport: HistorySupport - lastTestResult: { - ok: boolean - message: string - testedAt: string - } | null -} - -export type HistoryProvider = 'gemini' | 'copilot' | 'opencode' - -export interface HistorySourceConfig { - provider: HistoryProvider - /** VS Code workspace storage roots (or generic search roots for non-Copilot providers). */ - paths: string[] - /** CLI session-state directory paths. Only meaningful for `copilot`. */ - cliPaths?: string[] -} - -export function useBackendSettings() { - const [backends, setBackends] = useState([]) - const [loading, setLoading] = useState(true) - const [savingId, setSavingId] = useState(null) - const [errorMessage, setErrorMessage] = useState(null) - - const loadBackends = useCallback(async () => { - setLoading(true) - setErrorMessage(null) - - try { - const response = await fetch('/api/backends') - if (!response.ok) { - throw new Error(`Backend settings failed with status ${response.status}`) - } - - setBackends((await response.json()) as BackendSummary[]) - } catch (error) { - console.error('[useBackendSettings] load failed:', error) - setErrorMessage('Unable to load backend settings right now.') - } finally { - setLoading(false) - } - }, []) - - useEffect(() => { - void loadBackends() - }, [loadBackends]) - - const saveBackend = useCallback( - async ( - backendId: string, - patch: { - enabled?: boolean - command?: string | null - args?: string[] - name?: string - } - ) => { - setSavingId(backendId) - setErrorMessage(null) - - try { - const response = await fetch(`/api/backends/${encodeURIComponent(backendId)}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(patch), - }) - - if (!response.ok) { - throw new Error(`Backend save failed with status ${response.status}`) - } - - const updated = (await response.json()) as BackendSummary - setBackends((current) => - current.map((backend) => (backend.id === backendId ? updated : backend)) - ) - } catch (error) { - console.error('[useBackendSettings] save failed:', error) - setErrorMessage('Unable to save backend settings right now.') - throw error - } finally { - setSavingId(null) - } - }, - [] - ) - - const addBackend = useCallback( - async (input: { name: string; command: string; args?: string[] }) => { - setErrorMessage(null) - - try { - const response = await fetch('/api/backends', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(input), - }) - - if (!response.ok) { - throw new Error(`Backend create failed with status ${response.status}`) - } - - const created = (await response.json()) as BackendSummary - setBackends((current) => [...current, created]) - } catch (error) { - console.error('[useBackendSettings] create failed:', error) - setErrorMessage('Unable to add a backend right now.') - throw error - } - }, - [] - ) - - return { - addBackend, - backends, - errorMessage, - loading, - saveBackend, - savingId, - } -} - -export function useHistorySources() { - const [sources, setSources] = useState([]) - const [loading, setLoading] = useState(true) - const [savingProvider, setSavingProvider] = useState(null) - const [errorMessage, setErrorMessage] = useState(null) - - const loadSources = useCallback(async () => { - setLoading(true) - setErrorMessage(null) - - try { - const response = await fetch('/api/history-sources') - if (!response.ok) { - throw new Error(`History sources failed with status ${response.status}`) - } - - setSources((await response.json()) as HistorySourceConfig[]) - } catch (error) { - console.error('[useHistorySources] load failed:', error) - setErrorMessage('Unable to load history sources right now.') - } finally { - setLoading(false) - } - }, []) - - useEffect(() => { - void loadSources() - }, [loadSources]) - - const saveSource = useCallback( - async (provider: HistoryProvider, patch: { paths?: string[]; cliPaths?: string[] }) => { - setSavingProvider(provider) - setErrorMessage(null) - - try { - const response = await fetch(`/api/history-sources/${encodeURIComponent(provider)}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(patch), - }) - - if (!response.ok) { - throw new Error(`History source save failed with status ${response.status}`) - } - - const updated = (await response.json()) as HistorySourceConfig - setSources((current) => { - const exists = current.some((s) => s.provider === provider) - return exists - ? current.map((s) => (s.provider === provider ? updated : s)) - : [...current, updated] - }) - } catch (error) { - console.error('[useHistorySources] save failed:', error) - setErrorMessage('Unable to save history source settings right now.') - throw error - } finally { - setSavingProvider(null) - } - }, - [] - ) - - return { - sources, - loading, - saveSource, - savingProvider, - errorMessage, - } -} diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..dbe41a6 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,5 @@ +import App from './App.svelte' +import './index.css' +import { mount } from 'svelte' + +mount(App, { target: document.getElementById('root')! }) diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx deleted file mode 100644 index 47f4b93..0000000 --- a/frontend/src/main.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' -import { App } from './App.js' -import './index.css' - -createRoot(document.getElementById('root')!).render( - - - -) diff --git a/frontend/src/router.test.svelte.ts b/frontend/src/router.test.svelte.ts new file mode 100644 index 0000000..411d0ff --- /dev/null +++ b/frontend/src/router.test.svelte.ts @@ -0,0 +1,251 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/svelte' +import App from './App.svelte' + +class MockEventSource { + close = vi.fn() + + addEventListener() {} +} + +function mockFetch() { + return vi.fn().mockImplementation((url: string, opts?: RequestInit) => { + if (url === '/api/history-sources') { + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve([ + { + provider: 'copilot', + paths: ['/mnt/c/Users/vries/AppData/Roaming/Code/User/workspaceStorage'], + cliPaths: [], + }, + { provider: 'gemini', paths: [] }, + { provider: 'opencode', paths: [] }, + ]), + } as Response) + } + + if (url === '/api/history-sources/status') { + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve([ + { + provider: 'copilot', + summary: { + readable: 2, + missing: 0, + invalid: 0, + containsHistory: 2, + totalSessions: 52, + }, + discoveredSources: [ + { + id: 'copilot:vscode_workspace_db:/mnt/c/Users/vries/AppData/Roaming/Code/User/workspaceStorage/x/state.vscdb', + backendId: 'copilot', + providerId: 'copilot', + kind: 'vscode_workspace_db', + path: '/mnt/c/Users/vries/AppData/Roaming/Code/User/workspaceStorage/x/state.vscdb', + platform: 'mounted_host', + access: 'readable', + signal: 'contains_history', + discoveredBy: 'manual', + sessionCount: 42, + }, + ], + }, + { + provider: 'gemini', + summary: { + readable: 0, + missing: 1, + invalid: 0, + containsHistory: 0, + totalSessions: 0, + }, + discoveredSources: [], + }, + { + provider: 'opencode', + summary: { + readable: 0, + missing: 0, + invalid: 0, + containsHistory: 0, + totalSessions: 0, + }, + discoveredSources: [], + }, + ]), + } as Response) + } + + if (url === '/api/agents') { + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve([ + { + id: 'copilot', + name: 'GitHub Copilot', + status: 'active', + command: 'copilot', + canResume: true, + }, + ]), + } as Response) + } + + if (url === '/api/projects') { + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve([ + { + id: 'acp-frontend', + name: 'ACP Frontend', + path: '/home/vries/projects/acp-frontend', + status: 'available', + }, + ]), + } as Response) + } + + if (url === '/api/projects/acp-frontend/tree') { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve([]), + } as Response) + } + + if (url === '/api/sessions') { + if (opts?.method === 'POST') { + return Promise.resolve({ + ok: true, + status: 201, + json: () => + Promise.resolve({ + id: 'test-session-id', + title: 'New chat', + updatedAt: '2026-03-18T08:00:00.000Z', + agentId: 'copilot', + project: { + id: 'acp-frontend', + name: 'ACP Frontend', + path: '/home/vries/projects/acp-frontend', + }, + messages: [], + }), + } as Response) + } + + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve([ + { + id: 'test-session-id', + title: 'New chat', + updatedAt: '2026-03-18T08:00:00.000Z', + agentId: 'copilot', + project: { + id: 'acp-frontend', + name: 'ACP Frontend', + path: '/home/vries/projects/acp-frontend', + }, + }, + ]), + } as Response) + } + + if ( + url === '/api/sessions/test-session-id' || + url.startsWith('/api/sessions/test-session-id?') + ) { + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + id: 'test-session-id', + title: 'New chat', + updatedAt: '2026-03-18T08:00:00.000Z', + agentId: 'copilot', + project: { + id: 'acp-frontend', + name: 'ACP Frontend', + path: '/home/vries/projects/acp-frontend', + }, + messages: [], + }), + } as Response) + } + + return Promise.reject(new Error(`Unexpected fetch: ${url}`)) + }) +} + +describe('app router', () => { + beforeEach(() => { + vi.stubGlobal('EventSource', MockEventSource) + vi.stubGlobal('fetch', mockFetch()) + }) + + afterEach(() => { + cleanup() + vi.unstubAllGlobals() + window.location.hash = '' + }) + + it('redirects to #/chat on empty hash', async () => { + window.location.hash = '' + + render(App) + + await waitFor(() => expect(window.location.hash).toBe('#/chat')) + expect(screen.getByPlaceholderText('Type a message…')).toBeDefined() + }) + + it('navigates to backend settings from the chat header', async () => { + window.location.hash = '#/chat?session=test-session-id&project=acp-frontend' + + render(App) + + await waitFor(() => expect(screen.getAllByRole('link', { name: 'Backends' }).length).toBe(2)) + fireEvent.click(screen.getAllByRole('link', { name: 'Backends' })[0]!) + + await waitFor(() => expect(screen.getByText('Agents')).toBeDefined()) + }) + + it('renders the backend settings route', async () => { + window.location.hash = '#/settings' + + render(App) + + await waitFor(() => expect(screen.getByText('Agents')).toBeDefined()) + expect(screen.getAllByText('GitHub Copilot').length).toBeGreaterThan(0) + expect(screen.getByText('Managed by acpx runtime configuration.')).toBeDefined() + expect(screen.getAllByText('History Sources').length).toBeGreaterThan(0) + await waitFor(() => expect(screen.getAllByText('Discovery status').length).toBeGreaterThan(0)) + expect(screen.getByText('2 readable')).toBeDefined() + expect(screen.getByText('52 sessions found')).toBeDefined() + expect(screen.getByRole('link', { name: 'Back To Chat' })).toBeDefined() + // History Sources section is present (separate from backend cards) + await waitFor(() => + expect( + screen.getByDisplayValue('/mnt/c/Users/vries/AppData/Roaming/Code/User/workspaceStorage') + ).toBeDefined() + ) + }) + + it('normalizes blank chat search params to undefined', async () => { + window.location.hash = '#/chat?session=%20%20%20&project=' + + render(App) + + await waitFor(() => expect(screen.getByPlaceholderText('Type a message…')).toBeDefined()) + await waitFor(() => expect(window.location.hash.includes('%20')).toBe(false)) + await waitFor(() => expect(window.location.hash.includes('project=')).toBe(true)) + }) +}) diff --git a/frontend/src/router.test.tsx b/frontend/src/router.test.tsx deleted file mode 100644 index f3e1d8f..0000000 --- a/frontend/src/router.test.tsx +++ /dev/null @@ -1,293 +0,0 @@ -// @vitest-environment happy-dom -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' -import { App } from './App.js' -import { createAppRouter } from './router.js' - -class MockEventSource { - close = vi.fn() - - addEventListener() {} -} - -function mockFetch() { - return vi.fn().mockImplementation((url: string, opts?: RequestInit) => { - if (url === '/api/backends') { - return Promise.resolve({ - ok: true, - json: () => - Promise.resolve([ - { - id: 'copilot-vscode-host', - name: 'GitHub Copilot VS Code (Host)', - status: 'active', - command: null, - detectedCommand: null, - args: [], - defaultArgs: [], - enabled: true, - usesCustomCommand: false, - endpointSupport: { - source: 'connection', - implemented: ['session/new'], - unknown: ['terminal/*', 'project discovery'], - }, - historySupport: { - source: 'derived', - supported: ['text', 'markdown'], - discoveredSources: [ - { - id: 'src-1', - backendId: 'copilot-vscode-host', - providerId: 'copilot-vscode-host', - kind: 'vscode_workspace_db', - path: '/mnt/c/Users/vries/AppData/Roaming/Code/User/workspaceStorage/x/state.vscdb', - platform: 'mounted_host', - access: 'readable', - signal: 'contains_history', - discoveredBy: 'auto', - sessionCount: 42, - }, - { - id: 'src-2', - backendId: 'copilot-vscode-host', - providerId: 'copilot-vscode-host', - kind: 'vscode_chat_sessions', - path: '/mnt/c/Users/vries/AppData/Roaming/Code/User/workspaceStorage/x/chatSessions', - platform: 'mounted_host', - access: 'readable', - signal: 'contains_history', - discoveredBy: 'auto', - sessionCount: 10, - }, - ], - discoverySummary: [ - { - family: 'vscode', - readable: 2, - missing: 0, - invalid: 0, - containsHistory: 2, - }, - ], - }, - lastTestResult: null, - }, - ]), - } as Response) - } - - if (url === '/api/history-sources') { - return Promise.resolve({ - ok: true, - json: () => - Promise.resolve([ - { - provider: 'copilot', - paths: ['/mnt/c/Users/vries/AppData/Roaming/Code/User/workspaceStorage'], - cliPaths: [], - }, - { provider: 'gemini', paths: [] }, - { provider: 'opencode', paths: [] }, - ]), - } as Response) - } - - if (url === '/api/backends' && opts?.method === 'POST') { - return Promise.resolve({ - ok: true, - status: 201, - json: () => - Promise.resolve({ - id: 'custom-wrapper', - name: 'Custom Wrapper', - status: 'detected', - command: 'custom-wrapper', - detectedCommand: 'custom-wrapper', - args: ['--acp'], - defaultArgs: ['--acp'], - enabled: true, - usesCustomCommand: true, - endpointSupport: { - source: 'unknown', - implemented: [], - unknown: ['session/new'], - }, - historySupport: { - source: 'none', - supported: [], - discoveredSources: [], - discoverySummary: [], - }, - }), - } as Response) - } - - if (url === '/api/agents') { - return Promise.resolve({ - ok: true, - json: () => - Promise.resolve([ - { id: 'copilot', name: 'GitHub Copilot', status: 'active', command: 'copilot' }, - ]), - } as Response) - } - - if (url === '/api/projects') { - return Promise.resolve({ - ok: true, - json: () => - Promise.resolve([ - { - id: 'acp-frontend', - name: 'ACP Frontend', - path: '/home/vries/projects/acp-frontend', - status: 'available', - }, - ]), - } as Response) - } - - if (url === '/api/projects/acp-frontend/tree') { - return Promise.resolve({ - ok: true, - json: () => Promise.resolve([]), - } as Response) - } - - if (url === '/api/sessions') { - if (opts?.method === 'POST') { - return Promise.resolve({ - ok: true, - status: 201, - json: () => - Promise.resolve({ - id: 'test-session-id', - title: 'New chat', - updatedAt: '2026-03-18T08:00:00.000Z', - agentId: 'copilot', - project: { - id: 'acp-frontend', - name: 'ACP Frontend', - path: '/home/vries/projects/acp-frontend', - }, - messages: [], - }), - } as Response) - } - - return Promise.resolve({ - ok: true, - json: () => - Promise.resolve([ - { - id: 'test-session-id', - title: 'New chat', - updatedAt: '2026-03-18T08:00:00.000Z', - agentId: 'copilot', - project: { - id: 'acp-frontend', - name: 'ACP Frontend', - path: '/home/vries/projects/acp-frontend', - }, - }, - ]), - } as Response) - } - - if (url === '/api/sessions/test-session-id') { - return Promise.resolve({ - ok: true, - json: () => - Promise.resolve({ - id: 'test-session-id', - title: 'New chat', - updatedAt: '2026-03-18T08:00:00.000Z', - agentId: 'copilot', - project: { - id: 'acp-frontend', - name: 'ACP Frontend', - path: '/home/vries/projects/acp-frontend', - }, - messages: [], - }), - } as Response) - } - - return Promise.reject(new Error(`Unexpected fetch: ${url}`)) - }) -} - -describe('app router', () => { - beforeEach(() => { - vi.stubGlobal('EventSource', MockEventSource) - vi.stubGlobal('fetch', mockFetch()) - }) - - afterEach(() => { - cleanup() - vi.unstubAllGlobals() - window.history.pushState({}, '', '/') - }) - - it('redirects the index route to /chat', async () => { - window.history.pushState({}, '', '/') - - render() - - await waitFor(() => expect(window.location.pathname).toBe('/chat')) - expect(screen.getByPlaceholderText('Type a message…')).toBeDefined() - }) - - it('navigates to backend settings from the chat header', async () => { - window.history.pushState({}, '', '/chat?session=test-session-id') - window.history.replaceState({}, '', '/chat?session=test-session-id&project=acp-frontend') - - render() - - await waitFor(() => expect(screen.getAllByRole('link', { name: 'Backends' }).length).toBe(2)) - fireEvent.click(screen.getAllByRole('link', { name: 'Backends' })[0]!) - - await waitFor(() => expect(screen.getByText('ACP Backends')).toBeDefined()) - }) - - it('renders the MCP settings route', async () => { - window.history.pushState({}, '', '/settings/mcp') - - render() - - await waitFor(() => expect(screen.getByText('MCP Configuration')).toBeDefined()) - expect(screen.getByText(/Manage ACP backends and MCP servers from one place/i)).toBeDefined() - }) - - it('renders the backend settings route', async () => { - window.history.pushState({}, '', '/settings/backends') - - render() - - await waitFor(() => expect(screen.getByText('ACP Backends')).toBeDefined()) - expect(screen.getByDisplayValue('GitHub Copilot VS Code (Host)')).toBeDefined() - expect(screen.getByText('Add Backend')).toBeDefined() - expect(screen.getAllByText('History Sources').length).toBeGreaterThan(0) - expect(screen.getByText('vscode_workspace_db')).toBeDefined() - expect(screen.getByText('vscode_chat_sessions')).toBeDefined() - expect(screen.getByText('42 sessions')).toBeDefined() - expect(screen.getByRole('link', { name: 'Back To Chat' })).toBeDefined() - // History Sources section is present (separate from backend cards) - await waitFor(() => - expect( - screen.getByDisplayValue('/mnt/c/Users/vries/AppData/Roaming/Code/User/workspaceStorage') - ).toBeDefined() - ) - }) - - it('normalizes blank chat search params to undefined', async () => { - window.history.pushState({}, '', '/chat?session=%20%20%20&project=') - - render() - - await waitFor(() => expect(screen.getByPlaceholderText('Type a message…')).toBeDefined()) - await waitFor(() => expect(window.location.search.includes('%20')).toBe(false)) - await waitFor(() => expect(window.location.search.includes('project=')).toBe(true)) - }) -}) diff --git a/frontend/src/router.ts b/frontend/src/router.ts deleted file mode 100644 index cdbdbdd..0000000 --- a/frontend/src/router.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { - Outlet, - createRootRoute, - createRoute, - createRouter, - redirect, -} from '@tanstack/react-router' -import { ChatPage } from './routes/chat.js' -import { SettingsPage } from './routes/settings.js' - -const rootRoute = createRootRoute({ - component: Outlet, -}) - -const indexRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/', - beforeLoad: () => { - throw redirect({ - to: '/chat', - search: { session: undefined, project: undefined }, - }) - }, -}) - -const chatRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/chat', - validateSearch: (search: Record) => ({ - session: normalizeSearchString(search.session), - project: normalizeSearchString(search.project), - }), - component: ChatPage, -}) - -const settingsRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/settings', - component: SettingsPage, -}) - -const mcpSettingsRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/settings/mcp', - beforeLoad: () => { - throw redirect({ to: '/settings' }) - }, -}) - -const backendSettingsRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/settings/backends', - beforeLoad: () => { - throw redirect({ to: '/settings' }) - }, -}) - -const routeTree = rootRoute.addChildren([ - indexRoute, - chatRoute, - settingsRoute, - mcpSettingsRoute, - backendSettingsRoute, -]) - -export function createAppRouter() { - return createRouter({ routeTree }) -} - -export type AppRouter = ReturnType - -export const router = createAppRouter() - -function normalizeSearchString(value: unknown): string | undefined { - if (typeof value !== 'string') return undefined - const trimmed = value.trim() - return trimmed.length > 0 ? trimmed : undefined -} - -declare module '@tanstack/react-router' { - interface Register { - router: typeof router - } -} diff --git a/frontend/src/routes/ChatLayout.svelte b/frontend/src/routes/ChatLayout.svelte new file mode 100644 index 0000000..45701c7 --- /dev/null +++ b/frontend/src/routes/ChatLayout.svelte @@ -0,0 +1,202 @@ + + +
        +
        + + +
        + + +
        + + + (composerValue = v)} + onSubmit={() => {}} + disabled={false} + canSubmit={composerValue.trim().length > 0} + helperText="The composer stays visible while you inspect files or diff." + /> +
        + + +
        +
        + + {#if mobileDrawer} + + {/if} +
        diff --git a/frontend/src/routes/ChatPage.svelte b/frontend/src/routes/ChatPage.svelte new file mode 100644 index 0000000..9eb09ce --- /dev/null +++ b/frontend/src/routes/ChatPage.svelte @@ -0,0 +1,608 @@ + + +
        +
        + + + +
        + + +
        +

        + {store.selectedProject?.name ?? 'Workspace'} +

        +

        {activeViewLabel}

        +
        + +
        + + +
        +
        + +
        + + + + +
        + + + + {#if workspaceView === 'chat'} + 0 && store.availableProjects.length > 0} + hasAnyProject={store.projects.length > 0} + hasAvailableAgent={store.activeAgents.length > 0} + hasAvailableProject={store.availableProjects.length > 0} + messages={store.messages} + projectPath={store.selectedProject?.path ?? null} + sessionId={store.currentSessionId} + hasSession={store.currentSessionId !== null} + loading={store.loading} + onOpenProjectManager={openProjectManager} + onStartSession={() => { + const firstAgent = store.activeAgents[0] + if (firstAgent) { + void store.startNewSession(firstAgent.id) + } + }} + ready={store.ready} + streamReconnecting={store.streamReconnecting} + thinking={store.thinking} + errorMessage={store.errorMessage} + historyLoading={store.historyLoading} + /> + {:else if workspaceView === 'diff'} +
        +
        + +
        +
        + {:else} + { + const collapsing = expandedPaths.includes(path) + expandedPaths = collapsing + ? expandedPaths.filter((item) => item !== path) + : [path] + await loadTree(collapsing ? getParentTreePath(path) : path) + }} + selectedEntryPath={selectedEntryPath} + onSelectEntry={(p) => (selectedEntryPath = p)} + /> + {/if} + + (input = v)} + onSubmit={handleSubmit} + disabled={!store.ready} + canSubmit={store.ready && input.trim().length > 0} + isHistorySession={isHistorySession} + historyLoading={store.historyLoading} + onResume={handleResume} + resumableAgents={resumableAgents} + resuming={resuming} + modelState={store.modelState} + onModelChange={store.setSessionModel} + helperText={store.ready + ? 'The composer stays available while you inspect files or diff so the conversation never loses context.' + : undefined} + /> +
        +
        +
        + + + {#if drawerOpen} + + + {/if} + +
        +
        diff --git a/frontend/src/routes/SettingsPage.svelte b/frontend/src/routes/SettingsPage.svelte new file mode 100644 index 0000000..e95ad46 --- /dev/null +++ b/frontend/src/routes/SettingsPage.svelte @@ -0,0 +1,119 @@ + + +
        +
        +
        +
        +
        +

        + Settings +

        +

        Settings

        +
        + + +
        + +

        + acpx manages live agent runtime state, while this screen manages local history source hints + used to discover imported sessions from each provider. +

        + + {#if errorMessage} +
        + {errorMessage} +
        + {/if} + +
        +

        + Agents +

        +

        + Agent runtime and availability are controlled by acpx. This section is read-only status so + you can verify what is currently reachable. +

        + + {#if backendStore.loading} +
        + Loading agent status... +
        + {:else} +
        + {#each backendStore.backends as backend (backend.id)} + + {/each} +
        + {/if} +
        + +
        +

        + History Sources +

        +

        + Configure where each AI provider stores its conversation history. These paths are used to + discover sessions for import. History path hints are stored separately from backend + connection settings. +

        + + {#if historyStore.loading} +
        + Loading history sources... +
        + {:else} +
        + {#each historyStore.sources as source (source.provider)} + entry.provider === source.provider) ?? null} + busy={historyStore.savingProvider === source.provider} + onSave={historyStore.saveSource} + /> + {/each} +
        + {/if} +
        + +
        +

        + MCP Servers +

        +

        MCP Configuration

        +

        + MCP server configuration will live in this section so backend and MCP settings share a + single entry point. +

        +
        +
        +
        +
        diff --git a/frontend/src/routes/chat-layout.stories.ts b/frontend/src/routes/chat-layout.stories.ts new file mode 100644 index 0000000..d49d08a --- /dev/null +++ b/frontend/src/routes/chat-layout.stories.ts @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from '@storybook/svelte' +import ChatLayout from './ChatLayout.svelte' + +const meta = { + title: 'Pages/ChatLayout', + component: ChatLayout, + parameters: { + layout: 'fullscreen', + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: { + thinking: true, + mobileDrawer: false, + }, +} + +export const MobileDrawerOpen: Story = { + args: { + mobileDrawer: true, + thinking: true, + }, +} diff --git a/frontend/src/routes/chat-layout.stories.tsx b/frontend/src/routes/chat-layout.stories.tsx deleted file mode 100644 index e402522..0000000 --- a/frontend/src/routes/chat-layout.stories.tsx +++ /dev/null @@ -1,434 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react-vite' -import { useState } from 'react' -import { ChatComposer } from '../components/chat/ChatComposer.js' -import { ChatDiffView } from '../components/chat/ChatDiffView.js' -import { ChatHeader } from '../components/chat/ChatHeader.js' -import { ProjectContextSwitcher } from '../components/chat/ProjectContextSwitcher.js' -import { ProjectWorkspacePanel } from '../components/chat/ProjectWorkspacePanel.js' -import { SessionList } from '../components/chat/SessionList.js' -import { ChatTranscript } from '../components/chat/ChatTranscript.js' - -type Surface = 'chat' | 'files' | 'diff' - -function ChatLayoutStory(props: { mobileDrawer?: boolean; surface?: Surface; thinking?: boolean }) { - const [value, setValue] = useState('') - - return ( -
        -
        - {children}} - project={{ - id: 'acp-frontend', - name: 'ACP Frontend', - path: '/home/vries/projects/acp-frontend', - status: 'available', - }} - sessionId="session-12345678" - ready - thinking={props.thinking ?? false} - title="Agentic Coding Presentation Outline" - /> - -
        - - -
        -
        -
        -

        - Workspace view -

        -

        - Keep the conversation central while switching files and diff in place. -

        -
        - -
        - - - -
        -
        - - {props.surface === 'diff' ? ( -
        -
        - -
        -
        - ) : props.surface === 'files' ? ( - {}} - activeAgentCount={2} - tree={[ - { name: 'src', path: 'src', type: 'directory', hasChildren: true }, - { name: 'package.json', path: 'package.json', type: 'file', hasChildren: false }, - ]} - treePath={null} - treeLoading={false} - treeError={null} - expandedPaths={[]} - onToggleFolder={() => {}} - selectedEntryPath="src" - onSelectEntry={() => {}} - /> - ) : ( - {}} - onStartSession={() => {}} - messages={[ - { - id: 'assistant-1', - role: 'assistant', - content: - 'The header should collapse into a tighter control bar while the side rails stop competing with the actual conversation.', - }, - { - id: 'user-1', - role: 'user', - content: 'Keep the layout tighter and less dashboard-like.', - }, - { - id: 'assistant-2', - role: 'assistant', - content: - 'A slimmer rail, quieter badges, and a more dominant transcript column will get much closer to that feeling.', - }, - ]} - hasSession - loading={false} - ready - thinking={props.thinking ?? true} - errorMessage={null} - /> - )} - - event.preventDefault()} - disabled={false} - canSubmit={value.trim().length > 0} - helperText="The composer stays visible while you inspect files or diff so the conversation keeps its place." - /> -
        - -
        - {}} - activeAgentCount={2} - tree={[ - { name: 'src', path: 'src', type: 'directory', hasChildren: true }, - { name: 'package.json', path: 'package.json', type: 'file', hasChildren: false }, - ]} - treePath={null} - treeLoading={false} - treeError={null} - expandedPaths={[]} - onToggleFolder={() => {}} - selectedEntryPath="src" - onSelectEntry={() => {}} - /> -
        -
        -
        - - {props.mobileDrawer ? ( - - ) : null} -
        - ) -} - -function tabClassName(active: boolean) { - return [ - 'inline-flex h-10 items-center justify-center rounded-xl border px-3.5 text-sm font-medium transition', - active - ? 'border-teal-500/30 bg-teal-500/12 text-teal-100' - : 'border-white/10 bg-slate-900/55 text-slate-300', - ].join(' ') -} - -const meta = { - title: 'Pages/ChatLayout', - component: ChatLayoutStory, - parameters: { - layout: 'fullscreen', - }, -} satisfies Meta - -export default meta -type Story = StoryObj - -export const Default: Story = { - args: { - surface: 'chat', - thinking: true, - }, -} - -export const DiffMode: Story = { - args: { - surface: 'diff', - thinking: false, - }, -} - -export const FilesMode: Story = { - args: { - surface: 'files', - thinking: false, - }, -} - -export const MobileDrawerOpen: Story = { - args: { - mobileDrawer: true, - surface: 'chat', - thinking: true, - }, -} diff --git a/frontend/src/routes/chat.test.tsx b/frontend/src/routes/chat.test.svelte.ts similarity index 72% rename from frontend/src/routes/chat.test.tsx rename to frontend/src/routes/chat.test.svelte.ts index 747a1ce..1ba5b8f 100644 --- a/frontend/src/routes/chat.test.tsx +++ b/frontend/src/routes/chat.test.svelte.ts @@ -1,15 +1,15 @@ // @vitest-environment happy-dom import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { render, screen, fireEvent, waitFor, cleanup, within } from '@testing-library/react' -import { EventType } from '@ag-ui/core' -import { App } from '../App.js' -import { buildSendMessagePayload } from '../hooks/useAgUiChat.js' -import { createAppRouter } from '../router.js' +import { render, screen, fireEvent, waitFor, cleanup, within } from '@testing-library/svelte' +import { StreamEvent } from '../stream-events.js' +import App from '../App.svelte' +import { buildSendMessagePayload } from '../store/chatStore.svelte.js' type SseHandler = (event: MessageEvent) => void class MockEventSource { static instance: MockEventSource | null = null + static instances: MockEventSource[] = [] readonly url: string private readonly handlers: Map = new Map() @@ -19,6 +19,7 @@ class MockEventSource { constructor(url: string) { this.url = url MockEventSource.instance = this + MockEventSource.instances.push(this) } addEventListener(type: string, handler: SseHandler) { @@ -35,9 +36,9 @@ class MockEventSource { const SESSION_ID = 'test-session-id' const SECOND_SESSION_ID = 'older-session-id' -function renderChatPage(path = '/chat') { - window.history.pushState({}, '', path) - return render() +function renderChatPage(hash = '#/chat') { + window.location.hash = hash + return render(App) } function mockFetch(options?: { @@ -234,6 +235,7 @@ function mockFetch(options?: { describe('ChatPage', () => { beforeEach(() => { MockEventSource.instance = null + MockEventSource.instances = [] vi.stubGlobal('EventSource', MockEventSource) vi.stubGlobal('fetch', mockFetch()) }) @@ -241,17 +243,17 @@ describe('ChatPage', () => { afterEach(() => { cleanup() vi.unstubAllGlobals() - window.history.pushState({}, '', '/') + window.location.hash = '' }) it('renders the input field', async () => { - renderChatPage('/chat?session=test-session-id&project=acp-frontend') + renderChatPage('#/chat?session=test-session-id&project=acp-frontend') await waitFor(() => expect(screen.getByPlaceholderText('Type a message…')).toBeDefined()) await waitFor(() => expect(MockEventSource.instance).not.toBeNull()) }) it('renders the workspace shell with header and extension panels', async () => { - renderChatPage('/chat?session=test-session-id&project=acp-frontend') + renderChatPage('#/chat?session=test-session-id&project=acp-frontend') await waitFor(() => expect(screen.getByText('Chat Workspace')).toBeDefined()) expect(screen.getByTestId('chat-composer')).toBeDefined() @@ -375,7 +377,7 @@ describe('ChatPage', () => { }) ) - renderChatPage('/chat?session=test-session-id&project=acp-frontend') + renderChatPage('#/chat?session=test-session-id&project=acp-frontend') const sessionPanel = await screen.findByTestId('chat-session-panel') await waitFor(() => @@ -385,7 +387,7 @@ describe('ChatPage', () => { }) it('shows the files view as supporting context without project picker controls', async () => { - renderChatPage('/chat?session=test-session-id&project=acp-frontend') + renderChatPage('#/chat?session=test-session-id&project=acp-frontend') await waitFor(() => expect(screen.getAllByRole('button', { name: 'Files' }).length).toBeGreaterThan(0) @@ -398,20 +400,173 @@ describe('ChatPage', () => { }) it('shows agent status dot and name for each session', async () => { - renderChatPage('/chat?session=test-session-id&project=acp-frontend') + renderChatPage('#/chat?session=test-session-id&project=acp-frontend') const sessionPanel = await screen.findByTestId('chat-session-panel') await waitFor(() => expect(within(sessionPanel).getAllByText('GitHub Copilot').length).toBeGreaterThan(0) ) - // Status dot for active agent should carry aria-label "online" const dots = within(sessionPanel).getAllByLabelText('online') expect(dots.length).toBeGreaterThan(0) }) + it('renders history sessions with provider-history label instead of live status dot', async () => { + const HISTORY_SESSION_ID = 'history-provider-row' + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation((url: string, opts?: RequestInit) => { + if (url === '/api/agents') { + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve([ + { id: 'copilot', name: 'GitHub Copilot', status: 'active', command: 'copilot' }, + ]), + } as Response) + } + + if (url === '/api/projects') { + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve([ + { + id: 'acp-frontend', + name: 'ACP Frontend', + path: '/home/vries/projects/acp-frontend', + status: 'available', + }, + ]), + } as Response) + } + + if (url === '/api/projects/acp-frontend/tree') { + return Promise.resolve({ ok: true, json: () => Promise.resolve([]) } as Response) + } + + if (url === '/api/sessions') { + if (opts?.method === 'POST') { + return Promise.resolve({ + ok: true, + status: 201, + json: () => + Promise.resolve({ + id: SESSION_ID, + title: 'New chat', + updatedAt: '2026-03-18T08:00:00.000Z', + agentId: 'copilot', + source: 'live', + project: { + id: 'acp-frontend', + name: 'ACP Frontend', + path: '/home/vries/projects/acp-frontend', + }, + messages: [], + }), + } as Response) + } + + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve([ + { + id: SESSION_ID, + title: 'Live copilot chat', + updatedAt: '2026-03-18T08:00:00.000Z', + agentId: 'copilot', + source: 'live', + project: { + id: 'acp-frontend', + name: 'ACP Frontend', + path: '/home/vries/projects/acp-frontend', + }, + }, + { + id: HISTORY_SESSION_ID, + title: 'Imported copilot thread', + updatedAt: '2026-03-17T09:30:00.000Z', + agentId: 'copilot', + source: 'history', + project: { + id: 'acp-frontend', + name: 'ACP Frontend', + path: '/home/vries/projects/acp-frontend', + }, + }, + ]), + } as Response) + } + + if ( + url === `/api/sessions/${SESSION_ID}` || + url.startsWith(`/api/sessions/${SESSION_ID}?`) + ) { + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + id: SESSION_ID, + title: 'Live copilot chat', + updatedAt: '2026-03-18T08:00:00.000Z', + agentId: 'copilot', + source: 'live', + project: { + id: 'acp-frontend', + name: 'ACP Frontend', + path: '/home/vries/projects/acp-frontend', + }, + messages: [], + }), + } as Response) + } + + if ( + url === `/api/sessions/${HISTORY_SESSION_ID}` || + url.startsWith(`/api/sessions/${HISTORY_SESSION_ID}?`) + ) { + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + id: HISTORY_SESSION_ID, + title: 'Imported copilot thread', + updatedAt: '2026-03-17T09:30:00.000Z', + agentId: 'copilot', + source: 'history', + project: { + id: 'acp-frontend', + name: 'ACP Frontend', + path: '/home/vries/projects/acp-frontend', + }, + messages: [], + }), + } as Response) + } + + return Promise.reject(new Error(`Unexpected fetch: ${url}`)) + }) + ) + + renderChatPage('#/chat?session=test-session-id&project=acp-frontend') + + const sessionPanel = await screen.findByTestId('chat-session-panel') + await waitFor(() => + expect(within(sessionPanel).getByText('Imported copilot thread')).toBeDefined() + ) + + expect(within(sessionPanel).getByText('GitHub Copilot history')).toBeDefined() + + const historyRow = within(sessionPanel) + .getByText('Imported copilot thread') + .closest('button') as HTMLElement + expect(historyRow).toBeDefined() + expect(within(historyRow).queryByLabelText('online')).toBeNull() + }) + it('shows the empty transcript state once session is ready', async () => { - renderChatPage('/chat?session=test-session-id&project=acp-frontend') + renderChatPage('#/chat?session=test-session-id&project=acp-frontend') await waitFor(() => expect(screen.getByText('Start the conversation')).toBeDefined()) }) @@ -420,7 +575,7 @@ describe('ChatPage', () => { const fetchSpy = mockFetch() vi.stubGlobal('fetch', fetchSpy) - renderChatPage('/chat?session=test-session-id&project=acp-frontend') + renderChatPage('#/chat?session=test-session-id&project=acp-frontend') await waitFor(() => expect(screen.getByPlaceholderText('Type a message…')).toBeDefined()) const input = screen.getByPlaceholderText('Type a message…') await waitFor(() => expect((input as HTMLInputElement).disabled).toBe(false)) @@ -454,7 +609,7 @@ describe('ChatPage', () => { }) it('shows user message immediately on submit', async () => { - renderChatPage('/chat?session=test-session-id&project=acp-frontend') + renderChatPage('#/chat?session=test-session-id&project=acp-frontend') await waitFor(() => expect(screen.getByPlaceholderText('Type a message…')).toBeDefined()) const input = screen.getByPlaceholderText('Type a message…') await waitFor(() => expect((input as HTMLInputElement).disabled).toBe(false)) @@ -468,7 +623,7 @@ describe('ChatPage', () => { it('shows a session error state when creating a session fails', async () => { vi.stubGlobal('fetch', mockFetch({ noSessions: true, sessionFails: true })) - renderChatPage('/chat?session=test-session-id&project=acp-frontend') + renderChatPage('#/chat?session=test-session-id&project=acp-frontend') const sessionPanel = await screen.findByTestId('chat-session-panel') fireEvent.click(within(sessionPanel).getByRole('button', { name: 'New chat' })) @@ -485,7 +640,7 @@ describe('ChatPage', () => { it('shows a send error state when posting a message fails', async () => { vi.stubGlobal('fetch', mockFetch({ messageFails: true })) - renderChatPage('/chat?session=test-session-id&project=acp-frontend') + renderChatPage('#/chat?session=test-session-id&project=acp-frontend') await waitFor(() => expect(screen.getByPlaceholderText('Type a message…')).toBeDefined()) const input = screen.getByPlaceholderText('Type a message…') await waitFor(() => expect((input as HTMLInputElement).disabled).toBe(false)) @@ -502,59 +657,59 @@ describe('ChatPage', () => { }) it('loads a previous transcript when a session is selected', async () => { - renderChatPage() + renderChatPage('#/chat') await waitFor(() => expect(screen.getByText('Start the conversation')).toBeDefined()) - await waitFor(() => expect(window.location.search).toContain(`session=${SESSION_ID}`)) + await waitFor(() => expect(window.location.hash).toContain(`session=${SESSION_ID}`)) await waitFor(() => expect(screen.getByText('Review SSE handling')).toBeDefined()) const sessionPanel = await screen.findByTestId('chat-session-panel') fireEvent.click(within(sessionPanel).getByRole('button', { name: /Review SSE handling/i })) - await waitFor(() => expect(window.location.search).toContain(`session=${SECOND_SESSION_ID}`)) + await waitFor(() => expect(window.location.hash).toContain(`session=${SECOND_SESSION_ID}`)) await waitFor(() => expect(screen.getByText('Previous answer')).toBeDefined()) }) it('reloads transcript content when the route session changes externally', async () => { - renderChatPage('/chat?session=test-session-id') + renderChatPage('#/chat?session=test-session-id') await waitFor(() => expect(screen.getByText('Start the conversation')).toBeDefined()) - window.history.pushState({}, '', `/chat?session=${SECOND_SESSION_ID}&project=acp-frontend`) - window.dispatchEvent(new PopStateEvent('popstate')) + window.location.hash = `#/chat?session=${SECOND_SESSION_ID}&project=acp-frontend` + window.dispatchEvent(new HashChangeEvent('hashchange')) await waitFor(() => expect(screen.getByText('Previous answer')).toBeDefined()) }) it('appends streamed assistant text on TEXT_MESSAGE_CONTENT events', async () => { - renderChatPage() + renderChatPage('#/chat') await waitFor(() => expect(MockEventSource.instance).not.toBeNull()) const sse = MockEventSource.instance! const messageId = 'msg-1' - sse.emit(EventType.TEXT_MESSAGE_START, { messageId, role: 'assistant' }) - sse.emit(EventType.TEXT_MESSAGE_CONTENT, { messageId, delta: 'Hello' }) - sse.emit(EventType.TEXT_MESSAGE_CONTENT, { messageId, delta: ' world' }) + sse.emit(StreamEvent.TEXT_MESSAGE_START, { messageId, role: 'assistant' }) + sse.emit(StreamEvent.TEXT_MESSAGE_CONTENT, { messageId, delta: 'Hello' }) + sse.emit(StreamEvent.TEXT_MESSAGE_CONTENT, { messageId, delta: ' world' }) await waitFor(() => expect(screen.getByText('Hello world')).toBeDefined()) }) it('shows thinking indicator on RUN_STARTED and hides on RUN_FINISHED', async () => { - renderChatPage() + renderChatPage('#/chat') await waitFor(() => expect(MockEventSource.instance).not.toBeNull()) const sse = MockEventSource.instance! - sse.emit(EventType.RUN_STARTED, { threadId: SESSION_ID, runId: 'run-1' }) + sse.emit(StreamEvent.RUN_STARTED, { threadId: SESSION_ID, runId: 'run-1' }) await waitFor(() => expect(screen.getByText('Thinking…')).toBeDefined()) - sse.emit(EventType.RUN_FINISHED, { threadId: SESSION_ID, runId: 'run-1' }) + sse.emit(StreamEvent.RUN_FINISHED, { threadId: SESSION_ID, runId: 'run-1' }) await waitFor(() => expect(screen.queryByText('Thinking…')).toBeNull()) }) it('renders an empty session state when no sessions exist', async () => { vi.stubGlobal('fetch', mockFetch({ noSessions: true })) - renderChatPage('/chat?project=acp-frontend') + renderChatPage('#/chat?project=acp-frontend') await waitFor(() => expect(screen.getByText('No chats yet.')).toBeDefined()) }) @@ -596,7 +751,7 @@ describe('ChatPage', () => { }) ) - renderChatPage('/chat') + renderChatPage('#/chat') await waitFor(() => expect( @@ -665,10 +820,10 @@ describe('ChatPage', () => { }) ) - renderChatPage('/chat?project=acp-frontend') + renderChatPage('#/chat?project=acp-frontend') await waitFor(() => expect(screen.getByText('Open a fresh chat in this project')).toBeDefined()) - expect(window.location.search).not.toContain('session=disabled-session') + expect(window.location.hash).not.toContain('session=disabled-session') }) it('clears the route session when the active project is removed', async () => { @@ -775,130 +930,84 @@ describe('ChatPage', () => { vi.stubGlobal('fetch', fetchSpy) - renderChatPage(`/chat?session=${SESSION_ID}&project=acp-frontend`) + renderChatPage(`#/chat?session=${SESSION_ID}&project=acp-frontend`) await waitFor(() => expect(screen.getByLabelText('Open project manager')).toBeDefined()) fireEvent.click(screen.getByLabelText('Open project manager')) await waitFor(() => expect(screen.getByRole('button', { name: 'Remove' })).toBeDefined()) fireEvent.click(screen.getByRole('button', { name: 'Remove' })) - await waitFor(() => expect(window.location.search).not.toContain(`session=${SESSION_ID}`)) + await waitFor(() => expect(window.location.hash).not.toContain(`session=${SESSION_ID}`)) }) - it('renders sessions grouped by project and updates the list when switching projects', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockImplementation((url: string, opts?: RequestInit) => { - if (url === '/api/agents') { - return Promise.resolve({ - ok: true, - json: () => - Promise.resolve([ - { id: 'copilot', name: 'GitHub Copilot', status: 'active', command: 'copilot' }, - { id: 'gemini-cli', name: 'Gemini CLI', status: 'active', command: 'gemini' }, - { id: 'claude-code', name: 'Claude Code', status: 'unavailable', command: null }, - ]), - } as Response) - } + it('renders sessions grouped by project and clears active session when switching projects', async () => { + const fetchMock = vi.fn().mockImplementation((url: string, opts?: RequestInit) => { + if (url === '/api/agents') { + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve([ + { id: 'copilot', name: 'GitHub Copilot', status: 'active', command: 'copilot' }, + { id: 'gemini-cli', name: 'Gemini CLI', status: 'active', command: 'gemini' }, + { id: 'claude-code', name: 'Claude Code', status: 'unavailable', command: null }, + ]), + } as Response) + } - if (url === '/api/projects') { + if (url === '/api/projects') { + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve([ + { + id: 'acp-frontend', + name: 'ACP Frontend', + path: '/home/vries/projects/acp-frontend', + status: 'available', + }, + { + id: 'docs-site', + name: 'Docs Site', + path: '/home/vries/projects/docs-site', + status: 'available', + }, + ]), + } as Response) + } + + if (url === '/api/projects/acp-frontend/tree') { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve([]), + } as Response) + } + + if (url === '/api/sessions') { + if (opts?.method === 'POST') { return Promise.resolve({ ok: true, + status: 201, json: () => - Promise.resolve([ - { + Promise.resolve({ + id: SESSION_ID, + title: 'New chat', + updatedAt: '2026-03-18T08:00:00.000Z', + agentId: 'copilot', + project: { id: 'acp-frontend', name: 'ACP Frontend', path: '/home/vries/projects/acp-frontend', - status: 'available', - }, - { - id: 'docs-site', - name: 'Docs Site', - path: '/home/vries/projects/docs-site', - status: 'available', - }, - ]), - } as Response) - } - - if (url === '/api/projects/acp-frontend/tree') { - return Promise.resolve({ - ok: true, - json: () => Promise.resolve([]), - } as Response) - } - - if (url === '/api/sessions') { - if (opts?.method === 'POST') { - return Promise.resolve({ - ok: true, - status: 201, - json: () => - Promise.resolve({ - id: SESSION_ID, - title: 'New chat', - updatedAt: '2026-03-18T08:00:00.000Z', - agentId: 'copilot', - project: { - id: 'acp-frontend', - name: 'ACP Frontend', - path: '/home/vries/projects/acp-frontend', - }, - messages: [], - }), - } as Response) - } - - return Promise.resolve({ - ok: true, - json: () => - Promise.resolve([ - { - id: SESSION_ID, - title: 'Inspect auth bug', - updatedAt: '2026-03-18T08:00:00.000Z', - agentId: 'copilot', - project: { - id: 'acp-frontend', - name: 'ACP Frontend', - path: '/home/vries/projects/acp-frontend', - }, - }, - { - id: 'gemini-session-id', - title: 'Gemini discovery notes', - updatedAt: '2026-03-18T10:00:00.000Z', - agentId: 'gemini-cli', - project: { - id: 'docs-site', - name: 'Docs Site', - path: '/home/vries/projects/docs-site', - }, }, - { - id: 'claude-session-id', - title: 'Claude backlog', - updatedAt: '2026-03-17T06:00:00.000Z', - agentId: 'claude-code', - project: { - id: 'docs-site', - name: 'Docs Site', - path: '/home/vries/projects/docs-site', - }, - }, - ]), + messages: [], + }), } as Response) } - if ( - url === `/api/sessions/${SESSION_ID}` || - url.startsWith(`/api/sessions/${SESSION_ID}?`) - ) { - return Promise.resolve({ - ok: true, - json: () => - Promise.resolve({ + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve([ + { id: SESSION_ID, title: 'Inspect auth bug', updatedAt: '2026-03-18T08:00:00.000Z', @@ -908,16 +1017,58 @@ describe('ChatPage', () => { name: 'ACP Frontend', path: '/home/vries/projects/acp-frontend', }, - messages: [], - }), - } as Response) - } + }, + { + id: 'gemini-session-id', + title: 'Gemini discovery notes', + updatedAt: '2026-03-18T10:00:00.000Z', + agentId: 'gemini-cli', + project: { + id: 'docs-site', + name: 'Docs Site', + path: '/home/vries/projects/docs-site', + }, + }, + { + id: 'claude-session-id', + title: 'Claude backlog', + updatedAt: '2026-03-17T06:00:00.000Z', + agentId: 'claude-code', + project: { + id: 'docs-site', + name: 'Docs Site', + path: '/home/vries/projects/docs-site', + }, + }, + ]), + } as Response) + } - return Promise.reject(new Error(`Unexpected fetch: ${url}`)) - }) - ) + if (url === `/api/sessions/${SESSION_ID}` || url.startsWith(`/api/sessions/${SESSION_ID}?`)) { + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + id: SESSION_ID, + title: 'Inspect auth bug', + updatedAt: '2026-03-18T08:00:00.000Z', + agentId: 'copilot', + project: { + id: 'acp-frontend', + name: 'ACP Frontend', + path: '/home/vries/projects/acp-frontend', + }, + messages: [], + }), + } as Response) + } - renderChatPage('/chat?session=test-session-id') + return Promise.reject(new Error(`Unexpected fetch: ${url}`)) + }) + + vi.stubGlobal('fetch', fetchMock) + + renderChatPage('#/chat?session=test-session-id') const sessionPanel = await screen.findByTestId('chat-session-panel') @@ -936,10 +1087,19 @@ describe('ChatPage', () => { const useButtons = await screen.findAllByRole('button', { name: /^Use$/ }) fireEvent.click(useButtons[0]!) - await waitFor(() => expect(within(sessionPanel).getAllByText('Current').length).toBe(1)) + await waitFor(() => expect(within(sessionPanel).getByText('Current')).toBeDefined()) await waitFor(() => expect(within(sessionPanel).getByText('Docs Site')).toBeDefined()) expect(within(sessionPanel).getByText('Gemini discovery notes')).toBeDefined() expect(within(sessionPanel).getByText('Claude backlog')).toBeDefined() + await waitFor(() => expect(screen.getByText('Open a fresh chat in this project')).toBeDefined()) + expect(window.location.hash).toContain('project=docs-site') + expect(window.location.hash).not.toContain('session=') + + const createdSessionCall = fetchMock.mock.calls.find( + (args: unknown[]) => + args[0] === '/api/sessions' && (args[1] as RequestInit | undefined)?.method === 'POST' + ) + expect(createdSessionCall).toBeUndefined() }) it('shows the delegation panel instead of the composer when viewing a history session', async () => { @@ -1030,17 +1190,173 @@ describe('ChatPage', () => { }) ) - renderChatPage(`/chat?session=${HISTORY_SESSION_ID}&project=acp-frontend`) + renderChatPage(`#/chat?session=${HISTORY_SESSION_ID}&project=acp-frontend`) - // Delegation panel should appear instead of the regular composer await waitFor(() => expect(screen.getByTestId('history-session-panel')).toBeDefined()) expect(screen.queryByTestId('chat-composer')).toBeNull() + expect(MockEventSource.instances.length).toBe(0) - // The active agent should be listed as a fork target (copilot does not support session/load) - expect(screen.getByTestId('fork-agent-copilot')).toBeDefined() + expect(screen.getByTestId('continue-agent-copilot')).toBeDefined() expect(screen.getAllByText('GitHub Copilot').length).toBeGreaterThan(0) }) + it('does not open SSE for history session on bootstrap, then opens once resumed to live', async () => { + const HISTORY_SESSION_ID = 'history-session-bootstrap' + const NEW_SESSION_ID = 'new-live-after-history' + const fetchMock = vi.fn().mockImplementation((url: string, opts?: RequestInit) => { + if (url === '/api/agents') { + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve([ + { + id: 'copilot', + name: 'GitHub Copilot', + status: 'active', + command: 'copilot', + canResume: true, + }, + ]), + } as Response) + } + + if (url === '/api/projects') { + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve([ + { + id: 'acp-frontend', + name: 'ACP Frontend', + path: '/home/vries/projects/acp-frontend', + status: 'available', + }, + ]), + } as Response) + } + + if (url === '/api/projects/acp-frontend/tree') { + return Promise.resolve({ ok: true, json: () => Promise.resolve([]) } as Response) + } + + if (url === '/api/sessions') { + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve([ + { + id: HISTORY_SESSION_ID, + title: 'Historical thread', + updatedAt: '2026-03-28T10:00:00.000Z', + agentId: 'copilot', + source: 'history', + project: { + id: 'acp-frontend', + name: 'ACP Frontend', + path: '/home/vries/projects/acp-frontend', + }, + }, + { + id: NEW_SESSION_ID, + title: 'Live continuation', + updatedAt: '2026-03-30T10:00:00.000Z', + agentId: 'copilot', + source: 'live', + project: { + id: 'acp-frontend', + name: 'ACP Frontend', + path: '/home/vries/projects/acp-frontend', + }, + }, + ]), + } as Response) + } + + if ( + url === `/api/sessions/${HISTORY_SESSION_ID}` || + url.startsWith(`/api/sessions/${HISTORY_SESSION_ID}?`) + ) { + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + id: HISTORY_SESSION_ID, + title: 'Historical thread', + updatedAt: '2026-03-28T10:00:00.000Z', + agentId: 'copilot', + source: 'history', + project: { + id: 'acp-frontend', + name: 'ACP Frontend', + path: '/home/vries/projects/acp-frontend', + }, + messages: [], + }), + } as Response) + } + + if (url === `/api/sessions/${HISTORY_SESSION_ID}/resume` && opts?.method === 'POST') { + return Promise.resolve({ + ok: true, + status: 201, + json: () => + Promise.resolve({ + id: NEW_SESSION_ID, + title: 'Live continuation', + updatedAt: '2026-03-30T10:00:00.000Z', + agentId: 'copilot', + source: 'live', + project: { + id: 'acp-frontend', + name: 'ACP Frontend', + path: '/home/vries/projects/acp-frontend', + }, + messages: [], + }), + } as Response) + } + + if ( + url === `/api/sessions/${NEW_SESSION_ID}` || + url.startsWith(`/api/sessions/${NEW_SESSION_ID}?`) + ) { + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + id: NEW_SESSION_ID, + title: 'Live continuation', + updatedAt: '2026-03-30T10:00:00.000Z', + agentId: 'copilot', + source: 'live', + project: { + id: 'acp-frontend', + name: 'ACP Frontend', + path: '/home/vries/projects/acp-frontend', + }, + messages: [], + }), + } as Response) + } + + return Promise.reject(new Error(`Unexpected fetch: ${url}`)) + }) + + vi.stubGlobal('fetch', fetchMock) + + renderChatPage(`#/chat?session=${HISTORY_SESSION_ID}&project=acp-frontend`) + + await waitFor(() => expect(screen.getByTestId('history-session-panel')).toBeDefined()) + expect(MockEventSource.instances.length).toBe(0) + + fireEvent.click(screen.getByTestId('continue-agent-copilot')) + + await waitFor(() => expect(screen.getByTestId('chat-composer')).toBeDefined()) + await waitFor(() => expect(MockEventSource.instances.length).toBeGreaterThan(0)) + const last = MockEventSource.instances.at(-1) + expect(last?.url).toContain(`/api/stream?sessionId=${encodeURIComponent(NEW_SESSION_ID)}`) + }) + it('calls the resume endpoint and navigates to the new session when Continue is clicked', async () => { const HISTORY_SESSION_ID = 'history-session-abc' const NEW_SESSION_ID = 'new-live-session-xyz' @@ -1184,20 +1500,16 @@ describe('ChatPage', () => { }) vi.stubGlobal('fetch', fetchMock) - renderChatPage(`/chat?session=${HISTORY_SESSION_ID}&project=acp-frontend`) + renderChatPage(`#/chat?session=${HISTORY_SESSION_ID}&project=acp-frontend`) - // Wait for the delegation panel to appear await waitFor(() => expect(screen.getByTestId('history-session-panel')).toBeDefined()) - // Click the Fork button for GitHub Copilot (copilot does not support session/load) - const continueButton = screen.getByTestId('fork-agent-copilot') + const continueButton = screen.getByTestId('continue-agent-copilot') fireEvent.click(continueButton) - // After resume, the regular composer should appear (live session) await waitFor(() => expect(screen.getByTestId('chat-composer')).toBeDefined()) expect(screen.queryByTestId('history-session-panel')).toBeNull() - // Verify the resume endpoint was called with the right payload const resumeCall = fetchMock.mock.calls.find( (args: unknown[]) => args[0] === `/api/sessions/${HISTORY_SESSION_ID}/resume` && diff --git a/frontend/src/routes/chat.tsx b/frontend/src/routes/chat.tsx deleted file mode 100644 index a41e2a7..0000000 --- a/frontend/src/routes/chat.tsx +++ /dev/null @@ -1,619 +0,0 @@ -import { useCallback, useEffect, useMemo, useState, type FormEvent } from 'react' -import { Link, useNavigate, useSearch } from '@tanstack/react-router' -import { ChatComposer } from '../components/chat/ChatComposer.js' -import { ChatDiffView } from '../components/chat/ChatDiffView.js' -import { ChatHeader } from '../components/chat/ChatHeader.js' -import { ProjectContextSwitcher } from '../components/chat/ProjectContextSwitcher.js' -import { SessionList } from '../components/chat/SessionList.js' -import { ChatTranscript } from '../components/chat/ChatTranscript.js' -import { useAgUiChat } from '../hooks/useAgUiChat.js' -import { - ProjectWorkspacePanel, - type ProjectTreeEntry, -} from '../components/chat/ProjectWorkspacePanel.js' - -type WorkspaceView = 'chat' | 'files' | 'diff' - -interface ProjectDiffResponse { - status: 'ok' | 'git_not_found' | 'error' - diff: string - message?: string -} - -export function ChatPage() { - const navigate = useNavigate({ from: '/chat' }) - const search = useSearch({ from: '/chat' }) - const sessionId = search.session ?? null - const projectId = search.project ?? null - - const onProjectSelected = useCallback( - (nextProjectId: string | null) => { - void navigate({ - to: '/chat', - search: (current) => ({ ...current, project: nextProjectId ?? undefined }), - }) - }, - [navigate] - ) - - const onSessionCreated = useCallback( - (nextSessionId: string) => { - void navigate({ - to: '/chat', - search: (current) => ({ ...current, session: nextSessionId }), - }) - }, - [navigate] - ) - - const onSessionSelected = useCallback( - (nextSessionId: string) => { - void navigate({ - to: '/chat', - search: (current) => ({ ...current, session: nextSessionId }), - }) - }, - [navigate] - ) - - const onSessionCleared = useCallback(() => { - void navigate({ - to: '/chat', - search: (current) => ({ ...current, session: undefined }), - }) - }, [navigate]) - - const { - addProject, - agents, - activeAgents, - availableProjects, - creatingSession, - currentSession, - errorMessage, - historyLoading, - loading, - loadHistorySession, - messages, - modelState, - projects, - ready, - removeProject, - resumeSession, - selectedProject, - selectProject, - selectSession, - sendMessage, - sessionId: activeSessionId, - sessions, - setSessionModel, - startNewSession, - streamReconnecting, - suggestProjectPaths, - thinking, - } = useAgUiChat({ - sessionId, - projectId, - onProjectSelected, - onSessionCreated, - onSessionSelected, - onSessionCleared, - }) - - const [input, setInput] = useState('') - const [workspaceView, setWorkspaceView] = useState('chat') - const [drawerOpen, setDrawerOpen] = useState(false) - const [projectManagerOpen, setProjectManagerOpen] = useState(false) - const [resuming, setResuming] = useState(false) - const [tree, setTree] = useState([]) - const [treePath, setTreePath] = useState(null) - const [treeLoading, setTreeLoading] = useState(false) - const [treeError, setTreeError] = useState(null) - const [expandedPaths, setExpandedPaths] = useState([]) - const [selectedEntryPath, setSelectedEntryPath] = useState(null) - const [visibleProjectIds, setVisibleProjectIds] = useState([]) - const [diffState, setDiffState] = useState< - 'loading' | 'error' | 'git_not_found' | 'empty' | 'ready' - >('empty') - const [diffValue, setDiffValue] = useState('') - const [diffMessage, setDiffMessage] = useState(null) - - const activeSession = useMemo( - () => sessions.find((session) => session.id === activeSessionId) ?? null, - [activeSessionId, sessions] - ) - const isHistorySession = currentSession?.source === 'history' - // For history sessions: all active agents (any can receive a handoff). - // For live sessions: all active agents except the current one (switch-agent). - const resumableAgents = useMemo( - () => - agents.filter( - (agent) => agent.canResume && (isHistorySession || agent.id !== currentSession?.agentId) - ), - [agents, currentSession?.agentId, isHistorySession] - ) - - // Primary resume agent: same agent as the history session AND supports session/load. - const resumeAgent = useMemo(() => { - if (!isHistorySession) return undefined - return resumableAgents.find((agent) => agent.id === currentSession?.agentId && agent.canLoad) - }, [isHistorySession, resumableAgents, currentSession?.agentId]) - - // Fork agents: all resumable agents except the primary resume agent. - const forkAgents = useMemo(() => { - if (!isHistorySession) return [] - return resumableAgents.filter((agent) => agent.id !== resumeAgent?.id) - }, [isHistorySession, resumableAgents, resumeAgent]) - const activeAgentName = useMemo(() => { - const agent = agents.find((candidate) => candidate.id === activeSession?.agentId) - return agent?.name ?? 'the agent' - }, [activeSession, agents]) - const filteredSessions = useMemo(() => { - const visibleProjectSet = new Set(visibleProjectIds) - return sessions.filter( - (session) => - !session.project || - visibleProjectSet.size === 0 || - visibleProjectSet.has(session.project.id) - ) - }, [sessions, visibleProjectIds]) - - const getParentTreePath = useCallback((path: string): string | null => { - const lastSlash = path.lastIndexOf('/') - return lastSlash >= 0 ? path.slice(0, lastSlash) : null - }, []) - - const loadTree = useCallback( - async (nextPath: string | null = null) => { - if (!selectedProject) { - setTree([]) - setTreePath(null) - return - } - - setTreeLoading(true) - setTreeError(null) - - try { - const query = nextPath ? `?path=${encodeURIComponent(nextPath)}` : '' - const response = await fetch( - `/api/projects/${encodeURIComponent(selectedProject.id)}/tree${query}` - ) - if (!response.ok) { - throw new Error(`Explorer request failed with status ${response.status}`) - } - - const nextTree = (await response.json()) as ProjectTreeEntry[] - setTree(nextTree) - setTreePath(nextPath) - } catch (error) { - console.error('[ChatPage] project tree load failed:', error) - setTree([]) - setTreeError('Unable to load the folder explorer right now. Try another project or reload.') - } finally { - setTreeLoading(false) - } - }, - [selectedProject] - ) - - const loadDiff = useCallback(async () => { - if (!selectedProject) { - setDiffState('empty') - setDiffValue('') - setDiffMessage('Choose a project to inspect the working tree.') - return - } - - setDiffState('loading') - setDiffValue('') - setDiffMessage(null) - - try { - const response = await fetch(`/api/projects/${encodeURIComponent(selectedProject.id)}/diff`) - if (!response.ok) { - throw new Error(`Diff request failed with status ${response.status}`) - } - - const payload = (await response.json()) as ProjectDiffResponse - setDiffValue(payload.diff) - setDiffMessage(payload.message ?? null) - - if (payload.status === 'git_not_found') { - setDiffState('git_not_found') - return - } - - if (payload.status === 'error') { - setDiffState('error') - return - } - - setDiffState(payload.diff.trim().length > 0 ? 'ready' : 'empty') - } catch (error) { - console.error('[ChatPage] project diff load failed:', error) - setDiffState('error') - setDiffValue('') - setDiffMessage('Unable to load the current project diff right now.') - } - }, [selectedProject]) - - useEffect(() => { - setExpandedPaths([]) - setSelectedEntryPath(null) - void loadTree(null) - }, [loadTree, selectedProject?.id]) - - useEffect(() => { - const nextVisible = availableProjects.map((project) => project.id) - setVisibleProjectIds((current) => { - if (current.length === 0) { - return nextVisible - } - - const filtered = current.filter((projectId) => nextVisible.includes(projectId)) - const added = nextVisible.filter((projectId) => !current.includes(projectId)) - const merged = [...filtered, ...added] - return merged.length > 0 ? merged : nextVisible - }) - }, [availableProjects]) - - useEffect(() => { - if (workspaceView === 'diff') { - void loadDiff() - } - }, [loadDiff, workspaceView]) - - const handleResume = async (agentId: string) => { - setResuming(true) - try { - // When the user picks the same agent that originally created this history - // session and that agent supports ACP session/load, use it to resume the - // real session instead of creating a new one with a handoff transcript. - const isSameAgent = agentId === currentSession?.agentId - const sessionAgent = agents.find((a) => a.id === agentId) - const supportsLoad = isSameAgent && (sessionAgent?.canLoad ?? false) - - if (isHistorySession && supportsLoad) { - await loadHistorySession(agentId) - } else { - await resumeSession(agentId) - } - } finally { - setResuming(false) - } - } - - const handleSubmit = async (e: FormEvent) => { - e.preventDefault() - const text = input.trim() - if (!text || !ready) return - - // Clear the input immediately so it doesn't stay populated for the - // entire duration of the agent's response. - setInput('') - setWorkspaceView('chat') - try { - await sendMessage(text) - } catch { - // Error state is already surfaced by the hook. - } - } - - const handleProjectSelect = async (nextProjectId: string) => { - setProjectManagerOpen(false) - await selectProject(nextProjectId) - } - - const toggleProjectVisibility = (projectIdToToggle: string, visible: boolean) => { - setVisibleProjectIds((current) => { - if (visible) { - return current.includes(projectIdToToggle) ? current : [...current, projectIdToToggle] - } - - const next = current.filter((projectIdValue) => projectIdValue !== projectIdToToggle) - return next.length > 0 ? next : current - }) - } - - const handleSessionSelect = async (nextSessionId: string) => { - setDrawerOpen(false) - setWorkspaceView('chat') - await selectSession(nextSessionId) - } - - const openProjectManager = () => { - setDrawerOpen(false) - setProjectManagerOpen(true) - } - - const activeViewLabel = - workspaceView === 'diff' ? 'Diff' : workspaceView === 'files' ? 'Files' : 'Chat' - - return ( -
        -
        - - -
        - - -
        -

        - {selectedProject?.name ?? 'Workspace'} -

        -

        {activeViewLabel}

        -
        - -
        - - -
        -
        - -
        - - -
        -
        -
        -

        - Workspace view -

        -

        - Keep the conversation central while switching files and diff in place. -

        -
        - -
        - - - -
        -
        - - {workspaceView === 'chat' ? ( - 0 && availableProjects.length > 0} - hasAnyProject={projects.length > 0} - hasAvailableAgent={activeAgents.length > 0} - hasAvailableProject={availableProjects.length > 0} - messages={messages} - projectPath={selectedProject?.path ?? null} - sessionId={activeSessionId} - hasSession={activeSessionId !== null} - loading={loading} - onOpenProjectManager={openProjectManager} - onStartSession={() => { - const firstAgent = activeAgents[0] - if (firstAgent) { - void startNewSession(firstAgent.id) - } - }} - ready={ready} - streamReconnecting={streamReconnecting} - thinking={thinking} - errorMessage={errorMessage} - historyLoading={historyLoading} - /> - ) : workspaceView === 'diff' ? ( -
        -
        - -
        -
        - ) : ( - { - const collapsing = expandedPaths.includes(path) - - setExpandedPaths((current) => - collapsing ? current.filter((item) => item !== path) : [path] - ) - - await loadTree(collapsing ? getParentTreePath(path) : path) - }} - selectedEntryPath={selectedEntryPath} - onSelectEntry={setSelectedEntryPath} - /> - )} - - 0} - isHistorySession={isHistorySession} - historyLoading={historyLoading} - resumeAgent={resumeAgent} - forkAgents={forkAgents} - onResume={handleResume} - onFork={handleResume} - resumableAgents={resumableAgents} - resuming={resuming} - modelState={modelState} - onModelChange={setSessionModel} - helperText={ - ready - ? 'The composer stays available while you inspect files or diff so the conversation never loses context.' - : undefined - } - /> -
        -
        -
        - - {drawerOpen ? ( - <> - - - -
        - { - setDrawerOpen(false) - await startNewSession(agentId) - }} - onSelect={handleSessionSelect} - /> -
        - -
        - - - setDrawerOpen(false)} - className="mt-3 inline-flex h-11 w-full items-center justify-center rounded-xl border border-white/10 bg-slate-900/55 text-sm font-medium text-slate-300 transition hover:border-white/15 hover:bg-slate-900" - > - Settings - -
        - - - ) : null} - -
        -
        - ) -} - -function buildSurfaceTabClassName(active: boolean): string { - return [ - 'inline-flex h-10 items-center justify-center rounded-xl border px-3.5 text-sm font-medium transition', - active - ? 'border-teal-500/30 bg-teal-500/12 text-teal-100' - : 'border-white/10 bg-slate-900/55 text-slate-300 hover:border-white/15 hover:bg-slate-900/85 hover:text-slate-100', - ].join(' ') -} diff --git a/frontend/src/routes/settings.tsx b/frontend/src/routes/settings.tsx deleted file mode 100644 index e8e3c1d..0000000 --- a/frontend/src/routes/settings.tsx +++ /dev/null @@ -1,463 +0,0 @@ -import { Link } from '@tanstack/react-router' -import { useState } from 'react' -import { - useBackendSettings, - useHistorySources, - type BackendSummary, - type HistoryProvider, - type HistorySourceConfig, - type HistorySourceDescriptor, -} from '../hooks/useBackendSettings.js' - -export function SettingsPage() { - const { - backends, - errorMessage: backendError, - loading: backendLoading, - saveBackend, - savingId, - addBackend, - } = useBackendSettings() - - const { - sources, - loading: sourcesLoading, - saveSource, - savingProvider, - errorMessage: sourcesError, - } = useHistorySources() - - const [newName, setNewName] = useState('') - const [newCommand, setNewCommand] = useState('') - const [newArgs, setNewArgs] = useState('') - - const errorMessage = backendError ?? sourcesError - - const handleAddBackend = async () => { - if (!newName.trim() || !newCommand.trim()) { - return - } - - await addBackend({ - name: newName, - command: newCommand, - args: parseArgs(newArgs), - }) - - setNewName('') - setNewCommand('') - setNewArgs('') - } - - return ( -
        -
        -
        -
        -
        -

        - Settings -

        -

        - Settings -

        -
        - -
        - - Back To Chat - -
        -
        - -

        - Manage ACP backends and MCP servers from one place. Backend capability claims now come - from the last established ACP connection, while history support tracks richer transcript - fidelity like reasoning, patches, attachments, and compaction notices. -

        - - {errorMessage ? ( -
        - {errorMessage} -
        - ) : null} - -
        -

        - ACP Backends -

        -

        - If no live handshake has happened yet, the app shows capability support as unknown - instead of guessing. -

        - - {backendLoading ? ( -
        - Loading backend settings... -
        - ) : ( -
        - {backends.map((backend) => ( - - ))} -
        - )} - -
        - - - Add Backend - -
        -
        - setNewName(event.target.value)} - placeholder="My ACP Wrapper" - className="rounded-lg border border-white/10 bg-slate-950 px-3 py-2 text-sm text-slate-100 outline-none focus:border-teal-500" - /> - setNewCommand(event.target.value)} - placeholder="my-acp-wrapper" - className="rounded-lg border border-white/10 bg-slate-950 px-3 py-2 text-sm text-slate-100 outline-none focus:border-teal-500" - /> - setNewArgs(event.target.value)} - placeholder="--acp" - className="rounded-lg border border-white/10 bg-slate-950 px-3 py-2 text-sm text-slate-100 outline-none focus:border-teal-500" - /> - -
        -
        -
        -
        - -
        -

        - History Sources -

        -

        - Configure where each AI provider stores its conversation history. These paths are used - to discover sessions for import. History path hints are stored separately from backend - connection settings. -

        - - {sourcesLoading ? ( -
        - Loading history sources... -
        - ) : ( -
        - {sources.map((source) => ( - - ))} -
        - )} -
        - -
        -

        - MCP Servers -

        -

        MCP Configuration

        -

        - MCP server configuration will live in this section so backend and MCP settings share a - single entry point. -

        -
        -
        -
        -
        - ) -} - -export const BackendSettingsPage = SettingsPage - -interface BackendCardProps { - backend: BackendSummary - busy: boolean - onSave: ( - backendId: string, - patch: { - enabled?: boolean - command?: string | null - args?: string[] - name?: string - } - ) => Promise -} - -function BackendCard({ backend, busy, onSave }: BackendCardProps) { - const [enabled, setEnabled] = useState(backend.enabled) - const [name, setName] = useState(backend.name) - const [command, setCommand] = useState(backend.command ?? '') - const [args, setArgs] = useState(backend.args.join(' ')) - - const detectedLabel = backend.detectedCommand - ? `Detected: ${backend.detectedCommand}` - : 'Not detected' - - const handleSave = async () => { - await onSave(backend.id, { - name, - enabled, - command: command.trim() || null, - args: parseArgs(args), - }) - } - - return ( -
        -
        -
        - setName(event.target.value)} - className="w-full rounded-lg border border-transparent bg-transparent px-0 py-0 text-xl font-semibold text-slate-50 outline-none focus:border-white/10 focus:bg-slate-950/60 focus:px-3 focus:py-2" - /> -

        - {backend.status} -

        -
        - - -
        - -
        - - - -
        - -
        - -
        - -
        -

        - {backend.endpointSupport.source === 'connection' - ? 'Capabilities come from the last successful ACP initialize response.' - : 'Capabilities are unknown until this backend completes a live ACP handshake.'} -

        - -
        -
        - ) -} - -interface HistorySourceCardProps { - source: HistorySourceConfig - busy: boolean - onSave: ( - provider: HistoryProvider, - patch: { paths?: string[]; cliPaths?: string[] } - ) => Promise -} - -function HistorySourceCard({ source, busy, onSave }: HistorySourceCardProps) { - const [paths, setPaths] = useState(source.paths.join('\n')) - const [cliPaths, setCliPaths] = useState((source.cliPaths ?? []).join('\n')) - - const isCopilot = source.provider === 'copilot' - - const handleSave = async () => { - await onSave(source.provider, { - paths: parseLines(paths), - ...(isCopilot ? { cliPaths: parseLines(cliPaths) } : {}), - }) - } - - const providerLabel: Record = { - copilot: 'GitHub Copilot', - gemini: 'Gemini CLI', - opencode: 'OpenCode', - } - - return ( -
        -

        {providerLabel[source.provider]}

        -

        {source.provider}

        - -
        -