diff --git a/README.md b/README.md index 91a42b0..ce7a185 100644 --- a/README.md +++ b/README.md @@ -72,15 +72,18 @@ in the same UI. ## BYOT Guide - GitHub PAT setup and usage: [docs/byot.md](docs/byot.md) +- OpenRouter key setup for AI chat: [docs/openrouter-byok.md](docs/openrouter-byok.md) ## Fine-Grained PAT Quick Setup -For PR/BYOT and AI chat flows, use a fine-grained GitHub PAT and follow the -existing setup guide: +For PR/BYOT flows, use a fine-grained GitHub PAT and follow the setup guide: - Full setup and behavior: [docs/byot.md](docs/byot.md) - Repository permissions screenshot: [docs/media/byot-repo-perms.png](docs/media/byot-repo-perms.png) -- Models permission screenshot: [docs/media/byot-model-perms.png](docs/media/byot-model-perms.png) + +For AI chat, connect an OpenRouter key from the chat drawer: + +- OpenRouter setup and limits: [docs/openrouter-byok.md](docs/openrouter-byok.md) ## License diff --git a/docs/ai-chat-context-and-payload-strategy.md b/docs/ai-chat-context-and-payload-strategy.md index 4ca4a5c..e9eba63 100644 --- a/docs/ai-chat-context-and-payload-strategy.md +++ b/docs/ai-chat-context-and-payload-strategy.md @@ -177,7 +177,7 @@ Potential ideas: Current strategy has focused Playwright coverage for the chat drawer behavior and context policy assertions in: -- playwright/github-byot-ai.spec.ts +- playwright/chat/ai-chat.spec.ts ## Scope note diff --git a/docs/byot.md b/docs/byot.md index eba1bc9..0ced166 100644 --- a/docs/byot.md +++ b/docs/byot.md @@ -1,6 +1,6 @@ # BYOT Setup for GitHub in @knighted/develop -This guide explains how to create and use a fine-grained GitHub Personal Access Token (PAT) for the BYOT flow in `@knighted/develop`. +This guide explains how to create and use a fine-grained GitHub Personal Access Token (PAT) for repository and pull-request workflows in `@knighted/develop`. ## What BYOT does in the app @@ -11,7 +11,9 @@ BYOT controls are available by default. The token is used to: - let you choose which repository to work with - use PR context features (Open PR / Push Commit flows) -The same token is also used for GitHub Models requests in AI chat flows. +AI chat no longer uses the GitHub PAT. Chat uses a separate OpenRouter API key. + +- OpenRouter key setup for chat: [openrouter-byok.md](openrouter-byok.md) ## Privacy and storage behavior @@ -24,10 +26,8 @@ The same token is also used for GitHub Models requests in AI chat flows. Create a fine-grained PAT in GitHub settings and grant the permissions below. - Repository permissions screenshot: [docs/media/byot-repo-perms.png](docs/media/byot-repo-perms.png) -- Models permission screenshot: [docs/media/byot-model-perms.png](docs/media/byot-model-perms.png) Repository PAT permissions -Models PAT permission ### Repository permissions @@ -35,10 +35,6 @@ Create a fine-grained PAT in GitHub settings and grant the permissions below. - Pull requests: Read and write - Metadata: Read-only (required) -### Account permissions - -- Models: Read-only - ### Repository access scope Use either of these scopes depending on your needs: @@ -55,7 +51,7 @@ Use either of these scopes depending on your needs: 3. Paste token into the BYOT input and click add. 4. Verify repository list loads. 5. Select your target repository. -6. Use AI chat as needed after connecting your token. +6. Use PR and repository workflows after connecting your token. ## Screenshots diff --git a/docs/localstorage-state.md b/docs/localstorage-state.md index ae273a2..7e7485b 100644 --- a/docs/localstorage-state.md +++ b/docs/localstorage-state.md @@ -8,9 +8,11 @@ This document is the source of truth for what `@knighted/develop` stores in `loc 1. `knighted:develop:github-pat` - GitHub personal access token used for API calls. -2. `knighted-develop:render-mode` +2. `knighted:develop:openrouter-key` + - OpenRouter API key used by AI chat requests. +3. `knighted-develop:render-mode` - Last selected render mode (`dom` or `react`). -3. Theme/UI preference keys managed by layout theme modules. +4. Theme/UI preference keys managed by layout theme modules. ## Not Allowed In localStorage diff --git a/docs/openrouter-byok.md b/docs/openrouter-byok.md new file mode 100644 index 0000000..6d1a417 --- /dev/null +++ b/docs/openrouter-byok.md @@ -0,0 +1,40 @@ +# OpenRouter BYOK Setup for AI Chat in @knighted/develop + +This guide explains how to create and use an OpenRouter API key for AI chat in `@knighted/develop`. + +## What this key does + +The OpenRouter key is used only for AI chat requests and model catalog requests in the chat drawer. + +- It enables chat completions against `https://openrouter.ai/api/v1/chat/completions`. +- It enables loading model options from `https://openrouter.ai/api/v1/models`. + +The key is independent from the GitHub PAT used by PR and repository workflows. + +## Free model limits + +OpenRouter free models still require an API key. + +- Free models have no per-token charge. +- Accounts without purchased credits are currently limited to 50 requests per day. +- Accounts that have purchased at least $10 in credits are currently limited to 1000 requests per day. + +## Privacy and storage behavior + +- Your OpenRouter key is stored only in your browser `localStorage`. +- The key is sent only to OpenRouter endpoints used by chat. +- The key is never sent to GitHub endpoints. +- You can remove it any time from the chat drawer key controls. + +## Create and connect an OpenRouter key + +1. Open https://openrouter.ai/keys and create an API key. +2. Open the Chat drawer in `@knighted/develop`. +3. Paste the key into the `OpenRouter API key` input. +4. Click `Save OpenRouter API key`. +5. Send a test prompt and confirm the assistant response appears. + +## Related docs + +- GitHub PAT setup for PR/repository workflows: [byot.md](byot.md) +- Local storage keys: [localstorage-state.md](localstorage-state.md) diff --git a/docs/openrouter-migration-plan.md b/docs/openrouter-migration-plan.md index 06b0ab6..e606a8a 100644 --- a/docs/openrouter-migration-plan.md +++ b/docs/openrouter-migration-plan.md @@ -43,7 +43,7 @@ Chat no longer depends on a selected repository. Local-mode users can chat to up editor tab with no GitHub connection at all. A selected repository remains useful context when one is connected, but it is never a precondition. -## Implementation status (updated 2026-09-06) +## Implementation status (updated 2026-09-07) ### Done @@ -67,20 +67,21 @@ when one is connected, but it is never a precondition. - Tests and checks completed for the implemented behaviors: - Focused Playwright coverage added for intent gating, tab-context sending, and apply behavior. - Lint checks are passing. - -### Remaining - -- Phase 4 model catalog work is not yet implemented in runtime code: - - No live `/api/v1/models` fetch integration yet. - - Free vs paid grouping in the model picker is still pending. - - Tool-support filtering from live model metadata is still pending. -- Phase 5 remains partial: - - Chat tests still live inside `playwright/github-byot-ai.spec.ts` rather than a split chat spec path. - - Dedicated OpenRouter usage docs listed below are not fully completed. -- Live production verification still pending for exhaustion states: - - 402 out-of-credits behavior. - - 429 rate-limit behavior. -- Optional one-time migration notice behavior is still pending. + - Chat test coverage is split into `playwright/chat/ai-chat.spec.ts`, with PR/BYOT + coverage retained in `playwright/github-byot-ai.spec.ts`. + - OpenRouter migration docs are in place (`docs/openrouter-byok.md`) and cross-linked + from README/BYOT docs. +- Phase 4 completed in runtime code: + - Live `/api/v1/models` catalog fetch is wired in `src/modules/chat/api/models.js`. + - Model picker groups models into Free and Paid sections. + - Model catalog entries are filtered to tool-capable models. + +### Remaining (non-blocking) + +- Optional follow-up coverage: + - Explicit targeted specs for 402/429 status messaging and catalog-fetch degradation. +- Optional UX follow-up: + - One-time migration notice on first load after upgrade. ### Correction to a common assumption diff --git a/playwright/chat/ai-chat.spec.ts b/playwright/chat/ai-chat.spec.ts new file mode 100644 index 0000000..6106ab0 --- /dev/null +++ b/playwright/chat/ai-chat.spec.ts @@ -0,0 +1,1089 @@ +import { expect, test } from '@playwright/test' +import { defaultChatModel } from '../../src/modules/chat/api/completions.js' +import type { ChatRequestBody, ChatRequestMessage } from '../helpers/app-test-helpers.js' +import { + appEntryPath, + connectByotWithSingleRepo, + connectOpenRouterKey, + ensureWorkspacesDrawerClosed, + openRouterTestKey, + openWorkspaceTab, + setComponentEditorSource, + setStylesEditorSource, + waitForAppReady, +} from '../helpers/app-test-helpers.js' +import { + openStoredWorkspaceContextById, + seedLocalWorkspaceContexts, +} from '../github-pr-drawer/github-pr-drawer.helpers.js' + +test('chat drawer prompts for an OpenRouter key and gates the composer', async ({ + page, +}) => { + await waitForAppReady(page) + + await page.getByRole('button', { name: 'Chat', exact: true }).click() + await expect(page.getByRole('complementary', { name: 'AI Chat' })).toBeVisible() + + const keyInput = page.getByLabel('OpenRouter API key', { exact: true }) + await expect(keyInput).toBeVisible() + await expect( + page.getByRole('button', { name: 'Save OpenRouter API key' }), + ).toBeVisible() + await expect( + page.getByRole('button', { name: 'Remove OpenRouter API key' }), + ).toBeHidden() + + await expect(page.getByLabel('Ask AI assistant')).toBeDisabled() + await expect(page.getByRole('button', { name: 'Send' })).toBeDisabled() + await expect(page.getByLabel('Chat model')).toBeDisabled() + + await connectOpenRouterKey(page) + + await expect(page.getByLabel('Ask AI assistant')).toBeEnabled() + await expect(page.getByRole('button', { name: 'Send' })).toBeEnabled() + await expect(page.getByLabel('Chat model')).toBeEnabled() +}) + +test('GitHub token is never sent to OpenRouter and the chat key is never sent to GitHub', async ({ + page, +}) => { + const openRouterAuthHeaders: string[] = [] + const githubAuthHeaders: string[] = [] + + page.on('request', request => { + const auth = request.headers().authorization ?? '' + if (!auth) { + return + } + + if (request.url().includes('openrouter.ai')) { + openRouterAuthHeaders.push(auth) + } + + if (request.url().includes('api.github.com')) { + githubAuthHeaders.push(auth) + } + }) + + await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + choices: [{ message: { role: 'assistant', content: 'ok' } }], + }), + }) + }) + + await waitForAppReady(page) + await connectByotWithSingleRepo(page) + await connectOpenRouterKey(page) + + await page.getByLabel('Ask AI assistant').fill('hello') + await page.getByRole('button', { name: 'Send' }).click() + await expect(page.getByText('ok', { exact: true })).toBeVisible() + + expect(openRouterAuthHeaders.length).toBeGreaterThan(0) + expect(githubAuthHeaders.length).toBeGreaterThan(0) + expect(openRouterAuthHeaders.every(header => header.includes(openRouterTestKey))).toBe( + true, + ) + expect(openRouterAuthHeaders.some(header => header.includes('github_pat'))).toBe(false) + expect(githubAuthHeaders.some(header => header.includes(openRouterTestKey))).toBe(false) +}) + +test('chat stays usable after opening a Local workspace with PAT connected', async ({ + page, +}) => { + const localWorkspaceId = 'local_chat_issue_128' + let streamRequestBody: ChatRequestBody | undefined + + await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { + streamRequestBody = route.request().postDataJSON() as ChatRequestBody + + await route.fulfill({ + status: 200, + contentType: 'text/event-stream', + body: [ + 'data: {"choices":[{"delta":{"content":"Local workspace chat works"}}]}', + '', + 'data: [DONE]', + '', + ].join('\n'), + }) + }) + + await waitForAppReady(page) + + await seedLocalWorkspaceContexts(page, [ + { + id: localWorkspaceId, + repo: '', + workspaceScope: 'local', + head: 'feat/local-chat-issue-128', + prTitle: 'Issue 128 local workspace', + prContextState: 'inactive', + tabs: [ + { + id: 'component', + path: 'src/component.tsx', + language: 'tsx', + role: 'component', + content: 'export const App = () =>
local chat issue 128
', + order: 0, + source: 'workspace', + dirty: false, + }, + ], + activeTabId: 'component', + }, + ]) + + await connectByotWithSingleRepo(page, { assertPrRepositorySelected: false }) + await openStoredWorkspaceContextById(page, localWorkspaceId, { + repositoryFilter: '__local__', + }) + await ensureWorkspacesDrawerClosed(page) + + await connectOpenRouterKey(page) + + await page.getByLabel('Ask AI assistant').fill('Confirm local workspace chat context.') + await page.getByRole('button', { name: 'Send' }).click() + + await expect(page.getByText('Local workspace chat works')).toBeVisible() + await expect( + page.getByText('Select a writable repository before starting chat.', { exact: true }), + ).toHaveCount(0) + + const repositorySystemMessage = streamRequestBody?.messages?.find( + (message: ChatRequestMessage) => + message.role === 'system' && + message.content?.includes('Selected repository context'), + ) + expect(repositorySystemMessage?.content).toContain( + 'Repository: knightedcodemonkey/develop', + ) +}) + +test('AI chat prefers streaming responses when available', async ({ page }) => { + let streamRequestBody: ChatRequestBody | undefined + + await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { + streamRequestBody = route.request().postDataJSON() as ChatRequestBody + + await route.fulfill({ + status: 200, + contentType: 'text/event-stream', + body: [ + 'data: {"choices":[{"delta":{"content":"Streaming "}}]}', + '', + 'data: {"choices":[{"delta":{"content":"response ready"}}]}', + '', + 'data: [DONE]', + '', + ].join('\n'), + }) + }) + + await waitForAppReady(page, `${appEntryPath}`) + await connectByotWithSingleRepo(page) + await connectOpenRouterKey(page) + + await page.getByLabel('Ask AI assistant').fill('Summarize this repository.') + await page.getByRole('button', { name: 'Send' }).click() + + await expect(page.getByText('Response streamed.', { exact: true })).toHaveText( + 'Response streamed.', + ) + await expect(page.getByText('Summarize this repository.')).toBeVisible() + await expect(page.getByText('Streaming response ready')).toBeVisible() + + expect(streamRequestBody?.metadata).toBeUndefined() + expect(streamRequestBody?.model).toBe(defaultChatModel) + expect(streamRequestBody?.tool_choice).toBeUndefined() + expect(streamRequestBody?.tools).toBeUndefined() + expect(streamRequestBody?.messages?.[0]?.role).toBe('system') + expect(streamRequestBody?.messages?.[0]?.content).toContain( + 'expert software development assistant focused on CSS dialects and JSX syntax', + ) + expect(streamRequestBody?.messages?.[0]?.content).toContain( + 'JSX is compiled for @knighted/jsx DOM runtime', + ) + expect(streamRequestBody?.messages?.[0]?.content).toContain( + 'Do not suggest React imports, hooks, or React-only runtime APIs', + ) + expect(streamRequestBody?.messages?.[0]?.content).toContain( + 'Preserve the selected style dialect and avoid cross-dialect rewrites', + ) + const systemMessages = streamRequestBody?.messages?.filter( + (message: ChatRequestMessage) => message.role === 'system', + ) + const repositorySystemMessage = systemMessages?.find((message: ChatRequestMessage) => + message.content?.includes('Selected repository context'), + ) + expect(repositorySystemMessage?.content).toContain( + 'Repository: knightedcodemonkey/develop', + ) + expect(repositorySystemMessage?.content).toContain( + 'Repository URL: https://github.com/knightedcodemonkey/develop', + ) + expect( + systemMessages?.some((message: ChatRequestMessage) => + message.content?.includes('Editor context:'), + ), + ).toBe(true) + expect( + systemMessages?.some( + (message: ChatRequestMessage) => + message.content?.includes('- Active tab:') && + message.content?.includes('App.tsx'), + ), + ).toBe(true) + expect( + systemMessages?.some((message: ChatRequestMessage) => + message.content?.includes('Available tab targets (id and path):'), + ), + ).toBe(true) + expect( + systemMessages?.some((message: ChatRequestMessage) => + message.content?.includes('Active tab source:'), + ), + ).toBe(true) +}) + +test('AI chat enables editor update tools only for explicit edit requests', async ({ + page, +}) => { + let streamRequestBody: ChatRequestBody | undefined + + await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { + streamRequestBody = route.request().postDataJSON() as ChatRequestBody + + await route.fulfill({ + status: 200, + contentType: 'text/event-stream', + body: [ + 'data: {"choices":[{"delta":{"content":"ok"}}]}', + '', + 'data: [DONE]', + '', + ].join('\n'), + }) + }) + + await waitForAppReady(page, `${appEntryPath}`) + await connectByotWithSingleRepo(page) + await connectOpenRouterKey(page) + + await page + .getByLabel('Ask AI assistant') + .fill('Please update app.css to use blue text.') + await page.getByRole('button', { name: 'Send' }).click() + await expect(page.getByText('Response streamed.', { exact: true })).toHaveText( + 'Response streamed.', + ) + + expect(streamRequestBody?.tool_choice).toBe('auto') + expect( + streamRequestBody?.tools?.some( + tool => tool.type === 'function' && tool.function?.name === 'propose_editor_update', + ), + ).toBe(true) +}) + +test('AI chat does not render apply actions for read-only visibility prompts', async ({ + page, +}) => { + let streamRequestBody: ChatRequestBody | undefined + + await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { + const body = route.request().postDataJSON() as ChatRequestBody | null + + if (body?.stream) { + streamRequestBody = body + await route.fulfill({ + status: 502, + contentType: 'application/json', + body: JSON.stringify({ message: 'stream intentionally disabled in this test' }), + }) + return + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + choices: [ + { + message: { + role: 'assistant', + content: + 'Yes, I can see your editor content.\n\n```jsx\nconst App = () =>

