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 72ca275..e9eba63 100644 --- a/docs/ai-chat-context-and-payload-strategy.md +++ b/docs/ai-chat-context-and-payload-strategy.md @@ -16,7 +16,7 @@ Each request includes a system prompt with policy guidance, then augments that p Primary implementation: -- src/modules/github/chat/payload.js +- src/modules/chat/payload.js ### 2. Repository context @@ -29,7 +29,7 @@ Each request includes repository targeting context as a dedicated system message Primary implementation: -- src/modules/github/chat/drawer.js +- src/modules/chat/drawer.js ### 3. Editor context (Send tab content) @@ -44,8 +44,8 @@ This context is designed to support dynamic proposal targeting by tab id/path an Primary implementation: -- src/modules/github/chat/active-tab-context.js -- src/modules/github/chat/drawer.js +- src/modules/chat/active-tab-context.js +- src/modules/chat/drawer.js ### 4. Tooling model @@ -62,9 +62,9 @@ Contract: Primary implementation: -- src/modules/github/chat/proposals.js -- src/modules/github/chat/tab-target-resolver.js -- src/modules/github/chat/drawer.js +- src/modules/chat/proposals.js +- src/modules/chat/tab-target-resolver.js +- src/modules/chat/drawer.js ### 5. Apply and undo behavior @@ -74,8 +74,8 @@ Primary implementation: Primary implementation: -- src/modules/github/chat/drawer.js -- src/modules/github/chat/tab-scoped-undo-state.js +- src/modules/chat/drawer.js +- src/modules/chat/tab-scoped-undo-state.js ### 6. Payload size controls and summary strategy @@ -88,7 +88,7 @@ The payload builder includes bounded-conversation controls: Primary implementation: -- src/modules/github/chat/payload.js +- src/modules/chat/payload.js ### 7. Fallback and transport behavior @@ -98,8 +98,8 @@ Primary implementation: Primary implementation: -- src/modules/github/chat/drawer.js -- src/modules/github/api/chat.js +- src/modules/chat/drawer.js +- src/modules/chat/api/completions.js ## Why this approach @@ -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/next-steps.md b/docs/next-steps.md index 36e6edc..6b616fd 100644 --- a/docs/next-steps.md +++ b/docs/next-steps.md @@ -26,10 +26,38 @@ Focused follow-up work for `@knighted/develop`. - Suggested implementation prompt: - "Evaluate and optionally optimize @knighted/develop GitHub file upsert behavior. Compare metadata-first preflight GET+PUT against optimistic PUT with retry-on-missing-sha for existing files. Keep current reliability guarantees and avoid reintroducing noisy false-positive failures. If implementing a hybrid/configurable strategy, keep defaults conservative, update docs, and validate with npm run lint plus targeted Playwright PR drawer flows." -5. **Promise handling conventions (consistency of intent)** - - Define a project default: use `async`/`await` with `try`/`catch` for most async control flow. - - Keep Promise chains where they better express intent (for example, fire-and-forget paths with explicit `.catch()` to avoid unhandled rejections, or concise pass-through composition). - - Document this as an intent-first rule so mixed syntax is acceptable only when deliberate and easy to reason about. - - Add a lightweight lint/review rule to flag mixed async styles in the same flow unless there is a clear justification. +5. **Document async handling conventions (consistency of intent)** + - The codebase already uses `async`/`await` for most multi-step async control flow. + - Keep Promise chains where they better express intent, such as concurrent composition, + concise pass-through composition, or fire-and-forget paths with explicit `.catch()` to + avoid unhandled rejections. + - Document this intent-first convention for future changes and code review. Do not pursue + a broad refactor or add a rigid lint rule solely to make syntax uniform. - Suggested implementation prompt: - - "Define and apply async handling conventions in @knighted/develop with consistency of intent: default to async/await + try/catch, allow Promise chains for explicit fire-and-forget and concise composition, and require explicit .catch on unawaited promises. Update docs and enforce via lint/review guidance without broad no-op refactors. Validate with npm run lint and targeted Playwright runs." + - "Document the existing async handling convention in @knighted/develop: prefer + async/await for multi-step control flow, allow Promise chains for deliberate + concurrency or fire-and-forget work, and require explicit rejection handling for + unawaited promises. Make only targeted cleanup changes where intent is unclear." + +6. **Render model Markdown responses as safe HTML** + - Evaluate rendering assistant Markdown as formatted HTML instead of displaying the + response as plain text, including fenced code blocks, inline code, links, lists, and + other common response structures. + - Compare small browser-compatible Markdown parsers that work with the CDN-first + runtime, such as `marked` or `markdown-it`, and load the chosen dependency lazily + through the existing CDN provider and fallback mechanism. + - Treat model output as untrusted input. Pair Markdown rendering with an explicit HTML + sanitization policy, such as DOMPurify or an equivalent sanitizer, and restrict link + protocols and external navigation behavior. + - Preserve the raw Markdown response for proposal extraction, streaming updates, and + conversation state; rendered HTML should be a presentation layer only. + - Define behavior for incomplete streamed Markdown, unsupported syntax, rendering + failures, and environments where the CDN dependency cannot be loaded. Plain-text + rendering should remain a usable fallback. + - Suggested implementation prompt: + - "Add safe Markdown rendering for @knighted/develop AI chat responses. Evaluate a + small CDN-compatible parser such as marked or markdown-it plus an HTML sanitizer, + load both lazily through the existing CDN fallback system, preserve raw Markdown + for proposal extraction and chat state, and keep plain-text rendering as the + failure fallback. Handle streamed/incomplete Markdown, safe links, code blocks, + and XSS cases. Validate with npm run lint and focused Playwright chat coverage." 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/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 4dd64ee..9c8e18d 100644 --- a/playwright/github-byot-ai.spec.ts +++ b/playwright/github-byot-ai.spec.ts @@ -1,26 +1,20 @@ import { expect, test } from '@playwright/test' -import { defaultGitHubChatModel } from '../src/modules/github/api/chat.js' -import type { ChatRequestBody, ChatRequestMessage } from './helpers/app-test-helpers.js' import { appEntryPath, connectByotWithSingleRepo, ensureWorkspacesDrawerClosed, - ensureAiChatDrawerOpen, 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' -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 +33,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() @@ -296,15 +290,6 @@ test('PAT connect after Local-only session preserves Local records and enables r ).toBe('local') }) -test('chat becomes available after token connect', async ({ page }) => { - await waitForAppReady(page) - await connectByotWithSingleRepo(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() -}) - test('workspace context status stays visible without PAT and after PAT connect', async ({ page, }) => { @@ -604,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://models.github.ai/inference/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) - await openStoredWorkspaceContextById(page, localWorkspaceId, { - repositoryFilter: '__local__', - }) - await ensureWorkspacesDrawerClosed(page) - - await ensureAiChatDrawerOpen(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) @@ -694,7 +606,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,801 +726,6 @@ 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 => { - 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 ensureAiChatDrawerOpen(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('Summarize this repository.')).toBeVisible() - await expect(page.getByText('Streaming response ready')).toBeVisible() - - expect(streamRequestBody?.metadata).toBeUndefined() - expect(streamRequestBody?.model).toBe(defaultGitHubChatModel) - expect(streamRequestBody?.tool_choice).toBe('auto') - expect( - streamRequestBody?.tools?.some( - tool => tool.type === 'function' && tool.function?.name === 'propose_editor_update', - ), - ).toBe(true) - 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 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 => { - 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 ensureAiChatDrawerOpen(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 from GitHub.', { exact: true }), - ).toHaveText('Response streamed from GitHub.') - - expect(streamRequestBody?.metadata).toBeUndefined() - expect(streamRequestBody?.tool_choice).toBe('none') - 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://models.github.ai/inference/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 ensureAiChatDrawerOpen(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://models.github.ai/inference/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 ensureAiChatDrawerOpen(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://models.github.ai/inference/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 ensureAiChatDrawerOpen(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://models.github.ai/inference/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 ensureAiChatDrawerOpen(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 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 => { - 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 ensureAiChatDrawerOpen(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.') - - 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://models.github.ai/inference/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 ensureAiChatDrawerOpen(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://models.github.ai/inference/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 ensureAiChatDrawerOpen(page) - - const selectedModel = 'openai/gpt-5-mini' - 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://models.github.ai/inference/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 ensureAiChatDrawerOpen(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 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() - - 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 ef2772a..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) => { @@ -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') { @@ -554,6 +568,7 @@ export const connectByotWithSingleRepo = async ( const workspacesRepositoryFilter = page.getByLabel('Workspace repository filter') await expect(workspacesRepositoryFilter).toBeVisible() + await expect(workspacesRepositoryFilter).toBeEnabled() await workspacesRepositoryFilter.selectOption('knightedcodemonkey/develop') await expect(workspacesRepositoryFilter).toHaveValue('knightedcodemonkey/develop') @@ -562,21 +577,42 @@ export const connectByotWithSingleRepo = async ( name: 'Initialize', exact: true, }) + const storedWorkspace = page.getByLabel('Stored workspace') + + await expect + .poll(async () => { + if (await initializeButton.isVisible()) { + return 'initialize' + } - if (await initializeButton.isVisible()) { + if (await storedWorkspace.isVisible()) { + const workspaceValue = await storedWorkspace + .locator('option:not([value=""])') + .first() + .getAttribute('value') + + if (workspaceValue) { + return 'stored' + } + } + + return '' + }) + .not.toBe('') + + const autoOpenMode = (await initializeButton.isVisible()) ? 'initialize' : 'stored' + + if (autoOpenMode === 'initialize') { await initializeButton.click() } else { - const storedWorkspace = page.getByLabel('Stored workspace') - if (await storedWorkspace.isVisible()) { - const workspaceValue = await storedWorkspace - .locator('option:not([value=""])') - .first() - .getAttribute('value') - - if (workspaceValue) { - await storedWorkspace.selectOption(workspaceValue) - await page.getByRole('button', { name: 'Open', exact: true }).click() - } + const workspaceValue = await storedWorkspace + .locator('option:not([value=""])') + .first() + .getAttribute('value') + + if (workspaceValue) { + await storedWorkspace.selectOption(workspaceValue) + await page.getByRole('button', { name: 'Open', exact: true }).click() } } } diff --git a/src/app.js b/src/app.js index 9ba30b4..c9fb9c5 100644 --- a/src/app.js +++ b/src/app.js @@ -51,10 +51,10 @@ import { persistClosedPrContextRecords } from './modules/app-core/pr-context-rec import { createPrContextStateChangeHandler } from './modules/app-core/pr-context-state-change-handler.js' import { createWorkspaceContextStatusController } from './modules/app-core/workspace-context-status-controller.js' import { createWorkspaceRecordAppliedHandler } from './modules/app-core/workspace-record-applied-handler.js' -import { createGitHubChatWorkspaceActions } from './modules/app-core/github-chat-workspace-actions.js' +import { createChatWorkspaceActions } from './modules/chat/workspace-actions.js' import { createShareCurrentLocalWorkspace } from './modules/app-core/workspace-share-action.js' import { createDiagnosticsUiController } from './modules/diagnostics/diagnostics-ui.js' -import { createGitHubChatDrawer } from './modules/github/chat/drawer.js' +import { initializeChatWorkflows } from './modules/app-core/chat-workflows.js' import { createGitHubByotControls } from './modules/github/byot-controls.js' import { formatActivePrReference, @@ -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 = createGitHubChatWorkspaceActions({ +const chatWorkspaceActions = createChatWorkspaceActions({ getActiveWorkspaceTab, isStyleWorkspaceTab, getCssSource: () => getCssSource(), @@ -1187,7 +1185,6 @@ const githubChatWorkspaceActions = createGitHubChatWorkspaceActions({ const githubWorkflows = createGitHubWorkflowsSetup({ factories: { createGitHubPrEditorSyncController, - createGitHubChatDrawer, createGitHubPrDrawer, createWorkspacesDrawer, }, @@ -1210,17 +1207,6 @@ const githubWorkflows = createGitHubWorkflowsSetup({ byotControls.clearSelectedRepositoryPreference(), }, ui: { - aiChatToggle, - aiChatDrawer, - aiChatClose, - aiChatPrompt, - aiChatModel, - aiChatIncludeEditors, - aiChatSend, - aiChatClear, - aiChatStatus, - aiChatRepository, - aiChatMessages, githubPrToggle, githubPrDrawer, githubPrClose, @@ -1351,7 +1337,6 @@ const githubWorkflows = createGitHubWorkflowsSetup({ /* Save failures are already surfaced through saver onError. */ }) }, - getPersistedActivePrContext, getTokenForVisibility: () => githubAiContextState.token, getActivePrEditorSyncKey: () => githubAiContextState.activePrEditorSyncKey, syncFromActiveContext: ({ tabTargets }) => { @@ -1373,7 +1358,6 @@ const githubWorkflows = createGitHubWorkflowsSetup({ setStatus, showAppToast, shareCurrentLocalWorkspace, - ...githubChatWorkspaceActions, scheduleRender: () => { if ( autoRenderToggle?.checked && @@ -1385,7 +1369,38 @@ const githubWorkflows = createGitHubWorkflowsSetup({ }, }) -chatDrawerController = githubWorkflows.chatDrawerController +const chatWorkflows = initializeChatWorkflows({ + aiChatToggle, + aiChatDrawer, + aiChatClose, + aiChatPrompt, + aiChatModel, + aiChatIncludeEditors, + aiChatSend, + aiChatClear, + aiChatStatus, + aiChatRepository, + aiChatMessages, + aiChatKey, + aiChatKeyInput, + aiChatKeyAdd, + aiChatKeyDelete, + getSelectedRepository: getCurrentSelectedRepository, + ...chatWorkspaceActions, + getRenderMode: () => renderMode.value, + getStyleMode: () => styleMode.value, + getPersistedActivePrContext, + scheduleRender: () => { + if ( + autoRenderToggle?.checked && + typeof renderRuntime?.scheduleRender === 'function' + ) { + renderRuntime.scheduleRender() + } + }, +}) + +chatDrawerController = chatWorkflows.chatDrawerController prDrawerController = githubWorkflows.prDrawerController workspacesDrawerController = githubWorkflows.workspacesDrawerController diff --git a/src/index.html b/src/index.html index 8e08ae1..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 Close - -

