From 0affb283a14619cc034d253723ca6b9b57601b18 Mon Sep 17 00:00:00 2001 From: KCM Date: Sun, 6 Sep 2026 16:34:44 -0500 Subject: [PATCH 1/2] feat: phases 2 and 3 of openrouter migration plan. --- docs/openrouter-migration-plan.md | 67 +++- playwright/github-byot-ai.spec.ts | 336 +++++++++++++++---- playwright/helpers/app-test-helpers.ts | 16 +- src/app.js | 21 +- src/index.html | 104 ++++-- src/modules/app-core/app-bindings-startup.js | 2 +- src/modules/app-core/chat-workflows.js | 10 +- src/modules/app-core/github-pr-context-ui.js | 15 +- src/modules/app-core/github-workflows.js | 2 +- src/modules/chat/api/completions.js | 12 +- src/modules/chat/api/constants.js | 39 +-- src/modules/chat/api/request.js | 107 ++---- src/modules/chat/drawer.js | 124 +++++-- src/modules/chat/key-controls.js | 98 ++++++ src/modules/chat/key-store.js | 60 ++++ src/modules/chat/payload.js | 27 +- src/modules/chat/proposals.js | 9 +- src/modules/chat/utils.js | 13 + src/styles/ai-controls.css | 72 +++- 19 files changed, 876 insertions(+), 258 deletions(-) create mode 100644 src/modules/chat/key-controls.js create mode 100644 src/modules/chat/key-store.js diff --git a/docs/openrouter-migration-plan.md b/docs/openrouter-migration-plan.md index 775b669..06b0ab6 100644 --- a/docs/openrouter-migration-plan.md +++ b/docs/openrouter-migration-plan.md @@ -43,6 +43,45 @@ 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) + +### Done + +- Phase 1 completed: chat extracted to `src/modules/chat` and decoupled from + `src/modules/github` imports. +- Phase 2 completed for core runtime path: + - OpenRouter chat completions endpoint is live. + - OpenRouter header and error handling is implemented. + - Streaming and fallback request paths are both wired. + - Live verification confirmed SSE keepalive comment handling and `[DONE]` sentinel flow. +- Phase 3 completed: + - Chat toggle remains visible regardless of GitHub PAT state. + - Chat works with no repository selected (local mode). + - In-drawer OpenRouter key controls are implemented with independent storage. + - PR visibility logic was split away from chat visibility behavior. +- Security and UX hardening completed after initial migration: + - Proposal/apply actions are intent-gated (explicit edit intent required). + - Read-only prompts do not surface apply actions from markdown fallback. + - Unmatched proposal targets show guidance instead of a misleading apply prompt. + - OpenRouter key controls now reuse the GitHub PAT-style control pattern and trash icon. +- 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. + ### Correction to a common assumption OpenRouter's free models are **not** keyless. Every request to the OpenRouter API requires @@ -71,8 +110,18 @@ plan's assumptions are measured rather than inferred. | Exposed response headers | Only `content-type` and `cf-ray` | | Catalog size | 430 models, 21 free, 18 free with `tools` support | -Still unverified, because both require a funded key: SSE keepalive handling, and the -402 / 404 / 429 error mappings. Both are Phase 2 opening tasks. +Still unverified, because both require exhausting an account: the 402 (out of credits) and +429 (rate limited) mappings. + +### Verified live with a funded key + +| Check | Result | +| ---------------------------- | -------------------------------------------------------------------------- | +| SSE keepalive comments | `: OPENROUTER PROCESSING` lines do appear; `parseSseDataLine` ignores them | +| `data: [DONE]` sentinel | Present and handled | +| Invalid model slug | Returns **400**, not 404 — `"... is not a valid model ID"` | +| Tool calling on a free model | `openrouter/free` emits a real `propose_editor_update` call | +| Apply + undo round trip | Proposal applies to the editor tab and the undo action appears | ## Current state @@ -238,15 +287,15 @@ still opens and renders. | Status | Meaning | Drawer message | | ------ | ---------------------- | ---------------------------------------------------- | + | 400 | Unknown model slug | Model unavailable; pick another | | 401 | Invalid or revoked key | Key rejected; re-enter or create a new one | | 402 | Out of credits | Out of credits; add credits or pick a free model | - | 404 | Unknown model slug | Model unavailable; pick another | | 429 | Rate limited | Free-model daily limit reached, or too many requests | The 402 and 429 cases are the ones users on free models will actually hit, so their copy should name the free-model limits explicitly and point at the free-model filter in the - picker. Only the 401 mapping is verified; OpenRouter checks auth before model validity, - so 402/404/429 could not be provoked with an invalid key. Confirm each during Phase 2. + picker. 400 and 401 are verified live. 402 and 429 remain unverified, since provoking + them means exhausting an account. 8. **Rate metadata.** Delete header-based rate parsing entirely rather than porting it. Verified: OpenRouter exposes only `content-type` and `cf-ray` to browser JavaScript via @@ -412,10 +461,10 @@ those 21 advertise tool support. | Browser CORS on `/api/v1/models` | Resolved | Verified: accessible cross-origin, no key required | | Free models lack tool support | Resolved | Verified: 18 of 21 free models advertise `tools` in `supported_parameters` | | Rate-limit headers unreadable in browser | Resolved | Verified: only `content-type` and `cf-ray` exposed. Drop header parsing; use `/api/v1/key` if needed | -| SSE keepalive comments break the stream reader | Open | Needs a funded key. First task of Phase 2 | -| 402/404/429 mappings unconfirmed | Open | Auth is checked first, so these need a valid key to provoke. Confirm during Phase 2 | -| `syncAiChatTokenVisibility` split leaves gaps | Open | Enumerated above; explicit matrix coverage in Playwright | -| Repository-independent chat hits untested paths | Open | Audit list above; local-mode specs with no PAT | +| SSE keepalive comments break the stream reader | Resolved | Verified live with a funded key; keepalive comments are ignored and stream completion is handled correctly | +| 402/404/429 mappings unconfirmed | Partial | 400 invalid model behavior is verified; 402 out-of-credits and 429 rate-limit remain to be validated against exhausted-account conditions | +| `syncAiChatTokenVisibility` split leaves gaps | Resolved | Chat visibility is decoupled from PAT gating; PR surface visibility remains PAT-scoped | +| Repository-independent chat hits untested paths | Partial | Core no-repository behavior is implemented and covered by focused tests; broader cross-browser matrix coverage remains | | Pinned default free slug goes away | Open | 404 on default falls back to the picker; periodic sanity check | | 50 req/day free limit feels broken to users | Open | Explicit 429 copy naming the limit and the credits threshold | | Key in `localStorage` is XSS-exposed | Accepted | Same threat model as the existing PAT; document it, and note the OpenRouter key is scoped to inference spend only, unlike the PAT which can write repositories | diff --git a/playwright/github-byot-ai.spec.ts b/playwright/github-byot-ai.spec.ts index ec587bd..99b4c18 100644 --- a/playwright/github-byot-ai.spec.ts +++ b/playwright/github-byot-ai.spec.ts @@ -5,7 +5,8 @@ import { appEntryPath, connectByotWithSingleRepo, ensureWorkspacesDrawerClosed, - ensureAiChatDrawerOpen, + connectOpenRouterKey, + openRouterTestKey, ensureOpenPrDrawerOpen, mockRepositoryBranches, openWorkspaceTab, @@ -20,7 +21,7 @@ import { } from './github-pr-drawer/github-pr-drawer.helpers.js' import { selectWorkspacesRepositoryFilter } from './github-pr-drawer/github-pr-drawer.helpers.js' -test('PR/BYOT controls are visible and chat stays hidden until token connect', async ({ +test('PR/BYOT controls are visible and chat is available without a GitHub token', async ({ page, }) => { await waitForAppReady(page) @@ -39,7 +40,7 @@ test('PR/BYOT controls are visible and chat stays hidden until token connect', a await expect(byotControls).toBeVisible() await expect(page.getByRole('textbox', { name: 'GitHub token' })).toBeVisible() await expect(page.getByRole('button', { name: 'Add GitHub token' })).toBeVisible() - await expect(page.getByRole('button', { name: 'Chat' })).toBeHidden() + await expect(page.getByRole('button', { name: 'Chat' })).toBeVisible() await expect(page.getByRole('heading', { name: 'AI Chat' })).toBeHidden() await expect(prToggle).toHaveCount(1) await expect(prToggle).toBeHidden() @@ -47,6 +48,34 @@ test('PR/BYOT controls are visible and chat stays hidden until token connect', a 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, }) => { @@ -296,13 +325,52 @@ test('PAT connect after Local-only session preserves Local records and enables r ).toBe('local') }) -test('chat becomes available after token connect', async ({ page }) => { +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 expect(page.getByRole('button', { name: 'Open pull request' })).toBeVisible() - await expect(page.getByRole('button', { name: 'Workspaces' })).toBeVisible() - await expect(page.getByRole('button', { name: 'Chat' })).toBeVisible() + 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 ({ @@ -610,7 +678,7 @@ test('chat stays usable after opening a Local workspace with PAT connected', asy const localWorkspaceId = 'local_chat_issue_128' let streamRequestBody: ChatRequestBody | undefined - await page.route('https://models.github.ai/inference/chat/completions', async route => { + await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { streamRequestBody = route.request().postDataJSON() as ChatRequestBody await route.fulfill({ @@ -657,7 +725,7 @@ test('chat stays usable after opening a Local workspace with PAT connected', asy }) await ensureWorkspacesDrawerClosed(page) - await ensureAiChatDrawerOpen(page) + await connectOpenRouterKey(page) await page.getByLabel('Ask AI assistant').fill('Confirm local workspace chat context.') await page.getByRole('button', { name: 'Send' }).click() @@ -694,7 +762,7 @@ test('BYOT controls render with default app entry', async ({ page }) => { await expect(byotControls).toBeVisible() await expect(page.getByRole('textbox', { name: 'GitHub token' })).toBeVisible() await expect(page.getByRole('button', { name: 'Add GitHub token' })).toBeVisible() - await expect(page.getByRole('button', { name: 'Chat' })).toBeHidden() + await expect(page.getByRole('button', { name: 'Chat' })).toBeVisible() await expect(prToggle).toHaveCount(1) await expect(prToggle).toBeHidden() await expect(workspacesToggle).toHaveCount(1) @@ -814,29 +882,10 @@ test('deleting saved GitHub token requires confirmation modal', async ({ page }) await expect(repositoryFilter).toHaveValue('__local__') }) -test('AI chat drawer opens and closes', async ({ page }) => { - await waitForAppReady(page, appEntryPath) - await connectByotWithSingleRepo(page) - - const chatToggle = page.getByRole('button', { name: 'Chat', exact: true }) - const chatDrawer = page.getByRole('heading', { name: 'AI Chat' }) - - await expect(chatToggle).toBeVisible() - await expect(chatToggle).toHaveAttribute('aria-expanded', 'false') - - await chatToggle.click() - await expect(chatDrawer).toBeVisible() - await expect(chatToggle).toHaveAttribute('aria-expanded', 'true') - - await page.getByRole('button', { name: 'Close AI chat drawer' }).click() - await expect(chatDrawer).toBeHidden() - await expect(chatToggle).toHaveAttribute('aria-expanded', 'false') -}) - test('AI chat prefers streaming responses when available', async ({ page }) => { let streamRequestBody: ChatRequestBody | undefined - await page.route('https://models.github.ai/inference/chat/completions', async route => { + await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { streamRequestBody = route.request().postDataJSON() as ChatRequestBody await route.fulfill({ @@ -855,25 +904,21 @@ test('AI chat prefers streaming responses when available', async ({ page }) => { await waitForAppReady(page, `${appEntryPath}`) await connectByotWithSingleRepo(page) - await ensureAiChatDrawerOpen(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 from GitHub.', { exact: true }), - ).toHaveText('Response streamed from GitHub.') + 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).toBe('auto') - expect( - streamRequestBody?.tools?.some( - tool => tool.type === 'function' && tool.function?.name === 'propose_editor_update', - ), - ).toBe(true) + 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', @@ -923,10 +968,102 @@ test('AI chat prefers streaming responses when available', async ({ page }) => { ).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://models.github.ai/inference/chat/completions', async route => { + await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { streamRequestBody = route.request().postDataJSON() as ChatRequestBody await route.fulfill({ @@ -943,7 +1080,7 @@ test('AI chat can disable editor context payload via checkbox', async ({ page }) await waitForAppReady(page, `${appEntryPath}`) await connectByotWithSingleRepo(page) - await ensureAiChatDrawerOpen(page) + await connectOpenRouterKey(page) const includeEditorsToggle = page.getByLabel('Send tab content') await expect(includeEditorsToggle).toBeChecked() @@ -951,9 +1088,9 @@ test('AI chat can disable editor context payload via checkbox', async ({ page }) 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 from GitHub.', { exact: true }), - ).toHaveText('Response streamed from GitHub.') + await expect(page.getByText('Response streamed.', { exact: true })).toHaveText( + 'Response streamed.', + ) expect(streamRequestBody?.metadata).toBeUndefined() expect(streamRequestBody?.tool_choice).toBe('none') @@ -982,7 +1119,7 @@ test('AI chat can disable editor context payload via checkbox', async ({ page }) test('AI chat proposals can be confirmed, applied, and undone per active tab', async ({ page, }) => { - await page.route('https://models.github.ai/inference/chat/completions', async route => { + await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { const body = route.request().postDataJSON() as ChatRequestBody | null if (body?.stream) { @@ -1041,7 +1178,7 @@ test('AI chat proposals can be confirmed, applied, and undone per active tab', a await setComponentEditorSource(page, 'const App = () => ') await setStylesEditorSource(page, '.button { color: red; }') await openWorkspaceTab(page, 'App.tsx') - await ensureAiChatDrawerOpen(page) + await connectOpenRouterKey(page) await page.getByLabel('Ask AI assistant').fill('Suggest updates for both editors.') await page.getByRole('button', { name: 'Send' }).click() @@ -1109,7 +1246,7 @@ test('AI chat proposals can be confirmed, applied, and undone per active tab', a }) test('AI chat apply actions resolve dynamic tab targets', async ({ page }) => { - await page.route('https://models.github.ai/inference/chat/completions', async route => { + await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { const body = route.request().postDataJSON() as ChatRequestBody | null if (body?.stream) { @@ -1166,7 +1303,7 @@ test('AI chat apply actions resolve dynamic tab targets', async ({ page }) => { await setComponentEditorSource(page, 'const App = () => ') await setStylesEditorSource(page, '.button { color: red; }') await openWorkspaceTab(page, 'App.tsx') - await ensureAiChatDrawerOpen(page) + await connectOpenRouterKey(page) await page.getByLabel('Ask AI assistant').fill('Suggest updates for both editors.') await page.getByRole('button', { name: 'Send' }).click() @@ -1195,7 +1332,7 @@ test('AI chat apply actions resolve dynamic tab targets', async ({ page }) => { test('AI chat applies the correct proposal when unresolved targets are filtered out', async ({ page, }) => { - await page.route('https://models.github.ai/inference/chat/completions', async route => { + await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { const body = route.request().postDataJSON() as ChatRequestBody | null if (body?.stream) { @@ -1251,7 +1388,7 @@ test('AI chat applies the correct proposal when unresolved targets are filtered await connectByotWithSingleRepo(page) await setComponentEditorSource(page, 'const App = () =>