Visible

\n```', + }, + }, + ], + }), + }) + }) + + await waitForAppReady(page, `${appEntryPath}`) + await connectByotWithSingleRepo(page) + await setComponentEditorSource(page, 'const App = () =>

Before

') + await openWorkspaceTab(page, 'App.tsx') + await connectOpenRouterKey(page) + + await page.getByLabel('Ask AI assistant').fill('Can you see my editor content?') + await page.getByRole('button', { name: 'Send' }).click() + + await expect(page.getByText('Fallback response loaded.', { exact: true })).toHaveText( + 'Fallback response loaded.', + ) + await expect(page.locator('button[data-action="request-apply"]')).toHaveCount(0) + expect(streamRequestBody?.tool_choice).toBeUndefined() + expect(streamRequestBody?.tools).toBeUndefined() +}) + +test('AI chat can disable editor context payload via checkbox', async ({ page }) => { + let streamRequestBody: ChatRequestBody | undefined + + await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { + streamRequestBody = route.request().postDataJSON() as ChatRequestBody + + await route.fulfill({ + status: 200, + contentType: 'text/event-stream', + body: [ + 'data: {"choices":[{"delta":{"content":"ok"}}]}', + '', + 'data: [DONE]', + '', + ].join('\n'), + }) + }) + + await waitForAppReady(page, `${appEntryPath}`) + await connectByotWithSingleRepo(page) + await connectOpenRouterKey(page) + + const includeEditorsToggle = page.getByLabel('Send tab content') + await expect(includeEditorsToggle).toBeChecked() + await includeEditorsToggle.uncheck() + + await page.getByLabel('Ask AI assistant').fill('No editor source this time.') + await page.getByRole('button', { name: 'Send' }).click() + await expect(page.getByText('Response streamed.', { exact: true })).toHaveText( + 'Response streamed.', + ) + + expect(streamRequestBody?.metadata).toBeUndefined() + expect(streamRequestBody?.tool_choice).toBeUndefined() + expect(streamRequestBody?.tools).toBeUndefined() + const systemMessages = streamRequestBody?.messages?.filter( + (message: ChatRequestMessage) => message.role === 'system', + ) + expect( + systemMessages?.some((message: ChatRequestMessage) => + message.content?.includes('Selected repository context'), + ), + ).toBe(true) + expect( + systemMessages?.some((message: ChatRequestMessage) => + message.content?.includes( + 'Repository URL: https://github.com/knightedcodemonkey/develop', + ), + ), + ).toBe(true) + expect( + systemMessages?.some((message: ChatRequestMessage) => + message.content?.includes('Editor context:'), + ), + ).toBe(false) +}) + +test('AI chat proposals can be confirmed, applied, and undone per active tab', async ({ + page, +}) => { + await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { + const body = route.request().postDataJSON() as ChatRequestBody | null + + if (body?.stream) { + await route.fulfill({ + status: 502, + contentType: 'application/json', + body: JSON.stringify({ message: 'stream intentionally disabled in this test' }), + }) + return + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + choices: [ + { + message: { + role: 'assistant', + content: 'Prepared updates for both editors.', + tool_calls: [ + { + id: 'call_component', + type: 'function', + function: { + name: 'propose_editor_update', + arguments: JSON.stringify({ + target: 'src/components/App.tsx', + content: 'const App = () => ', + rationale: 'Use explicit App component output.', + }), + }, + }, + { + id: 'call_styles', + type: 'function', + function: { + name: 'propose_editor_update', + arguments: JSON.stringify({ + target: 'src/styles/app.css', + content: '.button { color: rgb(10 20 30); }', + rationale: 'Provide deterministic button styling.', + }), + }, + }, + ], + }, + }, + ], + }), + }) + }) + + await waitForAppReady(page, `${appEntryPath}`) + await connectByotWithSingleRepo(page) + await setComponentEditorSource(page, 'const App = () => ') + await setStylesEditorSource(page, '.button { color: red; }') + await openWorkspaceTab(page, 'App.tsx') + await connectOpenRouterKey(page) + + await page.getByLabel('Ask AI assistant').fill('Suggest updates for both editors.') + await page.getByRole('button', { name: 'Send' }).click() + + await expect( + page.getByText('Prepared updates for both editors.', { exact: true }), + ).toBeVisible() + + await expect( + page.getByRole('button', { name: 'Apply update to App.tsx' }), + ).toBeVisible() + await expect( + page.getByRole('button', { name: 'Apply update to app.css' }), + ).toBeVisible() + + await page.getByRole('button', { name: 'Apply update to App.tsx' }).click() + + await expect(page.getByRole('button', { name: 'Apply update to App.tsx' })).toBeHidden() + await expect( + page.getByRole('button', { name: 'Undo last apply for App.tsx' }), + ).toBeVisible() + await expect( + page.getByRole('button', { name: 'Undo last apply for app.css' }), + ).toBeHidden() + await expect( + page.locator('.editor-panel[data-editor-kind="component"] .cm-content').first(), + ).toContainText('Updated') + + await openWorkspaceTab(page, 'app.css') + await expect( + page.getByRole('button', { name: 'Undo last apply for App.tsx' }), + ).toBeHidden() + await expect( + page.getByRole('button', { name: 'Apply update to app.css' }), + ).toBeVisible() + await page.getByRole('button', { name: 'Apply update to app.css' }).click() + + await expect( + page.locator('.editor-panel[data-editor-kind="styles"] .cm-content').first(), + ).toContainText('rgb(10 20 30)') + await expect( + page.getByRole('button', { name: 'Undo last apply for app.css' }), + ).toBeVisible() + await expect( + page.getByRole('button', { name: 'Undo last apply for App.tsx' }), + ).toBeHidden() + + await page.getByRole('button', { name: 'Undo last apply for app.css' }).click() + await expect( + page.locator('.editor-panel[data-editor-kind="styles"] .cm-content').first(), + ).toContainText('red') + + await openWorkspaceTab(page, 'App.tsx') + await expect( + page.getByRole('button', { name: 'Undo last apply for App.tsx' }), + ).toBeVisible() + await expect( + page.getByRole('button', { name: 'Undo last apply for app.css' }), + ).toBeHidden() + + await page.getByRole('button', { name: 'Undo last apply for App.tsx' }).click() + await expect( + page.locator('.editor-panel[data-editor-kind="component"] .cm-content').first(), + ).toContainText('Before') +}) + +test('AI chat apply actions resolve dynamic tab targets', async ({ page }) => { + await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { + const body = route.request().postDataJSON() as ChatRequestBody | null + + if (body?.stream) { + await route.fulfill({ + status: 502, + contentType: 'application/json', + body: JSON.stringify({ message: 'stream intentionally disabled in this test' }), + }) + return + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + choices: [ + { + message: { + role: 'assistant', + content: 'Prepared updates for both editors.', + tool_calls: [ + { + id: 'call_component', + type: 'function', + function: { + name: 'propose_editor_update', + arguments: JSON.stringify({ + target: 'src/components/App.tsx', + content: 'const App = () => ', + }), + }, + }, + { + id: 'call_styles', + type: 'function', + function: { + name: 'propose_editor_update', + arguments: JSON.stringify({ + target: 'src/styles/app.css', + content: '.button { color: rgb(10 20 30); }', + }), + }, + }, + ], + }, + }, + ], + }), + }) + }) + + await waitForAppReady(page, `${appEntryPath}`) + await connectByotWithSingleRepo(page) + await setComponentEditorSource(page, 'const App = () => ') + await setStylesEditorSource(page, '.button { color: red; }') + await openWorkspaceTab(page, 'App.tsx') + await connectOpenRouterKey(page) + + await page.getByLabel('Ask AI assistant').fill('Suggest updates for both editors.') + await page.getByRole('button', { name: 'Send' }).click() + + await expect( + page.getByText('Prepared updates for both editors.', { exact: true }), + ).toBeVisible() + + await expect( + page.getByRole('button', { name: 'Apply update to App.tsx' }), + ).toBeVisible() + await expect( + page.getByRole('button', { name: 'Apply update to app.css' }), + ).toBeVisible() + + await openWorkspaceTab(page, 'app.css') + + await expect( + page.getByRole('button', { name: 'Apply update to App.tsx' }), + ).toBeVisible() + await expect( + page.getByRole('button', { name: 'Apply update to app.css' }), + ).toBeVisible() +}) + +test('AI chat applies the correct proposal when unresolved targets are filtered out', async ({ + page, +}) => { + await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { + const body = route.request().postDataJSON() as ChatRequestBody | null + + if (body?.stream) { + await route.fulfill({ + status: 502, + contentType: 'application/json', + body: JSON.stringify({ message: 'stream intentionally disabled in this test' }), + }) + return + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + choices: [ + { + message: { + role: 'assistant', + content: 'Prepared updates for App tab.', + tool_calls: [ + { + id: 'call_unresolved', + type: 'function', + function: { + name: 'propose_editor_update', + arguments: JSON.stringify({ + target: 'src/components/missing.tsx', + content: 'const Missing = () => null', + }), + }, + }, + { + id: 'call_component', + type: 'function', + function: { + name: 'propose_editor_update', + arguments: JSON.stringify({ + target: 'src/components/App.tsx', + content: 'const App = () =>

Resolved update

', + }), + }, + }, + ], + }, + }, + ], + }), + }) + }) + + await waitForAppReady(page, `${appEntryPath}`) + await connectByotWithSingleRepo(page) + await setComponentEditorSource(page, 'const App = () =>

Before

') + await openWorkspaceTab(page, 'App.tsx') + await connectOpenRouterKey(page) + + await page.getByLabel('Ask AI assistant').fill('Update App tab only.') + await page.getByRole('button', { name: 'Send' }).click() + + await expect( + page.getByRole('button', { name: 'Apply update to App.tsx' }), + ).toBeVisible() + await page.getByRole('button', { name: 'Apply update to App.tsx' }).click() + + await expect( + page.locator('.editor-panel[data-editor-kind="component"] .cm-content').first(), + ).toContainText('Resolved update') +}) + +test('AI chat renders a single apply action for multiple targets resolving to the same tab', async ({ + page, +}) => { + await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { + const body = route.request().postDataJSON() as ChatRequestBody | null + + if (body?.stream) { + await route.fulfill({ + status: 502, + contentType: 'application/json', + body: JSON.stringify({ message: 'stream intentionally disabled in this test' }), + }) + return + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + choices: [ + { + message: { + role: 'assistant', + content: 'Prepared updates for App tab.', + tool_calls: [ + { + id: 'call_component_id', + type: 'function', + function: { + name: 'propose_editor_update', + arguments: JSON.stringify({ + target: 'component', + content: 'const App = () =>

By id

', + }), + }, + }, + { + id: 'call_component_path', + type: 'function', + function: { + name: 'propose_editor_update', + arguments: JSON.stringify({ + target: 'src/components/App.tsx', + content: 'const App = () =>

By path

', + }), + }, + }, + ], + }, + }, + ], + }), + }) + }) + + await waitForAppReady(page, `${appEntryPath}`) + await connectByotWithSingleRepo(page) + await setComponentEditorSource(page, 'const App = () =>

Before

') + await openWorkspaceTab(page, 'App.tsx') + await connectOpenRouterKey(page) + + await page.getByLabel('Ask AI assistant').fill('Update App tab once.') + await page.getByRole('button', { name: 'Send' }).click() + + await expect(page.getByRole('button', { name: 'Apply update to App.tsx' })).toHaveCount( + 1, + ) +}) + +test('AI chat shows guidance when an editor update target cannot be matched', async ({ + page, +}) => { + await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { + const body = route.request().postDataJSON() as ChatRequestBody | null + + if (body?.stream) { + await route.fulfill({ + status: 502, + contentType: 'application/json', + body: JSON.stringify({ message: 'stream intentionally disabled in this test' }), + }) + return + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + choices: [ + { + message: { + role: 'assistant', + content: '', + tool_calls: [ + { + id: 'call_unknown_target', + type: 'function', + function: { + name: 'propose_editor_update', + arguments: JSON.stringify({ + target: 'src/does-not-exist.ts', + content: 'export const value = 1', + }), + }, + }, + ], + }, + }, + ], + }), + }) + }) + + await waitForAppReady(page, `${appEntryPath}`) + await connectByotWithSingleRepo(page) + await setComponentEditorSource(page, 'const App = () =>

Before

') + await openWorkspaceTab(page, 'App.tsx') + await connectOpenRouterKey(page) + + await page.getByLabel('Ask AI assistant').fill('Can you still see my tab content?') + await page.getByRole('button', { name: 'Send' }).click() + + await expect( + page.getByText( + 'Proposed editor update is ready, but I could not match its target to an open tab. Ask me to target the active tab or one of the listed tab ids or paths.', + ), + ).toHaveCount(1) + await expect(page.locator('button[data-action="request-apply"]')).toHaveCount(0) +}) + +test('AI chat sends the currently active tab when context is enabled', async ({ + page, +}) => { + let streamRequestBody: ChatRequestBody | undefined + + await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { + streamRequestBody = route.request().postDataJSON() as ChatRequestBody + + await route.fulfill({ + status: 200, + contentType: 'text/event-stream', + body: [ + 'data: {"choices":[{"delta":{"content":"ok"}}]}', + '', + 'data: [DONE]', + '', + ].join('\n'), + }) + }) + + await waitForAppReady(page, `${appEntryPath}`) + await connectByotWithSingleRepo(page) + await setStylesEditorSource(page, '.button { color: red; }') + await connectOpenRouterKey(page) + + await page.getByLabel('Ask AI assistant').fill('Use active tab context only.') + await page.getByRole('button', { name: 'Send' }).click() + await expect(page.getByText('Response streamed.', { exact: true })).toHaveText( + 'Response streamed.', + ) + + const systemMessages = streamRequestBody?.messages?.filter( + (message: ChatRequestMessage) => message.role === 'system', + ) + expect( + systemMessages?.some( + (message: ChatRequestMessage) => + message.content?.includes('- Active tab:') && + message.content?.includes('app.css'), + ), + ).toBe(true) + expect( + systemMessages?.some((message: ChatRequestMessage) => + message.content?.includes('Active tab source:'), + ), + ).toBe(true) + expect( + systemMessages?.some((message: ChatRequestMessage) => + message.content?.includes('Available tab targets (id and path):'), + ), + ).toBe(true) +}) + +test('AI chat streaming text still updates while latest undo actions are visible', async ({ + page, +}) => { + let requestCount = 0 + + await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { + requestCount += 1 + const body = route.request().postDataJSON() as ChatRequestBody | null + + if (requestCount <= 2) { + if (body?.stream) { + await route.fulfill({ + status: 502, + contentType: 'application/json', + body: JSON.stringify({ message: 'force fallback for proposal setup' }), + }) + return + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + choices: [ + { + message: { + role: 'assistant', + content: 'Prepared updates for styles editor.', + tool_calls: [ + { + id: 'call_styles', + type: 'function', + function: { + name: 'propose_editor_update', + arguments: JSON.stringify({ + target: 'src/styles/app.css', + content: '.button { color: rgb(10 20 30); }', + }), + }, + }, + ], + }, + }, + ], + }), + }) + return + } + + if (body?.stream) { + await route.fulfill({ + status: 200, + contentType: 'text/event-stream', + body: [ + 'data: {"choices":[{"delta":{"content":"Streaming "}}]}', + '', + 'data: {"choices":[{"delta":{"content":"works with undo visible."}}]}', + '', + 'data: [DONE]', + '', + ].join('\n'), + }) + return + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + choices: [{ message: { role: 'assistant', content: 'fallback text' } }], + }), + }) + }) + + await waitForAppReady(page, `${appEntryPath}`) + await connectByotWithSingleRepo(page) + await setStylesEditorSource(page, '.button { color: red; }') + await connectOpenRouterKey(page) + + await page.getByLabel('Ask AI assistant').fill('Suggest a styles update.') + await page.getByRole('button', { name: 'Send' }).click() + + await expect( + page.getByText('Prepared updates for styles editor.', { exact: true }), + ).toBeVisible() + await page.getByRole('button', { name: 'Apply update to app.css' }).click() + await expect( + page.getByRole('button', { name: 'Undo last apply for app.css' }), + ).toBeVisible() + + await page + .getByLabel('Ask AI assistant') + .fill('Are you still working on that last request?') + await page.getByRole('button', { name: 'Send' }).click() + + await expect(page.getByText('Streaming works with undo visible.')).toBeVisible() +}) + +test('AI chat falls back to non-streaming response when streaming fails', async ({ + page, +}) => { + let streamAttemptCount = 0 + let fallbackAttemptCount = 0 + const attemptedModels: string[] = [] + + await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { + const body = route.request().postDataJSON() as ChatRequestBody | null + if (typeof body?.model === 'string') { + attemptedModels.push(body.model) + } + + if (body?.stream) { + streamAttemptCount += 1 + await route.fulfill({ + status: 502, + contentType: 'application/json', + body: JSON.stringify({ message: 'stream failed' }), + }) + return + } + + fallbackAttemptCount += 1 + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + rate_limit: { + remaining: 17, + reset: 1704067200, + }, + choices: [ + { + message: { + role: 'assistant', + content: 'Fallback response from JSON path.', + }, + }, + ], + }), + }) + }) + + await waitForAppReady(page, `${appEntryPath}`) + await connectByotWithSingleRepo(page) + await connectOpenRouterKey(page) + + const selectedModel = 'openai/gpt-6-astra' + await page.getByLabel('Chat model').selectOption(selectedModel) + await expect(page.getByLabel('Chat model')).toHaveValue(selectedModel) + + await page.getByLabel('Ask AI assistant').fill('Use fallback path.') + await page.getByRole('button', { name: 'Send' }).click() + + await expect(page.getByText('Fallback response loaded.', { exact: true })).toHaveText( + 'Fallback response loaded.', + ) + await expect(page.getByText('Fallback response from JSON path.')).toBeVisible() + expect(streamAttemptCount).toBeGreaterThan(0) + expect(fallbackAttemptCount).toBeGreaterThan(0) + expect(attemptedModels.length).toBeGreaterThan(0) + expect(attemptedModels.every(model => model === selectedModel)).toBe(true) +}) + +test('clearing chat removes previous conversation context from new request', async ({ + page, +}) => { + const streamBodies: ChatRequestBody[] = [] + + await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { + const body = route.request().postDataJSON() as ChatRequestBody + if (body?.stream) { + streamBodies.push(body) + await route.fulfill({ + status: 200, + contentType: 'text/event-stream', + body: [ + 'data: {"choices":[{"delta":{"content":"ok"}}]}', + '', + 'data: [DONE]', + '', + ].join('\n'), + }) + return + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + choices: [{ message: { role: 'assistant', content: 'ok' } }], + }), + }) + }) + + await waitForAppReady(page, `${appEntryPath}`) + await connectByotWithSingleRepo(page) + await connectOpenRouterKey(page) + + await page.getByLabel('Ask AI assistant').fill('First conversation prompt') + await page.getByRole('button', { name: 'Send' }).click() + await expect(page.getByText('Response streamed.', { exact: true })).toBeVisible() + + await page.getByRole('button', { name: 'Clear', exact: true }).click() + await expect(page.getByText('Chat cleared.', { exact: true })).toBeVisible() + + await page.getByLabel('Ask AI assistant').fill('Second conversation prompt') + await page.getByRole('button', { name: 'Send' }).click() + await expect(page.getByText('Response streamed.', { exact: true })).toBeVisible() + + expect(streamBodies.length).toBeGreaterThanOrEqual(2) + const latestMessages = streamBodies[streamBodies.length - 1]?.messages ?? [] + const allLatestContent = latestMessages.map(message => message.content ?? '').join('\n') + + expect(allLatestContent).toContain('Second conversation prompt') + expect(allLatestContent).not.toContain('First conversation prompt') +}) diff --git a/playwright/github-byot-ai.spec.ts b/playwright/github-byot-ai.spec.ts index b491630..9c8e18d 100644 --- a/playwright/github-byot-ai.spec.ts +++ b/playwright/github-byot-ai.spec.ts @@ -1,22 +1,15 @@ import { expect, test } from '@playwright/test' -import { defaultChatModel } from '../src/modules/chat/api/completions.js' -import type { ChatRequestBody, ChatRequestMessage } from './helpers/app-test-helpers.js' import { appEntryPath, connectByotWithSingleRepo, ensureWorkspacesDrawerClosed, - connectOpenRouterKey, - openRouterTestKey, ensureOpenPrDrawerOpen, mockRepositoryBranches, - openWorkspaceTab, setComponentEditorSource, - setStylesEditorSource, waitForAppReady, } from './helpers/app-test-helpers.js' import { getAllWorkspaceRecords, - openStoredWorkspaceContextById, seedLocalWorkspaceContexts, } from './github-pr-drawer/github-pr-drawer.helpers.js' import { selectWorkspacesRepositoryFilter } from './github-pr-drawer/github-pr-drawer.helpers.js' @@ -48,34 +41,6 @@ test('PR/BYOT controls are visible and chat is available without a GitHub token' await expect(workspacesToggle).toBeVisible() }) -test('chat drawer prompts for an OpenRouter key and gates the composer', async ({ - page, -}) => { - await waitForAppReady(page) - - await page.getByRole('button', { name: 'Chat', exact: true }).click() - await expect(page.getByRole('complementary', { name: 'AI Chat' })).toBeVisible() - - const keyInput = page.getByLabel('OpenRouter API key', { exact: true }) - await expect(keyInput).toBeVisible() - await expect( - page.getByRole('button', { name: 'Save OpenRouter API key' }), - ).toBeVisible() - await expect( - page.getByRole('button', { name: 'Remove OpenRouter API key' }), - ).toBeHidden() - - await expect(page.getByLabel('Ask AI assistant')).toBeDisabled() - await expect(page.getByRole('button', { name: 'Send' })).toBeDisabled() - await expect(page.getByLabel('Chat model')).toBeDisabled() - - await connectOpenRouterKey(page) - - await expect(page.getByLabel('Ask AI assistant')).toBeEnabled() - await expect(page.getByRole('button', { name: 'Send' })).toBeEnabled() - await expect(page.getByLabel('Chat model')).toBeEnabled() -}) - test('Workspaces repository filter is local-only and read-only without PAT', async ({ page, }) => { @@ -325,54 +290,6 @@ test('PAT connect after Local-only session preserves Local records and enables r ).toBe('local') }) -test('GitHub token is never sent to OpenRouter and the chat key is never sent to GitHub', async ({ - page, -}) => { - const openRouterAuthHeaders: string[] = [] - const githubAuthHeaders: string[] = [] - - page.on('request', request => { - const auth = request.headers().authorization ?? '' - if (!auth) { - return - } - - if (request.url().includes('openrouter.ai')) { - openRouterAuthHeaders.push(auth) - } - - if (request.url().includes('api.github.com')) { - githubAuthHeaders.push(auth) - } - }) - - await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - choices: [{ message: { role: 'assistant', content: 'ok' } }], - }), - }) - }) - - await waitForAppReady(page) - await connectByotWithSingleRepo(page) - await connectOpenRouterKey(page) - - await page.getByLabel('Ask AI assistant').fill('hello') - await page.getByRole('button', { name: 'Send' }).click() - await expect(page.getByText('ok', { exact: true })).toBeVisible() - - expect(openRouterAuthHeaders.length).toBeGreaterThan(0) - expect(githubAuthHeaders.length).toBeGreaterThan(0) - expect(openRouterAuthHeaders.every(header => header.includes(openRouterTestKey))).toBe( - true, - ) - expect(openRouterAuthHeaders.some(header => header.includes('github_pat'))).toBe(false) - expect(githubAuthHeaders.some(header => header.includes(openRouterTestKey))).toBe(false) -}) - test('workspace context status stays visible without PAT and after PAT connect', async ({ page, }) => { @@ -672,79 +589,6 @@ test('Repository-scoped workspace cannot be renamed from Workspaces drawer', asy await expect(renameButton).toBeHidden() }) -test('chat stays usable after opening a Local workspace with PAT connected', async ({ - page, -}) => { - const localWorkspaceId = 'local_chat_issue_128' - let streamRequestBody: ChatRequestBody | undefined - - await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { - streamRequestBody = route.request().postDataJSON() as ChatRequestBody - - await route.fulfill({ - status: 200, - contentType: 'text/event-stream', - body: [ - 'data: {"choices":[{"delta":{"content":"Local workspace chat works"}}]}', - '', - 'data: [DONE]', - '', - ].join('\n'), - }) - }) - - await waitForAppReady(page) - - await seedLocalWorkspaceContexts(page, [ - { - id: localWorkspaceId, - repo: '', - workspaceScope: 'local', - head: 'feat/local-chat-issue-128', - prTitle: 'Issue 128 local workspace', - prContextState: 'inactive', - tabs: [ - { - id: 'component', - path: 'src/component.tsx', - language: 'tsx', - role: 'component', - content: 'export const App = () =>
local chat issue 128
', - order: 0, - source: 'workspace', - dirty: false, - }, - ], - activeTabId: 'component', - }, - ]) - - await connectByotWithSingleRepo(page, { assertPrRepositorySelected: false }) - await openStoredWorkspaceContextById(page, localWorkspaceId, { - repositoryFilter: '__local__', - }) - await ensureWorkspacesDrawerClosed(page) - - await connectOpenRouterKey(page) - - await page.getByLabel('Ask AI assistant').fill('Confirm local workspace chat context.') - await page.getByRole('button', { name: 'Send' }).click() - - await expect(page.getByText('Local workspace chat works')).toBeVisible() - await expect( - page.getByText('Select a writable repository before starting chat.', { exact: true }), - ).toHaveCount(0) - - const repositorySystemMessage = streamRequestBody?.messages?.find( - (message: ChatRequestMessage) => - message.role === 'system' && - message.content?.includes('Selected repository context'), - ) - expect(repositorySystemMessage?.content).toContain( - 'Repository: knightedcodemonkey/develop', - ) -}) - test('BYOT controls render with default app entry', async ({ page }) => { await waitForAppReady(page, appEntryPath) @@ -882,928 +726,6 @@ test('deleting saved GitHub token requires confirmation modal', async ({ page }) await expect(repositoryFilter).toHaveValue('__local__') }) -test('AI chat prefers streaming responses when available', async ({ page }) => { - let streamRequestBody: ChatRequestBody | undefined - - await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { - streamRequestBody = route.request().postDataJSON() as ChatRequestBody - - await route.fulfill({ - status: 200, - contentType: 'text/event-stream', - body: [ - 'data: {"choices":[{"delta":{"content":"Streaming "}}]}', - '', - 'data: {"choices":[{"delta":{"content":"response ready"}}]}', - '', - 'data: [DONE]', - '', - ].join('\n'), - }) - }) - - await waitForAppReady(page, `${appEntryPath}`) - await connectByotWithSingleRepo(page) - await connectOpenRouterKey(page) - - await page.getByLabel('Ask AI assistant').fill('Summarize this repository.') - await page.getByRole('button', { name: 'Send' }).click() - - await expect(page.getByText('Response streamed.', { exact: true })).toHaveText( - 'Response streamed.', - ) - await expect(page.getByText('Summarize this repository.')).toBeVisible() - await expect(page.getByText('Streaming response ready')).toBeVisible() - - expect(streamRequestBody?.metadata).toBeUndefined() - expect(streamRequestBody?.model).toBe(defaultChatModel) - expect(streamRequestBody?.tool_choice).toBeUndefined() - expect(streamRequestBody?.tools).toBeUndefined() - expect(streamRequestBody?.messages?.[0]?.role).toBe('system') - expect(streamRequestBody?.messages?.[0]?.content).toContain( - 'expert software development assistant focused on CSS dialects and JSX syntax', - ) - expect(streamRequestBody?.messages?.[0]?.content).toContain( - 'JSX is compiled for @knighted/jsx DOM runtime', - ) - expect(streamRequestBody?.messages?.[0]?.content).toContain( - 'Do not suggest React imports, hooks, or React-only runtime APIs', - ) - expect(streamRequestBody?.messages?.[0]?.content).toContain( - 'Preserve the selected style dialect and avoid cross-dialect rewrites', - ) - const systemMessages = streamRequestBody?.messages?.filter( - (message: ChatRequestMessage) => message.role === 'system', - ) - const repositorySystemMessage = systemMessages?.find((message: ChatRequestMessage) => - message.content?.includes('Selected repository context'), - ) - expect(repositorySystemMessage?.content).toContain( - 'Repository: knightedcodemonkey/develop', - ) - expect(repositorySystemMessage?.content).toContain( - 'Repository URL: https://github.com/knightedcodemonkey/develop', - ) - expect( - systemMessages?.some((message: ChatRequestMessage) => - message.content?.includes('Editor context:'), - ), - ).toBe(true) - expect( - systemMessages?.some( - (message: ChatRequestMessage) => - message.content?.includes('- Active tab:') && - message.content?.includes('App.tsx'), - ), - ).toBe(true) - expect( - systemMessages?.some((message: ChatRequestMessage) => - message.content?.includes('Available tab targets (id and path):'), - ), - ).toBe(true) - expect( - systemMessages?.some((message: ChatRequestMessage) => - message.content?.includes('Active tab source:'), - ), - ).toBe(true) -}) - -test('AI chat enables editor update tools only for explicit edit requests', async ({ - page, -}) => { - let streamRequestBody: ChatRequestBody | undefined - - await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { - streamRequestBody = route.request().postDataJSON() as ChatRequestBody - - await route.fulfill({ - status: 200, - contentType: 'text/event-stream', - body: [ - 'data: {"choices":[{"delta":{"content":"ok"}}]}', - '', - 'data: [DONE]', - '', - ].join('\n'), - }) - }) - - await waitForAppReady(page, `${appEntryPath}`) - await connectByotWithSingleRepo(page) - await connectOpenRouterKey(page) - - await page - .getByLabel('Ask AI assistant') - .fill('Please update app.css to use blue text.') - await page.getByRole('button', { name: 'Send' }).click() - await expect(page.getByText('Response streamed.', { exact: true })).toHaveText( - 'Response streamed.', - ) - - expect(streamRequestBody?.tool_choice).toBe('auto') - expect( - streamRequestBody?.tools?.some( - tool => tool.type === 'function' && tool.function?.name === 'propose_editor_update', - ), - ).toBe(true) -}) - -test('AI chat does not render apply actions for read-only visibility prompts', async ({ - page, -}) => { - let streamRequestBody: ChatRequestBody | undefined - - await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { - const body = route.request().postDataJSON() as ChatRequestBody | null - - if (body?.stream) { - streamRequestBody = body - await route.fulfill({ - status: 502, - contentType: 'application/json', - body: JSON.stringify({ message: 'stream intentionally disabled in this test' }), - }) - return - } - - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - choices: [ - { - message: { - role: 'assistant', - content: - 'Yes, I can see your editor content.\n\n```jsx\nconst App = () =>