@@ -224,6 +201,29 @@

Workspaces + +

+
+
@@ -712,6 +765,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 new file mode 100644 index 0000000..4c06b37 --- /dev/null +++ b/src/modules/app-core/chat-workflows.js @@ -0,0 +1,60 @@ +import { createChatDrawer } from '../chat/drawer.js' + +const initializeChatWorkflows = ({ + aiChatToggle, + aiChatDrawer, + aiChatClose, + aiChatPrompt, + aiChatModel, + aiChatIncludeEditors, + aiChatSend, + aiChatClear, + aiChatStatus, + aiChatRepository, + aiChatMessages, + aiChatKey, + aiChatKeyInput, + aiChatKeyAdd, + aiChatKeyDelete, + getSelectedRepository, + getActiveWorkspaceTabContext, + getWorkspaceTabContexts, + applyWorkspaceTabContent, + scheduleRender, + getRenderMode, + getStyleMode, + getPersistedActivePrContext, +}) => { + const chatDrawerController = createChatDrawer({ + toggleButton: aiChatToggle, + drawer: aiChatDrawer, + closeButton: aiChatClose, + promptInput: aiChatPrompt, + modelSelect: aiChatModel, + includeEditorsContextToggle: aiChatIncludeEditors, + sendButton: aiChatSend, + clearButton: aiChatClear, + statusNode: aiChatStatus, + repositoryNode: aiChatRepository, + messagesNode: aiChatMessages, + keyRoot: aiChatKey, + keyInput: aiChatKeyInput, + keyAddButton: aiChatKeyAdd, + keyDeleteButton: aiChatKeyDelete, + getSelectedRepository, + getActiveWorkspaceTabContext, + getWorkspaceTabContexts, + applyWorkspaceTabContent, + scheduleRender, + getRenderMode, + getStyleMode, + getDrawerSide: () => { + return 'right' + }, + getPersistedActivePrContext, + }) + + return { chatDrawerController } +} + +export { initializeChatWorkflows } 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-setup.js b/src/modules/app-core/github-workflows-setup.js index 2edadb2..b4c39a7 100644 --- a/src/modules/app-core/github-workflows-setup.js +++ b/src/modules/app-core/github-workflows-setup.js @@ -35,7 +35,6 @@ const createGitHubWorkflowsSetup = ({ getEditorSyncTargets: workspace.getEditorSyncTargets, getRenderMode: runtime.getRenderMode, getStyleMode: runtime.getStyleMode, - getPersistedActivePrContext: runtime.getPersistedActivePrContext, setCurrentSelectedRepository: byot.setCurrentSelectedRepository, clearCurrentSelectedRepository: byot.clearCurrentSelectedRepository, reconcileWorkspaceTabsWithPushUpdates: @@ -56,9 +55,6 @@ const createGitHubWorkflowsSetup = ({ setStatus: actions.setStatus, showAppToast: actions.showAppToast, shareCurrentLocalWorkspace: actions.shareCurrentLocalWorkspace, - getActiveWorkspaceTabContext: actions.getActiveWorkspaceTabContext, - getWorkspaceTabContexts: actions.getWorkspaceTabContexts, - applyWorkspaceTabContent: actions.applyWorkspaceTabContent, scheduleRender: actions.scheduleRender, applyWorkspaceFontCssUrl: workspace.applyWorkspaceFontCssUrl, }) diff --git a/src/modules/app-core/github-workflows.js b/src/modules/app-core/github-workflows.js index 482f158..80d8871 100644 --- a/src/modules/app-core/github-workflows.js +++ b/src/modules/app-core/github-workflows.js @@ -2,7 +2,6 @@ import { toWorkspaceRecordKey } from '../workspace/workspace-tab-helpers.js' const initializeGitHubWorkflows = ({ createGitHubPrEditorSyncController, - createGitHubChatDrawer, createGitHubPrDrawer, createWorkspacesDrawer, ensureJsxTransformSource, @@ -13,17 +12,6 @@ const initializeGitHubWorkflows = ({ byotControls, getCurrentGitHubToken, getCurrentSelectedRepository, - aiChatToggle, - aiChatDrawer, - aiChatClose, - aiChatPrompt, - aiChatModel, - aiChatIncludeEditors, - aiChatSend, - aiChatClear, - aiChatStatus, - aiChatRepository, - aiChatMessages, githubPrToggle, githubPrDrawer, githubPrClose, @@ -71,7 +59,6 @@ const initializeGitHubWorkflows = ({ getStyleMode, setCurrentSelectedRepository, clearCurrentSelectedRepository, - getPersistedActivePrContext, reconcileWorkspaceTabsWithPushUpdates, getActivePrContextSyncKey, prContextUi, @@ -89,9 +76,6 @@ const initializeGitHubWorkflows = ({ setStatus, showAppToast, shareCurrentLocalWorkspace, - getActiveWorkspaceTabContext, - getWorkspaceTabContexts, - applyWorkspaceTabContent, scheduleRender, }) => { const getCurrentWritableRepositories = () => @@ -192,32 +176,6 @@ const initializeGitHubWorkflows = ({ shouldApplySyncResult: shouldApplyActivePrEditorSync, }) - const chatDrawerController = createGitHubChatDrawer({ - toggleButton: aiChatToggle, - drawer: aiChatDrawer, - closeButton: aiChatClose, - promptInput: aiChatPrompt, - modelSelect: aiChatModel, - includeEditorsContextToggle: aiChatIncludeEditors, - sendButton: aiChatSend, - clearButton: aiChatClear, - statusNode: aiChatStatus, - repositoryNode: aiChatRepository, - messagesNode: aiChatMessages, - getToken: getCurrentGitHubToken, - getSelectedRepository: getCurrentSelectedRepository, - getActiveWorkspaceTabContext, - getWorkspaceTabContexts, - applyWorkspaceTabContent, - scheduleRender, - getRenderMode, - getStyleMode, - getDrawerSide: () => { - return 'right' - }, - getPersistedActivePrContext, - }) - const prDrawerController = createGitHubPrDrawer({ toggleButton: githubPrToggle, drawer: githubPrDrawer, @@ -378,7 +336,7 @@ const initializeGitHubWorkflows = ({ }, onActivePrContextChange: activeContext => { prContextUi.setActivePrContext(activeContext) - prContextUi.syncAiChatTokenVisibility(getTokenForVisibility()) + prContextUi.syncPrSurfaceVisibility(getTokenForVisibility()) if (typeof onPrContextStateChange === 'function') { onPrContextStateChange(activeContext) @@ -792,7 +750,6 @@ const initializeGitHubWorkflows = ({ }) return { - chatDrawerController, prDrawerController, workspacesDrawerController, } diff --git a/src/modules/github/chat/active-tab-context.js b/src/modules/chat/active-tab-context.js similarity index 100% rename from src/modules/github/chat/active-tab-context.js rename to src/modules/chat/active-tab-context.js diff --git a/src/modules/github/api/chat.js b/src/modules/chat/api/completions.js similarity index 89% rename from src/modules/github/api/chat.js rename to src/modules/chat/api/completions.js index c0037f5..6a8e3b9 100644 --- a/src/modules/github/api/chat.js +++ b/src/modules/chat/api/completions.js @@ -1,14 +1,10 @@ -import { - defaultGitHubChatModel, - githubChatModelOptions, - githubModelsApiUrl, -} from './constants.js' +import { chatCompletionsUrl, defaultChatModel } from './constants.js' import { buildChatRequestHeaders, parseErrorResponse, parseRateMetadata, toApiError, -} from './core.js' +} from './request.js' const normalizeChatMessage = message => { if (!message || typeof message !== 'object') { @@ -278,17 +274,17 @@ const parseSseDataLine = line => { } } -const streamGitHubChatCompletion = async ({ +const streamChatCompletion = async ({ token, messages, signal, onToken, - model = defaultGitHubChatModel, + model = defaultChatModel, tools, toolChoice, }) => { if (typeof token !== 'string' || token.trim().length === 0) { - throw new Error('A GitHub token is required to start a chat request.') + throw new Error('An API key is required to start a chat request.') } const normalizedMessages = normalizeChatMessages(messages) @@ -296,7 +292,7 @@ const streamGitHubChatCompletion = async ({ throw new Error('At least one message is required to start a chat request.') } - const response = await fetch(githubModelsApiUrl, { + const response = await fetch(chatCompletionsUrl, { method: 'POST', headers: buildChatRequestHeaders({ token, stream: true }), body: JSON.stringify( @@ -312,8 +308,8 @@ const streamGitHubChatCompletion = 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) { @@ -392,20 +388,20 @@ const streamGitHubChatCompletion = async ({ content: combined, toolCalls: streamingToolCalls, model: responseModel || model, - rateLimit: parseRateMetadata({ headers: response.headers, body: null }), + rateLimit: parseRateMetadata(), } } -const requestGitHubChatCompletion = async ({ +const requestChatCompletion = async ({ token, messages, signal, - model = defaultGitHubChatModel, + model = defaultChatModel, tools, toolChoice, }) => { if (typeof token !== 'string' || token.trim().length === 0) { - throw new Error('A GitHub token is required to start a chat request.') + throw new Error('An API key is required to start a chat request.') } const normalizedMessages = normalizeChatMessages(messages) @@ -413,7 +409,7 @@ const requestGitHubChatCompletion = async ({ throw new Error('At least one message is required to start a chat request.') } - const response = await fetch(githubModelsApiUrl, { + const response = await fetch(chatCompletionsUrl, { method: 'POST', headers: buildChatRequestHeaders({ token, stream: false }), body: JSON.stringify( @@ -429,8 +425,8 @@ const requestGitHubChatCompletion = 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() @@ -438,20 +434,15 @@ const requestGitHubChatCompletion = async ({ const toolCalls = extractChatCompletionToolCalls(body) if (!content && toolCalls.length === 0) { - throw new Error('GitHub chat response did not include assistant content.') + throw new Error('Chat response did not include assistant content.') } return { content, toolCalls, model: typeof body?.model === 'string' && body.model ? body.model : model, - rateLimit: parseRateMetadata({ headers: response.headers, body }), + rateLimit: parseRateMetadata(), } } -export { - defaultGitHubChatModel, - githubChatModelOptions, - requestGitHubChatCompletion, - streamGitHubChatCompletion, -} +export { defaultChatModel, requestChatCompletion, streamChatCompletion } diff --git a/src/modules/chat/api/constants.js b/src/modules/chat/api/constants.js new file mode 100644 index 0000000..63ba871 --- /dev/null +++ b/src/modules/chat/api/constants.js @@ -0,0 +1,25 @@ +export const chatCompletionsUrl = 'https://openrouter.ai/api/v1/chat/completions' +export const chatModelsUrl = 'https://openrouter.ai/api/v1/models' +export const openRouterKeysUrl = 'https://openrouter.ai/keys' + +/* The free router auto-selects a free model, so it survives free-slug churn. */ +export const defaultChatModel = 'openrouter/free' + +/* + * 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 = [ + '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/models.js b/src/modules/chat/api/models.js new file mode 100644 index 0000000..f976799 --- /dev/null +++ b/src/modules/chat/api/models.js @@ -0,0 +1,92 @@ +import { chatModelOptions, chatModelsUrl, defaultChatModel } from './constants.js' + +const toText = value => (typeof value === 'string' ? value.trim() : '') + +const supportsTools = model => { + const supportedParameters = Array.isArray(model?.supported_parameters) + ? model.supported_parameters + : [] + + return supportedParameters.some(parameter => + typeof parameter === 'string' ? parameter.toLowerCase() === 'tools' : false, + ) +} + +const isFreeModel = model => { + const pricing = model?.pricing + if (!pricing || typeof pricing !== 'object') { + return false + } + + return ( + (pricing.prompt === 0 || pricing.prompt === '0') && + (pricing.completion === 0 || pricing.completion === '0') + ) +} + +const sortModelEntries = entries => { + return [...entries].sort((left, right) => { + if (left.isFree !== right.isFree) { + return left.isFree ? -1 : 1 + } + + return left.id.localeCompare(right.id) + }) +} + +const normalizeModelOptions = models => { + const normalizedModels = Array.isArray(models) ? models : [] + const byModelId = new Map() + + for (const model of normalizedModels) { + const modelId = toText(model?.id) + if (!modelId || !supportsTools(model)) { + continue + } + + byModelId.set(modelId, { + id: modelId, + isFree: isFreeModel(model), + }) + } + + const sortedModelOptions = sortModelEntries(Array.from(byModelId.values())) + + if (sortedModelOptions.length === 0) { + return chatModelOptions.map(id => ({ + id, + isFree: id === 'openrouter/free' || id.endsWith(':free'), + })) + } + + return [ + { id: defaultChatModel, isFree: true }, + ...sortedModelOptions.filter(entry => entry.id !== defaultChatModel), + ] +} + +const buildCatalogRequestHeaders = token => { + const normalizedToken = toText(token) + if (!normalizedToken) { + return undefined + } + + return { + Authorization: `Bearer ${normalizedToken}`, + } +} + +export const fetchChatModelOptions = async ({ token, signal } = {}) => { + const response = await fetch(chatModelsUrl, { + method: 'GET', + headers: buildCatalogRequestHeaders(token), + signal, + }) + + if (!response.ok) { + throw new Error(`Model catalog request failed with status ${response.status}`) + } + + const body = await response.json() + return normalizeModelOptions(body?.data) +} diff --git a/src/modules/chat/api/request.js b/src/modules/chat/api/request.js new file mode 100644 index 0000000..d3988a2 --- /dev/null +++ b/src/modules/chat/api/request.js @@ -0,0 +1,65 @@ +export const buildChatRequestHeaders = ({ token, stream }) => ({ + Accept: stream ? 'text/event-stream' : 'application/json', + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', +}) + +/* + * 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, +}) + +export const toApiError = ({ message, status, rateLimit }) => { + const error = new Error(message) + error.status = typeof status === 'number' ? status : null + error.rateLimit = rateLimit + return error +} + +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 extractErrorMessage = body => { + if (!body || typeof body !== 'object') { + return '' + } + + const nested = body.error + if (nested && typeof nested === 'object' && typeof nested.message === 'string') { + return nested.message.trim() + } + + if (typeof body.message === 'string') { + return body.message.trim() + } + + return '' +} + +export const parseErrorResponse = async response => { + let body = null + + try { + body = await response.json() + } catch { + /* noop */ + } + + const providerMessage = extractErrorMessage(body) + const statusMessage = statusMessages[response.status] + const fallbackMessage = + providerMessage || `Chat request failed with status ${response.status}` + + return { + message: statusMessage ?? fallbackMessage, + status: response.status, + rateLimit: parseRateMetadata(), + } +} diff --git a/src/modules/chat/drawer-events.js b/src/modules/chat/drawer-events.js new file mode 100644 index 0000000..0e5584a --- /dev/null +++ b/src/modules/chat/drawer-events.js @@ -0,0 +1,130 @@ +export const createChatDrawerEvents = ({ + toggleButton, + closeButton, + clearButton, + drawer, + sendButton, + promptInput, + setOpen, + isOpen, + onClear, + onRequestRun, + setChatStatus, + renderMessages, + getMessagesLength, + getMessageAt, + undoActiveTabApply, + applyProposalToTab, +}) => { + const onToggleButtonClick = () => { + setOpen?.(!isOpen?.()) + } + + const onCloseButtonClick = () => { + setOpen?.(false) + } + + const onClearButtonClick = () => { + onClear?.() + } + + const onDrawerClick = event => { + const target = event.target + if (!(target instanceof HTMLElement)) { + return + } + + const button = target.closest('button[data-action]') + if (!(button instanceof HTMLButtonElement)) { + return + } + + const action = button.dataset.action + + if (action === 'undo-tab-apply') { + const undone = undoActiveTabApply?.() ?? false + if (!undone) { + setChatStatus?.('No tab apply action is available to undo.', 'error') + } + renderMessages?.() + return + } + + const messageIndex = Number(button.dataset.messageIndex) + const messagesLength = getMessagesLength?.() ?? 0 + + if ( + !Number.isFinite(messageIndex) || + messageIndex < 0 || + messageIndex >= messagesLength + ) { + return + } + + const message = getMessageAt?.(messageIndex) + if (!message || message.role !== 'assistant') { + return + } + + if (action === 'request-apply') { + const proposalOriginalIndex = Number(button.dataset.proposalOriginalIndex) + if (!Number.isFinite(proposalOriginalIndex) || proposalOriginalIndex < 0) { + return + } + + const applied = applyProposalToTab?.({ messageIndex, proposalOriginalIndex }) + + if (!applied) { + setChatStatus?.('Could not apply proposal to tab.', 'error') + } else { + message.appliedTargets = { + ...(message.appliedTargets && typeof message.appliedTargets === 'object' + ? message.appliedTargets + : {}), + [applied.appliedKey]: true, + } + } + + renderMessages?.() + } + } + + const onSendButtonClick = () => { + void onRequestRun?.() + } + + const onPromptInputKeydown = event => { + if (event.key !== 'Enter' || (!event.metaKey && !event.ctrlKey)) { + return + } + + event.preventDefault() + void onRequestRun?.() + } + + const onDocumentKeydown = event => { + if (event.key === 'Escape' && isOpen?.()) { + setOpen?.(false) + } + } + + toggleButton?.addEventListener('click', onToggleButtonClick) + closeButton?.addEventListener('click', onCloseButtonClick) + clearButton?.addEventListener('click', onClearButtonClick) + drawer?.addEventListener('click', onDrawerClick) + sendButton?.addEventListener('click', onSendButtonClick) + promptInput?.addEventListener('keydown', onPromptInputKeydown) + document.addEventListener('keydown', onDocumentKeydown) + + return { + dispose: () => { + toggleButton?.removeEventListener('click', onToggleButtonClick) + closeButton?.removeEventListener('click', onCloseButtonClick) + clearButton?.removeEventListener('click', onClearButtonClick) + drawer?.removeEventListener('click', onDrawerClick) + sendButton?.removeEventListener('click', onSendButtonClick) + promptInput?.removeEventListener('keydown', onPromptInputKeydown) + document.removeEventListener('keydown', onDocumentKeydown) + }, + } +} diff --git a/src/modules/chat/drawer.js b/src/modules/chat/drawer.js new file mode 100644 index 0000000..7ec9a4e --- /dev/null +++ b/src/modules/chat/drawer.js @@ -0,0 +1,480 @@ +import { + isModelAccessStatusMessage, + toChatText, + toRepositoryLabel, + toRepositoryUrl, +} from './utils.js' +import { createChatKeyControls } from './key-controls.js' +import { createChatModelPicker } from './model-picker.js' +import { + buildActiveTabEditorContext, + normalizeWorkspaceTabContext, + normalizeWorkspaceTabContexts, +} from './active-tab-context.js' +import { buildOutboundMessages as buildPayloadMessages } from './payload.js' +import { createChatProposalActions } from './proposal-actions.js' +import { createChatRequestRunner } from './request-runner.js' +import { createChatMessageRenderer } from './message-renderer.js' +import { createChatDrawerEvents } from './drawer-events.js' + +export const createChatDrawer = ({ + toggleButton, + drawer, + closeButton, + promptInput, + modelSelect, + sendButton, + clearButton, + statusNode, + repositoryNode, + messagesNode, + keyRoot, + keyInput, + keyAddButton, + keyDeleteButton, + includeEditorsContextToggle, + getSelectedRepository, + getWorkspaceTabContexts, + applyWorkspaceTabContent, + scheduleRender, + getRenderMode, + getStyleMode, + getDrawerSide, + getActiveWorkspaceTabContext, +}) => { + let open = false + let pendingAbortController = null + const messages = [] + let compactedConversationSummary = '' + let proposalActions = null + let messageRenderer = null + let drawerEvents = null + + const getActiveTabContext = () => { + if (typeof getActiveWorkspaceTabContext !== 'function') { + return null + } + + return normalizeWorkspaceTabContext(getActiveWorkspaceTabContext()) + } + + const getWorkspaceTabs = () => { + if (typeof getWorkspaceTabContexts !== 'function') { + return [] + } + + return normalizeWorkspaceTabContexts(getWorkspaceTabContexts()) + } + + const resetChatContextState = () => { + compactedConversationSummary = '' + proposalActions?.resetChatContextState(messages) + } + + const cancelPendingAssistantBodyUpdate = () => { + messageRenderer?.cancelPendingAssistantBodyUpdate() + } + + const stopPendingRequest = () => { + pendingAbortController?.abort() + pendingAbortController = null + } + + const keyControls = createChatKeyControls({ + root: keyRoot, + input: keyInput, + addButton: keyAddButton, + deleteButton: keyDeleteButton, + onKeyChange: nextKey => { + modelPicker.invalidateCatalogCache() + modelPicker.syncModelSelectionForKey(nextKey) + syncComposerAvailability() + + const keyPresent = typeof nextKey === 'string' && nextKey.trim().length > 0 + + if (open && keyPresent) { + void modelPicker.loadModelOptionsFromCatalog({ force: true }) + } + }, + }) + + const getChatKey = () => keyControls.getKey() + const hasChatKey = () => keyControls.hasKey() + + const modelPicker = createChatModelPicker({ + modelSelect, + getChatKey, + resetModelAccessStatus: () => { + if (isModelAccessStatusMessage(statusNode?.textContent)) { + setChatStatus('Idle', 'neutral') + } + }, + }) + + const syncComposerAvailability = () => { + const keyPresent = hasChatKey() + + if (promptInput instanceof HTMLTextAreaElement) { + promptInput.disabled = !keyPresent + } + + if (sendButton instanceof HTMLButtonElement) { + sendButton.disabled = !keyPresent + } + } + + const getSelectedModel = () => modelPicker.getSelectedModel() + + const setOpen = nextOpen => { + open = nextOpen === true + + if (!toggleButton || !drawer) { + return + } + + const preferredSide = getDrawerSide?.() === 'left' ? 'left' : 'right' + drawer.classList.toggle('ai-chat-drawer--left', preferredSide === 'left') + drawer.classList.toggle('ai-chat-drawer--right', preferredSide !== 'left') + + toggleButton.setAttribute('aria-expanded', open ? 'true' : 'false') + drawer.toggleAttribute('hidden', !open) + + if (open && promptInput instanceof HTMLTextAreaElement) { + promptInput.focus() + } + + if (open && hasChatKey()) { + void modelPicker.loadModelOptionsFromCatalog() + } + } + + const setChatStatus = (text, level = 'neutral') => { + if (!statusNode) { + return + } + + statusNode.textContent = text + statusNode.dataset.level = level + } + + const syncRepositoryLabel = () => { + if (!repositoryNode) { + return + } + + repositoryNode.textContent = toRepositoryLabel(getSelectedRepository?.()) + } + + const buildRequestMessages = ({ repositoryContext, editorContext }) => { + const renderMode = + typeof getRenderMode === 'function' ? toChatText(getRenderMode()) : 'unknown' + const styleMode = + typeof getStyleMode === 'function' ? toChatText(getStyleMode()) : 'unknown' + + const { outboundMessages, nextSummary } = buildPayloadMessages({ + messages, + repositoryContext, + editorContext, + renderMode, + styleMode, + existingSummary: compactedConversationSummary, + }) + + compactedConversationSummary = nextSummary + return outboundMessages + } + + const renderMessages = () => { + messageRenderer?.renderMessages(messages) + } + + const appendMessage = message => { + messages.push(message) + renderMessages() + } + + const updateLastAssistantMessage = content => { + messageRenderer?.updateLastAssistantMessage(messages, content) + } + + const scheduleRenderAfterEditorUpdate = () => { + if (typeof scheduleRender !== 'function') { + return + } + + if (typeof requestAnimationFrame === 'function') { + requestAnimationFrame(() => { + scheduleRender() + }) + return + } + + setTimeout(() => { + scheduleRender() + }, 0) + } + + proposalActions = createChatProposalActions({ + getActiveTabContext, + getWorkspaceTabs, + applyWorkspaceTabContent, + scheduleRenderAfterEditorUpdate, + setChatStatus, + }) + + const resolveMessageProposals = message => + proposalActions?.resolveMessageProposals(message) ?? [] + + messageRenderer = createChatMessageRenderer({ + messagesNode, + resolveMessageProposals, + getActiveUndoState: () => proposalActions?.getActiveTabUndoState() ?? null, + isRequestPending: () => Boolean(pendingAbortController), + }) + + const collectRepositoryContext = () => { + const repository = getSelectedRepository?.() + const repositoryFullName = + typeof repository?.fullName === 'string' ? repository.fullName.trim() : '' + + if (!repositoryFullName) { + return '' + } + + const repositoryUrl = toRepositoryUrl(repository) + const defaultBranch = + repository && typeof repository.defaultBranch === 'string' + ? repository.defaultBranch + : 'unknown' + + const contextLines = [ + 'Selected repository context:', + `- Repository: ${repositoryFullName}`, + ...(repositoryUrl ? [`- Repository URL: ${repositoryUrl}`] : []), + `- Default branch: ${defaultBranch}`, + 'Use this repository as the default target for the user request unless they explicitly override it.', + ] + + return contextLines.join('\n') + } + + const collectEditorContext = () => { + if (!(includeEditorsContextToggle instanceof HTMLInputElement)) { + return null + } + + if (!includeEditorsContextToggle.checked) { + return null + } + + const activeTabContext = getActiveTabContext() + if (!activeTabContext) { + return null + } + + const workspaceTabs = getWorkspaceTabs() + + const renderMode = + typeof getRenderMode === 'function' ? toChatText(getRenderMode()) : '' + const styleMode = typeof getStyleMode === 'function' ? toChatText(getStyleMode()) : '' + + return buildActiveTabEditorContext({ + activeTabContext: { + ...activeTabContext, + content: toChatText(activeTabContext.content), + }, + workspaceTabContexts: workspaceTabs, + renderMode, + styleMode, + }) + } + + const setPendingState = isPending => { + 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 + } + + if (promptInput instanceof HTMLTextAreaElement) { + promptInput.disabled = !composerEnabled + } + + if (modelSelect instanceof HTMLSelectElement) { + if (pending) { + modelSelect.disabled = true + } else { + modelSelect.disabled = !hasChatKey() + } + } + + renderMessages() + } + + const attachAssistantResponseMetadata = ({ content, toolCalls, model, level }) => { + const lastMessage = messages[messages.length - 1] + if (!lastMessage || lastMessage.role !== 'assistant') { + return + } + + const normalizedToolCalls = Array.isArray(toolCalls) ? toolCalls : [] + 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 + : 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()) { + lastMessage.model = model + } + + if (level) { + lastMessage.level = level + } else { + delete lastMessage.level + } + + renderMessages() + } + + const markLastAssistantError = message => { + updateLastAssistantMessage(message) + const lastMessage = messages[messages.length - 1] + if (lastMessage) { + lastMessage.level = 'error' + } + renderMessages() + } + + const setLastAssistantModel = model => { + if (!model) { + return + } + + const lastMessage = messages[messages.length - 1] + if (lastMessage?.role === 'assistant' && lastMessage.model !== model) { + lastMessage.model = model + renderMessages() + } + } + + const requestRunner = createChatRequestRunner({ + getPrompt: () => promptInput?.value, + getToken: getChatKey, + getSelectedModel, + isEditorsContextIncluded: () => includeEditorsContextToggle?.checked === true, + stopPendingRequest, + setPendingAbortController: value => { + pendingAbortController = value + }, + getPendingAbortController: () => pendingAbortController, + appendMessage, + clearPrompt: () => { + if (promptInput instanceof HTMLTextAreaElement) { + promptInput.value = '' + } + }, + setPendingState, + setChatStatus, + buildOutboundMessages: () => { + const repositoryContext = collectRepositoryContext() + const editorContext = collectEditorContext() + return buildRequestMessages({ repositoryContext, editorContext }) + }, + updateLastAssistantMessage, + attachAssistantResponseMetadata, + markLastAssistantError, + setLastAssistantModel, + }) + + const runChatRequest = async () => { + await requestRunner.runChatRequest() + } + + const onClear = () => { + stopPendingRequest() + setPendingState(false) + cancelPendingAssistantBodyUpdate() + resetChatContextState() + messages.length = 0 + renderMessages() + setChatStatus('Chat cleared.', 'neutral') + } + + toggleButton?.setAttribute('aria-expanded', 'false') + drawer?.setAttribute('hidden', '') + modelPicker.initializeModelOptions() + modelPicker.syncModelSelectionForKey(getChatKey()) + syncComposerAvailability() + syncRepositoryLabel() + messageRenderer.ensureUndoActionsNode() + renderMessages() + setChatStatus('Idle', 'neutral') + + drawerEvents = createChatDrawerEvents({ + toggleButton, + closeButton, + clearButton, + drawer, + sendButton, + promptInput, + setOpen, + isOpen: () => open, + onClear, + onRequestRun: runChatRequest, + setChatStatus, + renderMessages, + getMessagesLength: () => messages.length, + getMessageAt: index => messages[index], + undoActiveTabApply: () => proposalActions?.undoActiveTabApply() ?? false, + applyProposalToTab: ({ messageIndex, proposalOriginalIndex }) => + proposalActions?.applyProposalToTab({ + messages, + messageIndex, + proposalOriginalIndex, + }), + }) + + return { + setOpen, + isOpen: () => open, + setSelectedRepository: () => { + syncRepositoryLabel() + }, + onActiveWorkspaceTabChange: () => { + renderMessages() + }, + dispose: () => { + stopPendingRequest() + setPendingState(false) + cancelPendingAssistantBodyUpdate() + resetChatContextState() + keyControls.dispose() + messageRenderer?.dispose() + drawerEvents?.dispose() + }, + } +} 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/message-renderer.js b/src/modules/chat/message-renderer.js new file mode 100644 index 0000000..cacd85f --- /dev/null +++ b/src/modules/chat/message-renderer.js @@ -0,0 +1,285 @@ +const svgNamespace = 'http://www.w3.org/2000/svg' + +const createMessageLabelIconTemplate = role => { + const iconPathByRole = { + user: 'M1 2.75C1 1.784 1.784 1 2.75 1h10.5c.966 0 1.75.784 1.75 1.75v7.5A1.75 1.75 0 0 1 13.25 12H9.06l-2.573 2.573A1.458 1.458 0 0 1 4 13.543V12H2.75A1.75 1.75 0 0 1 1 10.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h4.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z', + assistant: + 'M7.75 1a.75.75 0 0 1 0 1.5h-5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h2c.199 0 .39.079.53.22.141.14.22.331.22.53v2.19l2.72-2.72a.747.747 0 0 1 .53-.22h4.5a.25.25 0 0 0 .25-.25v-2a.75.75 0 0 1 1.5 0v2c0 .464-.184.909-.513 1.237A1.746 1.746 0 0 1 13.25 12H9.06l-2.573 2.573A1.457 1.457 0 0 1 4 13.543V12H2.75A1.75 1.75 0 0 1 1 10.25v-7.5C1 1.784 1.784 1 2.75 1h5Zm4.519-.837a.248.248 0 0 1 .466 0l.238.648a3.726 3.726 0 0 0 2.218 2.219l.649.238a.249.249 0 0 1 0 .467l-.649.238a3.725 3.725 0 0 0-2.218 2.218l-.238.649a.248.248 0 0 1-.466 0l-.239-.649a3.725 3.725 0 0 0-2.218-2.218l-.649-.238a.249.249 0 0 1 0-.467l.649-.238A3.726 3.726 0 0 0 12.03.811l.239-.648Z', + } + + const pathData = role === 'assistant' ? iconPathByRole.assistant : iconPathByRole.user + const svg = document.createElementNS(svgNamespace, 'svg') + svg.setAttribute('xmlns', svgNamespace) + svg.setAttribute('viewBox', '0 0 16 16') + svg.setAttribute('width', '16') + svg.setAttribute('height', '16') + svg.setAttribute('aria-hidden', 'true') + svg.classList.add('ai-chat-message__label-icon') + + const path = document.createElementNS(svgNamespace, 'path') + path.setAttribute('d', pathData) + svg.append(path) + + return svg +} + +export const createChatMessageRenderer = ({ + messagesNode, + resolveMessageProposals, + getActiveUndoState, + isRequestPending, +}) => { + let undoActionsNode = null + let lastAssistantBodyNode = null + let pendingAssistantBodyText = null + let pendingAssistantFrameId = null + const labelIconTemplateCache = { + user: null, + assistant: null, + } + + const cancelPendingAssistantBodyUpdate = () => { + if (pendingAssistantFrameId === null) { + return + } + + cancelAnimationFrame(pendingAssistantFrameId) + pendingAssistantFrameId = null + } + + const ensureUndoActionsNode = () => { + if (undoActionsNode) { + return undoActionsNode + } + + if (!(messagesNode instanceof HTMLElement)) { + return null + } + + const parentNode = messagesNode.parentElement + if (!(parentNode instanceof HTMLElement)) { + return null + } + + undoActionsNode = document.createElement('div') + undoActionsNode.className = 'ai-chat-drawer__undo-actions' + undoActionsNode.setAttribute('hidden', '') + messagesNode.insertAdjacentElement('afterend', undoActionsNode) + + return undoActionsNode + } + + const renderUndoActions = () => { + const undoNode = ensureUndoActionsNode() + if (!undoNode) { + return + } + + undoNode.replaceChildren() + + const activeUndoState = getActiveUndoState?.() ?? null + if (!activeUndoState) { + undoNode.setAttribute('hidden', '') + return + } + + const { activeTabContext, snapshot: activeTabUndoSnapshot } = activeUndoState + + const label = document.createElement('p') + label.className = 'ai-chat-drawer__undo-label' + label.textContent = 'Latest applied changes' + undoNode.append(label) + + const undoButton = document.createElement('button') + undoButton.type = 'button' + undoButton.className = + 'render-button render-button--small ai-chat-drawer__undo-action' + undoButton.dataset.action = 'undo-tab-apply' + const tabName = activeTabContext?.name || activeTabUndoSnapshot.tabName || 'tab' + undoButton.textContent = `Undo last apply for ${tabName}` + undoNode.append(undoButton) + + undoNode.removeAttribute('hidden') + } + + const renderMessages = messages => { + if (!(messagesNode instanceof HTMLElement)) { + return + } + + cancelPendingAssistantBodyUpdate() + pendingAssistantBodyText = null + lastAssistantBodyNode = null + + messagesNode.replaceChildren() + + if (!Array.isArray(messages) || messages.length === 0) { + const emptyNode = document.createElement('p') + emptyNode.className = 'ai-chat-empty' + emptyNode.textContent = + 'Ask for help developing your component, styles, or repository workflow.' + messagesNode.append(emptyNode) + renderUndoActions() + return + } + + for (const [index, message] of messages.entries()) { + const item = document.createElement('article') + item.className = `ai-chat-message ai-chat-message--${message.role}` + + const label = document.createElement('h3') + label.className = 'ai-chat-message__label' + const roleLabel = message.role === 'assistant' ? 'ASSISTANT' : 'YOU' + const roleKey = message.role === 'assistant' ? 'assistant' : 'user' + + if (!labelIconTemplateCache[roleKey]) { + labelIconTemplateCache[roleKey] = createMessageLabelIconTemplate(roleKey) + } + + const roleText = document.createElement('span') + roleText.textContent = roleLabel + label.append(roleText, labelIconTemplateCache[roleKey].cloneNode(true)) + + item.append(label) + + const body = document.createElement('p') + body.className = 'ai-chat-message__body' + body.textContent = message.content + item.append(body) + + const resolvedProposals = + message.role === 'assistant' ? (resolveMessageProposals?.(message) ?? []) : [] + const hasProposal = resolvedProposals.length > 0 + const appliedTargets = + message && typeof message.appliedTargets === 'object' && message.appliedTargets + ? message.appliedTargets + : {} + + if (hasProposal) { + const actions = document.createElement('div') + actions.className = 'ai-chat-message__actions' + actions.dataset.messageIndex = String(index) + + const buildApplyButton = ({ proposal }) => { + const button = document.createElement('button') + button.type = 'button' + button.className = 'render-button render-button--small ai-chat-message__action' + button.dataset.action = 'request-apply' + button.dataset.messageIndex = String(index) + button.dataset.proposalOriginalIndex = String(proposal.proposalOriginalIndex) + const tabLabel = + proposal.resolvedTab.name || + proposal.resolvedTab.path || + proposal.resolvedTab.id + button.textContent = `Apply update to ${tabLabel}` + button.setAttribute('aria-label', `Apply update to ${tabLabel}`) + if (isRequestPending?.()) { + button.disabled = true + } + return button + } + + const renderedApplyKeys = new Set() + + for (const proposal of resolvedProposals) { + if (!proposal?.appliedKey || appliedTargets[proposal.appliedKey] === true) { + continue + } + + if (renderedApplyKeys.has(proposal.appliedKey)) { + continue + } + + renderedApplyKeys.add(proposal.appliedKey) + actions.append(buildApplyButton({ proposal })) + } + + if (actions.childElementCount > 0) { + item.append(actions) + } + } + + if (message.role === 'assistant' && index === messages.length - 1) { + lastAssistantBodyNode = body + } + + if (message.level === 'error') { + item.classList.add('ai-chat-message--error') + } + + messagesNode.append(item) + } + + messagesNode.scrollTop = messagesNode.scrollHeight + renderUndoActions() + } + + const flushPendingAssistantBodyUpdate = messages => { + pendingAssistantFrameId = null + + if (pendingAssistantBodyText === null) { + return + } + + if (lastAssistantBodyNode) { + lastAssistantBodyNode.textContent = pendingAssistantBodyText + messagesNode.scrollTop = messagesNode.scrollHeight + pendingAssistantBodyText = null + return + } + + const nextText = pendingAssistantBodyText + pendingAssistantBodyText = null + const lastMessage = Array.isArray(messages) ? messages[messages.length - 1] : null + if (lastMessage && lastMessage.role === 'assistant') { + lastMessage.content = nextText + } + renderMessages(messages) + } + + const scheduleAssistantBodyUpdate = (messages, content) => { + pendingAssistantBodyText = content + + if (pendingAssistantFrameId !== null) { + return + } + + pendingAssistantFrameId = requestAnimationFrame(() => { + flushPendingAssistantBodyUpdate(messages) + }) + } + + const updateLastAssistantMessage = (messages, content) => { + const lastMessage = Array.isArray(messages) ? messages[messages.length - 1] : null + if (!lastMessage || lastMessage.role !== 'assistant') { + return + } + + lastMessage.content = content + + if (lastAssistantBodyNode) { + scheduleAssistantBodyUpdate(messages, content) + return + } + + renderMessages(messages) + } + + const dispose = () => { + cancelPendingAssistantBodyUpdate() + pendingAssistantBodyText = null + + if (undoActionsNode) { + undoActionsNode.remove() + undoActionsNode = null + } + } + + return { + cancelPendingAssistantBodyUpdate, + dispose, + ensureUndoActionsNode, + renderMessages, + updateLastAssistantMessage, + } +} diff --git a/src/modules/chat/model-picker.js b/src/modules/chat/model-picker.js new file mode 100644 index 0000000..78c99dc --- /dev/null +++ b/src/modules/chat/model-picker.js @@ -0,0 +1,165 @@ +import { chatModelOptions, defaultChatModel, isFreeChatModel } from './api/constants.js' +import { fetchChatModelOptions } from './api/models.js' +import { toModelId } from './utils.js' + +export const createChatModelPicker = ({ + modelSelect, + getChatKey, + resetModelAccessStatus, +}) => { + let loadedCatalogToken = null + let pendingCatalogLoadPromise = null + + const setModelSelectDisabled = isDisabled => { + if (!(modelSelect instanceof HTMLSelectElement)) { + return + } + + modelSelect.disabled = isDisabled + } + + const replaceModelOptions = ({ modelOptions, selectedModel }) => { + if (!(modelSelect instanceof HTMLSelectElement)) { + return + } + + const nextSelectedModel = toModelId(selectedModel) + const nextModelOptions = [ + { id: defaultChatModel, isFree: true }, + ...modelOptions.filter(option => option.id !== defaultChatModel), + ].filter( + (option, index, options) => + options.findIndex(candidate => candidate.id === option.id) === index, + ) + const freeModelOptions = [] + const paidModelOptions = [] + + for (const modelOption of nextModelOptions) { + if (modelOption.isFree) { + freeModelOptions.push(modelOption) + } else { + paidModelOptions.push(modelOption) + } + } + + modelSelect.replaceChildren() + + const appendGroupedOptions = (label, options) => { + if (options.length === 0) { + return + } + + const group = document.createElement('optgroup') + group.label = label + + for (const { id: modelId } of options) { + const option = document.createElement('option') + option.value = modelId + option.textContent = modelId + option.selected = modelId === nextSelectedModel + group.append(option) + } + + modelSelect.append(group) + } + + appendGroupedOptions('Free', freeModelOptions) + appendGroupedOptions('Paid', paidModelOptions) + + if (!nextModelOptions.some(option => option.id === nextSelectedModel)) { + modelSelect.value = defaultChatModel + } + } + + const getSelectedModel = () => { + if (!(modelSelect instanceof HTMLSelectElement)) { + return defaultChatModel + } + + return toModelId(modelSelect.value) + } + + const initializeModelOptions = () => { + replaceModelOptions({ + modelOptions: chatModelOptions.map(id => ({ + id, + isFree: isFreeChatModel(id), + })), + selectedModel: defaultChatModel, + }) + } + + const loadModelOptionsFromCatalog = async ({ force = false } = {}) => { + if (!(modelSelect instanceof HTMLSelectElement)) { + return + } + + const token = getChatKey() + const normalizedToken = typeof token === 'string' ? token.trim() : '' + + if (!normalizedToken) { + return + } + + if (!force && pendingCatalogLoadPromise) { + await pendingCatalogLoadPromise + return + } + + if (!force && loadedCatalogToken === normalizedToken) { + return + } + + const loadCatalog = async () => { + try { + const modelOptions = await fetchChatModelOptions({ token: normalizedToken }) + const selectedModel = getSelectedModel() + replaceModelOptions({ + modelOptions, + selectedModel, + }) + loadedCatalogToken = normalizedToken + } catch { + /* Keep fallback options when catalog loading fails. */ + } + } + + const catalogLoadPromise = loadCatalog() + + pendingCatalogLoadPromise = catalogLoadPromise + + try { + await catalogLoadPromise + } finally { + if (pendingCatalogLoadPromise === catalogLoadPromise) { + pendingCatalogLoadPromise = null + } + } + } + + const syncModelSelectionForKey = key => { + const keyPresent = typeof key === 'string' && key.trim().length > 0 + + setModelSelectDisabled(!keyPresent) + + if (!keyPresent && modelSelect instanceof HTMLSelectElement) { + modelSelect.value = defaultChatModel + } + + if (keyPresent) { + resetModelAccessStatus?.() + } + } + + const invalidateCatalogCache = () => { + loadedCatalogToken = null + } + + return { + getSelectedModel, + initializeModelOptions, + loadModelOptionsFromCatalog, + syncModelSelectionForKey, + invalidateCatalogCache, + } +} diff --git a/src/modules/github/chat/payload.js b/src/modules/chat/payload.js similarity index 85% rename from src/modules/github/chat/payload.js rename to src/modules/chat/payload.js index 29a0c2b..77dbb09 100644 --- a/src/modules/github/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/proposal-actions.js b/src/modules/chat/proposal-actions.js new file mode 100644 index 0000000..b23b9ef --- /dev/null +++ b/src/modules/chat/proposal-actions.js @@ -0,0 +1,195 @@ +import { toMessageEditorProposals } from './proposals.js' +import { resolveWorkspaceTabTarget } from './tab-target-resolver.js' +import { createTabScopedUndoState } from './tab-scoped-undo-state.js' + +const preserveTrailingNewlineIfNeeded = ({ previousValue, nextValue }) => { + if (typeof previousValue !== 'string' || typeof nextValue !== 'string') { + return nextValue + } + + if (!previousValue.endsWith('\n') || nextValue.endsWith('\n')) { + return nextValue + } + + return `${nextValue}\n` +} + +export const createChatProposalActions = ({ + getActiveTabContext, + getWorkspaceTabs, + applyWorkspaceTabContent, + scheduleRenderAfterEditorUpdate, + setChatStatus, +}) => { + const tabScopedUndoState = createTabScopedUndoState() + + const getFallbackProposalTarget = () => { + const activeTabContext = getActiveTabContext() + if (!activeTabContext) { + return '' + } + + return activeTabContext.path || activeTabContext.id + } + + const resetChatContextState = messages => { + tabScopedUndoState.clearAll() + + if (!Array.isArray(messages)) { + return + } + + for (const message of messages) { + if (!message || typeof message !== 'object') { + continue + } + + message.appliedTargets = null + } + } + + const resolveMessageProposals = message => { + const proposals = toMessageEditorProposals(message, { + fallbackTarget: getFallbackProposalTarget(), + allowMarkdownFallback: message?.allowApplyActions === true, + }) + const workspaceTabs = getWorkspaceTabs() + const activeTabId = getActiveTabContext()?.id || '' + + return proposals + .map((proposal, proposalOriginalIndex) => { + const resolvedTab = resolveWorkspaceTabTarget({ + target: proposal.target, + language: proposal.language, + tabs: workspaceTabs, + activeTabId, + }) + + if (!resolvedTab) { + return null + } + + return { + ...proposal, + proposalOriginalIndex, + appliedKey: resolvedTab.id, + resolvedTab, + } + }) + .filter(Boolean) + } + + const applyProposalToTab = ({ messages, messageIndex, proposalOriginalIndex }) => { + const message = Array.isArray(messages) ? messages[messageIndex] : null + if (!message || message.role !== 'assistant') { + return null + } + + const proposals = toMessageEditorProposals(message, { + fallbackTarget: getFallbackProposalTarget(), + allowMarkdownFallback: message?.allowApplyActions === true, + }) + const proposal = proposals[proposalOriginalIndex] + if (!proposal) { + return null + } + + const activeTabContext = getActiveTabContext() + const workspaceTabs = getWorkspaceTabs() + const resolvedTab = resolveWorkspaceTabTarget({ + target: proposal.target, + language: proposal.language, + tabs: workspaceTabs, + activeTabId: activeTabContext?.id || '', + }) + + if (!resolvedTab || typeof applyWorkspaceTabContent !== 'function') { + return null + } + + const previousValue = + typeof resolvedTab.content === 'string' ? resolvedTab.content : '' + const nextValue = preserveTrailingNewlineIfNeeded({ + previousValue, + nextValue: proposal.content, + }) + + const updatedTab = applyWorkspaceTabContent({ + tabId: resolvedTab.id, + content: nextValue, + }) + if (!updatedTab) { + return null + } + + tabScopedUndoState.setSnapshot({ + tabId: resolvedTab.id, + snapshot: { + previousValue, + tabName: resolvedTab.name, + }, + }) + + scheduleRenderAfterEditorUpdate?.() + const tabLabel = resolvedTab.name || resolvedTab.path || resolvedTab.id + setChatStatus?.(`Applied assistant proposal to ${tabLabel}.`, 'ok') + return { + appliedKey: resolvedTab.id, + tabId: resolvedTab.id, + } + } + + const getActiveTabUndoState = () => { + const activeTabContext = getActiveTabContext() + const activeTabId = activeTabContext?.id + if (!activeTabId) { + return null + } + + const snapshot = tabScopedUndoState.getSnapshot(activeTabId) + if (!snapshot) { + return null + } + + return { + activeTabContext, + snapshot, + } + } + + const undoActiveTabApply = () => { + if (typeof applyWorkspaceTabContent !== 'function') { + return false + } + + const activeUndoState = getActiveTabUndoState() + if (!activeUndoState) { + return false + } + + const { activeTabContext, snapshot } = activeUndoState + const activeTabId = activeTabContext.id + + const restored = applyWorkspaceTabContent({ + tabId: activeTabId, + content: snapshot.previousValue, + }) + if (!restored) { + return false + } + + tabScopedUndoState.clearSnapshot(activeTabId) + scheduleRenderAfterEditorUpdate?.() + const tabLabel = activeTabContext.name || snapshot.tabName || 'active tab' + setChatStatus?.(`Reverted last apply for ${tabLabel}.`, 'neutral') + return true + } + + return { + applyProposalToTab, + getActiveTabUndoState, + resolveMessageProposals, + resetChatContextState, + undoActiveTabApply, + } +} diff --git a/src/modules/github/chat/proposals.js b/src/modules/chat/proposals.js similarity index 94% rename from src/modules/github/chat/proposals.js rename to src/modules/chat/proposals.js index a6028d8..f149d7f 100644 --- a/src/modules/github/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/request-runner.js b/src/modules/chat/request-runner.js new file mode 100644 index 0000000..a3651d2 --- /dev/null +++ b/src/modules/chat/request-runner.js @@ -0,0 +1,237 @@ +import { requestChatCompletion, streamChatCompletion } from './api/completions.js' +import { shouldEnableEditorUpdateTools } from './payload.js' +import { editorProposalTools } from './proposals.js' +import { + formatModelAccessErrorMessage, + isCredentialError, + isModelAccessError, + 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, + getSelectedModel, + isEditorsContextIncluded, + stopPendingRequest, + setPendingAbortController, + getPendingAbortController, + appendMessage, + clearPrompt, + setPendingState, + setChatStatus, + buildOutboundMessages, + updateLastAssistantMessage, + attachAssistantResponseMetadata, + markLastAssistantError, + setLastAssistantModel, +}) => { + const runChatRequest = async () => { + const prompt = toChatText(getPrompt?.()) + + if (!prompt) { + setChatStatus?.('Enter a prompt before sending.', 'error') + return + } + + const token = getToken?.() + if (!token) { + setChatStatus?.('Add an OpenRouter API key before starting chat.', 'error') + return + } + + const selectedModel = getSelectedModel?.() + const allowEditorUpdateTools = + isEditorsContextIncluded?.() === true && shouldEnableEditorUpdateTools(prompt) + + stopPendingRequest?.() + const requestAbortController = new AbortController() + const requestSignal = requestAbortController.signal + setPendingAbortController?.(requestAbortController) + + appendMessage?.({ role: 'user', content: prompt }) + appendMessage?.({ + role: 'assistant', + content: '', + model: selectedModel, + allowApplyActions: allowEditorUpdateTools, + }) + + clearPrompt?.() + setPendingState?.(true) + setChatStatus?.('Streaming response...', 'pending') + + const outboundMessages = buildOutboundMessages?.() ?? [] + const toolChoice = allowEditorUpdateTools ? 'auto' : 'none' + const tools = allowEditorUpdateTools ? editorProposalTools : [] + + let streamedContent = '' + let streamSucceeded = false + + try { + const streamResult = await streamChatCompletion({ + token, + messages: outboundMessages, + model: selectedModel, + tools, + toolChoice, + signal: requestSignal, + onToken: tokenChunk => { + streamedContent += tokenChunk + const sanitizedContent = sanitizeAssistantContent(streamedContent) + updateLastAssistantMessage?.(sanitizedContent) + }, + }) + + streamSucceeded = true + const streamedModel = toChatText(streamResult?.model) + 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: streamToolCalls, + model: streamedModel, + }) + setChatStatus?.('Response streamed.', 'ok') + } catch (streamError) { + if (requestSignal.aborted) { + if (getPendingAbortController?.() === requestAbortController) { + setChatStatus?.('Chat request canceled.', 'neutral') + setPendingAbortController?.(null) + setPendingState?.(false) + } + return + } + + if (isModelAccessError(streamError)) { + const modelAccessMessage = formatModelAccessErrorMessage(selectedModel) + markLastAssistantError?.(modelAccessMessage) + setChatStatus?.(modelAccessMessage, 'error') + + if (getPendingAbortController?.() === requestAbortController) { + setPendingAbortController?.(null) + setPendingState?.(false) + } + return + } + + if (isCredentialError(streamError)) { + const credentialMessage = + streamError instanceof Error ? streamError.message : 'Chat request failed.' + + markLastAssistantError?.(credentialMessage) + setChatStatus?.(credentialMessage, 'error') + + if (getPendingAbortController?.() === requestAbortController) { + setPendingAbortController?.(null) + setPendingState?.(false) + } + return + } + + const streamStatus = streamError?.status + if (typeof streamStatus === 'number' && streamStatus >= 400 && streamStatus < 500) { + const streamMessage = + streamError instanceof Error ? streamError.message : 'Chat request failed.' + + markLastAssistantError?.(streamMessage) + setChatStatus?.(streamMessage, 'error') + + if (getPendingAbortController?.() === requestAbortController) { + setPendingAbortController?.(null) + setPendingState?.(false) + } + + return + } + + setChatStatus?.( + 'Streaming unavailable. Retrying with fallback response...', + 'pending', + ) + } + + if (streamSucceeded) { + if (getPendingAbortController?.() === requestAbortController) { + setPendingAbortController?.(null) + setPendingState?.(false) + } + return + } + + try { + const fallbackResult = await requestChatCompletion({ + token, + messages: outboundMessages, + model: selectedModel, + tools, + toolChoice, + 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: fallbackContent, + toolCalls: fallbackToolCalls, + }) + const fallbackModel = toChatText(fallbackResult.model) + setLastAssistantModel?.(fallbackModel) + setChatStatus?.('Fallback response loaded.', 'ok') + } catch (fallbackError) { + if (requestSignal.aborted) { + if (getPendingAbortController?.() === requestAbortController) { + setChatStatus?.('Chat request canceled.', 'neutral') + } + return + } + + const fallbackMessage = isModelAccessError(fallbackError) + ? formatModelAccessErrorMessage(selectedModel) + : fallbackError instanceof Error + ? fallbackError.message + : 'Chat request failed.' + + markLastAssistantError?.(fallbackMessage) + setChatStatus?.(`Chat request failed: ${fallbackMessage}`, 'error') + } finally { + if (getPendingAbortController?.() === requestAbortController) { + setPendingAbortController?.(null) + setPendingState?.(false) + } + } + } + + return { + runChatRequest, + } +} diff --git a/src/modules/github/chat/tab-scoped-undo-state.js b/src/modules/chat/tab-scoped-undo-state.js similarity index 100% rename from src/modules/github/chat/tab-scoped-undo-state.js rename to src/modules/chat/tab-scoped-undo-state.js diff --git a/src/modules/github/chat/tab-target-resolver.js b/src/modules/chat/tab-target-resolver.js similarity index 100% rename from src/modules/github/chat/tab-target-resolver.js rename to src/modules/chat/tab-target-resolver.js diff --git a/src/modules/github/chat/utils.js b/src/modules/chat/utils.js similarity index 72% rename from src/modules/github/chat/utils.js rename to src/modules/chat/utils.js index dd39ebe..60c8a6a 100644 --- a/src/modules/github/chat/utils.js +++ b/src/modules/chat/utils.js @@ -1,4 +1,4 @@ -import { defaultGitHubChatModel } from '../api/chat.js' +import { defaultChatModel } from './api/completions.js' export const toChatText = value => { if (typeof value !== 'string') { @@ -10,32 +10,45 @@ export const toChatText = value => { export const toModelId = value => { if (typeof value !== 'string') { - return defaultGitHubChatModel + return defaultChatModel } const model = value.trim() - return model || defaultGitHubChatModel + return model || defaultChatModel } 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('not a valid 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 token. Choose a different model.` + return `Selected model "${model}" is not available for this key. Choose a different model.` } export const isModelAccessStatusMessage = value => { @@ -44,7 +57,7 @@ export const isModelAccessStatusMessage = value => { } return ( - value.startsWith('Selected model "') && value.includes('not available for this token') + value.startsWith('Selected model "') && value.includes('not available for this key') ) } diff --git a/src/modules/app-core/github-chat-workspace-actions.js b/src/modules/chat/workspace-actions.js similarity index 95% rename from src/modules/app-core/github-chat-workspace-actions.js rename to src/modules/chat/workspace-actions.js index 3a5613d..a7b7267 100644 --- a/src/modules/app-core/github-chat-workspace-actions.js +++ b/src/modules/chat/workspace-actions.js @@ -1,4 +1,4 @@ -const createGitHubChatWorkspaceActions = ({ +const createChatWorkspaceActions = ({ getActiveWorkspaceTab, isStyleWorkspaceTab, getCssSource, @@ -82,4 +82,4 @@ const createGitHubChatWorkspaceActions = ({ } } -export { createGitHubChatWorkspaceActions } +export { createChatWorkspaceActions } diff --git a/src/modules/github/api/constants.js b/src/modules/github/api/constants.js index c2d2684..fbe912d 100644 --- a/src/modules/github/api/constants.js +++ b/src/modules/github/api/constants.js @@ -1,24 +1 @@ export const githubApiBaseUrl = 'https://api.github.com' -export const githubModelsApiUrl = 'https://models.github.ai/inference/chat/completions' - -export const defaultGitHubChatModel = 'openai/gpt-4.1-mini' - -/* Local model options avoid browser CORS failures when calling catalog endpoints directly. */ -export const githubChatModelOptions = [ - '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', -] diff --git a/src/modules/github/api/core.js b/src/modules/github/api/core.js index 27c3ddd..089bedc 100644 --- a/src/modules/github/api/core.js +++ b/src/modules/github/api/core.js @@ -39,13 +39,6 @@ const buildRequestHeaders = token => ({ 'X-GitHub-Api-Version': '2022-11-28', }) -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 @@ -204,7 +197,6 @@ const buildRepoApiUrl = ({ owner, repo, path }) => `${githubApiBaseUrl}/repos/${owner}/${repo}${path}` export { - buildChatRequestHeaders, buildRepoApiUrl, buildRequestHeaders, encodePathForApi, diff --git a/src/modules/github/chat/drawer.js b/src/modules/github/chat/drawer.js deleted file mode 100644 index 8727f67..0000000 --- a/src/modules/github/chat/drawer.js +++ /dev/null @@ -1,1041 +0,0 @@ -import { - defaultGitHubChatModel, - githubChatModelOptions, - requestGitHubChatCompletion, - streamGitHubChatCompletion, -} from '../api/chat.js' -import { - formatModelAccessErrorMessage, - isModelAccessError, - isModelAccessStatusMessage, - toChatText, - toModelId, - toRepositoryLabel, - toRepositoryUrl, -} from './utils.js' -import { - buildActiveTabEditorContext, - normalizeWorkspaceTabContext, - normalizeWorkspaceTabContexts, -} from './active-tab-context.js' -import { buildOutboundMessages as buildPayloadMessages } from './payload.js' -import { editorProposalTools, toMessageEditorProposals } from './proposals.js' -import { resolveWorkspaceTabTarget } from './tab-target-resolver.js' -import { createTabScopedUndoState } from './tab-scoped-undo-state.js' - -const svgNamespace = 'http://www.w3.org/2000/svg' - -const createMessageLabelIconTemplate = role => { - const iconPathByRole = { - user: 'M1 2.75C1 1.784 1.784 1 2.75 1h10.5c.966 0 1.75.784 1.75 1.75v7.5A1.75 1.75 0 0 1 13.25 12H9.06l-2.573 2.573A1.458 1.458 0 0 1 4 13.543V12H2.75A1.75 1.75 0 0 1 1 10.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h4.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z', - assistant: - 'M7.75 1a.75.75 0 0 1 0 1.5h-5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h2c.199 0 .39.079.53.22.141.14.22.331.22.53v2.19l2.72-2.72a.747.747 0 0 1 .53-.22h4.5a.25.25 0 0 0 .25-.25v-2a.75.75 0 0 1 1.5 0v2c0 .464-.184.909-.513 1.237A1.746 1.746 0 0 1 13.25 12H9.06l-2.573 2.573A1.457 1.457 0 0 1 4 13.543V12H2.75A1.75 1.75 0 0 1 1 10.25v-7.5C1 1.784 1.784 1 2.75 1h5Zm4.519-.837a.248.248 0 0 1 .466 0l.238.648a3.726 3.726 0 0 0 2.218 2.219l.649.238a.249.249 0 0 1 0 .467l-.649.238a3.725 3.725 0 0 0-2.218 2.218l-.238.649a.248.248 0 0 1-.466 0l-.239-.649a3.725 3.725 0 0 0-2.218-2.218l-.649-.238a.249.249 0 0 1 0-.467l.649-.238A3.726 3.726 0 0 0 12.03.811l.239-.648Z', - } - - const pathData = role === 'assistant' ? iconPathByRole.assistant : iconPathByRole.user - const svg = document.createElementNS(svgNamespace, 'svg') - svg.setAttribute('xmlns', svgNamespace) - svg.setAttribute('viewBox', '0 0 16 16') - svg.setAttribute('width', '16') - svg.setAttribute('height', '16') - svg.setAttribute('aria-hidden', 'true') - svg.classList.add('ai-chat-message__label-icon') - - const path = document.createElementNS(svgNamespace, 'path') - path.setAttribute('d', pathData) - svg.append(path) - - return svg -} - -export const createGitHubChatDrawer = ({ - toggleButton, - drawer, - closeButton, - promptInput, - modelSelect, - sendButton, - clearButton, - statusNode, - repositoryNode, - messagesNode, - includeEditorsContextToggle, - getToken, - getSelectedRepository, - getWorkspaceTabContexts, - applyWorkspaceTabContent, - scheduleRender, - getRenderMode, - getStyleMode, - getDrawerSide, - getActiveWorkspaceTabContext, -}) => { - let open = false - let pendingAbortController = null - const messages = [] - let lastAssistantBodyNode = null - let pendingAssistantBodyText = null - let pendingAssistantFrameId = null - let compactedConversationSummary = '' - let undoActionsNode = null - const labelIconTemplateCache = { - user: null, - assistant: null, - } - const tabScopedUndoState = createTabScopedUndoState() - - const getActiveTabContext = () => { - if (typeof getActiveWorkspaceTabContext !== 'function') { - return null - } - - return normalizeWorkspaceTabContext(getActiveWorkspaceTabContext()) - } - - const getWorkspaceTabs = () => { - if (typeof getWorkspaceTabContexts !== 'function') { - return [] - } - - return normalizeWorkspaceTabContexts(getWorkspaceTabContexts()) - } - - const getFallbackProposalTarget = () => { - const activeTabContext = getActiveTabContext() - if (!activeTabContext) { - return '' - } - - return activeTabContext.path || activeTabContext.id - } - - const resetChatContextState = () => { - compactedConversationSummary = '' - tabScopedUndoState.clearAll() - - for (const message of messages) { - if (!message || typeof message !== 'object') { - continue - } - - message.appliedTargets = null - } - } - - const cancelPendingAssistantBodyUpdate = () => { - if (pendingAssistantFrameId === null) { - return - } - - cancelAnimationFrame(pendingAssistantFrameId) - pendingAssistantFrameId = null - } - - const flushPendingAssistantBodyUpdate = () => { - pendingAssistantFrameId = null - - if (pendingAssistantBodyText === null) { - return - } - - if (lastAssistantBodyNode) { - lastAssistantBodyNode.textContent = pendingAssistantBodyText - if (messagesNode) { - messagesNode.scrollTop = messagesNode.scrollHeight - } - pendingAssistantBodyText = null - return - } - - const nextText = pendingAssistantBodyText - pendingAssistantBodyText = null - updateLastAssistantMessage(nextText) - } - - const scheduleAssistantBodyUpdate = content => { - pendingAssistantBodyText = content - - if (pendingAssistantFrameId !== null) { - return - } - - pendingAssistantFrameId = requestAnimationFrame(() => { - flushPendingAssistantBodyUpdate() - }) - } - - const stopPendingRequest = () => { - pendingAbortController?.abort() - pendingAbortController = null - } - - const setModelSelectDisabled = isDisabled => { - if (!(modelSelect instanceof HTMLSelectElement)) { - return - } - - modelSelect.disabled = isDisabled - } - - const replaceModelOptions = ({ modelIds, selectedModel }) => { - if (!(modelSelect instanceof HTMLSelectElement)) { - return - } - - const nextSelectedModel = toModelId(selectedModel) - const nextModelIds = [...new Set([defaultGitHubChatModel, ...modelIds])] - - modelSelect.replaceChildren() - - for (const modelId of nextModelIds) { - const option = document.createElement('option') - option.value = modelId - option.textContent = modelId - option.selected = modelId === nextSelectedModel - modelSelect.append(option) - } - - if (!nextModelIds.includes(nextSelectedModel)) { - modelSelect.value = defaultGitHubChatModel - } - } - - const getSelectedModel = () => { - if (!(modelSelect instanceof HTMLSelectElement)) { - return defaultGitHubChatModel - } - - return toModelId(modelSelect.value) - } - - const initializeModelOptions = () => { - replaceModelOptions({ - modelIds: githubChatModelOptions, - selectedModel: defaultGitHubChatModel, - }) - } - - const syncModelSelectionForToken = token => { - const hasToken = typeof token === 'string' && token.trim().length > 0 - - setModelSelectDisabled(!hasToken) - - if (!hasToken && modelSelect instanceof HTMLSelectElement) { - modelSelect.value = defaultGitHubChatModel - } - - if (hasToken && isModelAccessStatusMessage(statusNode?.textContent)) { - setChatStatus('Idle', 'neutral') - } - } - - const setOpen = nextOpen => { - open = nextOpen === true - - if (!toggleButton || !drawer) { - return - } - - const preferredSide = getDrawerSide?.() === 'left' ? 'left' : 'right' - drawer.classList.toggle('ai-chat-drawer--left', preferredSide === 'left') - drawer.classList.toggle('ai-chat-drawer--right', preferredSide !== 'left') - - toggleButton.setAttribute('aria-expanded', open ? 'true' : 'false') - drawer.toggleAttribute('hidden', !open) - - if (open && promptInput instanceof HTMLTextAreaElement) { - promptInput.focus() - } - } - - const setChatStatus = (text, level = 'neutral') => { - if (!statusNode) { - return - } - - statusNode.textContent = text - statusNode.dataset.level = level - } - - const syncRepositoryLabel = () => { - if (!repositoryNode) { - return - } - - repositoryNode.textContent = toRepositoryLabel(getSelectedRepository?.()) - } - - const buildRequestMessages = ({ repositoryContext, editorContext }) => { - const renderMode = - typeof getRenderMode === 'function' ? toChatText(getRenderMode()) : 'unknown' - const styleMode = - typeof getStyleMode === 'function' ? toChatText(getStyleMode()) : 'unknown' - - const { outboundMessages, nextSummary } = buildPayloadMessages({ - messages, - repositoryContext, - editorContext, - renderMode, - styleMode, - existingSummary: compactedConversationSummary, - }) - - compactedConversationSummary = nextSummary - return outboundMessages - } - - const ensureUndoActionsNode = () => { - if (undoActionsNode) { - return undoActionsNode - } - - if (!(messagesNode instanceof HTMLElement)) { - return null - } - - const parentNode = messagesNode.parentElement - if (!(parentNode instanceof HTMLElement)) { - return null - } - - undoActionsNode = document.createElement('div') - undoActionsNode.className = 'ai-chat-drawer__undo-actions' - undoActionsNode.setAttribute('hidden', '') - messagesNode.insertAdjacentElement('afterend', undoActionsNode) - - return undoActionsNode - } - - const renderUndoActions = () => { - const undoNode = ensureUndoActionsNode() - if (!undoNode) { - return - } - - undoNode.replaceChildren() - - const activeTabContext = getActiveTabContext() - const activeTabId = activeTabContext?.id - const activeTabUndoSnapshot = activeTabId - ? tabScopedUndoState.getSnapshot(activeTabId) - : null - - if (!activeTabUndoSnapshot) { - undoNode.setAttribute('hidden', '') - return - } - - const label = document.createElement('p') - label.className = 'ai-chat-drawer__undo-label' - label.textContent = 'Latest applied changes' - undoNode.append(label) - - const undoButton = document.createElement('button') - undoButton.type = 'button' - undoButton.className = - 'render-button render-button--small ai-chat-drawer__undo-action' - undoButton.dataset.action = 'undo-tab-apply' - const tabName = activeTabContext?.name || activeTabUndoSnapshot.tabName || 'tab' - undoButton.textContent = `Undo last apply for ${tabName}` - undoNode.append(undoButton) - - undoNode.removeAttribute('hidden') - } - - const renderMessages = () => { - if (!messagesNode) { - return - } - - cancelPendingAssistantBodyUpdate() - pendingAssistantBodyText = null - lastAssistantBodyNode = null - - messagesNode.replaceChildren() - - if (messages.length === 0) { - const emptyNode = document.createElement('p') - emptyNode.className = 'ai-chat-empty' - emptyNode.textContent = - 'Ask for help developing your component, styles, or repository workflow.' - messagesNode.append(emptyNode) - renderUndoActions() - return - } - - for (const [index, message] of messages.entries()) { - const item = document.createElement('article') - item.className = `ai-chat-message ai-chat-message--${message.role}` - - const label = document.createElement('h3') - label.className = 'ai-chat-message__label' - const roleLabel = message.role === 'assistant' ? 'ASSISTANT' : 'YOU' - const roleKey = message.role === 'assistant' ? 'assistant' : 'user' - - if (!labelIconTemplateCache[roleKey]) { - labelIconTemplateCache[roleKey] = createMessageLabelIconTemplate(roleKey) - } - - const roleText = document.createElement('span') - roleText.textContent = roleLabel - label.append(roleText, labelIconTemplateCache[roleKey].cloneNode(true)) - - item.append(label) - - const body = document.createElement('p') - body.className = 'ai-chat-message__body' - body.textContent = message.content - item.append(body) - - const resolvedProposals = - message.role === 'assistant' ? resolveMessageProposals(message) : [] - const hasProposal = resolvedProposals.length > 0 - const appliedTargets = - message && typeof message.appliedTargets === 'object' && message.appliedTargets - ? message.appliedTargets - : {} - - if (hasProposal) { - const actions = document.createElement('div') - actions.className = 'ai-chat-message__actions' - actions.dataset.messageIndex = String(index) - - const buildApplyButton = ({ proposal }) => { - const button = document.createElement('button') - button.type = 'button' - button.className = 'render-button render-button--small ai-chat-message__action' - button.dataset.action = 'request-apply' - button.dataset.messageIndex = String(index) - button.dataset.proposalOriginalIndex = String(proposal.proposalOriginalIndex) - const tabLabel = - proposal.resolvedTab.name || - proposal.resolvedTab.path || - proposal.resolvedTab.id - button.textContent = `Apply update to ${tabLabel}` - button.setAttribute('aria-label', `Apply update to ${tabLabel}`) - if (pendingAbortController) { - button.disabled = true - } - return button - } - - const renderedApplyKeys = new Set() - - for (const proposal of resolvedProposals) { - if (!proposal?.appliedKey || appliedTargets[proposal.appliedKey] === true) { - continue - } - - if (renderedApplyKeys.has(proposal.appliedKey)) { - continue - } - - renderedApplyKeys.add(proposal.appliedKey) - - actions.append( - buildApplyButton({ - proposal, - }), - ) - } - - if (actions.childElementCount > 0) { - item.append(actions) - } - } - - if (message.role === 'assistant' && index === messages.length - 1) { - lastAssistantBodyNode = body - } - - if (message.level === 'error') { - item.classList.add('ai-chat-message--error') - } - - messagesNode.append(item) - } - - messagesNode.scrollTop = messagesNode.scrollHeight - renderUndoActions() - } - - const appendMessage = message => { - messages.push(message) - renderMessages() - } - - const updateLastAssistantMessage = content => { - const lastMessage = messages[messages.length - 1] - if (!lastMessage || lastMessage.role !== 'assistant') { - return - } - - lastMessage.content = content - - if (lastAssistantBodyNode) { - scheduleAssistantBodyUpdate(content) - return - } - - renderMessages() - } - - const scheduleRenderAfterEditorUpdate = () => { - if (typeof scheduleRender !== 'function') { - return - } - - if (typeof requestAnimationFrame === 'function') { - requestAnimationFrame(() => { - scheduleRender() - }) - return - } - - setTimeout(() => { - scheduleRender() - }, 0) - } - - const preserveTrailingNewlineIfNeeded = ({ previousValue, nextValue }) => { - if (typeof previousValue !== 'string' || typeof nextValue !== 'string') { - return nextValue - } - - if (!previousValue.endsWith('\n') || nextValue.endsWith('\n')) { - return nextValue - } - - return `${nextValue}\n` - } - - const resolveMessageProposals = message => { - const proposals = toMessageEditorProposals(message, { - fallbackTarget: getFallbackProposalTarget(), - }) - const workspaceTabs = getWorkspaceTabs() - const activeTabId = getActiveTabContext()?.id || '' - - return proposals - .map((proposal, proposalOriginalIndex) => { - const resolvedTab = resolveWorkspaceTabTarget({ - target: proposal.target, - language: proposal.language, - tabs: workspaceTabs, - activeTabId, - }) - - if (!resolvedTab) { - return null - } - - return { - ...proposal, - proposalOriginalIndex, - appliedKey: resolvedTab.id, - resolvedTab, - } - }) - .filter(Boolean) - } - - const applyProposalToTab = ({ messageIndex, proposalOriginalIndex }) => { - const message = messages[messageIndex] - if (!message || message.role !== 'assistant') { - return null - } - - const proposals = toMessageEditorProposals(message, { - fallbackTarget: getFallbackProposalTarget(), - }) - const proposal = proposals[proposalOriginalIndex] - if (!proposal) { - return null - } - - const activeTabContext = getActiveTabContext() - const workspaceTabs = getWorkspaceTabs() - const resolvedTab = resolveWorkspaceTabTarget({ - target: proposal.target, - language: proposal.language, - tabs: workspaceTabs, - activeTabId: activeTabContext?.id || '', - }) - - if (!resolvedTab || typeof applyWorkspaceTabContent !== 'function') { - return null - } - - const previousValue = - typeof resolvedTab.content === 'string' ? resolvedTab.content : '' - const nextValue = preserveTrailingNewlineIfNeeded({ - previousValue, - nextValue: proposal.content, - }) - - const updatedTab = applyWorkspaceTabContent({ - tabId: resolvedTab.id, - content: nextValue, - }) - if (!updatedTab) { - return null - } - - tabScopedUndoState.setSnapshot({ - tabId: resolvedTab.id, - snapshot: { - previousValue, - tabName: resolvedTab.name, - }, - }) - - scheduleRenderAfterEditorUpdate() - const tabLabel = resolvedTab.name || resolvedTab.path || resolvedTab.id - setChatStatus(`Applied assistant proposal to ${tabLabel}.`, 'ok') - return { - appliedKey: resolvedTab.id, - tabId: resolvedTab.id, - } - } - - const undoActiveTabApply = () => { - const activeTabContext = getActiveTabContext() - const activeTabId = activeTabContext?.id - if (!activeTabId || typeof applyWorkspaceTabContent !== 'function') { - return false - } - - const snapshot = tabScopedUndoState.getSnapshot(activeTabId) - if (!snapshot) { - return false - } - - const restored = applyWorkspaceTabContent({ - tabId: activeTabId, - content: snapshot.previousValue, - }) - if (!restored) { - return false - } - - tabScopedUndoState.clearSnapshot(activeTabId) - scheduleRenderAfterEditorUpdate() - const tabLabel = activeTabContext?.name || snapshot.tabName || 'active tab' - setChatStatus(`Reverted last apply for ${tabLabel}.`, 'neutral') - return true - } - - const collectRepositoryContext = () => { - const repository = getSelectedRepository?.() - const repositoryFullName = - typeof repository?.fullName === 'string' ? repository.fullName.trim() : '' - - if (!repositoryFullName) { - return '' - } - - const repositoryUrl = toRepositoryUrl(repository) - const defaultBranch = - repository && typeof repository.defaultBranch === 'string' - ? repository.defaultBranch - : 'unknown' - - const contextLines = [ - 'Selected repository context:', - `- Repository: ${repositoryFullName}`, - ...(repositoryUrl ? [`- Repository URL: ${repositoryUrl}`] : []), - `- Default branch: ${defaultBranch}`, - 'Use this repository as the default target for the user request unless they explicitly override it.', - ] - - return contextLines.join('\n') - } - - const collectEditorContext = () => { - if (!(includeEditorsContextToggle instanceof HTMLInputElement)) { - return null - } - - if (!includeEditorsContextToggle.checked) { - return null - } - - const activeTabContext = getActiveTabContext() - if (!activeTabContext) { - return null - } - - const workspaceTabs = getWorkspaceTabs() - - const renderMode = - typeof getRenderMode === 'function' ? toChatText(getRenderMode()) : '' - const styleMode = typeof getStyleMode === 'function' ? toChatText(getStyleMode()) : '' - - return buildActiveTabEditorContext({ - activeTabContext: { - ...activeTabContext, - content: toChatText(activeTabContext.content), - }, - workspaceTabContexts: workspaceTabs, - renderMode, - styleMode, - }) - } - - const setPendingState = isPending => { - if (sendButton instanceof HTMLButtonElement) { - sendButton.disabled = isPending - } - - if (promptInput instanceof HTMLTextAreaElement) { - promptInput.disabled = isPending - } - - 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 - } - } - - renderMessages() - } - - const attachAssistantResponseMetadata = ({ content, toolCalls, model, level }) => { - const lastMessage = messages[messages.length - 1] - if (!lastMessage || lastMessage.role !== 'assistant') { - return - } - - const normalizedToolCalls = Array.isArray(toolCalls) ? toolCalls : [] - const normalizedContent = typeof content === 'string' ? content : lastMessage.content - const hasContent = - typeof normalizedContent === 'string' && normalizedContent.trim().length > 0 - - lastMessage.content = - hasContent || normalizedToolCalls.length === 0 - ? normalizedContent - : 'Proposed editor update is ready. Apply below.' - lastMessage.toolCalls = normalizedToolCalls - - if (typeof model === 'string' && model.trim()) { - lastMessage.model = model - } - - if (level) { - lastMessage.level = level - } else { - delete lastMessage.level - } - - renderMessages() - } - - const runChatRequest = async () => { - const prompt = toChatText(promptInput?.value) - - if (!prompt) { - setChatStatus('Enter a prompt before sending.', 'error') - return - } - - const token = getToken?.() - if (!token) { - setChatStatus('Add a GitHub token before starting chat.', 'error') - return - } - - const selectedModel = getSelectedModel() - - stopPendingRequest() - const requestAbortController = new AbortController() - const requestSignal = requestAbortController.signal - pendingAbortController = requestAbortController - - appendMessage({ role: 'user', content: prompt }) - appendMessage({ role: 'assistant', content: '', model: selectedModel }) - if (promptInput instanceof HTMLTextAreaElement) { - promptInput.value = '' - } - - setPendingState(true) - setChatStatus('Streaming response from GitHub...', 'pending') - - const repositoryContext = collectRepositoryContext() - const editorContext = collectEditorContext() - const outboundMessages = buildRequestMessages({ repositoryContext, editorContext }) - const toolChoice = includeEditorsContextToggle?.checked ? 'auto' : 'none' - - let streamedContent = '' - let streamSucceeded = false - - try { - const streamResult = await streamGitHubChatCompletion({ - token, - messages: outboundMessages, - model: selectedModel, - tools: editorProposalTools, - toolChoice, - signal: requestSignal, - onToken: tokenChunk => { - streamedContent += tokenChunk - updateLastAssistantMessage(streamedContent) - }, - }) - - streamSucceeded = true - const streamedModel = toChatText(streamResult?.model) - const streamContent = toChatText(streamResult?.content) - attachAssistantResponseMetadata({ - content: streamContent, - toolCalls: streamResult?.toolCalls, - model: streamedModel, - }) - setChatStatus('Response streamed from GitHub.', 'ok') - } catch (streamError) { - if (requestSignal.aborted) { - if (pendingAbortController === requestAbortController) { - setChatStatus('Chat request canceled.', 'neutral') - pendingAbortController = null - setPendingState(false) - } - return - } - - if (isModelAccessError(streamError)) { - const modelAccessMessage = formatModelAccessErrorMessage(selectedModel) - - updateLastAssistantMessage(modelAccessMessage) - const lastMessage = messages[messages.length - 1] - if (lastMessage) { - lastMessage.level = 'error' - } - renderMessages() - setChatStatus(modelAccessMessage, 'error') - - if (pendingAbortController === requestAbortController) { - pendingAbortController = null - setPendingState(false) - } - return - } - - setChatStatus( - 'Streaming unavailable. Retrying with fallback response...', - 'pending', - ) - } - - if (streamSucceeded) { - if (pendingAbortController === requestAbortController) { - pendingAbortController = null - setPendingState(false) - } - return - } - - try { - const fallbackResult = await requestGitHubChatCompletion({ - token, - messages: outboundMessages, - model: selectedModel, - tools: editorProposalTools, - toolChoice, - signal: requestSignal, - }) - - attachAssistantResponseMetadata({ - content: toChatText(fallbackResult.content), - toolCalls: fallbackResult?.toolCalls, - }) - const fallbackModel = toChatText(fallbackResult.model) - if (fallbackModel) { - const lastMessage = messages[messages.length - 1] - if (lastMessage?.role === 'assistant' && lastMessage.model !== fallbackModel) { - lastMessage.model = fallbackModel - renderMessages() - } - } - setChatStatus('Fallback response loaded.', 'ok') - } catch (fallbackError) { - if (requestSignal.aborted) { - if (pendingAbortController === requestAbortController) { - setChatStatus('Chat request canceled.', 'neutral') - } - return - } - - const fallbackMessage = isModelAccessError(fallbackError) - ? formatModelAccessErrorMessage(selectedModel) - : fallbackError instanceof Error - ? fallbackError.message - : 'Chat request failed.' - - updateLastAssistantMessage(fallbackMessage) - const lastMessage = messages[messages.length - 1] - if (lastMessage) { - lastMessage.level = 'error' - } - renderMessages() - setChatStatus(`Chat request failed: ${fallbackMessage}`, 'error') - } finally { - if (pendingAbortController === requestAbortController) { - pendingAbortController = null - setPendingState(false) - } - } - } - - toggleButton?.setAttribute('aria-expanded', 'false') - drawer?.setAttribute('hidden', '') - initializeModelOptions() - syncModelSelectionForToken(getToken?.()) - syncRepositoryLabel() - ensureUndoActionsNode() - renderMessages() - setChatStatus('Idle', 'neutral') - - const onToggleButtonClick = () => { - setOpen(!open) - } - - const onCloseButtonClick = () => { - setOpen(false) - } - - const onClearButtonClick = () => { - stopPendingRequest() - setPendingState(false) - cancelPendingAssistantBodyUpdate() - pendingAssistantBodyText = null - resetChatContextState() - messages.length = 0 - renderMessages() - setChatStatus('Chat cleared.', 'neutral') - } - - const onDrawerClick = event => { - const target = event.target - if (!(target instanceof HTMLElement)) { - return - } - - const button = target.closest('button[data-action]') - if (!(button instanceof HTMLButtonElement)) { - return - } - - const action = button.dataset.action - - if (action === 'undo-tab-apply') { - const undone = undoActiveTabApply() - if (!undone) { - setChatStatus('No tab apply action is available to undo.', 'error') - } - renderMessages() - return - } - - const messageIndex = Number(button.dataset.messageIndex) - - if ( - !Number.isFinite(messageIndex) || - messageIndex < 0 || - messageIndex >= messages.length - ) { - return - } - - const message = messages[messageIndex] - if (!message || message.role !== 'assistant') { - return - } - - if (action === 'request-apply') { - const proposalOriginalIndex = Number(button.dataset.proposalOriginalIndex) - if (!Number.isFinite(proposalOriginalIndex) || proposalOriginalIndex < 0) { - return - } - - const applied = applyProposalToTab({ - messageIndex, - proposalOriginalIndex, - }) - - if (!applied) { - setChatStatus('Could not apply proposal to tab.', 'error') - } else { - message.appliedTargets = { - ...(message.appliedTargets && typeof message.appliedTargets === 'object' - ? message.appliedTargets - : {}), - [applied.appliedKey]: true, - } - } - renderMessages() - return - } - } - - const onSendButtonClick = () => { - void runChatRequest() - } - - const onPromptInputKeydown = event => { - if (event.key !== 'Enter' || (!event.metaKey && !event.ctrlKey)) { - return - } - - event.preventDefault() - void runChatRequest() - } - - const onDocumentKeydown = event => { - if (event.key === 'Escape' && open) { - setOpen(false) - } - } - - toggleButton?.addEventListener('click', onToggleButtonClick) - closeButton?.addEventListener('click', onCloseButtonClick) - clearButton?.addEventListener('click', onClearButtonClick) - drawer?.addEventListener('click', onDrawerClick) - sendButton?.addEventListener('click', onSendButtonClick) - promptInput?.addEventListener('keydown', onPromptInputKeydown) - document.addEventListener('keydown', onDocumentKeydown) - - return { - setOpen, - isOpen: () => open, - setSelectedRepository: () => { - syncRepositoryLabel() - }, - onActiveWorkspaceTabChange: () => { - renderMessages() - }, - setToken: token => { - syncModelSelectionForToken(token) - }, - dispose: () => { - stopPendingRequest() - setPendingState(false) - cancelPendingAssistantBodyUpdate() - pendingAssistantBodyText = null - resetChatContextState() - if (undoActionsNode) { - undoActionsNode.remove() - undoActionsNode = null - } - toggleButton?.removeEventListener('click', onToggleButtonClick) - closeButton?.removeEventListener('click', onCloseButtonClick) - clearButton?.removeEventListener('click', onClearButtonClick) - drawer?.removeEventListener('click', onDrawerClick) - sendButton?.removeEventListener('click', onSendButtonClick) - promptInput?.removeEventListener('keydown', onPromptInputKeydown) - document.removeEventListener('keydown', onDocumentKeydown) - }, - } -} diff --git a/src/styles/ai-controls.css b/src/styles/ai-controls.css index 47e3555..6d6a866 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; @@ -861,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'] { @@ -875,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; @@ -1086,6 +1176,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);