Before

') await openWorkspaceTab(page, 'App.tsx') - await ensureAiChatDrawerOpen(page) + await connectOpenRouterKey(page) await page.getByLabel('Ask AI assistant').fill('Update App tab only.') await page.getByRole('button', { name: 'Send' }).click() @@ -1269,7 +1406,7 @@ test('AI chat applies the correct proposal when unresolved targets are filtered test('AI chat renders a single apply action for multiple targets resolving to the same tab', async ({ page, }) => { - await page.route('https://models.github.ai/inference/chat/completions', async route => { + await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { const body = route.request().postDataJSON() as ChatRequestBody | null if (body?.stream) { @@ -1325,7 +1462,7 @@ test('AI chat renders a single apply action for multiple targets resolving to th await connectByotWithSingleRepo(page) await setComponentEditorSource(page, 'const App = () =>

Before

') await openWorkspaceTab(page, 'App.tsx') - await ensureAiChatDrawerOpen(page) + await connectOpenRouterKey(page) await page.getByLabel('Ask AI assistant').fill('Update App tab once.') await page.getByRole('button', { name: 'Send' }).click() @@ -1335,12 +1472,73 @@ test('AI chat renders a single apply action for multiple targets resolving to th ) }) +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://models.github.ai/inference/chat/completions', async route => { + await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { streamRequestBody = route.request().postDataJSON() as ChatRequestBody await route.fulfill({ @@ -1358,13 +1556,13 @@ test('AI chat sends the currently active tab when context is enabled', async ({ await waitForAppReady(page, `${appEntryPath}`) await connectByotWithSingleRepo(page) await setStylesEditorSource(page, '.button { color: red; }') - await ensureAiChatDrawerOpen(page) + 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 from GitHub.', { exact: true }), - ).toHaveText('Response streamed from GitHub.') + await expect(page.getByText('Response streamed.', { exact: true })).toHaveText( + 'Response streamed.', + ) const systemMessages = streamRequestBody?.messages?.filter( (message: ChatRequestMessage) => message.role === 'system', @@ -1393,7 +1591,7 @@ test('AI chat streaming text still updates while latest undo actions are visible }) => { let requestCount = 0 - await page.route('https://models.github.ai/inference/chat/completions', async route => { + await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { requestCount += 1 const body = route.request().postDataJSON() as ChatRequestBody | null @@ -1465,7 +1663,7 @@ test('AI chat streaming text still updates while latest undo actions are visible await waitForAppReady(page, `${appEntryPath}`) await connectByotWithSingleRepo(page) await setStylesEditorSource(page, '.button { color: red; }') - await ensureAiChatDrawerOpen(page) + await connectOpenRouterKey(page) await page.getByLabel('Ask AI assistant').fill('Suggest a styles update.') await page.getByRole('button', { name: 'Send' }).click() @@ -1493,7 +1691,7 @@ test('AI chat falls back to non-streaming response when streaming fails', async let fallbackAttemptCount = 0 const attemptedModels: string[] = [] - await page.route('https://models.github.ai/inference/chat/completions', async route => { + 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) @@ -1532,9 +1730,9 @@ test('AI chat falls back to non-streaming response when streaming fails', async await waitForAppReady(page, `${appEntryPath}`) await connectByotWithSingleRepo(page) - await ensureAiChatDrawerOpen(page) + await connectOpenRouterKey(page) - const selectedModel = 'openai/gpt-5-mini' + const selectedModel = 'openai/gpt-6-astra' await page.getByLabel('Chat model').selectOption(selectedModel) await expect(page.getByLabel('Chat model')).toHaveValue(selectedModel) @@ -1556,7 +1754,7 @@ test('clearing chat removes previous conversation context from new request', asy }) => { const streamBodies: ChatRequestBody[] = [] - await page.route('https://models.github.ai/inference/chat/completions', async route => { + 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) @@ -1584,22 +1782,18 @@ test('clearing chat removes previous conversation context from new request', asy await waitForAppReady(page, `${appEntryPath}`) await connectByotWithSingleRepo(page) - await ensureAiChatDrawerOpen(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 from GitHub.', { exact: true }), - ).toBeVisible() + 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 from GitHub.', { exact: true }), - ).toBeVisible() + await expect(page.getByText('Response streamed.', { exact: true })).toBeVisible() expect(streamBodies.length).toBeGreaterThanOrEqual(2) const latestMessages = streamBodies[streamBodies.length - 1]?.messages ?? [] diff --git a/playwright/helpers/app-test-helpers.ts b/playwright/helpers/app-test-helpers.ts index ef2772a..066bbda 100644 --- a/playwright/helpers/app-test-helpers.ts +++ b/playwright/helpers/app-test-helpers.ts @@ -408,8 +408,22 @@ export const ensureDiagnosticsDrawerClosed = async (page: Page) => { await expect(page.getByRole('complementary', { name: 'Diagnostics' })).toBeHidden() } +export const openRouterTestKey = 'sk-or-v1-fake-chat-key-1234567890' + +export const connectOpenRouterKey = async ( + page: Page, + key: string = openRouterTestKey, +) => { + await ensureAiChatDrawerOpen(page) + await page.getByLabel('OpenRouter API key', { exact: true }).fill(key) + await page.getByRole('button', { name: 'Save OpenRouter API key' }).click() + await expect( + page.getByRole('button', { name: 'Remove OpenRouter API key' }), + ).toBeVisible() +} + export const ensureAiChatDrawerOpen = async (page: Page) => { - const toggle = page.getByRole('button', { name: 'Chat' }) + const toggle = page.getByRole('button', { name: 'Chat', exact: true }) const isExpanded = await toggle.getAttribute('aria-expanded') if (isExpanded !== 'true') { diff --git a/src/app.js b/src/app.js index b120a23..c9fb9c5 100644 --- a/src/app.js +++ b/src/app.js @@ -132,6 +132,10 @@ const aiChatSend = document.getElementById('ai-chat-send') const aiChatStatus = document.getElementById('ai-chat-status') const aiChatRepository = document.getElementById('ai-chat-repository') const aiChatMessages = document.getElementById('ai-chat-messages') +const aiChatKey = document.getElementById('ai-chat-key') +const aiChatKeyInput = document.getElementById('ai-chat-key-input') +const aiChatKeyAdd = document.getElementById('ai-chat-key-add') +const aiChatKeyDelete = document.getElementById('ai-chat-key-delete') const githubPrToggle = document.getElementById('github-pr-toggle') const githubPrToggleLabel = document.getElementById('github-pr-toggle-label') const githubPrToggleIcon = document.getElementById('github-pr-toggle-icon') @@ -523,7 +527,6 @@ let chatDrawerController = { setOpen: () => {}, setSelectedRepository: () => {}, onActiveWorkspaceTabChange: () => {}, - setToken: () => {}, dispose: () => {}, } @@ -552,12 +555,8 @@ const prContextUi = createGitHubPrContextUiController({ stylesPrSyncIcon, stylesPrSyncIconPath, githubPrContextClose, - aiChatToggle, githubPrOpenIcon, githubPrPushCommitIcon, - closeChatDrawer: () => { - chatDrawerController.setOpen(false) - }, closePrDrawer: () => { prDrawerController.setOpen(false) }, @@ -634,8 +633,7 @@ const byotControls = createGitHubByotControls({ onTokenChange: token => { githubAiContextState.token = token workspaceContextStatusController.syncTokenState(token) - prContextUi.syncAiChatTokenVisibility(token) - chatDrawerController.setToken(token) + prContextUi.syncPrSurfaceVisibility(token) prDrawerController.setToken(token) editedIndicatorVisibilityController.refreshIndicators() }, @@ -1172,7 +1170,7 @@ const onPrContextStateChange = createPrContextStateChangeHandler({ editedIndicatorVisibilityController, }) -const githubChatWorkspaceActions = createChatWorkspaceActions({ +const chatWorkspaceActions = createChatWorkspaceActions({ getActiveWorkspaceTab, isStyleWorkspaceTab, getCssSource: () => getCssSource(), @@ -1383,9 +1381,12 @@ const chatWorkflows = initializeChatWorkflows({ aiChatStatus, aiChatRepository, aiChatMessages, - getToken: getCurrentGitHubToken, + aiChatKey, + aiChatKeyInput, + aiChatKeyAdd, + aiChatKeyDelete, getSelectedRepository: getCurrentSelectedRepository, - ...githubChatWorkspaceActions, + ...chatWorkspaceActions, getRenderMode: () => renderMode.value, getStyleMode: () => styleMode.value, getPersistedActivePrContext, diff --git a/src/index.html b/src/index.html index 8e08ae1..a79ebd0 100644 --- a/src/index.html +++ b/src/index.html @@ -176,30 +176,6 @@

Close - -
@@ -224,6 +200,29 @@

Workspaces + +

+
+
+ + + + +
+

+ Chat needs an OpenRouter API key, including for free models. Free models cost + nothing but allow 50 requests per day without purchased credits. Your key is + stored only in this browser. + Create a key +

+
+
@@ -712,6 +764,10 @@

class="ai-chat-prompt" id="ai-chat-prompt" rows="4" + autocomplete="off" + autocapitalize="sentences" + autocorrect="on" + spellcheck="true" placeholder="Ask for help developing your component and styles" > diff --git a/src/modules/app-core/app-bindings-startup.js b/src/modules/app-core/app-bindings-startup.js index 7eacb25..7e2c75c 100644 --- a/src/modules/app-core/app-bindings-startup.js +++ b/src/modules/app-core/app-bindings-startup.js @@ -488,7 +488,7 @@ const bindAppEventsAndStart = ({ updateRenderModeEditability() compactAiControlsUi.setOpen(false) githubTokenInfoUi.close() - prContextUi.syncAiChatTokenVisibility(githubAiContextState.token) + prContextUi.syncPrSurfaceVisibility(githubAiContextState.token) updateRenderButtonVisibility() setDiagnosticsDrawerOpen(false) diff --git a/src/modules/app-core/chat-workflows.js b/src/modules/app-core/chat-workflows.js index 29c92ae..4c06b37 100644 --- a/src/modules/app-core/chat-workflows.js +++ b/src/modules/app-core/chat-workflows.js @@ -12,7 +12,10 @@ const initializeChatWorkflows = ({ aiChatStatus, aiChatRepository, aiChatMessages, - getToken, + aiChatKey, + aiChatKeyInput, + aiChatKeyAdd, + aiChatKeyDelete, getSelectedRepository, getActiveWorkspaceTabContext, getWorkspaceTabContexts, @@ -34,7 +37,10 @@ const initializeChatWorkflows = ({ statusNode: aiChatStatus, repositoryNode: aiChatRepository, messagesNode: aiChatMessages, - getToken, + keyRoot: aiChatKey, + keyInput: aiChatKeyInput, + keyAddButton: aiChatKeyAdd, + keyDeleteButton: aiChatKeyDelete, getSelectedRepository, getActiveWorkspaceTabContext, getWorkspaceTabContexts, diff --git a/src/modules/app-core/github-pr-context-ui.js b/src/modules/app-core/github-pr-context-ui.js index fd5689e..1beb26e 100644 --- a/src/modules/app-core/github-pr-context-ui.js +++ b/src/modules/app-core/github-pr-context-ui.js @@ -10,10 +10,8 @@ export const createGitHubPrContextUiController = ({ stylesPrSyncIcon, stylesPrSyncIconPath, githubPrContextClose, - aiChatToggle, githubPrOpenIcon, githubPrPushCommitIcon, - closeChatDrawer, closePrDrawer, closeWorkspacesDrawer, }) => { @@ -99,14 +97,10 @@ export const createGitHubPrContextUiController = ({ syncEditorPrContextIndicators(true) } - const syncAiChatTokenVisibility = token => { + const syncPrSurfaceVisibility = token => { const hasToken = typeof token === 'string' && token.trim().length > 0 if (hasToken) { - if (aiChatToggle instanceof HTMLElement) { - aiChatToggle.hidden = false - } - if (githubPrToggle instanceof HTMLElement) { githubPrToggle.hidden = false } @@ -119,10 +113,6 @@ export const createGitHubPrContextUiController = ({ return } - if (aiChatToggle instanceof HTMLElement) { - aiChatToggle.hidden = true - } - aiChatToggle?.setAttribute('aria-expanded', 'false') contextState.activePrContext = null contextState.activePrEditorSyncKey = '' contextState.hasSyncedActivePrEditorContent = false @@ -133,7 +123,6 @@ export const createGitHubPrContextUiController = ({ } githubPrToggle?.setAttribute('aria-expanded', 'false') githubPrContextClose?.setAttribute('hidden', '') - closeChatDrawer?.() closePrDrawer?.() closeWorkspacesDrawer?.() } @@ -141,6 +130,6 @@ export const createGitHubPrContextUiController = ({ return { markActivePrEditorContentSynced, setActivePrContext, - syncAiChatTokenVisibility, + syncPrSurfaceVisibility, } } diff --git a/src/modules/app-core/github-workflows.js b/src/modules/app-core/github-workflows.js index dd21b5d..80d8871 100644 --- a/src/modules/app-core/github-workflows.js +++ b/src/modules/app-core/github-workflows.js @@ -336,7 +336,7 @@ const initializeGitHubWorkflows = ({ }, onActivePrContextChange: activeContext => { prContextUi.setActivePrContext(activeContext) - prContextUi.syncAiChatTokenVisibility(getTokenForVisibility()) + prContextUi.syncPrSurfaceVisibility(getTokenForVisibility()) if (typeof onPrContextStateChange === 'function') { onPrContextStateChange(activeContext) diff --git a/src/modules/chat/api/completions.js b/src/modules/chat/api/completions.js index 4d1eb21..2e63639 100644 --- a/src/modules/chat/api/completions.js +++ b/src/modules/chat/api/completions.js @@ -308,8 +308,8 @@ const streamChatCompletion = async ({ }) if (!response.ok) { - const { message, rateLimit } = await parseErrorResponse(response) - throw toApiError({ message, rateLimit }) + const { message, status, rateLimit } = await parseErrorResponse(response) + throw toApiError({ message, status, rateLimit }) } if (!response.body) { @@ -388,7 +388,7 @@ const streamChatCompletion = async ({ content: combined, toolCalls: streamingToolCalls, model: responseModel || model, - rateLimit: parseRateMetadata({ headers: response.headers, body: null }), + rateLimit: parseRateMetadata(), } } @@ -425,8 +425,8 @@ const requestChatCompletion = async ({ }) if (!response.ok) { - const { message, rateLimit } = await parseErrorResponse(response) - throw toApiError({ message, rateLimit }) + const { message, status, rateLimit } = await parseErrorResponse(response) + throw toApiError({ message, status, rateLimit }) } const body = await response.json() @@ -441,7 +441,7 @@ const requestChatCompletion = async ({ content, toolCalls, model: typeof body?.model === 'string' && body.model ? body.model : model, - rateLimit: parseRateMetadata({ headers: response.headers, body }), + rateLimit: parseRateMetadata(), } } diff --git a/src/modules/chat/api/constants.js b/src/modules/chat/api/constants.js index f12d9eb..f427018 100644 --- a/src/modules/chat/api/constants.js +++ b/src/modules/chat/api/constants.js @@ -1,23 +1,24 @@ -export const chatCompletionsUrl = 'https://models.github.ai/inference/chat/completions' +export const chatCompletionsUrl = 'https://openrouter.ai/api/v1/chat/completions' +export const openRouterKeysUrl = 'https://openrouter.ai/keys' -export const defaultChatModel = 'openai/gpt-4.1-mini' +/* The free router auto-selects a free model, so it survives free-slug churn. */ +export const defaultChatModel = 'openrouter/free' -/* Local model options avoid browser CORS failures when calling catalog endpoints directly. */ +/* + * Fallback catalog for when the live model list is unavailable. Every entry is + * tool-capable, since editor proposals depend on tool calling. + */ export const chatModelOptions = [ - 'openai/gpt-4.1-mini', - 'openai/gpt-4.1', - 'openai/gpt-4.1-nano', - 'openai/gpt-4o', - 'openai/gpt-4o-mini', - 'openai/gpt-5', - 'openai/gpt-5-chat', - 'openai/gpt-5-mini', - 'openai/gpt-5-nano', - 'cohere/cohere-command-r-plus-08-2024', - 'deepseek/deepseek-v3-0324', - 'meta/llama-4-maverick-17b-128e-instruct-fp8', - 'meta/llama-4-scout-17b-16e-instruct', - 'mistral-ai/ministral-3b', - 'mistral-ai/mistral-medium-2505', - 'mistral-ai/mistral-small-2503', + 'openrouter/free', + 'nvidia/nemotron-3-ultra-550b-a55b:free', + 'minimax/minimax-m3:free', + 'google/gemma-4-31b-it:free', + 'thinkingmachines/inkling:free', + 'openai/gpt-6-astra', + 'anthropic/claude-sonnet-5', + 'google/gemini-3.8-flash', + 'deepseek/deepseek-v4-flash-0731', ] + +export const isFreeChatModel = model => + typeof model === 'string' && (model === 'openrouter/free' || model.endsWith(':free')) diff --git a/src/modules/chat/api/request.js b/src/modules/chat/api/request.js index 28c4102..d3988a2 100644 --- a/src/modules/chat/api/request.js +++ b/src/modules/chat/api/request.js @@ -2,85 +2,45 @@ export const buildChatRequestHeaders = ({ token, stream }) => ({ Accept: stream ? 'text/event-stream' : 'application/json', Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', - 'X-GitHub-Api-Version': '2022-11-28', }) -const toFiniteNumber = value => { - if (value === null || value === undefined) { - return null - } - - if (typeof value === 'string' && value.trim().length === 0) { - return null - } +/* + * OpenRouter exposes only content-type and cf-ray to browser JavaScript, so rate-limit + * headers are unreadable cross-origin. Usage data must come from GET /api/v1/key instead. + */ +export const parseRateMetadata = () => ({ + remaining: null, + resetEpochSeconds: null, +}) - const numberValue = Number(value) - return Number.isFinite(numberValue) ? numberValue : null +export const toApiError = ({ message, status, rateLimit }) => { + const error = new Error(message) + error.status = typeof status === 'number' ? status : null + error.rateLimit = rateLimit + return error } -const parseRateMetadataFromHeaders = headers => { - if (!headers || typeof headers.get !== 'function') { - return { - remaining: null, - resetEpochSeconds: null, - } - } - - const remaining = - toFiniteNumber(headers.get('x-ratelimit-remaining')) ?? - toFiniteNumber(headers.get('ratelimit-remaining')) - - const resetEpochSeconds = - toFiniteNumber(headers.get('x-ratelimit-reset')) ?? - toFiniteNumber(headers.get('ratelimit-reset')) - - return { - remaining, - resetEpochSeconds, - } +const statusMessages = { + 401: 'OpenRouter rejected the API key. Re-enter it or create a new one.', + 402: 'OpenRouter credits exhausted. Add credits or switch to a free model.', + 404: 'That model is not available on OpenRouter. Choose a different model.', + 429: 'Rate limited by OpenRouter. Free models allow 50 requests per day without purchased credits.', } - -const parseRateMetadataFromBody = body => { +const extractErrorMessage = body => { if (!body || typeof body !== 'object') { - return { - remaining: null, - resetEpochSeconds: null, - } + return '' } - const rateLimit = body.rate_limit ?? body.rateLimit ?? null - - const remaining = - toFiniteNumber(rateLimit?.remaining) ?? toFiniteNumber(body.remaining) ?? null - - const resetEpochSeconds = - toFiniteNumber(rateLimit?.reset) ?? - toFiniteNumber(rateLimit?.reset_epoch_seconds) ?? - toFiniteNumber(rateLimit?.resetEpochSeconds) ?? - toFiniteNumber(body.reset) ?? - null - - return { - remaining, - resetEpochSeconds, + const nested = body.error + if (nested && typeof nested === 'object' && typeof nested.message === 'string') { + return nested.message.trim() } -} -const mergeRateMetadata = (primary, fallback) => ({ - remaining: primary.remaining ?? fallback.remaining ?? null, - resetEpochSeconds: primary.resetEpochSeconds ?? fallback.resetEpochSeconds ?? null, -}) - -export const parseRateMetadata = ({ headers, body }) => { - const fromHeaders = parseRateMetadataFromHeaders(headers) - const fromBody = parseRateMetadataFromBody(body) - return mergeRateMetadata(fromHeaders, fromBody) -} + if (typeof body.message === 'string') { + return body.message.trim() + } -export const toApiError = ({ message, rateLimit }) => { - const error = new Error(message) - error.rateLimit = rateLimit - return error + return '' } export const parseErrorResponse = async response => { @@ -92,13 +52,14 @@ export const parseErrorResponse = async response => { /* noop */ } - const message = - body && typeof body.message === 'string' && body.message.trim() - ? body.message - : `Chat API request failed with status ${response.status}` + const providerMessage = extractErrorMessage(body) + const statusMessage = statusMessages[response.status] + const fallbackMessage = + providerMessage || `Chat request failed with status ${response.status}` return { - message, - rateLimit: parseRateMetadata({ headers: response.headers, body }), + message: statusMessage ?? fallbackMessage, + status: response.status, + rateLimit: parseRateMetadata(), } } diff --git a/src/modules/chat/drawer.js b/src/modules/chat/drawer.js index 52e3f3d..d2b680a 100644 --- a/src/modules/chat/drawer.js +++ b/src/modules/chat/drawer.js @@ -6,6 +6,7 @@ import { } from './api/completions.js' import { formatModelAccessErrorMessage, + isCredentialError, isModelAccessError, isModelAccessStatusMessage, toChatText, @@ -13,12 +14,16 @@ import { toRepositoryLabel, toRepositoryUrl, } from './utils.js' +import { createChatKeyControls } from './key-controls.js' import { buildActiveTabEditorContext, normalizeWorkspaceTabContext, normalizeWorkspaceTabContexts, } from './active-tab-context.js' -import { buildOutboundMessages as buildPayloadMessages } from './payload.js' +import { + buildOutboundMessages as buildPayloadMessages, + shouldEnableEditorUpdateTools, +} from './payload.js' import { editorProposalTools, toMessageEditorProposals } from './proposals.js' import { resolveWorkspaceTabTarget } from './tab-target-resolver.js' import { createTabScopedUndoState } from './tab-scoped-undo-state.js' @@ -59,8 +64,11 @@ export const createChatDrawer = ({ statusNode, repositoryNode, messagesNode, + keyRoot, + keyInput, + keyAddButton, + keyDeleteButton, includeEditorsContextToggle, - getToken, getSelectedRepository, getWorkspaceTabContexts, applyWorkspaceTabContent, @@ -177,6 +185,32 @@ export const createChatDrawer = ({ modelSelect.disabled = isDisabled } + const keyControls = createChatKeyControls({ + root: keyRoot, + input: keyInput, + addButton: keyAddButton, + deleteButton: keyDeleteButton, + onKeyChange: nextKey => { + syncModelSelectionForKey(nextKey) + syncComposerAvailability() + }, + }) + + const getChatKey = () => keyControls.getKey() + const hasChatKey = () => keyControls.hasKey() + + const syncComposerAvailability = () => { + const keyPresent = hasChatKey() + + if (promptInput instanceof HTMLTextAreaElement) { + promptInput.disabled = !keyPresent + } + + if (sendButton instanceof HTMLButtonElement) { + sendButton.disabled = !keyPresent + } + } + const replaceModelOptions = ({ modelIds, selectedModel }) => { if (!(modelSelect instanceof HTMLSelectElement)) { return @@ -215,16 +249,16 @@ export const createChatDrawer = ({ }) } - const syncModelSelectionForToken = token => { - const hasToken = typeof token === 'string' && token.trim().length > 0 + const syncModelSelectionForKey = key => { + const keyPresent = typeof key === 'string' && key.trim().length > 0 - setModelSelectDisabled(!hasToken) + setModelSelectDisabled(!keyPresent) - if (!hasToken && modelSelect instanceof HTMLSelectElement) { + if (!keyPresent && modelSelect instanceof HTMLSelectElement) { modelSelect.value = defaultChatModel } - if (hasToken && isModelAccessStatusMessage(statusNode?.textContent)) { + if (keyPresent && isModelAccessStatusMessage(statusNode?.textContent)) { setChatStatus('Idle', 'neutral') } } @@ -512,6 +546,7 @@ export const createChatDrawer = ({ const resolveMessageProposals = message => { const proposals = toMessageEditorProposals(message, { fallbackTarget: getFallbackProposalTarget(), + allowMarkdownFallback: message?.allowApplyActions === true, }) const workspaceTabs = getWorkspaceTabs() const activeTabId = getActiveTabContext()?.id || '' @@ -547,6 +582,7 @@ export const createChatDrawer = ({ const proposals = toMessageEditorProposals(message, { fallbackTarget: getFallbackProposalTarget(), + allowMarkdownFallback: message?.allowApplyActions === true, }) const proposal = proposals[proposalOriginalIndex] if (!proposal) { @@ -683,21 +719,21 @@ export const createChatDrawer = ({ } const setPendingState = isPending => { + const composerEnabled = !isPending && hasChatKey() + if (sendButton instanceof HTMLButtonElement) { - sendButton.disabled = isPending + sendButton.disabled = !composerEnabled } if (promptInput instanceof HTMLTextAreaElement) { - promptInput.disabled = isPending + promptInput.disabled = !composerEnabled } if (modelSelect instanceof HTMLSelectElement) { if (isPending) { modelSelect.disabled = true } else { - const token = getToken?.() - const hasToken = typeof token === 'string' && token.trim().length > 0 - modelSelect.disabled = !hasToken + modelSelect.disabled = !hasChatKey() } } @@ -714,11 +750,21 @@ export const createChatDrawer = ({ const normalizedContent = typeof content === 'string' ? content : lastMessage.content const hasContent = typeof normalizedContent === 'string' && normalizedContent.trim().length > 0 + const hasActionableToolProposal = + !hasContent && + normalizedToolCalls.length > 0 && + resolveMessageProposals({ + role: 'assistant', + content: normalizedContent, + toolCalls: normalizedToolCalls, + }).length > 0 lastMessage.content = hasContent || normalizedToolCalls.length === 0 ? normalizedContent - : 'Proposed editor update is ready. Apply below.' + : hasActionableToolProposal + ? 'Proposed editor update is ready. Apply below.' + : '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.' lastMessage.toolCalls = normalizedToolCalls if (typeof model === 'string' && model.trim()) { @@ -742,13 +788,16 @@ export const createChatDrawer = ({ return } - const token = getToken?.() + const token = getChatKey() if (!token) { - setChatStatus('Add a GitHub token before starting chat.', 'error') + setChatStatus('Add an OpenRouter API key before starting chat.', 'error') return } const selectedModel = getSelectedModel() + const allowEditorUpdateTools = + includeEditorsContextToggle?.checked === true && + shouldEnableEditorUpdateTools(prompt) stopPendingRequest() const requestAbortController = new AbortController() @@ -756,18 +805,25 @@ export const createChatDrawer = ({ pendingAbortController = requestAbortController appendMessage({ role: 'user', content: prompt }) - appendMessage({ role: 'assistant', content: '', model: selectedModel }) + appendMessage({ + role: 'assistant', + content: '', + model: selectedModel, + allowApplyActions: allowEditorUpdateTools, + }) + if (promptInput instanceof HTMLTextAreaElement) { promptInput.value = '' } setPendingState(true) - setChatStatus('Streaming response from GitHub...', 'pending') + setChatStatus('Streaming response...', 'pending') const repositoryContext = collectRepositoryContext() const editorContext = collectEditorContext() const outboundMessages = buildRequestMessages({ repositoryContext, editorContext }) - const toolChoice = includeEditorsContextToggle?.checked ? 'auto' : 'none' + const toolChoice = allowEditorUpdateTools ? 'auto' : 'none' + const tools = allowEditorUpdateTools ? editorProposalTools : [] let streamedContent = '' let streamSucceeded = false @@ -777,7 +833,7 @@ export const createChatDrawer = ({ token, messages: outboundMessages, model: selectedModel, - tools: editorProposalTools, + tools, toolChoice, signal: requestSignal, onToken: tokenChunk => { @@ -794,7 +850,7 @@ export const createChatDrawer = ({ toolCalls: streamResult?.toolCalls, model: streamedModel, }) - setChatStatus('Response streamed from GitHub.', 'ok') + setChatStatus('Response streamed.', 'ok') } catch (streamError) { if (requestSignal.aborted) { if (pendingAbortController === requestAbortController) { @@ -823,6 +879,25 @@ export const createChatDrawer = ({ return } + if (isCredentialError(streamError)) { + const credentialMessage = + streamError instanceof Error ? streamError.message : 'Chat request failed.' + + updateLastAssistantMessage(credentialMessage) + const lastMessage = messages[messages.length - 1] + if (lastMessage) { + lastMessage.level = 'error' + } + renderMessages() + setChatStatus(credentialMessage, 'error') + + if (pendingAbortController === requestAbortController) { + pendingAbortController = null + setPendingState(false) + } + return + } + setChatStatus( 'Streaming unavailable. Retrying with fallback response...', 'pending', @@ -842,7 +917,7 @@ export const createChatDrawer = ({ token, messages: outboundMessages, model: selectedModel, - tools: editorProposalTools, + tools, toolChoice, signal: requestSignal, }) @@ -892,7 +967,8 @@ export const createChatDrawer = ({ toggleButton?.setAttribute('aria-expanded', 'false') drawer?.setAttribute('hidden', '') initializeModelOptions() - syncModelSelectionForToken(getToken?.()) + syncModelSelectionForKey(getChatKey()) + syncComposerAvailability() syncRepositoryLabel() ensureUndoActionsNode() renderMessages() @@ -1016,15 +1092,13 @@ export const createChatDrawer = ({ onActiveWorkspaceTabChange: () => { renderMessages() }, - setToken: token => { - syncModelSelectionForToken(token) - }, dispose: () => { stopPendingRequest() setPendingState(false) cancelPendingAssistantBodyUpdate() pendingAssistantBodyText = null resetChatContextState() + keyControls.dispose() if (undoActionsNode) { undoActionsNode.remove() undoActionsNode = null diff --git a/src/modules/chat/key-controls.js b/src/modules/chat/key-controls.js new file mode 100644 index 0000000..d1b6b89 --- /dev/null +++ b/src/modules/chat/key-controls.js @@ -0,0 +1,98 @@ +import { + clearOpenRouterKey, + loadOpenRouterKey, + maskOpenRouterKey, + saveOpenRouterKey, +} from './key-store.js' + +export const createChatKeyControls = ({ + root, + input, + addButton, + deleteButton, + onKeyChange, +}) => { + let savedKey = loadOpenRouterKey() + + const hasKey = () => typeof savedKey === 'string' && savedKey.trim().length > 0 + + const syncFieldState = () => { + const keyPresent = hasKey() + + if (root instanceof HTMLElement) { + root.dataset.keyState = keyPresent ? 'present' : 'missing' + } + + if (input instanceof HTMLInputElement) { + input.value = keyPresent ? maskOpenRouterKey(savedKey) : '' + input.readOnly = keyPresent + input.disabled = keyPresent + input.dataset.keyState = keyPresent ? 'locked' : 'editable' + } + + if (addButton instanceof HTMLButtonElement) { + addButton.hidden = keyPresent + } + + if (deleteButton instanceof HTMLButtonElement) { + deleteButton.hidden = !keyPresent + } + } + + const emitKeyChange = () => { + if (typeof onKeyChange === 'function') { + onKeyChange(savedKey) + } + } + + const handleAdd = () => { + if (!(input instanceof HTMLInputElement)) { + return + } + + const nextKey = input.value.trim() + if (!nextKey) { + return + } + + if (!saveOpenRouterKey(nextKey)) { + return + } + + savedKey = nextKey + syncFieldState() + emitKeyChange() + } + + const handleDelete = () => { + clearOpenRouterKey() + savedKey = null + syncFieldState() + emitKeyChange() + } + + const handleKeydown = event => { + if (event.key !== 'Enter') { + return + } + + event.preventDefault() + handleAdd() + } + + addButton?.addEventListener('click', handleAdd) + deleteButton?.addEventListener('click', handleDelete) + input?.addEventListener('keydown', handleKeydown) + + syncFieldState() + + return { + getKey: () => savedKey, + hasKey, + dispose: () => { + addButton?.removeEventListener('click', handleAdd) + deleteButton?.removeEventListener('click', handleDelete) + input?.removeEventListener('keydown', handleKeydown) + }, + } +} diff --git a/src/modules/chat/key-store.js b/src/modules/chat/key-store.js new file mode 100644 index 0000000..555a69a --- /dev/null +++ b/src/modules/chat/key-store.js @@ -0,0 +1,60 @@ +const openRouterKeyStorageKey = 'knighted:develop:openrouter-key' + +const safelyGetItem = key => { + try { + return localStorage.getItem(key) + } catch { + return null + } +} + +const safelySetItem = (key, value) => { + try { + localStorage.setItem(key, value) + return true + } catch { + return false + } +} + +const safelyRemoveItem = key => { + try { + localStorage.removeItem(key) + } catch { + /* noop */ + } +} + +export const loadOpenRouterKey = () => safelyGetItem(openRouterKeyStorageKey) + +export const saveOpenRouterKey = key => { + if (typeof key !== 'string') { + return false + } + + const normalizedKey = key.trim() + if (!normalizedKey) { + return false + } + + return safelySetItem(openRouterKeyStorageKey, normalizedKey) +} + +export const clearOpenRouterKey = () => { + safelyRemoveItem(openRouterKeyStorageKey) +} + +export const maskOpenRouterKey = key => { + if (typeof key !== 'string') { + return '' + } + + const normalizedKey = key.trim() + if (normalizedKey.length <= 8) { + return '*'.repeat(Math.max(0, normalizedKey.length)) + } + + return `${normalizedKey.slice(0, 4)}${'*'.repeat( + normalizedKey.length - 8, + )}${normalizedKey.slice(-4)}` +} diff --git a/src/modules/chat/payload.js b/src/modules/chat/payload.js index 29a0c2b..77dbb09 100644 --- a/src/modules/chat/payload.js +++ b/src/modules/chat/payload.js @@ -6,10 +6,22 @@ const chatMaxConversationMessages = 14 const systemPromptMessage = [ 'You are an expert software development assistant focused on CSS dialects and JSX syntax across React and native DOM APIs.', 'Prioritize practical, safe, and minimal changes that fit the current project architecture.', - 'When proposing concrete editor edits, prefer tool calls so the user can explicitly review and apply changes.', + 'Only use propose_editor_update tool calls when the user explicitly asks you to modify code (for example: apply, update, edit, patch, rewrite, refactor, or fix).', + 'For read-only questions (for example: can you see, summarize, explain, review, or what changed), respond with text and do not emit propose_editor_update tool calls.', 'Do not assume framework migrations unless the user asks.', ].join(' ') +const explicitEditorUpdateIntentPatterns = [ + /\b(apply|update|edit|modify|change|rewrite|refactor|patch|fix|replace|insert|remove|delete|rename)\b/i, + /\b(make|prepare|propose|generate)\b.{0,40}\b(update|updates|edit|edits|patch|patches|change|changes)\b/i, +] + +const readOnlyPromptPatterns = [ + /\b(can you|could you|please)\s+(see|summarize|explain|review|inspect|analyze)\b/i, + /\bwhat\s+(do you|did you)\s+(see|find|notice|think)\b/i, + /\bcan\s+you\s+still\s+see\b/i, +] + const toUtf8ByteLength = value => { const text = typeof value === 'string' ? value : '' return new TextEncoder().encode(text).length @@ -143,6 +155,19 @@ const collectConversation = messages => { .filter(message => Boolean(message.content)) } +export const shouldEnableEditorUpdateTools = prompt => { + const promptText = toChatText(prompt) + if (!promptText) { + return false + } + + if (readOnlyPromptPatterns.some(pattern => pattern.test(promptText))) { + return false + } + + return explicitEditorUpdateIntentPatterns.some(pattern => pattern.test(promptText)) +} + export const buildOutboundMessages = ({ messages, repositoryContext, diff --git a/src/modules/chat/proposals.js b/src/modules/chat/proposals.js index a6028d8..f149d7f 100644 --- a/src/modules/chat/proposals.js +++ b/src/modules/chat/proposals.js @@ -128,13 +128,20 @@ const extractEditorProposalsFromMarkdown = ({ content, fallbackTarget }) => { ] } -export const toMessageEditorProposals = (message, { fallbackTarget = '' } = {}) => { +export const toMessageEditorProposals = ( + message, + { fallbackTarget = '', allowMarkdownFallback = true } = {}, +) => { const fromToolCalls = extractEditorProposalsFromToolCalls(message?.toolCalls) if (fromToolCalls.length > 0) { return fromToolCalls } + if (!allowMarkdownFallback) { + return [] + } + return extractEditorProposalsFromMarkdown({ content: message?.content, fallbackTarget, diff --git a/src/modules/chat/utils.js b/src/modules/chat/utils.js index 9ee64a3..1816d4d 100644 --- a/src/modules/chat/utils.js +++ b/src/modules/chat/utils.js @@ -18,21 +18,34 @@ export const toModelId = value => { } export const isModelAccessError = error => { + if (error?.status === 404) { + return true + } + const message = error instanceof Error ? error.message.toLowerCase() : '' if (!message) { return false } + /* OpenRouter reports an unknown slug as 400 "... is not a valid model ID". */ + if (error?.status === 400 && message.includes('model')) { + return true + } + return ( (message.includes('model') && message.includes('access')) || (message.includes('model') && message.includes('permission')) || (message.includes('model') && message.includes('not available')) || (message.includes('model') && message.includes('not found')) || (message.includes('model') && message.includes('not enabled')) || + (message.includes('model') && message.includes('not a valid')) || (message.includes('forbidden') && message.includes('model')) ) } +/* 401 means the key itself is bad, so retrying a non-stream request cannot help. */ +export const isCredentialError = error => error?.status === 401 + export const formatModelAccessErrorMessage = selectedModel => { const model = toModelId(selectedModel) return `Selected model "${model}" is not available for this key. Choose a different model.` diff --git a/src/styles/ai-controls.css b/src/styles/ai-controls.css index 47e3555..e7f98ee 100644 --- a/src/styles/ai-controls.css +++ b/src/styles/ai-controls.css @@ -444,11 +444,63 @@ backdrop-filter: blur(8px); overflow: hidden; display: grid; - grid-template-rows: auto auto minmax(120px, 1fr) auto auto auto; + grid-template-rows: auto auto auto minmax(120px, 1fr) auto auto auto; gap: 10px; z-index: 95; } +.ai-chat-key { + display: grid; + gap: 6px; +} + +.ai-chat-key[data-key-state='present'] .ai-chat-key__hint { + display: none; +} + +.ai-chat-key__field { + display: flex; + align-items: center; + gap: 6px; +} + +.ai-chat-key__input { + flex: 1; + min-width: 0; + padding: 6px 8px; + border: 1px solid var(--border-subtle); + border-radius: 8px; + background: color-mix(in srgb, var(--surface-panel) 70%, transparent); + color: var(--panel-text); + font: inherit; + font-size: 12px; +} + +.ai-chat-key__input:disabled { + opacity: 0.75; +} + +.ai-chat-key__input:focus-visible { + outline: 2px solid var(--focus-ring); + outline-offset: 1px; +} + +.ai-chat-key__action[hidden] { + display: none; +} + +.ai-chat-key__hint { + margin: 0; + font-size: 11px; + line-height: 1.45; + color: color-mix(in srgb, var(--panel-text) 72%, transparent); +} + +.ai-chat-key__hint a { + color: inherit; + text-decoration: underline; +} + .ai-chat-drawer.ai-chat-drawer--right { right: 24px; left: auto; @@ -1086,6 +1138,24 @@ border: 0; } + .diagnostics-toggle.ai-chat-toggle { + gap: 0; + margin-left: 0; + padding-inline: 10px; + } + + .diagnostics-toggle.ai-chat-toggle > span { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } + .app-grid-ai-controls { position: absolute; top: calc(100% + 10px); From 4df3d04e0be28de7f6c4672b444de5c608fe287a Mon Sep 17 00:00:00 2001 From: KCM Date: Sun, 6 Sep 2026 16:54:37 -0500 Subject: [PATCH 2/2] refactor: fix failing spec, address comments. --- playwright/github-byot-ai.spec.ts | 5 +++-- src/modules/chat/drawer.js | 23 +++++++++++++++++++++++ src/modules/chat/utils.js | 2 +- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/playwright/github-byot-ai.spec.ts b/playwright/github-byot-ai.spec.ts index 99b4c18..b491630 100644 --- a/playwright/github-byot-ai.spec.ts +++ b/playwright/github-byot-ai.spec.ts @@ -719,7 +719,7 @@ test('chat stays usable after opening a Local workspace with PAT connected', asy }, ]) - await connectByotWithSingleRepo(page) + await connectByotWithSingleRepo(page, { assertPrRepositorySelected: false }) await openStoredWorkspaceContextById(page, localWorkspaceId, { repositoryFilter: '__local__', }) @@ -1093,7 +1093,8 @@ test('AI chat can disable editor context payload via checkbox', async ({ page }) ) expect(streamRequestBody?.metadata).toBeUndefined() - expect(streamRequestBody?.tool_choice).toBe('none') + expect(streamRequestBody?.tool_choice).toBeUndefined() + expect(streamRequestBody?.tools).toBeUndefined() const systemMessages = streamRequestBody?.messages?.filter( (message: ChatRequestMessage) => message.role === 'system', ) diff --git a/src/modules/chat/drawer.js b/src/modules/chat/drawer.js index d2b680a..89383e1 100644 --- a/src/modules/chat/drawer.js +++ b/src/modules/chat/drawer.js @@ -898,6 +898,29 @@ export const createChatDrawer = ({ return } + const streamStatus = streamError?.status + if (typeof streamStatus === 'number' && streamStatus >= 400 && streamStatus < 500) { + const streamMessage = + streamError instanceof Error ? streamError.message : 'Chat request failed.' + + updateLastAssistantMessage(streamMessage) + const lastMessage = messages[messages.length - 1] + + if (lastMessage) { + lastMessage.level = 'error' + } + + renderMessages() + setChatStatus(streamMessage, 'error') + + if (pendingAbortController === requestAbortController) { + pendingAbortController = null + setPendingState(false) + } + + return + } + setChatStatus( 'Streaming unavailable. Retrying with fallback response...', 'pending', diff --git a/src/modules/chat/utils.js b/src/modules/chat/utils.js index 1816d4d..60c8a6a 100644 --- a/src/modules/chat/utils.js +++ b/src/modules/chat/utils.js @@ -28,7 +28,7 @@ export const isModelAccessError = error => { } /* OpenRouter reports an unknown slug as 400 "... is not a valid model ID". */ - if (error?.status === 400 && message.includes('model')) { + if (error?.status === 400 && message.includes('not a valid model')) { return true }