Visible

\n```', - }, - }, - ], - }), - }) - }) - - await waitForAppReady(page, `${appEntryPath}`) - await connectByotWithSingleRepo(page) - await setComponentEditorSource(page, 'const App = () =>

Before

') - await openWorkspaceTab(page, 'App.tsx') - await connectOpenRouterKey(page) - - await page.getByLabel('Ask AI assistant').fill('Can you see my editor content?') - await page.getByRole('button', { name: 'Send' }).click() - - await expect(page.getByText('Fallback response loaded.', { exact: true })).toHaveText( - 'Fallback response loaded.', - ) - await expect(page.locator('button[data-action="request-apply"]')).toHaveCount(0) - expect(streamRequestBody?.tool_choice).toBeUndefined() - expect(streamRequestBody?.tools).toBeUndefined() -}) - -test('AI chat can disable editor context payload via checkbox', async ({ page }) => { - let streamRequestBody: ChatRequestBody | undefined - - await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { - streamRequestBody = route.request().postDataJSON() as ChatRequestBody - - await route.fulfill({ - status: 200, - contentType: 'text/event-stream', - body: [ - 'data: {"choices":[{"delta":{"content":"ok"}}]}', - '', - 'data: [DONE]', - '', - ].join('\n'), - }) - }) - - await waitForAppReady(page, `${appEntryPath}`) - await connectByotWithSingleRepo(page) - await connectOpenRouterKey(page) - - const includeEditorsToggle = page.getByLabel('Send tab content') - await expect(includeEditorsToggle).toBeChecked() - await includeEditorsToggle.uncheck() - - await page.getByLabel('Ask AI assistant').fill('No editor source this time.') - await page.getByRole('button', { name: 'Send' }).click() - await expect(page.getByText('Response streamed.', { exact: true })).toHaveText( - 'Response streamed.', - ) - - expect(streamRequestBody?.metadata).toBeUndefined() - expect(streamRequestBody?.tool_choice).toBeUndefined() - expect(streamRequestBody?.tools).toBeUndefined() - const systemMessages = streamRequestBody?.messages?.filter( - (message: ChatRequestMessage) => message.role === 'system', - ) - expect( - systemMessages?.some((message: ChatRequestMessage) => - message.content?.includes('Selected repository context'), - ), - ).toBe(true) - expect( - systemMessages?.some((message: ChatRequestMessage) => - message.content?.includes( - 'Repository URL: https://github.com/knightedcodemonkey/develop', - ), - ), - ).toBe(true) - expect( - systemMessages?.some((message: ChatRequestMessage) => - message.content?.includes('Editor context:'), - ), - ).toBe(false) -}) - -test('AI chat proposals can be confirmed, applied, and undone per active tab', async ({ - page, -}) => { - await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { - const body = route.request().postDataJSON() as ChatRequestBody | null - - if (body?.stream) { - await route.fulfill({ - status: 502, - contentType: 'application/json', - body: JSON.stringify({ message: 'stream intentionally disabled in this test' }), - }) - return - } - - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - choices: [ - { - message: { - role: 'assistant', - content: 'Prepared updates for both editors.', - tool_calls: [ - { - id: 'call_component', - type: 'function', - function: { - name: 'propose_editor_update', - arguments: JSON.stringify({ - target: 'src/components/App.tsx', - content: 'const App = () => ', - rationale: 'Use explicit App component output.', - }), - }, - }, - { - id: 'call_styles', - type: 'function', - function: { - name: 'propose_editor_update', - arguments: JSON.stringify({ - target: 'src/styles/app.css', - content: '.button { color: rgb(10 20 30); }', - rationale: 'Provide deterministic button styling.', - }), - }, - }, - ], - }, - }, - ], - }), - }) - }) - - await waitForAppReady(page, `${appEntryPath}`) - await connectByotWithSingleRepo(page) - await setComponentEditorSource(page, 'const App = () => ') - await setStylesEditorSource(page, '.button { color: red; }') - await openWorkspaceTab(page, 'App.tsx') - await connectOpenRouterKey(page) - - await page.getByLabel('Ask AI assistant').fill('Suggest updates for both editors.') - await page.getByRole('button', { name: 'Send' }).click() - - await expect( - page.getByText('Prepared updates for both editors.', { exact: true }), - ).toBeVisible() - - await expect( - page.getByRole('button', { name: 'Apply update to App.tsx' }), - ).toBeVisible() - await expect( - page.getByRole('button', { name: 'Apply update to app.css' }), - ).toBeVisible() - - await page.getByRole('button', { name: 'Apply update to App.tsx' }).click() - - await expect(page.getByRole('button', { name: 'Apply update to App.tsx' })).toBeHidden() - await expect( - page.getByRole('button', { name: 'Undo last apply for App.tsx' }), - ).toBeVisible() - await expect( - page.getByRole('button', { name: 'Undo last apply for app.css' }), - ).toBeHidden() - await expect( - page.locator('.editor-panel[data-editor-kind="component"] .cm-content').first(), - ).toContainText('Updated') - - await openWorkspaceTab(page, 'app.css') - await expect( - page.getByRole('button', { name: 'Undo last apply for App.tsx' }), - ).toBeHidden() - await expect( - page.getByRole('button', { name: 'Apply update to app.css' }), - ).toBeVisible() - await page.getByRole('button', { name: 'Apply update to app.css' }).click() - - await expect( - page.locator('.editor-panel[data-editor-kind="styles"] .cm-content').first(), - ).toContainText('rgb(10 20 30)') - await expect( - page.getByRole('button', { name: 'Undo last apply for app.css' }), - ).toBeVisible() - await expect( - page.getByRole('button', { name: 'Undo last apply for App.tsx' }), - ).toBeHidden() - - await page.getByRole('button', { name: 'Undo last apply for app.css' }).click() - await expect( - page.locator('.editor-panel[data-editor-kind="styles"] .cm-content').first(), - ).toContainText('red') - - await openWorkspaceTab(page, 'App.tsx') - await expect( - page.getByRole('button', { name: 'Undo last apply for App.tsx' }), - ).toBeVisible() - await expect( - page.getByRole('button', { name: 'Undo last apply for app.css' }), - ).toBeHidden() - - await page.getByRole('button', { name: 'Undo last apply for App.tsx' }).click() - await expect( - page.locator('.editor-panel[data-editor-kind="component"] .cm-content').first(), - ).toContainText('Before') -}) - -test('AI chat apply actions resolve dynamic tab targets', async ({ page }) => { - await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { - const body = route.request().postDataJSON() as ChatRequestBody | null - - if (body?.stream) { - await route.fulfill({ - status: 502, - contentType: 'application/json', - body: JSON.stringify({ message: 'stream intentionally disabled in this test' }), - }) - return - } - - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - choices: [ - { - message: { - role: 'assistant', - content: 'Prepared updates for both editors.', - tool_calls: [ - { - id: 'call_component', - type: 'function', - function: { - name: 'propose_editor_update', - arguments: JSON.stringify({ - target: 'src/components/App.tsx', - content: 'const App = () => ', - }), - }, - }, - { - id: 'call_styles', - type: 'function', - function: { - name: 'propose_editor_update', - arguments: JSON.stringify({ - target: 'src/styles/app.css', - content: '.button { color: rgb(10 20 30); }', - }), - }, - }, - ], - }, - }, - ], - }), - }) - }) - - await waitForAppReady(page, `${appEntryPath}`) - await connectByotWithSingleRepo(page) - await setComponentEditorSource(page, 'const App = () => ') - await setStylesEditorSource(page, '.button { color: red; }') - await openWorkspaceTab(page, 'App.tsx') - await connectOpenRouterKey(page) - - await page.getByLabel('Ask AI assistant').fill('Suggest updates for both editors.') - await page.getByRole('button', { name: 'Send' }).click() - - await expect( - page.getByText('Prepared updates for both editors.', { exact: true }), - ).toBeVisible() - - await expect( - page.getByRole('button', { name: 'Apply update to App.tsx' }), - ).toBeVisible() - await expect( - page.getByRole('button', { name: 'Apply update to app.css' }), - ).toBeVisible() - - await openWorkspaceTab(page, 'app.css') - - await expect( - page.getByRole('button', { name: 'Apply update to App.tsx' }), - ).toBeVisible() - await expect( - page.getByRole('button', { name: 'Apply update to app.css' }), - ).toBeVisible() -}) - -test('AI chat applies the correct proposal when unresolved targets are filtered out', async ({ - page, -}) => { - await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { - const body = route.request().postDataJSON() as ChatRequestBody | null - - if (body?.stream) { - await route.fulfill({ - status: 502, - contentType: 'application/json', - body: JSON.stringify({ message: 'stream intentionally disabled in this test' }), - }) - return - } - - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - choices: [ - { - message: { - role: 'assistant', - content: 'Prepared updates for App tab.', - tool_calls: [ - { - id: 'call_unresolved', - type: 'function', - function: { - name: 'propose_editor_update', - arguments: JSON.stringify({ - target: 'src/components/missing.tsx', - content: 'const Missing = () => null', - }), - }, - }, - { - id: 'call_component', - type: 'function', - function: { - name: 'propose_editor_update', - arguments: JSON.stringify({ - target: 'src/components/App.tsx', - content: 'const App = () =>

Resolved update

', - }), - }, - }, - ], - }, - }, - ], - }), - }) - }) - - await waitForAppReady(page, `${appEntryPath}`) - await connectByotWithSingleRepo(page) - await setComponentEditorSource(page, 'const App = () =>

Before

') - await openWorkspaceTab(page, 'App.tsx') - await connectOpenRouterKey(page) - - await page.getByLabel('Ask AI assistant').fill('Update App tab only.') - await page.getByRole('button', { name: 'Send' }).click() - - await expect( - page.getByRole('button', { name: 'Apply update to App.tsx' }), - ).toBeVisible() - await page.getByRole('button', { name: 'Apply update to App.tsx' }).click() - - await expect( - page.locator('.editor-panel[data-editor-kind="component"] .cm-content').first(), - ).toContainText('Resolved update') -}) - -test('AI chat renders a single apply action for multiple targets resolving to the same tab', async ({ - page, -}) => { - await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { - const body = route.request().postDataJSON() as ChatRequestBody | null - - if (body?.stream) { - await route.fulfill({ - status: 502, - contentType: 'application/json', - body: JSON.stringify({ message: 'stream intentionally disabled in this test' }), - }) - return - } - - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - choices: [ - { - message: { - role: 'assistant', - content: 'Prepared updates for App tab.', - tool_calls: [ - { - id: 'call_component_id', - type: 'function', - function: { - name: 'propose_editor_update', - arguments: JSON.stringify({ - target: 'component', - content: 'const App = () =>

By id

', - }), - }, - }, - { - id: 'call_component_path', - type: 'function', - function: { - name: 'propose_editor_update', - arguments: JSON.stringify({ - target: 'src/components/App.tsx', - content: 'const App = () =>

By path

', - }), - }, - }, - ], - }, - }, - ], - }), - }) - }) - - await waitForAppReady(page, `${appEntryPath}`) - await connectByotWithSingleRepo(page) - await setComponentEditorSource(page, 'const App = () =>

Before

') - await openWorkspaceTab(page, 'App.tsx') - await connectOpenRouterKey(page) - - await page.getByLabel('Ask AI assistant').fill('Update App tab once.') - await page.getByRole('button', { name: 'Send' }).click() - - await expect(page.getByRole('button', { name: 'Apply update to App.tsx' })).toHaveCount( - 1, - ) -}) - -test('AI chat shows guidance when an editor update target cannot be matched', async ({ - page, -}) => { - await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { - const body = route.request().postDataJSON() as ChatRequestBody | null - - if (body?.stream) { - await route.fulfill({ - status: 502, - contentType: 'application/json', - body: JSON.stringify({ message: 'stream intentionally disabled in this test' }), - }) - return - } - - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - choices: [ - { - message: { - role: 'assistant', - content: '', - tool_calls: [ - { - id: 'call_unknown_target', - type: 'function', - function: { - name: 'propose_editor_update', - arguments: JSON.stringify({ - target: 'src/does-not-exist.ts', - content: 'export const value = 1', - }), - }, - }, - ], - }, - }, - ], - }), - }) - }) - - await waitForAppReady(page, `${appEntryPath}`) - await connectByotWithSingleRepo(page) - await setComponentEditorSource(page, 'const App = () =>

Before

') - await openWorkspaceTab(page, 'App.tsx') - await connectOpenRouterKey(page) - - await page.getByLabel('Ask AI assistant').fill('Can you still see my tab content?') - await page.getByRole('button', { name: 'Send' }).click() - - await expect( - page.getByText( - 'Proposed editor update is ready, but I could not match its target to an open tab. Ask me to target the active tab or one of the listed tab ids or paths.', - ), - ).toHaveCount(1) - await expect(page.locator('button[data-action="request-apply"]')).toHaveCount(0) -}) - -test('AI chat sends the currently active tab when context is enabled', async ({ - page, -}) => { - let streamRequestBody: ChatRequestBody | undefined - - await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { - streamRequestBody = route.request().postDataJSON() as ChatRequestBody - - await route.fulfill({ - status: 200, - contentType: 'text/event-stream', - body: [ - 'data: {"choices":[{"delta":{"content":"ok"}}]}', - '', - 'data: [DONE]', - '', - ].join('\n'), - }) - }) - - await waitForAppReady(page, `${appEntryPath}`) - await connectByotWithSingleRepo(page) - await setStylesEditorSource(page, '.button { color: red; }') - await connectOpenRouterKey(page) - - await page.getByLabel('Ask AI assistant').fill('Use active tab context only.') - await page.getByRole('button', { name: 'Send' }).click() - await expect(page.getByText('Response streamed.', { exact: true })).toHaveText( - 'Response streamed.', - ) - - const systemMessages = streamRequestBody?.messages?.filter( - (message: ChatRequestMessage) => message.role === 'system', - ) - expect( - systemMessages?.some( - (message: ChatRequestMessage) => - message.content?.includes('- Active tab:') && - message.content?.includes('app.css'), - ), - ).toBe(true) - expect( - systemMessages?.some((message: ChatRequestMessage) => - message.content?.includes('Active tab source:'), - ), - ).toBe(true) - expect( - systemMessages?.some((message: ChatRequestMessage) => - message.content?.includes('Available tab targets (id and path):'), - ), - ).toBe(true) -}) - -test('AI chat streaming text still updates while latest undo actions are visible', async ({ - page, -}) => { - let requestCount = 0 - - await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { - requestCount += 1 - const body = route.request().postDataJSON() as ChatRequestBody | null - - if (requestCount <= 2) { - if (body?.stream) { - await route.fulfill({ - status: 502, - contentType: 'application/json', - body: JSON.stringify({ message: 'force fallback for proposal setup' }), - }) - return - } - - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - choices: [ - { - message: { - role: 'assistant', - content: 'Prepared updates for styles editor.', - tool_calls: [ - { - id: 'call_styles', - type: 'function', - function: { - name: 'propose_editor_update', - arguments: JSON.stringify({ - target: 'src/styles/app.css', - content: '.button { color: rgb(10 20 30); }', - }), - }, - }, - ], - }, - }, - ], - }), - }) - return - } - - if (body?.stream) { - await route.fulfill({ - status: 200, - contentType: 'text/event-stream', - body: [ - 'data: {"choices":[{"delta":{"content":"Streaming "}}]}', - '', - 'data: {"choices":[{"delta":{"content":"works with undo visible."}}]}', - '', - 'data: [DONE]', - '', - ].join('\n'), - }) - return - } - - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - choices: [{ message: { role: 'assistant', content: 'fallback text' } }], - }), - }) - }) - - await waitForAppReady(page, `${appEntryPath}`) - await connectByotWithSingleRepo(page) - await setStylesEditorSource(page, '.button { color: red; }') - await connectOpenRouterKey(page) - - await page.getByLabel('Ask AI assistant').fill('Suggest a styles update.') - await page.getByRole('button', { name: 'Send' }).click() - - await expect( - page.getByText('Prepared updates for styles editor.', { exact: true }), - ).toBeVisible() - await page.getByRole('button', { name: 'Apply update to app.css' }).click() - await expect( - page.getByRole('button', { name: 'Undo last apply for app.css' }), - ).toBeVisible() - - await page - .getByLabel('Ask AI assistant') - .fill('Are you still working on that last request?') - await page.getByRole('button', { name: 'Send' }).click() - - await expect(page.getByText('Streaming works with undo visible.')).toBeVisible() -}) - -test('AI chat falls back to non-streaming response when streaming fails', async ({ - page, -}) => { - let streamAttemptCount = 0 - let fallbackAttemptCount = 0 - const attemptedModels: string[] = [] - - await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { - const body = route.request().postDataJSON() as ChatRequestBody | null - if (typeof body?.model === 'string') { - attemptedModels.push(body.model) - } - - if (body?.stream) { - streamAttemptCount += 1 - await route.fulfill({ - status: 502, - contentType: 'application/json', - body: JSON.stringify({ message: 'stream failed' }), - }) - return - } - - fallbackAttemptCount += 1 - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - rate_limit: { - remaining: 17, - reset: 1704067200, - }, - choices: [ - { - message: { - role: 'assistant', - content: 'Fallback response from JSON path.', - }, - }, - ], - }), - }) - }) - - await waitForAppReady(page, `${appEntryPath}`) - await connectByotWithSingleRepo(page) - await connectOpenRouterKey(page) - - const selectedModel = 'openai/gpt-6-astra' - await page.getByLabel('Chat model').selectOption(selectedModel) - await expect(page.getByLabel('Chat model')).toHaveValue(selectedModel) - - await page.getByLabel('Ask AI assistant').fill('Use fallback path.') - await page.getByRole('button', { name: 'Send' }).click() - - await expect(page.getByText('Fallback response loaded.', { exact: true })).toHaveText( - 'Fallback response loaded.', - ) - await expect(page.getByText('Fallback response from JSON path.')).toBeVisible() - expect(streamAttemptCount).toBeGreaterThan(0) - expect(fallbackAttemptCount).toBeGreaterThan(0) - expect(attemptedModels.length).toBeGreaterThan(0) - expect(attemptedModels.every(model => model === selectedModel)).toBe(true) -}) - -test('clearing chat removes previous conversation context from new request', async ({ - page, -}) => { - const streamBodies: ChatRequestBody[] = [] - - await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { - const body = route.request().postDataJSON() as ChatRequestBody - if (body?.stream) { - streamBodies.push(body) - await route.fulfill({ - status: 200, - contentType: 'text/event-stream', - body: [ - 'data: {"choices":[{"delta":{"content":"ok"}}]}', - '', - 'data: [DONE]', - '', - ].join('\n'), - }) - return - } - - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - choices: [{ message: { role: 'assistant', content: 'ok' } }], - }), - }) - }) - - await waitForAppReady(page, `${appEntryPath}`) - await connectByotWithSingleRepo(page) - await connectOpenRouterKey(page) - - await page.getByLabel('Ask AI assistant').fill('First conversation prompt') - await page.getByRole('button', { name: 'Send' }).click() - await expect(page.getByText('Response streamed.', { exact: true })).toBeVisible() - - await page.getByRole('button', { name: 'Clear', exact: true }).click() - await expect(page.getByText('Chat cleared.', { exact: true })).toBeVisible() - - await page.getByLabel('Ask AI assistant').fill('Second conversation prompt') - await page.getByRole('button', { name: 'Send' }).click() - await expect(page.getByText('Response streamed.', { exact: true })).toBeVisible() - - expect(streamBodies.length).toBeGreaterThanOrEqual(2) - const latestMessages = streamBodies[streamBodies.length - 1]?.messages ?? [] - const allLatestContent = latestMessages.map(message => message.content ?? '').join('\n') - - expect(allLatestContent).toContain('Second conversation prompt') - expect(allLatestContent).not.toContain('First conversation prompt') -}) - test('BYOT remembers selected repository across reloads', async ({ page }) => { test.setTimeout(90_000) diff --git a/playwright/helpers/app-test-helpers.ts b/playwright/helpers/app-test-helpers.ts index 8e3f0a6..0b7c580 100644 --- a/playwright/helpers/app-test-helpers.ts +++ b/playwright/helpers/app-test-helpers.ts @@ -45,7 +45,7 @@ const isRetryableGotoError = (error: unknown) => { return false } - return /WebKit encountered an internal error|Test timeout/i.test(error.message) + return /WebKit encountered an internal error|page\.goto: Timeout/i.test(error.message) } const navigateToApp = async (page: Page, path: string) => { diff --git a/src/index.html b/src/index.html index a79ebd0..3ce47a4 100644 --- a/src/index.html +++ b/src/index.html @@ -78,8 +78,9 @@

- Provide a GitHub PAT to open pull requests against your repos or chat with - GitHub models. Read more about it in the + Provide a GitHub PAT to open pull requests against your repos. AI chat + uses a separate OpenRouter API key in the chat drawer. Read more about + both in the { + const pending = isPending === true const composerEnabled = !isPending && hasChatKey() + if (drawer instanceof HTMLElement) { + drawer.dataset.chatPending = pending ? 'true' : 'false' + } + + if (statusNode instanceof HTMLElement) { + statusNode.setAttribute('aria-busy', pending ? 'true' : 'false') + } + if (sendButton instanceof HTMLButtonElement) { sendButton.disabled = !composerEnabled } @@ -301,7 +310,7 @@ export const createChatDrawer = ({ } if (modelSelect instanceof HTMLSelectElement) { - if (isPending) { + if (pending) { modelSelect.disabled = true } else { modelSelect.disabled = !hasChatKey() diff --git a/src/modules/chat/model-picker.js b/src/modules/chat/model-picker.js index c0d9e16..3259dd2 100644 --- a/src/modules/chat/model-picker.js +++ b/src/modules/chat/model-picker.js @@ -101,26 +101,31 @@ export const createChatModelPicker = ({ return } - const selectedModel = getSelectedModel() - const catalogLoadPromise = fetchChatModelOptions({ token: normalizedToken }) - .then(modelIds => { + const loadCatalog = async () => { + try { + const modelIds = await fetchChatModelOptions({ token: normalizedToken }) + const selectedModel = getSelectedModel() replaceModelOptions({ modelIds, selectedModel, }) loadedCatalogToken = normalizedToken - }) - .catch(() => { + } catch { /* Keep fallback options when catalog loading fails. */ - }) - .finally(() => { - if (pendingCatalogLoadPromise === catalogLoadPromise) { - pendingCatalogLoadPromise = null - } - }) + } + } + + const catalogLoadPromise = loadCatalog() pendingCatalogLoadPromise = catalogLoadPromise - await catalogLoadPromise + + try { + await catalogLoadPromise + } finally { + if (pendingCatalogLoadPromise === catalogLoadPromise) { + pendingCatalogLoadPromise = null + } + } } const syncModelSelectionForKey = key => { diff --git a/src/modules/chat/request-runner.js b/src/modules/chat/request-runner.js index 9d89339..a3651d2 100644 --- a/src/modules/chat/request-runner.js +++ b/src/modules/chat/request-runner.js @@ -8,6 +8,21 @@ import { toChatText, } from './utils.js' +const sanitizeAssistantContent = value => { + if (typeof value !== 'string' || !value) { + return '' + } + + let sanitized = value.replace(/<\|tool_call_start\|>[\s\S]*?<\|tool_call_end\|>/g, '') + + const openToolCallIndex = sanitized.indexOf('<|tool_call_start|>') + if (openToolCallIndex !== -1) { + sanitized = sanitized.slice(0, openToolCallIndex) + } + + return sanitized.replace(/<\|tool_call_start\|>|<\|tool_call_end\|>/g, '') +} + export const createChatRequestRunner = ({ getPrompt, getToken, @@ -78,16 +93,25 @@ export const createChatRequestRunner = ({ signal: requestSignal, onToken: tokenChunk => { streamedContent += tokenChunk - updateLastAssistantMessage?.(streamedContent) + const sanitizedContent = sanitizeAssistantContent(streamedContent) + updateLastAssistantMessage?.(sanitizedContent) }, }) streamSucceeded = true const streamedModel = toChatText(streamResult?.model) - const streamContent = toChatText(streamResult?.content) + const streamContent = toChatText(sanitizeAssistantContent(streamResult?.content)) + const streamToolCalls = Array.isArray(streamResult?.toolCalls) + ? streamResult.toolCalls + : [] + + if (!streamContent && streamToolCalls.length === 0) { + throw new Error('Streaming returned control syntax without assistant content.') + } + attachAssistantResponseMetadata?.({ content: streamContent, - toolCalls: streamResult?.toolCalls, + toolCalls: streamToolCalls, model: streamedModel, }) setChatStatus?.('Response streamed.', 'ok') @@ -167,9 +191,18 @@ export const createChatRequestRunner = ({ signal: requestSignal, }) + const fallbackContent = toChatText(sanitizeAssistantContent(fallbackResult.content)) + const fallbackToolCalls = Array.isArray(fallbackResult?.toolCalls) + ? fallbackResult.toolCalls + : [] + + if (!fallbackContent && fallbackToolCalls.length === 0) { + throw new Error('Chat response did not include assistant content.') + } + attachAssistantResponseMetadata?.({ - content: toChatText(fallbackResult.content), - toolCalls: fallbackResult?.toolCalls, + content: fallbackContent, + toolCalls: fallbackToolCalls, }) const fallbackModel = toChatText(fallbackResult.model) setLastAssistantModel?.(fallbackModel) diff --git a/src/styles/ai-controls.css b/src/styles/ai-controls.css index e7f98ee..6d6a866 100644 --- a/src/styles/ai-controls.css +++ b/src/styles/ai-controls.css @@ -913,10 +913,31 @@ .ai-chat-drawer__status { color: var(--text-subtle); text-align: left; + display: inline-block; } .ai-chat-drawer__status[data-level='pending'] { - color: color-mix(in srgb, var(--panel-text) 72%, var(--accent)); + --ai-chat-status-pending-base: color-mix(in srgb, var(--panel-text) 68%, var(--accent)); + --ai-chat-status-pending-glint: color-mix( + in srgb, + var(--ai-chat-sparkle-color) 84%, + white 16% + ); + color: var(--ai-chat-status-pending-base); + background-image: linear-gradient( + 110deg, + var(--ai-chat-status-pending-base) 0%, + var(--ai-chat-status-pending-base) 36%, + var(--ai-chat-status-pending-glint) 50%, + var(--ai-chat-status-pending-base) 64%, + var(--ai-chat-status-pending-base) 100% + ); + background-size: 230% 100%; + background-position: 0% 50%; + -webkit-background-clip: text; + background-clip: text; + color: transparent; + animation: ai-chat-status-text-shimmer 1300ms linear infinite; } .ai-chat-drawer__status[data-level='ok'] { @@ -927,6 +948,23 @@ color: color-mix(in srgb, rgb(var(--danger-rgb)) 85%, var(--panel-text)); } +@keyframes ai-chat-status-text-shimmer { + 0% { + background-position: 200% 50%; + } + + 100% { + background-position: -40% 50%; + } +} + +@media (prefers-reduced-motion: reduce) { + .ai-chat-drawer__status[data-level='pending'] { + animation: none; + background-position: 50% 50%; + } +} + .ai-chat-messages { border: 1px solid var(--border-subtle); border-radius: 10px;