From 0d314236e2a78ac1a58b5cf42fa8b851fe8f25e0 Mon Sep 17 00:00:00 2001 From: KCM Date: Sun, 6 Sep 2026 10:24:23 -0500 Subject: [PATCH 1/8] docs: outline chat migration plan. --- docs/openrouter-migration-plan.md | 440 ++++++++++++++++++++++++++++++ 1 file changed, 440 insertions(+) create mode 100644 docs/openrouter-migration-plan.md diff --git a/docs/openrouter-migration-plan.md b/docs/openrouter-migration-plan.md new file mode 100644 index 0000000..775b669 --- /dev/null +++ b/docs/openrouter-migration-plan.md @@ -0,0 +1,440 @@ +# OpenRouter Migration Plan + +Plan for migrating the AI chat feature in `@knighted/develop` off the retired GitHub Models +inference API and onto OpenRouter, and for relocating chat out of the GitHub module into a +standalone `src/modules/chat` feature. + +## Background + +GitHub Models retired on July 30, 2026. Every request to +`https://models.github.ai/inference/chat/completions` now fails, and because the retired +host no longer answers CORS preflight the failure surfaces in the browser as a network/CORS +error rather than a clean HTTP status. The chat drawer is fully broken. + +Microsoft Foundry Models is the vendor-recommended migration path, but it is a poor fit +here. It requires an Azure subscription with a payment method, per-model deployments inside +a Foundry Tools resource, and its inference endpoints are not intended for cross-origin +browser calls. That would either reintroduce the same CORS failure or force a backend into +an app whose entire premise is CDN-first and browser-only. + +OpenRouter is the chosen target. It is callable directly from the browser, implements the +OpenAI `/chat/completions` specification that our request and SSE parsing code already +speaks, exposes a public model catalog, and keeps the bring-your-own-credential model +intact. + +## Decisions + +| Decision | Choice | +| ------------------ | --------------------------------------------------------------- | +| Provider | OpenRouter, direct browser `fetch` | +| Credential entry | Paste field inside the chat drawer (no OAuth flow for now) | +| Credential storage | `localStorage`, separate key from the GitHub PAT | +| Chat gating | Toggle button always visible; key field lives inside the drawer | +| Repository | Chat is independent of repository selection | +| Model catalog | Live fetch from `/api/v1/models`, free models grouped first | +| Module location | `src/modules/chat`, decoupled from `src/modules/github` | + +The chat toggle button is always rendered, regardless of any credential. The drawer itself +stays closed until the user clicks the toggle, exactly as it behaves today. Opening the +drawer with no OpenRouter key stored reveals the key field and an explainer in place of a +usable composer; the drawer does not auto-open. + +Chat no longer depends on a selected repository. Local-mode users can chat to update an +editor tab with no GitHub connection at all. A selected repository remains useful context +when one is connected, but it is never a precondition. + +### Correction to a common assumption + +OpenRouter's free models are **not** keyless. Every request to the OpenRouter API requires +an `Authorization: Bearer` API key, including requests for `:free` model variants. "Free" +means no per-token charge, not anonymous access. Free-model rate limits are 50 requests per +day for accounts with no purchased credits and 1000 per day once the account has purchased +at least $10 in credits. + +The practical consequence: an OpenRouter key is **mandatory** for chat, not optional. The +UX still improves over the status quo, because a user can create a key and use free models +without ever spending money, but the drawer must hard-gate sending on the presence of a +key. + +## Verification performed + +Probed live from a browser at a non-OpenRouter origin before writing this plan, so the +plan's assumptions are measured rather than inferred. + +| Check | Result | +| ------------------------------------ | ------------------------------------------------------------------------ | +| `POST /api/v1/chat/completions` CORS | Passes with `Authorization`, `Content-Type`, `Accept: text/event-stream` | +| Attribution headers CORS | `HTTP-Referer` and `X-OpenRouter-Title` also pass preflight | +| `GET /api/v1/models` CORS | Accessible cross-origin, no key required | +| `GET /api/v1/key` CORS | Accessible cross-origin | +| Error body shape | `{"error":{"message":"User not found.","code":401}}` | +| Exposed response headers | Only `content-type` and `cf-ray` | +| Catalog size | 430 models, 21 free, 18 free with `tools` support | + +Still unverified, because both require a funded key: SSE keepalive handling, and the +402 / 404 / 429 error mappings. Both are Phase 2 opening tasks. + +## Current state + +The chat code is provider-neutral almost everywhere. The message normalization, tool-call +assembly, SSE parsing, and proposal/undo machinery are all plain OpenAI-shape handling that +carries over unchanged. What is GitHub-specific is narrow: the endpoint URL, two request +headers, the rate-limit header names, the hardcoded model list, and the fact that a single +GitHub PAT authorizes both repository writes and chat. + +Files in scope: + +| File | GitHub coupling | +| -------------------------------------------------- | ------------------------------------------------------------------------------- | +| `src/modules/github/api/constants.js` | Endpoint URL, default model, hardcoded model list | +| `src/modules/github/api/core.js` | `buildChatRequestHeaders`, `parseRateMetadata`, `parseErrorResponse` | +| `src/modules/github/api/chat.js` | Imports the above; otherwise provider-neutral | +| `src/modules/github/chat/drawer.js` | Model select population, token gating, status copy | +| `src/modules/github/chat/utils.js` | Model-access error string heuristics | +| `src/modules/github/chat/payload.js` | None (context assembly) | +| `src/modules/github/chat/active-tab-context.js` | None | +| `src/modules/github/chat/proposals.js` | None | +| `src/modules/github/chat/tab-target-resolver.js` | None | +| `src/modules/github/chat/tab-scoped-undo-state.js` | None | +| `src/modules/app-core/github-workflows.js` | Wires `getCurrentGitHubToken` and 11 `aiChat*` DOM handles into the chat drawer | +| `src/modules/app-core/github-workflows-setup.js` | Threads `githubAiContextState` through to chat | +| `src/modules/app-core/app-composition-options.js` | Passes `githubAiContextState` through GitHub-named plumbing | +| `src/modules/app-core/app-bindings-startup.js` | Calls `syncAiChatTokenVisibility` at startup | +| `src/modules/app-core/github-pr-context-ui.js` | `syncAiChatTokenVisibility` hides the chat toggle without a PAT | +| `src/app.js` | 11 `aiChat*` DOM handles, `githubAiContextState` | +| `src/index.html` | Chat drawer markup, model `` into a "Free" `` first and "Paid" second, so the zero-cost path is + the discoverable default. +3. Filter to models whose `supported_parameters` array includes `"tools"`. The editor + proposal flow depends on `tools` / `tool_choice`, and a model without tool support fails + silently rather than erroring. This drops the free set from 21 to 18 and the full + catalog to a far more navigable size. +4. Default selection is a specific free, tool-capable slug pinned as a constant rather than + inferred, so behavior is deterministic when the catalog fetch fails. Note that free slugs + churn — the pinned default needs a periodic sanity check, and an unknown-model 404 on the + default must fall back to the picker rather than dead-ending. +5. Static fallback list in `src/modules/chat/api/constants.js` for fetch failure, consistent + with the CDN fallback philosophy in `src/modules/cdn.js`. A catalog fetch failure must + not disable chat. + +## Phase 5 — Tests and docs + +### Playwright + +- Retarget all 11 `page.route` mocks in `playwright/github-byot-ai.spec.ts` from + `https://models.github.ai/inference/chat/completions` to + `https://openrouter.ai/api/v1/chat/completions`, and add a mock for + `https://openrouter.ai/api/v1/models`. +- Split the spec. Chat is no longer a GitHub feature, so the chat cases move to + `playwright/chat/` and `github-byot-ai.spec.ts` keeps only PR/BYOT coverage. +- `connectByotWithSingleRepo` in `playwright/helpers/app-test-helpers.ts` grows a sibling + helper for connecting an OpenRouter key, so specs can set up either credential + independently. +- The existing "chat stays hidden until token connect" case encodes the old coupling and is + rewritten, not patched. Replace it with coverage of all four cells of the gating matrix, + asserting the chat toggle is visible in every one. +- Assert the chat drawer stays closed until the toggle is clicked, in all four cells. +- Assert that deleting the GitHub PAT while the chat drawer is open leaves it open and + functional. +- Add local-mode chat specs with no PAT at all: send a message, apply a proposal to an + editor tab, and undo it, with no repository selected at any point. +- Add a spec asserting no request to `openrouter.ai` carries the PAT, and no request to + `api.github.com` carries the OpenRouter key. +- Add coverage for the free-model grouping, the 402 and 429 error messages, and graceful + degradation when the catalog fetch fails. +- Follow the repo's accessible-selector convention for the new key field: label it and + reach it with `getByLabel`, not a CSS locator. + +### Docs + +- New `docs/openrouter-byok.md` covering key creation, free-model limits, and the + browser-local storage guarantee. +- `docs/byot.md` narrows to the GitHub PAT and cross-links the new doc. +- `docs/ai-chat-context-and-payload-strategy.md` file paths updated for the move. +- `docs/localstorage-state.md` gains the new storage key. +- The in-app token info panel and the doc link in `src/index.html` updated to describe two + independent, optional-in-different-ways credentials. +- `README.md` chat section updated. + +## Risks + +| Risk | Status | Mitigation | +| ----------------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Browser CORS on `/chat/completions` | Resolved | Verified: preflight passes with `Authorization`, `Content-Type`, `Accept: text/event-stream` | +| Browser CORS on `/api/v1/models` | Resolved | Verified: accessible cross-origin, no key required | +| Free models lack tool support | Resolved | Verified: 18 of 21 free models advertise `tools` in `supported_parameters` | +| Rate-limit headers unreadable in browser | Resolved | Verified: only `content-type` and `cf-ray` exposed. Drop header parsing; use `/api/v1/key` if needed | +| SSE keepalive comments break the stream reader | Open | Needs a funded key. First task of Phase 2 | +| 402/404/429 mappings unconfirmed | Open | Auth is checked first, so these need a valid key to provoke. Confirm during Phase 2 | +| `syncAiChatTokenVisibility` split leaves gaps | Open | Enumerated above; explicit matrix coverage in Playwright | +| Repository-independent chat hits untested paths | Open | Audit list above; local-mode specs with no PAT | +| Pinned default free slug goes away | Open | 404 on default falls back to the picker; periodic sanity check | +| 50 req/day free limit feels broken to users | Open | Explicit 429 copy naming the limit and the credits threshold | +| Key in `localStorage` is XSS-exposed | Accepted | Same threat model as the existing PAT; document it, and note the OpenRouter key is scoped to inference spend only, unlike the PAT which can write repositories | +| Phase 1 rename churn hides regressions | Accepted | Keep Phase 1 as a pure move with no behavior change and lint/smoke before Phase 2 | + +## Out of scope + +- OAuth PKCE connect flow. Better UX than a paste field and worth revisiting, but it is a + larger change and the paste field matches the existing BYOT pattern. +- Microsoft Foundry support. The provider seam introduced in Phase 1 leaves room for a + second implementation, but nothing here should be generalized speculatively for it. +- Any change to the context assembly, proposal, or undo behavior. Those files move and are + otherwise untouched. + +## Approvals needed + +Per `AGENTS.md`, confirm before implementation starts: + +- Module relocation and identifier renames, which change file layout and documented paths. +- The gating change making chat independent of the GitHub PAT, which is user-visible + behavior documented in the README. +- No new dependencies are proposed; the migration is plain `fetch` throughout. From 813f15bfc18c7b3e3ab63c38961160fa1af158a3 Mon Sep 17 00:00:00 2001 From: KCM Date: Sun, 6 Sep 2026 15:12:14 -0500 Subject: [PATCH 2/8] refactor: chat module. (#147) --- .github/workflows/playwright.yml | 1 + docs/ai-chat-context-and-payload-strategy.md | 24 ++-- playwright/github-byot-ai.spec.ts | 4 +- src/app.js | 50 ++++++--- src/modules/app-core/chat-workflows.js | 54 +++++++++ .../app-core/github-workflows-setup.js | 4 - src/modules/app-core/github-workflows.js | 43 -------- .../{github => }/chat/active-tab-context.js | 0 .../api/chat.js => chat/api/completions.js} | 33 ++---- src/modules/chat/api/constants.js | 23 ++++ src/modules/chat/api/request.js | 104 ++++++++++++++++++ src/modules/{github => }/chat/drawer.js | 28 ++--- src/modules/{github => }/chat/payload.js | 0 src/modules/{github => }/chat/proposals.js | 0 .../chat/tab-scoped-undo-state.js | 0 .../{github => }/chat/tab-target-resolver.js | 0 src/modules/{github => }/chat/utils.js | 10 +- .../workspace-actions.js} | 4 +- src/modules/github/api/constants.js | 23 ---- src/modules/github/api/core.js | 8 -- 20 files changed, 261 insertions(+), 152 deletions(-) create mode 100644 src/modules/app-core/chat-workflows.js rename src/modules/{github => }/chat/active-tab-context.js (100%) rename src/modules/{github/api/chat.js => chat/api/completions.js} (93%) create mode 100644 src/modules/chat/api/constants.js create mode 100644 src/modules/chat/api/request.js rename src/modules/{github => }/chat/drawer.js (97%) rename src/modules/{github => }/chat/payload.js (100%) rename src/modules/{github => }/chat/proposals.js (100%) rename src/modules/{github => }/chat/tab-scoped-undo-state.js (100%) rename src/modules/{github => }/chat/tab-target-resolver.js (100%) rename src/modules/{github => }/chat/utils.js (87%) rename src/modules/{app-core/github-chat-workspace-actions.js => chat/workspace-actions.js} (95%) diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 62f58c0..6069f9d 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -4,6 +4,7 @@ on: pull_request: branches: - main + - chat types: - opened - synchronize diff --git a/docs/ai-chat-context-and-payload-strategy.md b/docs/ai-chat-context-and-payload-strategy.md index 72ca275..4ca4a5c 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 diff --git a/playwright/github-byot-ai.spec.ts b/playwright/github-byot-ai.spec.ts index 4dd64ee..ec587bd 100644 --- a/playwright/github-byot-ai.spec.ts +++ b/playwright/github-byot-ai.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test' -import { defaultGitHubChatModel } from '../src/modules/github/api/chat.js' +import { defaultChatModel } from '../src/modules/chat/api/completions.js' import type { ChatRequestBody, ChatRequestMessage } from './helpers/app-test-helpers.js' import { appEntryPath, @@ -867,7 +867,7 @@ test('AI chat prefers streaming responses when available', async ({ page }) => { await expect(page.getByText('Streaming response ready')).toBeVisible() expect(streamRequestBody?.metadata).toBeUndefined() - expect(streamRequestBody?.model).toBe(defaultGitHubChatModel) + expect(streamRequestBody?.model).toBe(defaultChatModel) expect(streamRequestBody?.tool_choice).toBe('auto') expect( streamRequestBody?.tools?.some( diff --git a/src/app.js b/src/app.js index 9ba30b4..b120a23 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, @@ -1172,7 +1172,7 @@ const onPrContextStateChange = createPrContextStateChangeHandler({ editedIndicatorVisibilityController, }) -const githubChatWorkspaceActions = createGitHubChatWorkspaceActions({ +const githubChatWorkspaceActions = createChatWorkspaceActions({ getActiveWorkspaceTab, isStyleWorkspaceTab, getCssSource: () => getCssSource(), @@ -1187,7 +1187,6 @@ const githubChatWorkspaceActions = createGitHubChatWorkspaceActions({ const githubWorkflows = createGitHubWorkflowsSetup({ factories: { createGitHubPrEditorSyncController, - createGitHubChatDrawer, createGitHubPrDrawer, createWorkspacesDrawer, }, @@ -1210,17 +1209,6 @@ const githubWorkflows = createGitHubWorkflowsSetup({ byotControls.clearSelectedRepositoryPreference(), }, ui: { - aiChatToggle, - aiChatDrawer, - aiChatClose, - aiChatPrompt, - aiChatModel, - aiChatIncludeEditors, - aiChatSend, - aiChatClear, - aiChatStatus, - aiChatRepository, - aiChatMessages, githubPrToggle, githubPrDrawer, githubPrClose, @@ -1351,7 +1339,6 @@ const githubWorkflows = createGitHubWorkflowsSetup({ /* Save failures are already surfaced through saver onError. */ }) }, - getPersistedActivePrContext, getTokenForVisibility: () => githubAiContextState.token, getActivePrEditorSyncKey: () => githubAiContextState.activePrEditorSyncKey, syncFromActiveContext: ({ tabTargets }) => { @@ -1373,7 +1360,6 @@ const githubWorkflows = createGitHubWorkflowsSetup({ setStatus, showAppToast, shareCurrentLocalWorkspace, - ...githubChatWorkspaceActions, scheduleRender: () => { if ( autoRenderToggle?.checked && @@ -1385,7 +1371,35 @@ const githubWorkflows = createGitHubWorkflowsSetup({ }, }) -chatDrawerController = githubWorkflows.chatDrawerController +const chatWorkflows = initializeChatWorkflows({ + aiChatToggle, + aiChatDrawer, + aiChatClose, + aiChatPrompt, + aiChatModel, + aiChatIncludeEditors, + aiChatSend, + aiChatClear, + aiChatStatus, + aiChatRepository, + aiChatMessages, + getToken: getCurrentGitHubToken, + getSelectedRepository: getCurrentSelectedRepository, + ...githubChatWorkspaceActions, + 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/modules/app-core/chat-workflows.js b/src/modules/app-core/chat-workflows.js new file mode 100644 index 0000000..29c92ae --- /dev/null +++ b/src/modules/app-core/chat-workflows.js @@ -0,0 +1,54 @@ +import { createChatDrawer } from '../chat/drawer.js' + +const initializeChatWorkflows = ({ + aiChatToggle, + aiChatDrawer, + aiChatClose, + aiChatPrompt, + aiChatModel, + aiChatIncludeEditors, + aiChatSend, + aiChatClear, + aiChatStatus, + aiChatRepository, + aiChatMessages, + getToken, + 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, + getToken, + getSelectedRepository, + getActiveWorkspaceTabContext, + getWorkspaceTabContexts, + applyWorkspaceTabContent, + scheduleRender, + getRenderMode, + getStyleMode, + getDrawerSide: () => { + return 'right' + }, + getPersistedActivePrContext, + }) + + return { chatDrawerController } +} + +export { initializeChatWorkflows } 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..dd21b5d 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, @@ -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 93% rename from src/modules/github/api/chat.js rename to src/modules/chat/api/completions.js index c0037f5..4d1eb21 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, chatModelOptions, 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( @@ -396,16 +392,16 @@ const streamGitHubChatCompletion = async ({ } } -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( @@ -438,7 +434,7 @@ 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 { @@ -449,9 +445,4 @@ const requestGitHubChatCompletion = async ({ } } -export { - defaultGitHubChatModel, - githubChatModelOptions, - requestGitHubChatCompletion, - streamGitHubChatCompletion, -} +export { chatModelOptions, 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..f12d9eb --- /dev/null +++ b/src/modules/chat/api/constants.js @@ -0,0 +1,23 @@ +export const chatCompletionsUrl = 'https://models.github.ai/inference/chat/completions' + +export const defaultChatModel = 'openai/gpt-4.1-mini' + +/* Local model options avoid browser CORS failures when calling catalog endpoints directly. */ +export const chatModelOptions = [ + 'openai/gpt-4.1-mini', + 'openai/gpt-4.1', + 'openai/gpt-4.1-nano', + 'openai/gpt-4o', + 'openai/gpt-4o-mini', + 'openai/gpt-5', + 'openai/gpt-5-chat', + 'openai/gpt-5-mini', + 'openai/gpt-5-nano', + 'cohere/cohere-command-r-plus-08-2024', + 'deepseek/deepseek-v3-0324', + 'meta/llama-4-maverick-17b-128e-instruct-fp8', + 'meta/llama-4-scout-17b-16e-instruct', + 'mistral-ai/ministral-3b', + 'mistral-ai/mistral-medium-2505', + 'mistral-ai/mistral-small-2503', +] diff --git a/src/modules/chat/api/request.js b/src/modules/chat/api/request.js new file mode 100644 index 0000000..28c4102 --- /dev/null +++ b/src/modules/chat/api/request.js @@ -0,0 +1,104 @@ +export const buildChatRequestHeaders = ({ token, stream }) => ({ + Accept: stream ? 'text/event-stream' : 'application/json', + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + 'X-GitHub-Api-Version': '2022-11-28', +}) + +const toFiniteNumber = value => { + if (value === null || value === undefined) { + return null + } + + if (typeof value === 'string' && value.trim().length === 0) { + return null + } + + const numberValue = Number(value) + return Number.isFinite(numberValue) ? numberValue : null +} + +const parseRateMetadataFromHeaders = headers => { + if (!headers || typeof headers.get !== 'function') { + return { + remaining: null, + resetEpochSeconds: null, + } + } + + const remaining = + toFiniteNumber(headers.get('x-ratelimit-remaining')) ?? + toFiniteNumber(headers.get('ratelimit-remaining')) + + const resetEpochSeconds = + toFiniteNumber(headers.get('x-ratelimit-reset')) ?? + toFiniteNumber(headers.get('ratelimit-reset')) + + return { + remaining, + resetEpochSeconds, + } +} + +const parseRateMetadataFromBody = body => { + if (!body || typeof body !== 'object') { + return { + remaining: null, + resetEpochSeconds: null, + } + } + + const rateLimit = body.rate_limit ?? body.rateLimit ?? null + + const remaining = + toFiniteNumber(rateLimit?.remaining) ?? toFiniteNumber(body.remaining) ?? null + + const resetEpochSeconds = + toFiniteNumber(rateLimit?.reset) ?? + toFiniteNumber(rateLimit?.reset_epoch_seconds) ?? + toFiniteNumber(rateLimit?.resetEpochSeconds) ?? + toFiniteNumber(body.reset) ?? + null + + return { + remaining, + resetEpochSeconds, + } +} + +const mergeRateMetadata = (primary, fallback) => ({ + remaining: primary.remaining ?? fallback.remaining ?? null, + resetEpochSeconds: primary.resetEpochSeconds ?? fallback.resetEpochSeconds ?? null, +}) + +export const parseRateMetadata = ({ headers, body }) => { + const fromHeaders = parseRateMetadataFromHeaders(headers) + const fromBody = parseRateMetadataFromBody(body) + return mergeRateMetadata(fromHeaders, fromBody) +} + +export const toApiError = ({ message, rateLimit }) => { + const error = new Error(message) + error.rateLimit = rateLimit + return error +} + +export const parseErrorResponse = async response => { + let body = null + + try { + body = await response.json() + } catch { + /* noop */ + } + + const message = + body && typeof body.message === 'string' && body.message.trim() + ? body.message + : `Chat API request failed with status ${response.status}` + + return { + message, + rateLimit: parseRateMetadata({ headers: response.headers, body }), + } +} diff --git a/src/modules/github/chat/drawer.js b/src/modules/chat/drawer.js similarity index 97% rename from src/modules/github/chat/drawer.js rename to src/modules/chat/drawer.js index 8727f67..52e3f3d 100644 --- a/src/modules/github/chat/drawer.js +++ b/src/modules/chat/drawer.js @@ -1,9 +1,9 @@ import { - defaultGitHubChatModel, - githubChatModelOptions, - requestGitHubChatCompletion, - streamGitHubChatCompletion, -} from '../api/chat.js' + chatModelOptions, + defaultChatModel, + requestChatCompletion, + streamChatCompletion, +} from './api/completions.js' import { formatModelAccessErrorMessage, isModelAccessError, @@ -48,7 +48,7 @@ const createMessageLabelIconTemplate = role => { return svg } -export const createGitHubChatDrawer = ({ +export const createChatDrawer = ({ toggleButton, drawer, closeButton, @@ -183,7 +183,7 @@ export const createGitHubChatDrawer = ({ } const nextSelectedModel = toModelId(selectedModel) - const nextModelIds = [...new Set([defaultGitHubChatModel, ...modelIds])] + const nextModelIds = [...new Set([defaultChatModel, ...modelIds])] modelSelect.replaceChildren() @@ -196,13 +196,13 @@ export const createGitHubChatDrawer = ({ } if (!nextModelIds.includes(nextSelectedModel)) { - modelSelect.value = defaultGitHubChatModel + modelSelect.value = defaultChatModel } } const getSelectedModel = () => { if (!(modelSelect instanceof HTMLSelectElement)) { - return defaultGitHubChatModel + return defaultChatModel } return toModelId(modelSelect.value) @@ -210,8 +210,8 @@ export const createGitHubChatDrawer = ({ const initializeModelOptions = () => { replaceModelOptions({ - modelIds: githubChatModelOptions, - selectedModel: defaultGitHubChatModel, + modelIds: chatModelOptions, + selectedModel: defaultChatModel, }) } @@ -221,7 +221,7 @@ export const createGitHubChatDrawer = ({ setModelSelectDisabled(!hasToken) if (!hasToken && modelSelect instanceof HTMLSelectElement) { - modelSelect.value = defaultGitHubChatModel + modelSelect.value = defaultChatModel } if (hasToken && isModelAccessStatusMessage(statusNode?.textContent)) { @@ -773,7 +773,7 @@ export const createGitHubChatDrawer = ({ let streamSucceeded = false try { - const streamResult = await streamGitHubChatCompletion({ + const streamResult = await streamChatCompletion({ token, messages: outboundMessages, model: selectedModel, @@ -838,7 +838,7 @@ export const createGitHubChatDrawer = ({ } try { - const fallbackResult = await requestGitHubChatCompletion({ + const fallbackResult = await requestChatCompletion({ token, messages: outboundMessages, model: selectedModel, diff --git a/src/modules/github/chat/payload.js b/src/modules/chat/payload.js similarity index 100% rename from src/modules/github/chat/payload.js rename to src/modules/chat/payload.js diff --git a/src/modules/github/chat/proposals.js b/src/modules/chat/proposals.js similarity index 100% rename from src/modules/github/chat/proposals.js rename to src/modules/chat/proposals.js 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 87% rename from src/modules/github/chat/utils.js rename to src/modules/chat/utils.js index dd39ebe..9ee64a3 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,11 +10,11 @@ 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 => { @@ -35,7 +35,7 @@ export const isModelAccessError = error => { 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 +44,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, From 784e1b0bdf512cc7c7e35669af0ae53d29d8d373 Mon Sep 17 00:00:00 2001 From: KCM Date: Sun, 6 Sep 2026 17:05:53 -0500 Subject: [PATCH 3/8] feat: phases 2 and 3 of openrouter migration plan. (#148) --- docs/openrouter-migration-plan.md | 67 +++- playwright/github-byot-ai.spec.ts | 341 +++++++++++++++---- playwright/helpers/app-test-helpers.ts | 16 +- src/app.js | 21 +- src/index.html | 104 ++++-- src/modules/app-core/app-bindings-startup.js | 2 +- src/modules/app-core/chat-workflows.js | 10 +- src/modules/app-core/github-pr-context-ui.js | 15 +- src/modules/app-core/github-workflows.js | 2 +- src/modules/chat/api/completions.js | 12 +- src/modules/chat/api/constants.js | 39 +-- src/modules/chat/api/request.js | 107 ++---- src/modules/chat/drawer.js | 147 ++++++-- src/modules/chat/key-controls.js | 98 ++++++ src/modules/chat/key-store.js | 60 ++++ src/modules/chat/payload.js | 27 +- src/modules/chat/proposals.js | 9 +- src/modules/chat/utils.js | 13 + src/styles/ai-controls.css | 72 +++- 19 files changed, 902 insertions(+), 260 deletions(-) create mode 100644 src/modules/chat/key-controls.js create mode 100644 src/modules/chat/key-store.js diff --git a/docs/openrouter-migration-plan.md b/docs/openrouter-migration-plan.md index 775b669..06b0ab6 100644 --- a/docs/openrouter-migration-plan.md +++ b/docs/openrouter-migration-plan.md @@ -43,6 +43,45 @@ Chat no longer depends on a selected repository. Local-mode users can chat to up editor tab with no GitHub connection at all. A selected repository remains useful context when one is connected, but it is never a precondition. +## Implementation status (updated 2026-09-06) + +### Done + +- Phase 1 completed: chat extracted to `src/modules/chat` and decoupled from + `src/modules/github` imports. +- Phase 2 completed for core runtime path: + - OpenRouter chat completions endpoint is live. + - OpenRouter header and error handling is implemented. + - Streaming and fallback request paths are both wired. + - Live verification confirmed SSE keepalive comment handling and `[DONE]` sentinel flow. +- Phase 3 completed: + - Chat toggle remains visible regardless of GitHub PAT state. + - Chat works with no repository selected (local mode). + - In-drawer OpenRouter key controls are implemented with independent storage. + - PR visibility logic was split away from chat visibility behavior. +- Security and UX hardening completed after initial migration: + - Proposal/apply actions are intent-gated (explicit edit intent required). + - Read-only prompts do not surface apply actions from markdown fallback. + - Unmatched proposal targets show guidance instead of a misleading apply prompt. + - OpenRouter key controls now reuse the GitHub PAT-style control pattern and trash icon. +- Tests and checks completed for the implemented behaviors: + - Focused Playwright coverage added for intent gating, tab-context sending, and apply behavior. + - Lint checks are passing. + +### Remaining + +- Phase 4 model catalog work is not yet implemented in runtime code: + - No live `/api/v1/models` fetch integration yet. + - Free vs paid grouping in the model picker is still pending. + - Tool-support filtering from live model metadata is still pending. +- Phase 5 remains partial: + - Chat tests still live inside `playwright/github-byot-ai.spec.ts` rather than a split chat spec path. + - Dedicated OpenRouter usage docs listed below are not fully completed. +- Live production verification still pending for exhaustion states: + - 402 out-of-credits behavior. + - 429 rate-limit behavior. +- Optional one-time migration notice behavior is still pending. + ### Correction to a common assumption OpenRouter's free models are **not** keyless. Every request to the OpenRouter API requires @@ -71,8 +110,18 @@ plan's assumptions are measured rather than inferred. | Exposed response headers | Only `content-type` and `cf-ray` | | Catalog size | 430 models, 21 free, 18 free with `tools` support | -Still unverified, because both require a funded key: SSE keepalive handling, and the -402 / 404 / 429 error mappings. Both are Phase 2 opening tasks. +Still unverified, because both require exhausting an account: the 402 (out of credits) and +429 (rate limited) mappings. + +### Verified live with a funded key + +| Check | Result | +| ---------------------------- | -------------------------------------------------------------------------- | +| SSE keepalive comments | `: OPENROUTER PROCESSING` lines do appear; `parseSseDataLine` ignores them | +| `data: [DONE]` sentinel | Present and handled | +| Invalid model slug | Returns **400**, not 404 — `"... is not a valid model ID"` | +| Tool calling on a free model | `openrouter/free` emits a real `propose_editor_update` call | +| Apply + undo round trip | Proposal applies to the editor tab and the undo action appears | ## Current state @@ -238,15 +287,15 @@ still opens and renders. | Status | Meaning | Drawer message | | ------ | ---------------------- | ---------------------------------------------------- | + | 400 | Unknown model slug | Model unavailable; pick another | | 401 | Invalid or revoked key | Key rejected; re-enter or create a new one | | 402 | Out of credits | Out of credits; add credits or pick a free model | - | 404 | Unknown model slug | Model unavailable; pick another | | 429 | Rate limited | Free-model daily limit reached, or too many requests | The 402 and 429 cases are the ones users on free models will actually hit, so their copy should name the free-model limits explicitly and point at the free-model filter in the - picker. Only the 401 mapping is verified; OpenRouter checks auth before model validity, - so 402/404/429 could not be provoked with an invalid key. Confirm each during Phase 2. + picker. 400 and 401 are verified live. 402 and 429 remain unverified, since provoking + them means exhausting an account. 8. **Rate metadata.** Delete header-based rate parsing entirely rather than porting it. Verified: OpenRouter exposes only `content-type` and `cf-ray` to browser JavaScript via @@ -412,10 +461,10 @@ those 21 advertise tool support. | Browser CORS on `/api/v1/models` | Resolved | Verified: accessible cross-origin, no key required | | Free models lack tool support | Resolved | Verified: 18 of 21 free models advertise `tools` in `supported_parameters` | | Rate-limit headers unreadable in browser | Resolved | Verified: only `content-type` and `cf-ray` exposed. Drop header parsing; use `/api/v1/key` if needed | -| SSE keepalive comments break the stream reader | Open | Needs a funded key. First task of Phase 2 | -| 402/404/429 mappings unconfirmed | Open | Auth is checked first, so these need a valid key to provoke. Confirm during Phase 2 | -| `syncAiChatTokenVisibility` split leaves gaps | Open | Enumerated above; explicit matrix coverage in Playwright | -| Repository-independent chat hits untested paths | Open | Audit list above; local-mode specs with no PAT | +| SSE keepalive comments break the stream reader | Resolved | Verified live with a funded key; keepalive comments are ignored and stream completion is handled correctly | +| 402/404/429 mappings unconfirmed | Partial | 400 invalid model behavior is verified; 402 out-of-credits and 429 rate-limit remain to be validated against exhausted-account conditions | +| `syncAiChatTokenVisibility` split leaves gaps | Resolved | Chat visibility is decoupled from PAT gating; PR surface visibility remains PAT-scoped | +| Repository-independent chat hits untested paths | Partial | Core no-repository behavior is implemented and covered by focused tests; broader cross-browser matrix coverage remains | | Pinned default free slug goes away | Open | 404 on default falls back to the picker; periodic sanity check | | 50 req/day free limit feels broken to users | Open | Explicit 429 copy naming the limit and the credits threshold | | Key in `localStorage` is XSS-exposed | Accepted | Same threat model as the existing PAT; document it, and note the OpenRouter key is scoped to inference spend only, unlike the PAT which can write repositories | diff --git a/playwright/github-byot-ai.spec.ts b/playwright/github-byot-ai.spec.ts index ec587bd..b491630 100644 --- a/playwright/github-byot-ai.spec.ts +++ b/playwright/github-byot-ai.spec.ts @@ -5,7 +5,8 @@ import { appEntryPath, connectByotWithSingleRepo, ensureWorkspacesDrawerClosed, - ensureAiChatDrawerOpen, + connectOpenRouterKey, + openRouterTestKey, ensureOpenPrDrawerOpen, mockRepositoryBranches, openWorkspaceTab, @@ -20,7 +21,7 @@ import { } from './github-pr-drawer/github-pr-drawer.helpers.js' import { selectWorkspacesRepositoryFilter } from './github-pr-drawer/github-pr-drawer.helpers.js' -test('PR/BYOT controls are visible and chat stays hidden until token connect', async ({ +test('PR/BYOT controls are visible and chat is available without a GitHub token', async ({ page, }) => { await waitForAppReady(page) @@ -39,7 +40,7 @@ test('PR/BYOT controls are visible and chat stays hidden until token connect', a await expect(byotControls).toBeVisible() await expect(page.getByRole('textbox', { name: 'GitHub token' })).toBeVisible() await expect(page.getByRole('button', { name: 'Add GitHub token' })).toBeVisible() - await expect(page.getByRole('button', { name: 'Chat' })).toBeHidden() + await expect(page.getByRole('button', { name: 'Chat' })).toBeVisible() await expect(page.getByRole('heading', { name: 'AI Chat' })).toBeHidden() await expect(prToggle).toHaveCount(1) await expect(prToggle).toBeHidden() @@ -47,6 +48,34 @@ test('PR/BYOT controls are visible and chat stays hidden until token connect', a await expect(workspacesToggle).toBeVisible() }) +test('chat drawer prompts for an OpenRouter key and gates the composer', async ({ + page, +}) => { + await waitForAppReady(page) + + await page.getByRole('button', { name: 'Chat', exact: true }).click() + await expect(page.getByRole('complementary', { name: 'AI Chat' })).toBeVisible() + + const keyInput = page.getByLabel('OpenRouter API key', { exact: true }) + await expect(keyInput).toBeVisible() + await expect( + page.getByRole('button', { name: 'Save OpenRouter API key' }), + ).toBeVisible() + await expect( + page.getByRole('button', { name: 'Remove OpenRouter API key' }), + ).toBeHidden() + + await expect(page.getByLabel('Ask AI assistant')).toBeDisabled() + await expect(page.getByRole('button', { name: 'Send' })).toBeDisabled() + await expect(page.getByLabel('Chat model')).toBeDisabled() + + await connectOpenRouterKey(page) + + await expect(page.getByLabel('Ask AI assistant')).toBeEnabled() + await expect(page.getByRole('button', { name: 'Send' })).toBeEnabled() + await expect(page.getByLabel('Chat model')).toBeEnabled() +}) + test('Workspaces repository filter is local-only and read-only without PAT', async ({ page, }) => { @@ -296,13 +325,52 @@ test('PAT connect after Local-only session preserves Local records and enables r ).toBe('local') }) -test('chat becomes available after token connect', async ({ page }) => { +test('GitHub token is never sent to OpenRouter and the chat key is never sent to GitHub', async ({ + page, +}) => { + const openRouterAuthHeaders: string[] = [] + const githubAuthHeaders: string[] = [] + + page.on('request', request => { + const auth = request.headers().authorization ?? '' + if (!auth) { + return + } + + if (request.url().includes('openrouter.ai')) { + openRouterAuthHeaders.push(auth) + } + + if (request.url().includes('api.github.com')) { + githubAuthHeaders.push(auth) + } + }) + + await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + choices: [{ message: { role: 'assistant', content: 'ok' } }], + }), + }) + }) + await waitForAppReady(page) await connectByotWithSingleRepo(page) + await connectOpenRouterKey(page) - await expect(page.getByRole('button', { name: 'Open pull request' })).toBeVisible() - await expect(page.getByRole('button', { name: 'Workspaces' })).toBeVisible() - await expect(page.getByRole('button', { name: 'Chat' })).toBeVisible() + await page.getByLabel('Ask AI assistant').fill('hello') + await page.getByRole('button', { name: 'Send' }).click() + await expect(page.getByText('ok', { exact: true })).toBeVisible() + + expect(openRouterAuthHeaders.length).toBeGreaterThan(0) + expect(githubAuthHeaders.length).toBeGreaterThan(0) + expect(openRouterAuthHeaders.every(header => header.includes(openRouterTestKey))).toBe( + true, + ) + expect(openRouterAuthHeaders.some(header => header.includes('github_pat'))).toBe(false) + expect(githubAuthHeaders.some(header => header.includes(openRouterTestKey))).toBe(false) }) test('workspace context status stays visible without PAT and after PAT connect', async ({ @@ -610,7 +678,7 @@ test('chat stays usable after opening a Local workspace with PAT connected', asy const localWorkspaceId = 'local_chat_issue_128' let streamRequestBody: ChatRequestBody | undefined - await page.route('https://models.github.ai/inference/chat/completions', async route => { + await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { streamRequestBody = route.request().postDataJSON() as ChatRequestBody await route.fulfill({ @@ -651,13 +719,13 @@ test('chat stays usable after opening a Local workspace with PAT connected', asy }, ]) - await connectByotWithSingleRepo(page) + await connectByotWithSingleRepo(page, { assertPrRepositorySelected: false }) await openStoredWorkspaceContextById(page, localWorkspaceId, { repositoryFilter: '__local__', }) await ensureWorkspacesDrawerClosed(page) - await ensureAiChatDrawerOpen(page) + await connectOpenRouterKey(page) await page.getByLabel('Ask AI assistant').fill('Confirm local workspace chat context.') await page.getByRole('button', { name: 'Send' }).click() @@ -694,7 +762,7 @@ test('BYOT controls render with default app entry', async ({ page }) => { await expect(byotControls).toBeVisible() await expect(page.getByRole('textbox', { name: 'GitHub token' })).toBeVisible() await expect(page.getByRole('button', { name: 'Add GitHub token' })).toBeVisible() - await expect(page.getByRole('button', { name: 'Chat' })).toBeHidden() + await expect(page.getByRole('button', { name: 'Chat' })).toBeVisible() await expect(prToggle).toHaveCount(1) await expect(prToggle).toBeHidden() await expect(workspacesToggle).toHaveCount(1) @@ -814,29 +882,10 @@ test('deleting saved GitHub token requires confirmation modal', async ({ page }) await expect(repositoryFilter).toHaveValue('__local__') }) -test('AI chat drawer opens and closes', async ({ page }) => { - await waitForAppReady(page, appEntryPath) - await connectByotWithSingleRepo(page) - - const chatToggle = page.getByRole('button', { name: 'Chat', exact: true }) - const chatDrawer = page.getByRole('heading', { name: 'AI Chat' }) - - await expect(chatToggle).toBeVisible() - await expect(chatToggle).toHaveAttribute('aria-expanded', 'false') - - await chatToggle.click() - await expect(chatDrawer).toBeVisible() - await expect(chatToggle).toHaveAttribute('aria-expanded', 'true') - - await page.getByRole('button', { name: 'Close AI chat drawer' }).click() - await expect(chatDrawer).toBeHidden() - await expect(chatToggle).toHaveAttribute('aria-expanded', 'false') -}) - test('AI chat prefers streaming responses when available', async ({ page }) => { let streamRequestBody: ChatRequestBody | undefined - await page.route('https://models.github.ai/inference/chat/completions', async route => { + await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { streamRequestBody = route.request().postDataJSON() as ChatRequestBody await route.fulfill({ @@ -855,25 +904,21 @@ test('AI chat prefers streaming responses when available', async ({ page }) => { await waitForAppReady(page, `${appEntryPath}`) await connectByotWithSingleRepo(page) - await ensureAiChatDrawerOpen(page) + await connectOpenRouterKey(page) await page.getByLabel('Ask AI assistant').fill('Summarize this repository.') await page.getByRole('button', { name: 'Send' }).click() - await expect( - page.getByText('Response streamed from GitHub.', { exact: true }), - ).toHaveText('Response streamed from GitHub.') + await expect(page.getByText('Response streamed.', { exact: true })).toHaveText( + 'Response streamed.', + ) await expect(page.getByText('Summarize this repository.')).toBeVisible() await expect(page.getByText('Streaming response ready')).toBeVisible() expect(streamRequestBody?.metadata).toBeUndefined() expect(streamRequestBody?.model).toBe(defaultChatModel) - expect(streamRequestBody?.tool_choice).toBe('auto') - expect( - streamRequestBody?.tools?.some( - tool => tool.type === 'function' && tool.function?.name === 'propose_editor_update', - ), - ).toBe(true) + expect(streamRequestBody?.tool_choice).toBeUndefined() + expect(streamRequestBody?.tools).toBeUndefined() expect(streamRequestBody?.messages?.[0]?.role).toBe('system') expect(streamRequestBody?.messages?.[0]?.content).toContain( 'expert software development assistant focused on CSS dialects and JSX syntax', @@ -923,10 +968,102 @@ test('AI chat prefers streaming responses when available', async ({ page }) => { ).toBe(true) }) +test('AI chat enables editor update tools only for explicit edit requests', async ({ + page, +}) => { + let streamRequestBody: ChatRequestBody | undefined + + await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { + streamRequestBody = route.request().postDataJSON() as ChatRequestBody + + await route.fulfill({ + status: 200, + contentType: 'text/event-stream', + body: [ + 'data: {"choices":[{"delta":{"content":"ok"}}]}', + '', + 'data: [DONE]', + '', + ].join('\n'), + }) + }) + + await waitForAppReady(page, `${appEntryPath}`) + await connectByotWithSingleRepo(page) + await connectOpenRouterKey(page) + + await page + .getByLabel('Ask AI assistant') + .fill('Please update app.css to use blue text.') + await page.getByRole('button', { name: 'Send' }).click() + await expect(page.getByText('Response streamed.', { exact: true })).toHaveText( + 'Response streamed.', + ) + + expect(streamRequestBody?.tool_choice).toBe('auto') + expect( + streamRequestBody?.tools?.some( + tool => tool.type === 'function' && tool.function?.name === 'propose_editor_update', + ), + ).toBe(true) +}) + +test('AI chat does not render apply actions for read-only visibility prompts', async ({ + page, +}) => { + let streamRequestBody: ChatRequestBody | undefined + + await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { + const body = route.request().postDataJSON() as ChatRequestBody | null + + if (body?.stream) { + streamRequestBody = body + await route.fulfill({ + status: 502, + contentType: 'application/json', + body: JSON.stringify({ message: 'stream intentionally disabled in this test' }), + }) + return + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + choices: [ + { + message: { + role: 'assistant', + content: + 'Yes, I can see your editor content.\n\n```jsx\nconst App = () =>

Visible

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

Before

') + await openWorkspaceTab(page, 'App.tsx') + await connectOpenRouterKey(page) + + await page.getByLabel('Ask AI assistant').fill('Can you see my editor content?') + await page.getByRole('button', { name: 'Send' }).click() + + await expect(page.getByText('Fallback response loaded.', { exact: true })).toHaveText( + 'Fallback response loaded.', + ) + await expect(page.locator('button[data-action="request-apply"]')).toHaveCount(0) + expect(streamRequestBody?.tool_choice).toBeUndefined() + expect(streamRequestBody?.tools).toBeUndefined() +}) + test('AI chat can disable editor context payload via checkbox', async ({ page }) => { let streamRequestBody: ChatRequestBody | undefined - await page.route('https://models.github.ai/inference/chat/completions', async route => { + await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { streamRequestBody = route.request().postDataJSON() as ChatRequestBody await route.fulfill({ @@ -943,7 +1080,7 @@ test('AI chat can disable editor context payload via checkbox', async ({ page }) await waitForAppReady(page, `${appEntryPath}`) await connectByotWithSingleRepo(page) - await ensureAiChatDrawerOpen(page) + await connectOpenRouterKey(page) const includeEditorsToggle = page.getByLabel('Send tab content') await expect(includeEditorsToggle).toBeChecked() @@ -951,12 +1088,13 @@ test('AI chat can disable editor context payload via checkbox', async ({ page }) await page.getByLabel('Ask AI assistant').fill('No editor source this time.') await page.getByRole('button', { name: 'Send' }).click() - await expect( - page.getByText('Response streamed from GitHub.', { exact: true }), - ).toHaveText('Response streamed from GitHub.') + await expect(page.getByText('Response streamed.', { exact: true })).toHaveText( + 'Response streamed.', + ) expect(streamRequestBody?.metadata).toBeUndefined() - expect(streamRequestBody?.tool_choice).toBe('none') + expect(streamRequestBody?.tool_choice).toBeUndefined() + expect(streamRequestBody?.tools).toBeUndefined() const systemMessages = streamRequestBody?.messages?.filter( (message: ChatRequestMessage) => message.role === 'system', ) @@ -982,7 +1120,7 @@ test('AI chat can disable editor context payload via checkbox', async ({ page }) test('AI chat proposals can be confirmed, applied, and undone per active tab', async ({ page, }) => { - await page.route('https://models.github.ai/inference/chat/completions', async route => { + await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { const body = route.request().postDataJSON() as ChatRequestBody | null if (body?.stream) { @@ -1041,7 +1179,7 @@ test('AI chat proposals can be confirmed, applied, and undone per active tab', a await setComponentEditorSource(page, 'const App = () => ') await setStylesEditorSource(page, '.button { color: red; }') await openWorkspaceTab(page, 'App.tsx') - await ensureAiChatDrawerOpen(page) + await connectOpenRouterKey(page) await page.getByLabel('Ask AI assistant').fill('Suggest updates for both editors.') await page.getByRole('button', { name: 'Send' }).click() @@ -1109,7 +1247,7 @@ test('AI chat proposals can be confirmed, applied, and undone per active tab', a }) test('AI chat apply actions resolve dynamic tab targets', async ({ page }) => { - await page.route('https://models.github.ai/inference/chat/completions', async route => { + await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { const body = route.request().postDataJSON() as ChatRequestBody | null if (body?.stream) { @@ -1166,7 +1304,7 @@ test('AI chat apply actions resolve dynamic tab targets', async ({ page }) => { await setComponentEditorSource(page, 'const App = () => ') await setStylesEditorSource(page, '.button { color: red; }') await openWorkspaceTab(page, 'App.tsx') - await ensureAiChatDrawerOpen(page) + await connectOpenRouterKey(page) await page.getByLabel('Ask AI assistant').fill('Suggest updates for both editors.') await page.getByRole('button', { name: 'Send' }).click() @@ -1195,7 +1333,7 @@ test('AI chat apply actions resolve dynamic tab targets', async ({ page }) => { test('AI chat applies the correct proposal when unresolved targets are filtered out', async ({ page, }) => { - await page.route('https://models.github.ai/inference/chat/completions', async route => { + await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { const body = route.request().postDataJSON() as ChatRequestBody | null if (body?.stream) { @@ -1251,7 +1389,7 @@ test('AI chat applies the correct proposal when unresolved targets are filtered await connectByotWithSingleRepo(page) await setComponentEditorSource(page, 'const App = () =>

Before

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

Before

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

Before

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

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

Workspaces + +

+
+
+ + + + +
+

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

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

class="ai-chat-prompt" id="ai-chat-prompt" rows="4" + autocomplete="off" + autocapitalize="sentences" + autocorrect="on" + spellcheck="true" placeholder="Ask for help developing your component and styles" > diff --git a/src/modules/app-core/app-bindings-startup.js b/src/modules/app-core/app-bindings-startup.js index 7eacb25..7e2c75c 100644 --- a/src/modules/app-core/app-bindings-startup.js +++ b/src/modules/app-core/app-bindings-startup.js @@ -488,7 +488,7 @@ const bindAppEventsAndStart = ({ updateRenderModeEditability() compactAiControlsUi.setOpen(false) githubTokenInfoUi.close() - prContextUi.syncAiChatTokenVisibility(githubAiContextState.token) + prContextUi.syncPrSurfaceVisibility(githubAiContextState.token) updateRenderButtonVisibility() setDiagnosticsDrawerOpen(false) diff --git a/src/modules/app-core/chat-workflows.js b/src/modules/app-core/chat-workflows.js index 29c92ae..4c06b37 100644 --- a/src/modules/app-core/chat-workflows.js +++ b/src/modules/app-core/chat-workflows.js @@ -12,7 +12,10 @@ const initializeChatWorkflows = ({ aiChatStatus, aiChatRepository, aiChatMessages, - getToken, + aiChatKey, + aiChatKeyInput, + aiChatKeyAdd, + aiChatKeyDelete, getSelectedRepository, getActiveWorkspaceTabContext, getWorkspaceTabContexts, @@ -34,7 +37,10 @@ const initializeChatWorkflows = ({ statusNode: aiChatStatus, repositoryNode: aiChatRepository, messagesNode: aiChatMessages, - getToken, + keyRoot: aiChatKey, + keyInput: aiChatKeyInput, + keyAddButton: aiChatKeyAdd, + keyDeleteButton: aiChatKeyDelete, getSelectedRepository, getActiveWorkspaceTabContext, getWorkspaceTabContexts, diff --git a/src/modules/app-core/github-pr-context-ui.js b/src/modules/app-core/github-pr-context-ui.js index fd5689e..1beb26e 100644 --- a/src/modules/app-core/github-pr-context-ui.js +++ b/src/modules/app-core/github-pr-context-ui.js @@ -10,10 +10,8 @@ export const createGitHubPrContextUiController = ({ stylesPrSyncIcon, stylesPrSyncIconPath, githubPrContextClose, - aiChatToggle, githubPrOpenIcon, githubPrPushCommitIcon, - closeChatDrawer, closePrDrawer, closeWorkspacesDrawer, }) => { @@ -99,14 +97,10 @@ export const createGitHubPrContextUiController = ({ syncEditorPrContextIndicators(true) } - const syncAiChatTokenVisibility = token => { + const syncPrSurfaceVisibility = token => { const hasToken = typeof token === 'string' && token.trim().length > 0 if (hasToken) { - if (aiChatToggle instanceof HTMLElement) { - aiChatToggle.hidden = false - } - if (githubPrToggle instanceof HTMLElement) { githubPrToggle.hidden = false } @@ -119,10 +113,6 @@ export const createGitHubPrContextUiController = ({ return } - if (aiChatToggle instanceof HTMLElement) { - aiChatToggle.hidden = true - } - aiChatToggle?.setAttribute('aria-expanded', 'false') contextState.activePrContext = null contextState.activePrEditorSyncKey = '' contextState.hasSyncedActivePrEditorContent = false @@ -133,7 +123,6 @@ export const createGitHubPrContextUiController = ({ } githubPrToggle?.setAttribute('aria-expanded', 'false') githubPrContextClose?.setAttribute('hidden', '') - closeChatDrawer?.() closePrDrawer?.() closeWorkspacesDrawer?.() } @@ -141,6 +130,6 @@ export const createGitHubPrContextUiController = ({ return { markActivePrEditorContentSynced, setActivePrContext, - syncAiChatTokenVisibility, + syncPrSurfaceVisibility, } } diff --git a/src/modules/app-core/github-workflows.js b/src/modules/app-core/github-workflows.js index dd21b5d..80d8871 100644 --- a/src/modules/app-core/github-workflows.js +++ b/src/modules/app-core/github-workflows.js @@ -336,7 +336,7 @@ const initializeGitHubWorkflows = ({ }, onActivePrContextChange: activeContext => { prContextUi.setActivePrContext(activeContext) - prContextUi.syncAiChatTokenVisibility(getTokenForVisibility()) + prContextUi.syncPrSurfaceVisibility(getTokenForVisibility()) if (typeof onPrContextStateChange === 'function') { onPrContextStateChange(activeContext) diff --git a/src/modules/chat/api/completions.js b/src/modules/chat/api/completions.js index 4d1eb21..2e63639 100644 --- a/src/modules/chat/api/completions.js +++ b/src/modules/chat/api/completions.js @@ -308,8 +308,8 @@ const streamChatCompletion = async ({ }) if (!response.ok) { - const { message, rateLimit } = await parseErrorResponse(response) - throw toApiError({ message, rateLimit }) + const { message, status, rateLimit } = await parseErrorResponse(response) + throw toApiError({ message, status, rateLimit }) } if (!response.body) { @@ -388,7 +388,7 @@ const streamChatCompletion = async ({ content: combined, toolCalls: streamingToolCalls, model: responseModel || model, - rateLimit: parseRateMetadata({ headers: response.headers, body: null }), + rateLimit: parseRateMetadata(), } } @@ -425,8 +425,8 @@ const requestChatCompletion = async ({ }) if (!response.ok) { - const { message, rateLimit } = await parseErrorResponse(response) - throw toApiError({ message, rateLimit }) + const { message, status, rateLimit } = await parseErrorResponse(response) + throw toApiError({ message, status, rateLimit }) } const body = await response.json() @@ -441,7 +441,7 @@ const requestChatCompletion = async ({ content, toolCalls, model: typeof body?.model === 'string' && body.model ? body.model : model, - rateLimit: parseRateMetadata({ headers: response.headers, body }), + rateLimit: parseRateMetadata(), } } diff --git a/src/modules/chat/api/constants.js b/src/modules/chat/api/constants.js index f12d9eb..f427018 100644 --- a/src/modules/chat/api/constants.js +++ b/src/modules/chat/api/constants.js @@ -1,23 +1,24 @@ -export const chatCompletionsUrl = 'https://models.github.ai/inference/chat/completions' +export const chatCompletionsUrl = 'https://openrouter.ai/api/v1/chat/completions' +export const openRouterKeysUrl = 'https://openrouter.ai/keys' -export const defaultChatModel = 'openai/gpt-4.1-mini' +/* The free router auto-selects a free model, so it survives free-slug churn. */ +export const defaultChatModel = 'openrouter/free' -/* Local model options avoid browser CORS failures when calling catalog endpoints directly. */ +/* + * Fallback catalog for when the live model list is unavailable. Every entry is + * tool-capable, since editor proposals depend on tool calling. + */ export const chatModelOptions = [ - 'openai/gpt-4.1-mini', - 'openai/gpt-4.1', - 'openai/gpt-4.1-nano', - 'openai/gpt-4o', - 'openai/gpt-4o-mini', - 'openai/gpt-5', - 'openai/gpt-5-chat', - 'openai/gpt-5-mini', - 'openai/gpt-5-nano', - 'cohere/cohere-command-r-plus-08-2024', - 'deepseek/deepseek-v3-0324', - 'meta/llama-4-maverick-17b-128e-instruct-fp8', - 'meta/llama-4-scout-17b-16e-instruct', - 'mistral-ai/ministral-3b', - 'mistral-ai/mistral-medium-2505', - 'mistral-ai/mistral-small-2503', + 'openrouter/free', + 'nvidia/nemotron-3-ultra-550b-a55b:free', + 'minimax/minimax-m3:free', + 'google/gemma-4-31b-it:free', + 'thinkingmachines/inkling:free', + 'openai/gpt-6-astra', + 'anthropic/claude-sonnet-5', + 'google/gemini-3.8-flash', + 'deepseek/deepseek-v4-flash-0731', ] + +export const isFreeChatModel = model => + typeof model === 'string' && (model === 'openrouter/free' || model.endsWith(':free')) diff --git a/src/modules/chat/api/request.js b/src/modules/chat/api/request.js index 28c4102..d3988a2 100644 --- a/src/modules/chat/api/request.js +++ b/src/modules/chat/api/request.js @@ -2,85 +2,45 @@ export const buildChatRequestHeaders = ({ token, stream }) => ({ Accept: stream ? 'text/event-stream' : 'application/json', Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', - 'X-GitHub-Api-Version': '2022-11-28', }) -const toFiniteNumber = value => { - if (value === null || value === undefined) { - return null - } - - if (typeof value === 'string' && value.trim().length === 0) { - return null - } +/* + * OpenRouter exposes only content-type and cf-ray to browser JavaScript, so rate-limit + * headers are unreadable cross-origin. Usage data must come from GET /api/v1/key instead. + */ +export const parseRateMetadata = () => ({ + remaining: null, + resetEpochSeconds: null, +}) - const numberValue = Number(value) - return Number.isFinite(numberValue) ? numberValue : null +export const toApiError = ({ message, status, rateLimit }) => { + const error = new Error(message) + error.status = typeof status === 'number' ? status : null + error.rateLimit = rateLimit + return error } -const parseRateMetadataFromHeaders = headers => { - if (!headers || typeof headers.get !== 'function') { - return { - remaining: null, - resetEpochSeconds: null, - } - } - - const remaining = - toFiniteNumber(headers.get('x-ratelimit-remaining')) ?? - toFiniteNumber(headers.get('ratelimit-remaining')) - - const resetEpochSeconds = - toFiniteNumber(headers.get('x-ratelimit-reset')) ?? - toFiniteNumber(headers.get('ratelimit-reset')) - - return { - remaining, - resetEpochSeconds, - } +const statusMessages = { + 401: 'OpenRouter rejected the API key. Re-enter it or create a new one.', + 402: 'OpenRouter credits exhausted. Add credits or switch to a free model.', + 404: 'That model is not available on OpenRouter. Choose a different model.', + 429: 'Rate limited by OpenRouter. Free models allow 50 requests per day without purchased credits.', } - -const parseRateMetadataFromBody = body => { +const extractErrorMessage = body => { if (!body || typeof body !== 'object') { - return { - remaining: null, - resetEpochSeconds: null, - } + return '' } - const rateLimit = body.rate_limit ?? body.rateLimit ?? null - - const remaining = - toFiniteNumber(rateLimit?.remaining) ?? toFiniteNumber(body.remaining) ?? null - - const resetEpochSeconds = - toFiniteNumber(rateLimit?.reset) ?? - toFiniteNumber(rateLimit?.reset_epoch_seconds) ?? - toFiniteNumber(rateLimit?.resetEpochSeconds) ?? - toFiniteNumber(body.reset) ?? - null - - return { - remaining, - resetEpochSeconds, + const nested = body.error + if (nested && typeof nested === 'object' && typeof nested.message === 'string') { + return nested.message.trim() } -} -const mergeRateMetadata = (primary, fallback) => ({ - remaining: primary.remaining ?? fallback.remaining ?? null, - resetEpochSeconds: primary.resetEpochSeconds ?? fallback.resetEpochSeconds ?? null, -}) - -export const parseRateMetadata = ({ headers, body }) => { - const fromHeaders = parseRateMetadataFromHeaders(headers) - const fromBody = parseRateMetadataFromBody(body) - return mergeRateMetadata(fromHeaders, fromBody) -} + if (typeof body.message === 'string') { + return body.message.trim() + } -export const toApiError = ({ message, rateLimit }) => { - const error = new Error(message) - error.rateLimit = rateLimit - return error + return '' } export const parseErrorResponse = async response => { @@ -92,13 +52,14 @@ export const parseErrorResponse = async response => { /* noop */ } - const message = - body && typeof body.message === 'string' && body.message.trim() - ? body.message - : `Chat API request failed with status ${response.status}` + const providerMessage = extractErrorMessage(body) + const statusMessage = statusMessages[response.status] + const fallbackMessage = + providerMessage || `Chat request failed with status ${response.status}` return { - message, - rateLimit: parseRateMetadata({ headers: response.headers, body }), + message: statusMessage ?? fallbackMessage, + status: response.status, + rateLimit: parseRateMetadata(), } } diff --git a/src/modules/chat/drawer.js b/src/modules/chat/drawer.js index 52e3f3d..89383e1 100644 --- a/src/modules/chat/drawer.js +++ b/src/modules/chat/drawer.js @@ -6,6 +6,7 @@ import { } from './api/completions.js' import { formatModelAccessErrorMessage, + isCredentialError, isModelAccessError, isModelAccessStatusMessage, toChatText, @@ -13,12 +14,16 @@ import { toRepositoryLabel, toRepositoryUrl, } from './utils.js' +import { createChatKeyControls } from './key-controls.js' import { buildActiveTabEditorContext, normalizeWorkspaceTabContext, normalizeWorkspaceTabContexts, } from './active-tab-context.js' -import { buildOutboundMessages as buildPayloadMessages } from './payload.js' +import { + buildOutboundMessages as buildPayloadMessages, + shouldEnableEditorUpdateTools, +} from './payload.js' import { editorProposalTools, toMessageEditorProposals } from './proposals.js' import { resolveWorkspaceTabTarget } from './tab-target-resolver.js' import { createTabScopedUndoState } from './tab-scoped-undo-state.js' @@ -59,8 +64,11 @@ export const createChatDrawer = ({ statusNode, repositoryNode, messagesNode, + keyRoot, + keyInput, + keyAddButton, + keyDeleteButton, includeEditorsContextToggle, - getToken, getSelectedRepository, getWorkspaceTabContexts, applyWorkspaceTabContent, @@ -177,6 +185,32 @@ export const createChatDrawer = ({ modelSelect.disabled = isDisabled } + const keyControls = createChatKeyControls({ + root: keyRoot, + input: keyInput, + addButton: keyAddButton, + deleteButton: keyDeleteButton, + onKeyChange: nextKey => { + syncModelSelectionForKey(nextKey) + syncComposerAvailability() + }, + }) + + const getChatKey = () => keyControls.getKey() + const hasChatKey = () => keyControls.hasKey() + + const syncComposerAvailability = () => { + const keyPresent = hasChatKey() + + if (promptInput instanceof HTMLTextAreaElement) { + promptInput.disabled = !keyPresent + } + + if (sendButton instanceof HTMLButtonElement) { + sendButton.disabled = !keyPresent + } + } + const replaceModelOptions = ({ modelIds, selectedModel }) => { if (!(modelSelect instanceof HTMLSelectElement)) { return @@ -215,16 +249,16 @@ export const createChatDrawer = ({ }) } - const syncModelSelectionForToken = token => { - const hasToken = typeof token === 'string' && token.trim().length > 0 + const syncModelSelectionForKey = key => { + const keyPresent = typeof key === 'string' && key.trim().length > 0 - setModelSelectDisabled(!hasToken) + setModelSelectDisabled(!keyPresent) - if (!hasToken && modelSelect instanceof HTMLSelectElement) { + if (!keyPresent && modelSelect instanceof HTMLSelectElement) { modelSelect.value = defaultChatModel } - if (hasToken && isModelAccessStatusMessage(statusNode?.textContent)) { + if (keyPresent && isModelAccessStatusMessage(statusNode?.textContent)) { setChatStatus('Idle', 'neutral') } } @@ -512,6 +546,7 @@ export const createChatDrawer = ({ const resolveMessageProposals = message => { const proposals = toMessageEditorProposals(message, { fallbackTarget: getFallbackProposalTarget(), + allowMarkdownFallback: message?.allowApplyActions === true, }) const workspaceTabs = getWorkspaceTabs() const activeTabId = getActiveTabContext()?.id || '' @@ -547,6 +582,7 @@ export const createChatDrawer = ({ const proposals = toMessageEditorProposals(message, { fallbackTarget: getFallbackProposalTarget(), + allowMarkdownFallback: message?.allowApplyActions === true, }) const proposal = proposals[proposalOriginalIndex] if (!proposal) { @@ -683,21 +719,21 @@ export const createChatDrawer = ({ } const setPendingState = isPending => { + const composerEnabled = !isPending && hasChatKey() + if (sendButton instanceof HTMLButtonElement) { - sendButton.disabled = isPending + sendButton.disabled = !composerEnabled } if (promptInput instanceof HTMLTextAreaElement) { - promptInput.disabled = isPending + promptInput.disabled = !composerEnabled } if (modelSelect instanceof HTMLSelectElement) { if (isPending) { modelSelect.disabled = true } else { - const token = getToken?.() - const hasToken = typeof token === 'string' && token.trim().length > 0 - modelSelect.disabled = !hasToken + modelSelect.disabled = !hasChatKey() } } @@ -714,11 +750,21 @@ export const createChatDrawer = ({ const normalizedContent = typeof content === 'string' ? content : lastMessage.content const hasContent = typeof normalizedContent === 'string' && normalizedContent.trim().length > 0 + const hasActionableToolProposal = + !hasContent && + normalizedToolCalls.length > 0 && + resolveMessageProposals({ + role: 'assistant', + content: normalizedContent, + toolCalls: normalizedToolCalls, + }).length > 0 lastMessage.content = hasContent || normalizedToolCalls.length === 0 ? normalizedContent - : 'Proposed editor update is ready. Apply below.' + : hasActionableToolProposal + ? 'Proposed editor update is ready. Apply below.' + : 'Proposed editor update is ready, but I could not match its target to an open tab. Ask me to target the active tab or one of the listed tab ids or paths.' lastMessage.toolCalls = normalizedToolCalls if (typeof model === 'string' && model.trim()) { @@ -742,13 +788,16 @@ export const createChatDrawer = ({ return } - const token = getToken?.() + const token = getChatKey() if (!token) { - setChatStatus('Add a GitHub token before starting chat.', 'error') + setChatStatus('Add an OpenRouter API key before starting chat.', 'error') return } const selectedModel = getSelectedModel() + const allowEditorUpdateTools = + includeEditorsContextToggle?.checked === true && + shouldEnableEditorUpdateTools(prompt) stopPendingRequest() const requestAbortController = new AbortController() @@ -756,18 +805,25 @@ export const createChatDrawer = ({ pendingAbortController = requestAbortController appendMessage({ role: 'user', content: prompt }) - appendMessage({ role: 'assistant', content: '', model: selectedModel }) + appendMessage({ + role: 'assistant', + content: '', + model: selectedModel, + allowApplyActions: allowEditorUpdateTools, + }) + if (promptInput instanceof HTMLTextAreaElement) { promptInput.value = '' } setPendingState(true) - setChatStatus('Streaming response from GitHub...', 'pending') + setChatStatus('Streaming response...', 'pending') const repositoryContext = collectRepositoryContext() const editorContext = collectEditorContext() const outboundMessages = buildRequestMessages({ repositoryContext, editorContext }) - const toolChoice = includeEditorsContextToggle?.checked ? 'auto' : 'none' + const toolChoice = allowEditorUpdateTools ? 'auto' : 'none' + const tools = allowEditorUpdateTools ? editorProposalTools : [] let streamedContent = '' let streamSucceeded = false @@ -777,7 +833,7 @@ export const createChatDrawer = ({ token, messages: outboundMessages, model: selectedModel, - tools: editorProposalTools, + tools, toolChoice, signal: requestSignal, onToken: tokenChunk => { @@ -794,7 +850,7 @@ export const createChatDrawer = ({ toolCalls: streamResult?.toolCalls, model: streamedModel, }) - setChatStatus('Response streamed from GitHub.', 'ok') + setChatStatus('Response streamed.', 'ok') } catch (streamError) { if (requestSignal.aborted) { if (pendingAbortController === requestAbortController) { @@ -823,6 +879,48 @@ export const createChatDrawer = ({ return } + if (isCredentialError(streamError)) { + const credentialMessage = + streamError instanceof Error ? streamError.message : 'Chat request failed.' + + updateLastAssistantMessage(credentialMessage) + const lastMessage = messages[messages.length - 1] + if (lastMessage) { + lastMessage.level = 'error' + } + renderMessages() + setChatStatus(credentialMessage, 'error') + + if (pendingAbortController === requestAbortController) { + pendingAbortController = null + setPendingState(false) + } + return + } + + const streamStatus = streamError?.status + if (typeof streamStatus === 'number' && streamStatus >= 400 && streamStatus < 500) { + const streamMessage = + streamError instanceof Error ? streamError.message : 'Chat request failed.' + + updateLastAssistantMessage(streamMessage) + const lastMessage = messages[messages.length - 1] + + if (lastMessage) { + lastMessage.level = 'error' + } + + renderMessages() + setChatStatus(streamMessage, 'error') + + if (pendingAbortController === requestAbortController) { + pendingAbortController = null + setPendingState(false) + } + + return + } + setChatStatus( 'Streaming unavailable. Retrying with fallback response...', 'pending', @@ -842,7 +940,7 @@ export const createChatDrawer = ({ token, messages: outboundMessages, model: selectedModel, - tools: editorProposalTools, + tools, toolChoice, signal: requestSignal, }) @@ -892,7 +990,8 @@ export const createChatDrawer = ({ toggleButton?.setAttribute('aria-expanded', 'false') drawer?.setAttribute('hidden', '') initializeModelOptions() - syncModelSelectionForToken(getToken?.()) + syncModelSelectionForKey(getChatKey()) + syncComposerAvailability() syncRepositoryLabel() ensureUndoActionsNode() renderMessages() @@ -1016,15 +1115,13 @@ export const createChatDrawer = ({ onActiveWorkspaceTabChange: () => { renderMessages() }, - setToken: token => { - syncModelSelectionForToken(token) - }, dispose: () => { stopPendingRequest() setPendingState(false) cancelPendingAssistantBodyUpdate() pendingAssistantBodyText = null resetChatContextState() + keyControls.dispose() if (undoActionsNode) { undoActionsNode.remove() undoActionsNode = null diff --git a/src/modules/chat/key-controls.js b/src/modules/chat/key-controls.js new file mode 100644 index 0000000..d1b6b89 --- /dev/null +++ b/src/modules/chat/key-controls.js @@ -0,0 +1,98 @@ +import { + clearOpenRouterKey, + loadOpenRouterKey, + maskOpenRouterKey, + saveOpenRouterKey, +} from './key-store.js' + +export const createChatKeyControls = ({ + root, + input, + addButton, + deleteButton, + onKeyChange, +}) => { + let savedKey = loadOpenRouterKey() + + const hasKey = () => typeof savedKey === 'string' && savedKey.trim().length > 0 + + const syncFieldState = () => { + const keyPresent = hasKey() + + if (root instanceof HTMLElement) { + root.dataset.keyState = keyPresent ? 'present' : 'missing' + } + + if (input instanceof HTMLInputElement) { + input.value = keyPresent ? maskOpenRouterKey(savedKey) : '' + input.readOnly = keyPresent + input.disabled = keyPresent + input.dataset.keyState = keyPresent ? 'locked' : 'editable' + } + + if (addButton instanceof HTMLButtonElement) { + addButton.hidden = keyPresent + } + + if (deleteButton instanceof HTMLButtonElement) { + deleteButton.hidden = !keyPresent + } + } + + const emitKeyChange = () => { + if (typeof onKeyChange === 'function') { + onKeyChange(savedKey) + } + } + + const handleAdd = () => { + if (!(input instanceof HTMLInputElement)) { + return + } + + const nextKey = input.value.trim() + if (!nextKey) { + return + } + + if (!saveOpenRouterKey(nextKey)) { + return + } + + savedKey = nextKey + syncFieldState() + emitKeyChange() + } + + const handleDelete = () => { + clearOpenRouterKey() + savedKey = null + syncFieldState() + emitKeyChange() + } + + const handleKeydown = event => { + if (event.key !== 'Enter') { + return + } + + event.preventDefault() + handleAdd() + } + + addButton?.addEventListener('click', handleAdd) + deleteButton?.addEventListener('click', handleDelete) + input?.addEventListener('keydown', handleKeydown) + + syncFieldState() + + return { + getKey: () => savedKey, + hasKey, + dispose: () => { + addButton?.removeEventListener('click', handleAdd) + deleteButton?.removeEventListener('click', handleDelete) + input?.removeEventListener('keydown', handleKeydown) + }, + } +} diff --git a/src/modules/chat/key-store.js b/src/modules/chat/key-store.js new file mode 100644 index 0000000..555a69a --- /dev/null +++ b/src/modules/chat/key-store.js @@ -0,0 +1,60 @@ +const openRouterKeyStorageKey = 'knighted:develop:openrouter-key' + +const safelyGetItem = key => { + try { + return localStorage.getItem(key) + } catch { + return null + } +} + +const safelySetItem = (key, value) => { + try { + localStorage.setItem(key, value) + return true + } catch { + return false + } +} + +const safelyRemoveItem = key => { + try { + localStorage.removeItem(key) + } catch { + /* noop */ + } +} + +export const loadOpenRouterKey = () => safelyGetItem(openRouterKeyStorageKey) + +export const saveOpenRouterKey = key => { + if (typeof key !== 'string') { + return false + } + + const normalizedKey = key.trim() + if (!normalizedKey) { + return false + } + + return safelySetItem(openRouterKeyStorageKey, normalizedKey) +} + +export const clearOpenRouterKey = () => { + safelyRemoveItem(openRouterKeyStorageKey) +} + +export const maskOpenRouterKey = key => { + if (typeof key !== 'string') { + return '' + } + + const normalizedKey = key.trim() + if (normalizedKey.length <= 8) { + return '*'.repeat(Math.max(0, normalizedKey.length)) + } + + return `${normalizedKey.slice(0, 4)}${'*'.repeat( + normalizedKey.length - 8, + )}${normalizedKey.slice(-4)}` +} diff --git a/src/modules/chat/payload.js b/src/modules/chat/payload.js index 29a0c2b..77dbb09 100644 --- a/src/modules/chat/payload.js +++ b/src/modules/chat/payload.js @@ -6,10 +6,22 @@ const chatMaxConversationMessages = 14 const systemPromptMessage = [ 'You are an expert software development assistant focused on CSS dialects and JSX syntax across React and native DOM APIs.', 'Prioritize practical, safe, and minimal changes that fit the current project architecture.', - 'When proposing concrete editor edits, prefer tool calls so the user can explicitly review and apply changes.', + 'Only use propose_editor_update tool calls when the user explicitly asks you to modify code (for example: apply, update, edit, patch, rewrite, refactor, or fix).', + 'For read-only questions (for example: can you see, summarize, explain, review, or what changed), respond with text and do not emit propose_editor_update tool calls.', 'Do not assume framework migrations unless the user asks.', ].join(' ') +const explicitEditorUpdateIntentPatterns = [ + /\b(apply|update|edit|modify|change|rewrite|refactor|patch|fix|replace|insert|remove|delete|rename)\b/i, + /\b(make|prepare|propose|generate)\b.{0,40}\b(update|updates|edit|edits|patch|patches|change|changes)\b/i, +] + +const readOnlyPromptPatterns = [ + /\b(can you|could you|please)\s+(see|summarize|explain|review|inspect|analyze)\b/i, + /\bwhat\s+(do you|did you)\s+(see|find|notice|think)\b/i, + /\bcan\s+you\s+still\s+see\b/i, +] + const toUtf8ByteLength = value => { const text = typeof value === 'string' ? value : '' return new TextEncoder().encode(text).length @@ -143,6 +155,19 @@ const collectConversation = messages => { .filter(message => Boolean(message.content)) } +export const shouldEnableEditorUpdateTools = prompt => { + const promptText = toChatText(prompt) + if (!promptText) { + return false + } + + if (readOnlyPromptPatterns.some(pattern => pattern.test(promptText))) { + return false + } + + return explicitEditorUpdateIntentPatterns.some(pattern => pattern.test(promptText)) +} + export const buildOutboundMessages = ({ messages, repositoryContext, diff --git a/src/modules/chat/proposals.js b/src/modules/chat/proposals.js index a6028d8..f149d7f 100644 --- a/src/modules/chat/proposals.js +++ b/src/modules/chat/proposals.js @@ -128,13 +128,20 @@ const extractEditorProposalsFromMarkdown = ({ content, fallbackTarget }) => { ] } -export const toMessageEditorProposals = (message, { fallbackTarget = '' } = {}) => { +export const toMessageEditorProposals = ( + message, + { fallbackTarget = '', allowMarkdownFallback = true } = {}, +) => { const fromToolCalls = extractEditorProposalsFromToolCalls(message?.toolCalls) if (fromToolCalls.length > 0) { return fromToolCalls } + if (!allowMarkdownFallback) { + return [] + } + return extractEditorProposalsFromMarkdown({ content: message?.content, fallbackTarget, diff --git a/src/modules/chat/utils.js b/src/modules/chat/utils.js index 9ee64a3..60c8a6a 100644 --- a/src/modules/chat/utils.js +++ b/src/modules/chat/utils.js @@ -18,21 +18,34 @@ export const toModelId = value => { } export const isModelAccessError = error => { + if (error?.status === 404) { + return true + } + const message = error instanceof Error ? error.message.toLowerCase() : '' if (!message) { return false } + /* OpenRouter reports an unknown slug as 400 "... is not a valid model ID". */ + if (error?.status === 400 && message.includes('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 key. Choose a different model.` diff --git a/src/styles/ai-controls.css b/src/styles/ai-controls.css index 47e3555..e7f98ee 100644 --- a/src/styles/ai-controls.css +++ b/src/styles/ai-controls.css @@ -444,11 +444,63 @@ backdrop-filter: blur(8px); overflow: hidden; display: grid; - grid-template-rows: auto auto minmax(120px, 1fr) auto auto auto; + grid-template-rows: auto auto auto minmax(120px, 1fr) auto auto auto; gap: 10px; z-index: 95; } +.ai-chat-key { + display: grid; + gap: 6px; +} + +.ai-chat-key[data-key-state='present'] .ai-chat-key__hint { + display: none; +} + +.ai-chat-key__field { + display: flex; + align-items: center; + gap: 6px; +} + +.ai-chat-key__input { + flex: 1; + min-width: 0; + padding: 6px 8px; + border: 1px solid var(--border-subtle); + border-radius: 8px; + background: color-mix(in srgb, var(--surface-panel) 70%, transparent); + color: var(--panel-text); + font: inherit; + font-size: 12px; +} + +.ai-chat-key__input:disabled { + opacity: 0.75; +} + +.ai-chat-key__input:focus-visible { + outline: 2px solid var(--focus-ring); + outline-offset: 1px; +} + +.ai-chat-key__action[hidden] { + display: none; +} + +.ai-chat-key__hint { + margin: 0; + font-size: 11px; + line-height: 1.45; + color: color-mix(in srgb, var(--panel-text) 72%, transparent); +} + +.ai-chat-key__hint a { + color: inherit; + text-decoration: underline; +} + .ai-chat-drawer.ai-chat-drawer--right { right: 24px; left: auto; @@ -1086,6 +1138,24 @@ border: 0; } + .diagnostics-toggle.ai-chat-toggle { + gap: 0; + margin-left: 0; + padding-inline: 10px; + } + + .diagnostics-toggle.ai-chat-toggle > span { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } + .app-grid-ai-controls { position: absolute; top: calc(100% + 10px); From 2c2cd87eb73450e22e3540e7c8e797280de0972e Mon Sep 17 00:00:00 2001 From: KCM Date: Mon, 7 Sep 2026 11:00:05 -0500 Subject: [PATCH 4/8] feat: load available models. (#149) --- playwright/helpers/app-test-helpers.ts | 46 ++++++-- src/modules/chat/api/completions.js | 4 +- src/modules/chat/api/constants.js | 1 + src/modules/chat/api/models.js | 88 ++++++++++++++ src/modules/chat/drawer.js | 96 +++++----------- src/modules/chat/model-picker.js | 151 +++++++++++++++++++++++++ 6 files changed, 303 insertions(+), 83 deletions(-) create mode 100644 src/modules/chat/api/models.js create mode 100644 src/modules/chat/model-picker.js diff --git a/playwright/helpers/app-test-helpers.ts b/playwright/helpers/app-test-helpers.ts index 066bbda..8e3f0a6 100644 --- a/playwright/helpers/app-test-helpers.ts +++ b/playwright/helpers/app-test-helpers.ts @@ -568,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') @@ -576,21 +577,42 @@ export const connectByotWithSingleRepo = async ( name: 'Initialize', exact: true, }) + const storedWorkspace = page.getByLabel('Stored workspace') - if (await initializeButton.isVisible()) { + await expect + .poll(async () => { + if (await initializeButton.isVisible()) { + return 'initialize' + } + + 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/modules/chat/api/completions.js b/src/modules/chat/api/completions.js index 2e63639..6a8e3b9 100644 --- a/src/modules/chat/api/completions.js +++ b/src/modules/chat/api/completions.js @@ -1,4 +1,4 @@ -import { chatCompletionsUrl, chatModelOptions, defaultChatModel } from './constants.js' +import { chatCompletionsUrl, defaultChatModel } from './constants.js' import { buildChatRequestHeaders, parseErrorResponse, @@ -445,4 +445,4 @@ const requestChatCompletion = async ({ } } -export { chatModelOptions, defaultChatModel, requestChatCompletion, streamChatCompletion } +export { defaultChatModel, requestChatCompletion, streamChatCompletion } diff --git a/src/modules/chat/api/constants.js b/src/modules/chat/api/constants.js index f427018..63ba871 100644 --- a/src/modules/chat/api/constants.js +++ b/src/modules/chat/api/constants.js @@ -1,4 +1,5 @@ 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. */ diff --git a/src/modules/chat/api/models.js b/src/modules/chat/api/models.js new file mode 100644 index 0000000..a2b3a83 --- /dev/null +++ b/src/modules/chat/api/models.js @@ -0,0 +1,88 @@ +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 sortedModelIds = sortModelEntries(Array.from(byModelId.values())).map( + entry => entry.id, + ) + + if (sortedModelIds.length === 0) { + return chatModelOptions + } + + return [...new Set([defaultChatModel, ...sortedModelIds])] +} + +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/drawer.js b/src/modules/chat/drawer.js index 89383e1..cf69b7f 100644 --- a/src/modules/chat/drawer.js +++ b/src/modules/chat/drawer.js @@ -1,20 +1,15 @@ -import { - chatModelOptions, - defaultChatModel, - requestChatCompletion, - streamChatCompletion, -} from './api/completions.js' +import { requestChatCompletion, streamChatCompletion } from './api/completions.js' import { formatModelAccessErrorMessage, isCredentialError, isModelAccessError, isModelAccessStatusMessage, toChatText, - toModelId, toRepositoryLabel, toRepositoryUrl, } from './utils.js' import { createChatKeyControls } from './key-controls.js' +import { createChatModelPicker } from './model-picker.js' import { buildActiveTabEditorContext, normalizeWorkspaceTabContext, @@ -177,28 +172,37 @@ export const createChatDrawer = ({ pendingAbortController = null } - const setModelSelectDisabled = isDisabled => { - if (!(modelSelect instanceof HTMLSelectElement)) { - return - } - - modelSelect.disabled = isDisabled - } - const keyControls = createChatKeyControls({ root: keyRoot, input: keyInput, addButton: keyAddButton, deleteButton: keyDeleteButton, onKeyChange: nextKey => { - syncModelSelectionForKey(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() @@ -211,57 +215,7 @@ export const createChatDrawer = ({ } } - const replaceModelOptions = ({ modelIds, selectedModel }) => { - if (!(modelSelect instanceof HTMLSelectElement)) { - return - } - - const nextSelectedModel = toModelId(selectedModel) - const nextModelIds = [...new Set([defaultChatModel, ...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 = defaultChatModel - } - } - - const getSelectedModel = () => { - if (!(modelSelect instanceof HTMLSelectElement)) { - return defaultChatModel - } - - return toModelId(modelSelect.value) - } - - const initializeModelOptions = () => { - replaceModelOptions({ - modelIds: chatModelOptions, - selectedModel: defaultChatModel, - }) - } - - const syncModelSelectionForKey = key => { - const keyPresent = typeof key === 'string' && key.trim().length > 0 - - setModelSelectDisabled(!keyPresent) - - if (!keyPresent && modelSelect instanceof HTMLSelectElement) { - modelSelect.value = defaultChatModel - } - - if (keyPresent && isModelAccessStatusMessage(statusNode?.textContent)) { - setChatStatus('Idle', 'neutral') - } - } + const getSelectedModel = () => modelPicker.getSelectedModel() const setOpen = nextOpen => { open = nextOpen === true @@ -280,6 +234,10 @@ export const createChatDrawer = ({ if (open && promptInput instanceof HTMLTextAreaElement) { promptInput.focus() } + + if (open && hasChatKey()) { + void modelPicker.loadModelOptionsFromCatalog() + } } const setChatStatus = (text, level = 'neutral') => { @@ -989,8 +947,8 @@ export const createChatDrawer = ({ toggleButton?.setAttribute('aria-expanded', 'false') drawer?.setAttribute('hidden', '') - initializeModelOptions() - syncModelSelectionForKey(getChatKey()) + modelPicker.initializeModelOptions() + modelPicker.syncModelSelectionForKey(getChatKey()) syncComposerAvailability() syncRepositoryLabel() ensureUndoActionsNode() diff --git a/src/modules/chat/model-picker.js b/src/modules/chat/model-picker.js new file mode 100644 index 0000000..c0d9e16 --- /dev/null +++ b/src/modules/chat/model-picker.js @@ -0,0 +1,151 @@ +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 = ({ modelIds, selectedModel }) => { + if (!(modelSelect instanceof HTMLSelectElement)) { + return + } + + const nextSelectedModel = toModelId(selectedModel) + const nextModelIds = [...new Set([defaultChatModel, ...modelIds])] + const freeModelIds = [] + const paidModelIds = [] + + for (const modelId of nextModelIds) { + if (isFreeChatModel(modelId)) { + freeModelIds.push(modelId) + } else { + paidModelIds.push(modelId) + } + } + + modelSelect.replaceChildren() + + const appendGroupedOptions = (label, ids) => { + if (ids.length === 0) { + return + } + + const group = document.createElement('optgroup') + group.label = label + + for (const modelId of ids) { + const option = document.createElement('option') + option.value = modelId + option.textContent = modelId + option.selected = modelId === nextSelectedModel + group.append(option) + } + + modelSelect.append(group) + } + + appendGroupedOptions('Free', freeModelIds) + appendGroupedOptions('Paid', paidModelIds) + + if (!nextModelIds.includes(nextSelectedModel)) { + modelSelect.value = defaultChatModel + } + } + + const getSelectedModel = () => { + if (!(modelSelect instanceof HTMLSelectElement)) { + return defaultChatModel + } + + return toModelId(modelSelect.value) + } + + const initializeModelOptions = () => { + replaceModelOptions({ + modelIds: chatModelOptions, + 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 selectedModel = getSelectedModel() + const catalogLoadPromise = fetchChatModelOptions({ token: normalizedToken }) + .then(modelIds => { + replaceModelOptions({ + modelIds, + selectedModel, + }) + loadedCatalogToken = normalizedToken + }) + .catch(() => { + /* Keep fallback options when catalog loading fails. */ + }) + .finally(() => { + if (pendingCatalogLoadPromise === catalogLoadPromise) { + pendingCatalogLoadPromise = null + } + }) + + pendingCatalogLoadPromise = catalogLoadPromise + await catalogLoadPromise + } + + 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, + } +} From 872ac13f7eb6172f364760b603e7a12ac1ff3e33 Mon Sep 17 00:00:00 2001 From: KCM Date: Mon, 7 Sep 2026 11:42:10 -0500 Subject: [PATCH 5/8] refactor: chat module. (#150) --- src/modules/chat/drawer-events.js | 130 +++++ src/modules/chat/drawer.js | 829 ++++----------------------- src/modules/chat/message-renderer.js | 285 +++++++++ src/modules/chat/proposal-actions.js | 195 +++++++ src/modules/chat/request-runner.js | 204 +++++++ 5 files changed, 916 insertions(+), 727 deletions(-) create mode 100644 src/modules/chat/drawer-events.js create mode 100644 src/modules/chat/message-renderer.js create mode 100644 src/modules/chat/proposal-actions.js create mode 100644 src/modules/chat/request-runner.js 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 index cf69b7f..cfc3c0d 100644 --- a/src/modules/chat/drawer.js +++ b/src/modules/chat/drawer.js @@ -1,8 +1,4 @@ -import { requestChatCompletion, streamChatCompletion } from './api/completions.js' import { - formatModelAccessErrorMessage, - isCredentialError, - isModelAccessError, isModelAccessStatusMessage, toChatText, toRepositoryLabel, @@ -15,38 +11,11 @@ import { normalizeWorkspaceTabContext, normalizeWorkspaceTabContexts, } from './active-tab-context.js' -import { - buildOutboundMessages as buildPayloadMessages, - shouldEnableEditorUpdateTools, -} from './payload.js' -import { editorProposalTools, toMessageEditorProposals } from './proposals.js' -import { resolveWorkspaceTabTarget } from './tab-target-resolver.js' -import { createTabScopedUndoState } from './tab-scoped-undo-state.js' - -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 -} +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, @@ -76,16 +45,10 @@ export const createChatDrawer = ({ 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() + let proposalActions = null + let messageRenderer = null + let drawerEvents = null const getActiveTabContext = () => { if (typeof getActiveWorkspaceTabContext !== 'function') { @@ -103,68 +66,13 @@ export const createChatDrawer = ({ 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 - } + proposalActions?.resetChatContextState(messages) } 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() - }) + messageRenderer?.cancelPendingAssistantBodyUpdate() } const stopPendingRequest = () => { @@ -276,179 +184,8 @@ export const createChatDrawer = ({ 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() + messageRenderer?.renderMessages(messages) } const appendMessage = message => { @@ -457,19 +194,7 @@ export const createChatDrawer = ({ } const updateLastAssistantMessage = content => { - const lastMessage = messages[messages.length - 1] - if (!lastMessage || lastMessage.role !== 'assistant') { - return - } - - lastMessage.content = content - - if (lastAssistantBodyNode) { - scheduleAssistantBodyUpdate(content) - return - } - - renderMessages() + messageRenderer?.updateLastAssistantMessage(messages, content) } const scheduleRenderAfterEditorUpdate = () => { @@ -489,135 +214,23 @@ export const createChatDrawer = ({ }, 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(), - 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 = ({ messageIndex, proposalOriginalIndex }) => { - const message = messages[messageIndex] - 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 undoActiveTabApply = () => { - const activeTabContext = getActiveTabContext() - const activeTabId = activeTabContext?.id - if (!activeTabId || typeof applyWorkspaceTabContent !== 'function') { - return false - } - - const snapshot = tabScopedUndoState.getSnapshot(activeTabId) - if (!snapshot) { - return false - } + proposalActions = createChatProposalActions({ + getActiveTabContext, + getWorkspaceTabs, + applyWorkspaceTabContent, + scheduleRenderAfterEditorUpdate, + setChatStatus, + }) - const restored = applyWorkspaceTabContent({ - tabId: activeTabId, - content: snapshot.previousValue, - }) - if (!restored) { - return false - } + const resolveMessageProposals = message => + proposalActions?.resolveMessageProposals(message) ?? [] - tabScopedUndoState.clearSnapshot(activeTabId) - scheduleRenderAfterEditorUpdate() - const tabLabel = activeTabContext?.name || snapshot.tabName || 'active tab' - setChatStatus(`Reverted last apply for ${tabLabel}.`, 'neutral') - return true - } + messageRenderer = createChatMessageRenderer({ + messagesNode, + resolveMessageProposals, + getActiveUndoState: () => proposalActions?.getActiveTabUndoState() ?? null, + isRequestPending: () => Boolean(pendingAbortController), + }) const collectRepositoryContext = () => { const repository = getSelectedRepository?.() @@ -738,331 +351,103 @@ export const createChatDrawer = ({ renderMessages() } - const runChatRequest = async () => { - const prompt = toChatText(promptInput?.value) - - if (!prompt) { - setChatStatus('Enter a prompt before sending.', 'error') - return - } - - const token = getChatKey() - if (!token) { - setChatStatus('Add an OpenRouter API key before starting chat.', 'error') - return - } - - const selectedModel = getSelectedModel() - const allowEditorUpdateTools = - includeEditorsContextToggle?.checked === true && - shouldEnableEditorUpdateTools(prompt) - - stopPendingRequest() - const requestAbortController = new AbortController() - const requestSignal = requestAbortController.signal - pendingAbortController = requestAbortController - - appendMessage({ role: 'user', content: prompt }) - appendMessage({ - role: 'assistant', - content: '', - model: selectedModel, - allowApplyActions: allowEditorUpdateTools, - }) - - if (promptInput instanceof HTMLTextAreaElement) { - promptInput.value = '' - } - - setPendingState(true) - setChatStatus('Streaming response...', 'pending') - - const repositoryContext = collectRepositoryContext() - const editorContext = collectEditorContext() - const outboundMessages = buildRequestMessages({ repositoryContext, editorContext }) - 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 - 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.', '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 - } - - if (isCredentialError(streamError)) { - const credentialMessage = - streamError instanceof Error ? streamError.message : 'Chat request failed.' - - updateLastAssistantMessage(credentialMessage) - const lastMessage = messages[messages.length - 1] - if (lastMessage) { - lastMessage.level = 'error' - } - renderMessages() - setChatStatus(credentialMessage, 'error') - - if (pendingAbortController === requestAbortController) { - pendingAbortController = null - setPendingState(false) - } - return - } - - const streamStatus = streamError?.status - if (typeof streamStatus === 'number' && streamStatus >= 400 && streamStatus < 500) { - const streamMessage = - streamError instanceof Error ? streamError.message : 'Chat request failed.' - - updateLastAssistantMessage(streamMessage) - const lastMessage = messages[messages.length - 1] - - if (lastMessage) { - lastMessage.level = 'error' - } - - renderMessages() - setChatStatus(streamMessage, 'error') - - if (pendingAbortController === requestAbortController) { - pendingAbortController = null - setPendingState(false) - } - - return - } - - setChatStatus( - 'Streaming unavailable. Retrying with fallback response...', - 'pending', - ) + const markLastAssistantError = message => { + updateLastAssistantMessage(message) + const lastMessage = messages[messages.length - 1] + if (lastMessage) { + lastMessage.level = 'error' } + renderMessages() + } - if (streamSucceeded) { - if (pendingAbortController === requestAbortController) { - pendingAbortController = null - setPendingState(false) - } + const setLastAssistantModel = model => { + if (!model) { return } - try { - const fallbackResult = await requestChatCompletion({ - token, - messages: outboundMessages, - model: selectedModel, - tools, - 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' - } + const lastMessage = messages[messages.length - 1] + if (lastMessage?.role === 'assistant' && lastMessage.model !== model) { + lastMessage.model = model renderMessages() - setChatStatus(`Chat request failed: ${fallbackMessage}`, 'error') - } finally { - if (pendingAbortController === requestAbortController) { - pendingAbortController = null - setPendingState(false) - } } } - toggleButton?.setAttribute('aria-expanded', 'false') - drawer?.setAttribute('hidden', '') - modelPicker.initializeModelOptions() - modelPicker.syncModelSelectionForKey(getChatKey()) - syncComposerAvailability() - syncRepositoryLabel() - ensureUndoActionsNode() - renderMessages() - setChatStatus('Idle', 'neutral') - - const onToggleButtonClick = () => { - setOpen(!open) - } + 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 onCloseButtonClick = () => { - setOpen(false) + const runChatRequest = async () => { + await requestRunner.runChatRequest() } - const onClearButtonClick = () => { + const onClear = () => { 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 - } + toggleButton?.setAttribute('aria-expanded', 'false') + drawer?.setAttribute('hidden', '') + modelPicker.initializeModelOptions() + modelPicker.syncModelSelectionForKey(getChatKey()) + syncComposerAvailability() + syncRepositoryLabel() + messageRenderer.ensureUndoActionsNode() + renderMessages() + setChatStatus('Idle', 'neutral') - const applied = applyProposalToTab({ + 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, - }) - - 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, @@ -1077,20 +462,10 @@ export const createChatDrawer = ({ stopPendingRequest() setPendingState(false) cancelPendingAssistantBodyUpdate() - pendingAssistantBodyText = null resetChatContextState() keyControls.dispose() - 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) + messageRenderer?.dispose() + drawerEvents?.dispose() }, } } 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/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/chat/request-runner.js b/src/modules/chat/request-runner.js new file mode 100644 index 0000000..9d89339 --- /dev/null +++ b/src/modules/chat/request-runner.js @@ -0,0 +1,204 @@ +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' + +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 + 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.', '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, + }) + + attachAssistantResponseMetadata?.({ + content: toChatText(fallbackResult.content), + toolCalls: fallbackResult?.toolCalls, + }) + 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, + } +} From d72c84eee20d380a1a70e25eee9022e6e24836bc Mon Sep 17 00:00:00 2001 From: KCM Date: Mon, 7 Sep 2026 12:57:34 -0500 Subject: [PATCH 6/8] feat: round out phase 5 of chat migration. (#151) --- README.md | 9 +- docs/ai-chat-context-and-payload-strategy.md | 2 +- docs/byot.md | 14 +- docs/localstorage-state.md | 6 +- docs/openrouter-byok.md | 40 + docs/openrouter-migration-plan.md | 31 +- playwright/chat/ai-chat.spec.ts | 1089 ++++++++++++++++++ playwright/github-byot-ai.spec.ts | 1078 ----------------- playwright/helpers/app-test-helpers.ts | 2 +- src/index.html | 5 +- src/modules/chat/drawer.js | 11 +- src/modules/chat/model-picker.js | 29 +- src/modules/chat/request-runner.js | 43 +- src/styles/ai-controls.css | 40 +- 14 files changed, 1269 insertions(+), 1130 deletions(-) create mode 100644 docs/openrouter-byok.md create mode 100644 playwright/chat/ai-chat.spec.ts diff --git a/README.md b/README.md index 91a42b0..ce7a185 100644 --- a/README.md +++ b/README.md @@ -72,15 +72,18 @@ in the same UI. ## BYOT Guide - GitHub PAT setup and usage: [docs/byot.md](docs/byot.md) +- OpenRouter key setup for AI chat: [docs/openrouter-byok.md](docs/openrouter-byok.md) ## Fine-Grained PAT Quick Setup -For PR/BYOT and AI chat flows, use a fine-grained GitHub PAT and follow the -existing setup guide: +For PR/BYOT flows, use a fine-grained GitHub PAT and follow the setup guide: - Full setup and behavior: [docs/byot.md](docs/byot.md) - Repository permissions screenshot: [docs/media/byot-repo-perms.png](docs/media/byot-repo-perms.png) -- Models permission screenshot: [docs/media/byot-model-perms.png](docs/media/byot-model-perms.png) + +For AI chat, connect an OpenRouter key from the chat drawer: + +- OpenRouter setup and limits: [docs/openrouter-byok.md](docs/openrouter-byok.md) ## License diff --git a/docs/ai-chat-context-and-payload-strategy.md b/docs/ai-chat-context-and-payload-strategy.md index 4ca4a5c..e9eba63 100644 --- a/docs/ai-chat-context-and-payload-strategy.md +++ b/docs/ai-chat-context-and-payload-strategy.md @@ -177,7 +177,7 @@ Potential ideas: Current strategy has focused Playwright coverage for the chat drawer behavior and context policy assertions in: -- playwright/github-byot-ai.spec.ts +- playwright/chat/ai-chat.spec.ts ## Scope note diff --git a/docs/byot.md b/docs/byot.md index eba1bc9..0ced166 100644 --- a/docs/byot.md +++ b/docs/byot.md @@ -1,6 +1,6 @@ # BYOT Setup for GitHub in @knighted/develop -This guide explains how to create and use a fine-grained GitHub Personal Access Token (PAT) for the BYOT flow in `@knighted/develop`. +This guide explains how to create and use a fine-grained GitHub Personal Access Token (PAT) for repository and pull-request workflows in `@knighted/develop`. ## What BYOT does in the app @@ -11,7 +11,9 @@ BYOT controls are available by default. The token is used to: - let you choose which repository to work with - use PR context features (Open PR / Push Commit flows) -The same token is also used for GitHub Models requests in AI chat flows. +AI chat no longer uses the GitHub PAT. Chat uses a separate OpenRouter API key. + +- OpenRouter key setup for chat: [openrouter-byok.md](openrouter-byok.md) ## Privacy and storage behavior @@ -24,10 +26,8 @@ The same token is also used for GitHub Models requests in AI chat flows. Create a fine-grained PAT in GitHub settings and grant the permissions below. - Repository permissions screenshot: [docs/media/byot-repo-perms.png](docs/media/byot-repo-perms.png) -- Models permission screenshot: [docs/media/byot-model-perms.png](docs/media/byot-model-perms.png) Repository PAT permissions -Models PAT permission ### Repository permissions @@ -35,10 +35,6 @@ Create a fine-grained PAT in GitHub settings and grant the permissions below. - Pull requests: Read and write - Metadata: Read-only (required) -### Account permissions - -- Models: Read-only - ### Repository access scope Use either of these scopes depending on your needs: @@ -55,7 +51,7 @@ Use either of these scopes depending on your needs: 3. Paste token into the BYOT input and click add. 4. Verify repository list loads. 5. Select your target repository. -6. Use AI chat as needed after connecting your token. +6. Use PR and repository workflows after connecting your token. ## Screenshots diff --git a/docs/localstorage-state.md b/docs/localstorage-state.md index ae273a2..7e7485b 100644 --- a/docs/localstorage-state.md +++ b/docs/localstorage-state.md @@ -8,9 +8,11 @@ This document is the source of truth for what `@knighted/develop` stores in `loc 1. `knighted:develop:github-pat` - GitHub personal access token used for API calls. -2. `knighted-develop:render-mode` +2. `knighted:develop:openrouter-key` + - OpenRouter API key used by AI chat requests. +3. `knighted-develop:render-mode` - Last selected render mode (`dom` or `react`). -3. Theme/UI preference keys managed by layout theme modules. +4. Theme/UI preference keys managed by layout theme modules. ## Not Allowed In localStorage diff --git a/docs/openrouter-byok.md b/docs/openrouter-byok.md new file mode 100644 index 0000000..6d1a417 --- /dev/null +++ b/docs/openrouter-byok.md @@ -0,0 +1,40 @@ +# OpenRouter BYOK Setup for AI Chat in @knighted/develop + +This guide explains how to create and use an OpenRouter API key for AI chat in `@knighted/develop`. + +## What this key does + +The OpenRouter key is used only for AI chat requests and model catalog requests in the chat drawer. + +- It enables chat completions against `https://openrouter.ai/api/v1/chat/completions`. +- It enables loading model options from `https://openrouter.ai/api/v1/models`. + +The key is independent from the GitHub PAT used by PR and repository workflows. + +## Free model limits + +OpenRouter free models still require an API key. + +- Free models have no per-token charge. +- Accounts without purchased credits are currently limited to 50 requests per day. +- Accounts that have purchased at least $10 in credits are currently limited to 1000 requests per day. + +## Privacy and storage behavior + +- Your OpenRouter key is stored only in your browser `localStorage`. +- The key is sent only to OpenRouter endpoints used by chat. +- The key is never sent to GitHub endpoints. +- You can remove it any time from the chat drawer key controls. + +## Create and connect an OpenRouter key + +1. Open https://openrouter.ai/keys and create an API key. +2. Open the Chat drawer in `@knighted/develop`. +3. Paste the key into the `OpenRouter API key` input. +4. Click `Save OpenRouter API key`. +5. Send a test prompt and confirm the assistant response appears. + +## Related docs + +- GitHub PAT setup for PR/repository workflows: [byot.md](byot.md) +- Local storage keys: [localstorage-state.md](localstorage-state.md) diff --git a/docs/openrouter-migration-plan.md b/docs/openrouter-migration-plan.md index 06b0ab6..e606a8a 100644 --- a/docs/openrouter-migration-plan.md +++ b/docs/openrouter-migration-plan.md @@ -43,7 +43,7 @@ Chat no longer depends on a selected repository. Local-mode users can chat to up editor tab with no GitHub connection at all. A selected repository remains useful context when one is connected, but it is never a precondition. -## Implementation status (updated 2026-09-06) +## Implementation status (updated 2026-09-07) ### Done @@ -67,20 +67,21 @@ when one is connected, but it is never a precondition. - Tests and checks completed for the implemented behaviors: - Focused Playwright coverage added for intent gating, tab-context sending, and apply behavior. - Lint checks are passing. - -### Remaining - -- Phase 4 model catalog work is not yet implemented in runtime code: - - No live `/api/v1/models` fetch integration yet. - - Free vs paid grouping in the model picker is still pending. - - Tool-support filtering from live model metadata is still pending. -- Phase 5 remains partial: - - Chat tests still live inside `playwright/github-byot-ai.spec.ts` rather than a split chat spec path. - - Dedicated OpenRouter usage docs listed below are not fully completed. -- Live production verification still pending for exhaustion states: - - 402 out-of-credits behavior. - - 429 rate-limit behavior. -- Optional one-time migration notice behavior is still pending. + - Chat test coverage is split into `playwright/chat/ai-chat.spec.ts`, with PR/BYOT + coverage retained in `playwright/github-byot-ai.spec.ts`. + - OpenRouter migration docs are in place (`docs/openrouter-byok.md`) and cross-linked + from README/BYOT docs. +- Phase 4 completed in runtime code: + - Live `/api/v1/models` catalog fetch is wired in `src/modules/chat/api/models.js`. + - Model picker groups models into Free and Paid sections. + - Model catalog entries are filtered to tool-capable models. + +### Remaining (non-blocking) + +- Optional follow-up coverage: + - Explicit targeted specs for 402/429 status messaging and catalog-fetch degradation. +- Optional UX follow-up: + - One-time migration notice on first load after upgrade. ### Correction to a common assumption diff --git a/playwright/chat/ai-chat.spec.ts b/playwright/chat/ai-chat.spec.ts new file mode 100644 index 0000000..6106ab0 --- /dev/null +++ b/playwright/chat/ai-chat.spec.ts @@ -0,0 +1,1089 @@ +import { expect, test } from '@playwright/test' +import { defaultChatModel } from '../../src/modules/chat/api/completions.js' +import type { ChatRequestBody, ChatRequestMessage } from '../helpers/app-test-helpers.js' +import { + appEntryPath, + connectByotWithSingleRepo, + connectOpenRouterKey, + ensureWorkspacesDrawerClosed, + openRouterTestKey, + openWorkspaceTab, + setComponentEditorSource, + setStylesEditorSource, + waitForAppReady, +} from '../helpers/app-test-helpers.js' +import { + openStoredWorkspaceContextById, + seedLocalWorkspaceContexts, +} from '../github-pr-drawer/github-pr-drawer.helpers.js' + +test('chat drawer prompts for an OpenRouter key and gates the composer', async ({ + page, +}) => { + await waitForAppReady(page) + + await page.getByRole('button', { name: 'Chat', exact: true }).click() + await expect(page.getByRole('complementary', { name: 'AI Chat' })).toBeVisible() + + const keyInput = page.getByLabel('OpenRouter API key', { exact: true }) + await expect(keyInput).toBeVisible() + await expect( + page.getByRole('button', { name: 'Save OpenRouter API key' }), + ).toBeVisible() + await expect( + page.getByRole('button', { name: 'Remove OpenRouter API key' }), + ).toBeHidden() + + await expect(page.getByLabel('Ask AI assistant')).toBeDisabled() + await expect(page.getByRole('button', { name: 'Send' })).toBeDisabled() + await expect(page.getByLabel('Chat model')).toBeDisabled() + + await connectOpenRouterKey(page) + + await expect(page.getByLabel('Ask AI assistant')).toBeEnabled() + await expect(page.getByRole('button', { name: 'Send' })).toBeEnabled() + await expect(page.getByLabel('Chat model')).toBeEnabled() +}) + +test('GitHub token is never sent to OpenRouter and the chat key is never sent to GitHub', async ({ + page, +}) => { + const openRouterAuthHeaders: string[] = [] + const githubAuthHeaders: string[] = [] + + page.on('request', request => { + const auth = request.headers().authorization ?? '' + if (!auth) { + return + } + + if (request.url().includes('openrouter.ai')) { + openRouterAuthHeaders.push(auth) + } + + if (request.url().includes('api.github.com')) { + githubAuthHeaders.push(auth) + } + }) + + await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + choices: [{ message: { role: 'assistant', content: 'ok' } }], + }), + }) + }) + + await waitForAppReady(page) + await connectByotWithSingleRepo(page) + await connectOpenRouterKey(page) + + await page.getByLabel('Ask AI assistant').fill('hello') + await page.getByRole('button', { name: 'Send' }).click() + await expect(page.getByText('ok', { exact: true })).toBeVisible() + + expect(openRouterAuthHeaders.length).toBeGreaterThan(0) + expect(githubAuthHeaders.length).toBeGreaterThan(0) + expect(openRouterAuthHeaders.every(header => header.includes(openRouterTestKey))).toBe( + true, + ) + expect(openRouterAuthHeaders.some(header => header.includes('github_pat'))).toBe(false) + expect(githubAuthHeaders.some(header => header.includes(openRouterTestKey))).toBe(false) +}) + +test('chat stays usable after opening a Local workspace with PAT connected', async ({ + page, +}) => { + const localWorkspaceId = 'local_chat_issue_128' + let streamRequestBody: ChatRequestBody | undefined + + await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { + streamRequestBody = route.request().postDataJSON() as ChatRequestBody + + await route.fulfill({ + status: 200, + contentType: 'text/event-stream', + body: [ + 'data: {"choices":[{"delta":{"content":"Local workspace chat works"}}]}', + '', + 'data: [DONE]', + '', + ].join('\n'), + }) + }) + + await waitForAppReady(page) + + await seedLocalWorkspaceContexts(page, [ + { + id: localWorkspaceId, + repo: '', + workspaceScope: 'local', + head: 'feat/local-chat-issue-128', + prTitle: 'Issue 128 local workspace', + prContextState: 'inactive', + tabs: [ + { + id: 'component', + path: 'src/component.tsx', + language: 'tsx', + role: 'component', + content: 'export const App = () =>
local chat issue 128
', + order: 0, + source: 'workspace', + dirty: false, + }, + ], + activeTabId: 'component', + }, + ]) + + await connectByotWithSingleRepo(page, { assertPrRepositorySelected: false }) + await openStoredWorkspaceContextById(page, localWorkspaceId, { + repositoryFilter: '__local__', + }) + await ensureWorkspacesDrawerClosed(page) + + await connectOpenRouterKey(page) + + await page.getByLabel('Ask AI assistant').fill('Confirm local workspace chat context.') + await page.getByRole('button', { name: 'Send' }).click() + + await expect(page.getByText('Local workspace chat works')).toBeVisible() + await expect( + page.getByText('Select a writable repository before starting chat.', { exact: true }), + ).toHaveCount(0) + + const repositorySystemMessage = streamRequestBody?.messages?.find( + (message: ChatRequestMessage) => + message.role === 'system' && + message.content?.includes('Selected repository context'), + ) + expect(repositorySystemMessage?.content).toContain( + 'Repository: knightedcodemonkey/develop', + ) +}) + +test('AI chat prefers streaming responses when available', async ({ page }) => { + let streamRequestBody: ChatRequestBody | undefined + + await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { + streamRequestBody = route.request().postDataJSON() as ChatRequestBody + + await route.fulfill({ + status: 200, + contentType: 'text/event-stream', + body: [ + 'data: {"choices":[{"delta":{"content":"Streaming "}}]}', + '', + 'data: {"choices":[{"delta":{"content":"response ready"}}]}', + '', + 'data: [DONE]', + '', + ].join('\n'), + }) + }) + + await waitForAppReady(page, `${appEntryPath}`) + await connectByotWithSingleRepo(page) + await connectOpenRouterKey(page) + + await page.getByLabel('Ask AI assistant').fill('Summarize this repository.') + await page.getByRole('button', { name: 'Send' }).click() + + await expect(page.getByText('Response streamed.', { exact: true })).toHaveText( + 'Response streamed.', + ) + await expect(page.getByText('Summarize this repository.')).toBeVisible() + await expect(page.getByText('Streaming response ready')).toBeVisible() + + expect(streamRequestBody?.metadata).toBeUndefined() + expect(streamRequestBody?.model).toBe(defaultChatModel) + expect(streamRequestBody?.tool_choice).toBeUndefined() + expect(streamRequestBody?.tools).toBeUndefined() + expect(streamRequestBody?.messages?.[0]?.role).toBe('system') + expect(streamRequestBody?.messages?.[0]?.content).toContain( + 'expert software development assistant focused on CSS dialects and JSX syntax', + ) + expect(streamRequestBody?.messages?.[0]?.content).toContain( + 'JSX is compiled for @knighted/jsx DOM runtime', + ) + expect(streamRequestBody?.messages?.[0]?.content).toContain( + 'Do not suggest React imports, hooks, or React-only runtime APIs', + ) + expect(streamRequestBody?.messages?.[0]?.content).toContain( + 'Preserve the selected style dialect and avoid cross-dialect rewrites', + ) + const systemMessages = streamRequestBody?.messages?.filter( + (message: ChatRequestMessage) => message.role === 'system', + ) + const repositorySystemMessage = systemMessages?.find((message: ChatRequestMessage) => + message.content?.includes('Selected repository context'), + ) + expect(repositorySystemMessage?.content).toContain( + 'Repository: knightedcodemonkey/develop', + ) + expect(repositorySystemMessage?.content).toContain( + 'Repository URL: https://github.com/knightedcodemonkey/develop', + ) + expect( + systemMessages?.some((message: ChatRequestMessage) => + message.content?.includes('Editor context:'), + ), + ).toBe(true) + expect( + systemMessages?.some( + (message: ChatRequestMessage) => + message.content?.includes('- Active tab:') && + message.content?.includes('App.tsx'), + ), + ).toBe(true) + expect( + systemMessages?.some((message: ChatRequestMessage) => + message.content?.includes('Available tab targets (id and path):'), + ), + ).toBe(true) + expect( + systemMessages?.some((message: ChatRequestMessage) => + message.content?.includes('Active tab source:'), + ), + ).toBe(true) +}) + +test('AI chat enables editor update tools only for explicit edit requests', async ({ + page, +}) => { + let streamRequestBody: ChatRequestBody | undefined + + await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { + streamRequestBody = route.request().postDataJSON() as ChatRequestBody + + await route.fulfill({ + status: 200, + contentType: 'text/event-stream', + body: [ + 'data: {"choices":[{"delta":{"content":"ok"}}]}', + '', + 'data: [DONE]', + '', + ].join('\n'), + }) + }) + + await waitForAppReady(page, `${appEntryPath}`) + await connectByotWithSingleRepo(page) + await connectOpenRouterKey(page) + + await page + .getByLabel('Ask AI assistant') + .fill('Please update app.css to use blue text.') + await page.getByRole('button', { name: 'Send' }).click() + await expect(page.getByText('Response streamed.', { exact: true })).toHaveText( + 'Response streamed.', + ) + + expect(streamRequestBody?.tool_choice).toBe('auto') + expect( + streamRequestBody?.tools?.some( + tool => tool.type === 'function' && tool.function?.name === 'propose_editor_update', + ), + ).toBe(true) +}) + +test('AI chat does not render apply actions for read-only visibility prompts', async ({ + page, +}) => { + let streamRequestBody: ChatRequestBody | undefined + + await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { + const body = route.request().postDataJSON() as ChatRequestBody | null + + if (body?.stream) { + streamRequestBody = body + await route.fulfill({ + status: 502, + contentType: 'application/json', + body: JSON.stringify({ message: 'stream intentionally disabled in this test' }), + }) + return + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + choices: [ + { + message: { + role: 'assistant', + content: + 'Yes, I can see your editor content.\n\n```jsx\nconst App = () =>

Visible

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

Before

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

Resolved update

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

Before

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

By id

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

By path

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

Before

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

Before

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

Visible

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

Before

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

Resolved update

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

Before

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

By id

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

By path

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

Before

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

Before

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

- Provide a GitHub PAT to open pull requests against your repos or chat with - GitHub models. Read more about it in the + Provide a GitHub PAT to open pull requests against your repos. AI chat + uses a separate OpenRouter API key in the chat drawer. Read more about + both in the { + const pending = isPending === true const composerEnabled = !isPending && hasChatKey() + if (drawer instanceof HTMLElement) { + drawer.dataset.chatPending = pending ? 'true' : 'false' + } + + if (statusNode instanceof HTMLElement) { + statusNode.setAttribute('aria-busy', pending ? 'true' : 'false') + } + if (sendButton instanceof HTMLButtonElement) { sendButton.disabled = !composerEnabled } @@ -301,7 +310,7 @@ export const createChatDrawer = ({ } if (modelSelect instanceof HTMLSelectElement) { - if (isPending) { + if (pending) { modelSelect.disabled = true } else { modelSelect.disabled = !hasChatKey() diff --git a/src/modules/chat/model-picker.js b/src/modules/chat/model-picker.js index c0d9e16..3259dd2 100644 --- a/src/modules/chat/model-picker.js +++ b/src/modules/chat/model-picker.js @@ -101,26 +101,31 @@ export const createChatModelPicker = ({ return } - const selectedModel = getSelectedModel() - const catalogLoadPromise = fetchChatModelOptions({ token: normalizedToken }) - .then(modelIds => { + const loadCatalog = async () => { + try { + const modelIds = await fetchChatModelOptions({ token: normalizedToken }) + const selectedModel = getSelectedModel() replaceModelOptions({ modelIds, selectedModel, }) loadedCatalogToken = normalizedToken - }) - .catch(() => { + } catch { /* Keep fallback options when catalog loading fails. */ - }) - .finally(() => { - if (pendingCatalogLoadPromise === catalogLoadPromise) { - pendingCatalogLoadPromise = null - } - }) + } + } + + const catalogLoadPromise = loadCatalog() pendingCatalogLoadPromise = catalogLoadPromise - await catalogLoadPromise + + try { + await catalogLoadPromise + } finally { + if (pendingCatalogLoadPromise === catalogLoadPromise) { + pendingCatalogLoadPromise = null + } + } } const syncModelSelectionForKey = key => { diff --git a/src/modules/chat/request-runner.js b/src/modules/chat/request-runner.js index 9d89339..a3651d2 100644 --- a/src/modules/chat/request-runner.js +++ b/src/modules/chat/request-runner.js @@ -8,6 +8,21 @@ import { toChatText, } from './utils.js' +const sanitizeAssistantContent = value => { + if (typeof value !== 'string' || !value) { + return '' + } + + let sanitized = value.replace(/<\|tool_call_start\|>[\s\S]*?<\|tool_call_end\|>/g, '') + + const openToolCallIndex = sanitized.indexOf('<|tool_call_start|>') + if (openToolCallIndex !== -1) { + sanitized = sanitized.slice(0, openToolCallIndex) + } + + return sanitized.replace(/<\|tool_call_start\|>|<\|tool_call_end\|>/g, '') +} + export const createChatRequestRunner = ({ getPrompt, getToken, @@ -78,16 +93,25 @@ export const createChatRequestRunner = ({ signal: requestSignal, onToken: tokenChunk => { streamedContent += tokenChunk - updateLastAssistantMessage?.(streamedContent) + const sanitizedContent = sanitizeAssistantContent(streamedContent) + updateLastAssistantMessage?.(sanitizedContent) }, }) streamSucceeded = true const streamedModel = toChatText(streamResult?.model) - const streamContent = toChatText(streamResult?.content) + const streamContent = toChatText(sanitizeAssistantContent(streamResult?.content)) + const streamToolCalls = Array.isArray(streamResult?.toolCalls) + ? streamResult.toolCalls + : [] + + if (!streamContent && streamToolCalls.length === 0) { + throw new Error('Streaming returned control syntax without assistant content.') + } + attachAssistantResponseMetadata?.({ content: streamContent, - toolCalls: streamResult?.toolCalls, + toolCalls: streamToolCalls, model: streamedModel, }) setChatStatus?.('Response streamed.', 'ok') @@ -167,9 +191,18 @@ export const createChatRequestRunner = ({ signal: requestSignal, }) + const fallbackContent = toChatText(sanitizeAssistantContent(fallbackResult.content)) + const fallbackToolCalls = Array.isArray(fallbackResult?.toolCalls) + ? fallbackResult.toolCalls + : [] + + if (!fallbackContent && fallbackToolCalls.length === 0) { + throw new Error('Chat response did not include assistant content.') + } + attachAssistantResponseMetadata?.({ - content: toChatText(fallbackResult.content), - toolCalls: fallbackResult?.toolCalls, + content: fallbackContent, + toolCalls: fallbackToolCalls, }) const fallbackModel = toChatText(fallbackResult.model) setLastAssistantModel?.(fallbackModel) diff --git a/src/styles/ai-controls.css b/src/styles/ai-controls.css index e7f98ee..6d6a866 100644 --- a/src/styles/ai-controls.css +++ b/src/styles/ai-controls.css @@ -913,10 +913,31 @@ .ai-chat-drawer__status { color: var(--text-subtle); text-align: left; + display: inline-block; } .ai-chat-drawer__status[data-level='pending'] { - color: color-mix(in srgb, var(--panel-text) 72%, var(--accent)); + --ai-chat-status-pending-base: color-mix(in srgb, var(--panel-text) 68%, var(--accent)); + --ai-chat-status-pending-glint: color-mix( + in srgb, + var(--ai-chat-sparkle-color) 84%, + white 16% + ); + color: var(--ai-chat-status-pending-base); + background-image: linear-gradient( + 110deg, + var(--ai-chat-status-pending-base) 0%, + var(--ai-chat-status-pending-base) 36%, + var(--ai-chat-status-pending-glint) 50%, + var(--ai-chat-status-pending-base) 64%, + var(--ai-chat-status-pending-base) 100% + ); + background-size: 230% 100%; + background-position: 0% 50%; + -webkit-background-clip: text; + background-clip: text; + color: transparent; + animation: ai-chat-status-text-shimmer 1300ms linear infinite; } .ai-chat-drawer__status[data-level='ok'] { @@ -927,6 +948,23 @@ color: color-mix(in srgb, rgb(var(--danger-rgb)) 85%, var(--panel-text)); } +@keyframes ai-chat-status-text-shimmer { + 0% { + background-position: 200% 50%; + } + + 100% { + background-position: -40% 50%; + } +} + +@media (prefers-reduced-motion: reduce) { + .ai-chat-drawer__status[data-level='pending'] { + animation: none; + background-position: 50% 50%; + } +} + .ai-chat-messages { border: 1px solid var(--border-subtle); border-radius: 10px; From 1e393b56555ed980545356db1d10386ae65a16c2 Mon Sep 17 00:00:00 2001 From: KCM Date: Mon, 7 Sep 2026 14:36:25 -0500 Subject: [PATCH 7/8] feat: migrate chat to openrouter. --- docs/next-steps.md | 40 ++- docs/openrouter-migration-plan.md | 490 ------------------------------ src/modules/chat/api/models.js | 16 +- src/modules/chat/model-picker.js | 45 +-- 4 files changed, 71 insertions(+), 520 deletions(-) delete mode 100644 docs/openrouter-migration-plan.md 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-migration-plan.md b/docs/openrouter-migration-plan.md deleted file mode 100644 index e606a8a..0000000 --- a/docs/openrouter-migration-plan.md +++ /dev/null @@ -1,490 +0,0 @@ -# OpenRouter Migration Plan - -Plan for migrating the AI chat feature in `@knighted/develop` off the retired GitHub Models -inference API and onto OpenRouter, and for relocating chat out of the GitHub module into a -standalone `src/modules/chat` feature. - -## Background - -GitHub Models retired on July 30, 2026. Every request to -`https://models.github.ai/inference/chat/completions` now fails, and because the retired -host no longer answers CORS preflight the failure surfaces in the browser as a network/CORS -error rather than a clean HTTP status. The chat drawer is fully broken. - -Microsoft Foundry Models is the vendor-recommended migration path, but it is a poor fit -here. It requires an Azure subscription with a payment method, per-model deployments inside -a Foundry Tools resource, and its inference endpoints are not intended for cross-origin -browser calls. That would either reintroduce the same CORS failure or force a backend into -an app whose entire premise is CDN-first and browser-only. - -OpenRouter is the chosen target. It is callable directly from the browser, implements the -OpenAI `/chat/completions` specification that our request and SSE parsing code already -speaks, exposes a public model catalog, and keeps the bring-your-own-credential model -intact. - -## Decisions - -| Decision | Choice | -| ------------------ | --------------------------------------------------------------- | -| Provider | OpenRouter, direct browser `fetch` | -| Credential entry | Paste field inside the chat drawer (no OAuth flow for now) | -| Credential storage | `localStorage`, separate key from the GitHub PAT | -| Chat gating | Toggle button always visible; key field lives inside the drawer | -| Repository | Chat is independent of repository selection | -| Model catalog | Live fetch from `/api/v1/models`, free models grouped first | -| Module location | `src/modules/chat`, decoupled from `src/modules/github` | - -The chat toggle button is always rendered, regardless of any credential. The drawer itself -stays closed until the user clicks the toggle, exactly as it behaves today. Opening the -drawer with no OpenRouter key stored reveals the key field and an explainer in place of a -usable composer; the drawer does not auto-open. - -Chat no longer depends on a selected repository. Local-mode users can chat to update an -editor tab with no GitHub connection at all. A selected repository remains useful context -when one is connected, but it is never a precondition. - -## Implementation status (updated 2026-09-07) - -### Done - -- Phase 1 completed: chat extracted to `src/modules/chat` and decoupled from - `src/modules/github` imports. -- Phase 2 completed for core runtime path: - - OpenRouter chat completions endpoint is live. - - OpenRouter header and error handling is implemented. - - Streaming and fallback request paths are both wired. - - Live verification confirmed SSE keepalive comment handling and `[DONE]` sentinel flow. -- Phase 3 completed: - - Chat toggle remains visible regardless of GitHub PAT state. - - Chat works with no repository selected (local mode). - - In-drawer OpenRouter key controls are implemented with independent storage. - - PR visibility logic was split away from chat visibility behavior. -- Security and UX hardening completed after initial migration: - - Proposal/apply actions are intent-gated (explicit edit intent required). - - Read-only prompts do not surface apply actions from markdown fallback. - - Unmatched proposal targets show guidance instead of a misleading apply prompt. - - OpenRouter key controls now reuse the GitHub PAT-style control pattern and trash icon. -- Tests and checks completed for the implemented behaviors: - - Focused Playwright coverage added for intent gating, tab-context sending, and apply behavior. - - Lint checks are passing. - - Chat test coverage is split into `playwright/chat/ai-chat.spec.ts`, with PR/BYOT - coverage retained in `playwright/github-byot-ai.spec.ts`. - - OpenRouter migration docs are in place (`docs/openrouter-byok.md`) and cross-linked - from README/BYOT docs. -- Phase 4 completed in runtime code: - - Live `/api/v1/models` catalog fetch is wired in `src/modules/chat/api/models.js`. - - Model picker groups models into Free and Paid sections. - - Model catalog entries are filtered to tool-capable models. - -### Remaining (non-blocking) - -- Optional follow-up coverage: - - Explicit targeted specs for 402/429 status messaging and catalog-fetch degradation. -- Optional UX follow-up: - - One-time migration notice on first load after upgrade. - -### Correction to a common assumption - -OpenRouter's free models are **not** keyless. Every request to the OpenRouter API requires -an `Authorization: Bearer` API key, including requests for `:free` model variants. "Free" -means no per-token charge, not anonymous access. Free-model rate limits are 50 requests per -day for accounts with no purchased credits and 1000 per day once the account has purchased -at least $10 in credits. - -The practical consequence: an OpenRouter key is **mandatory** for chat, not optional. The -UX still improves over the status quo, because a user can create a key and use free models -without ever spending money, but the drawer must hard-gate sending on the presence of a -key. - -## Verification performed - -Probed live from a browser at a non-OpenRouter origin before writing this plan, so the -plan's assumptions are measured rather than inferred. - -| Check | Result | -| ------------------------------------ | ------------------------------------------------------------------------ | -| `POST /api/v1/chat/completions` CORS | Passes with `Authorization`, `Content-Type`, `Accept: text/event-stream` | -| Attribution headers CORS | `HTTP-Referer` and `X-OpenRouter-Title` also pass preflight | -| `GET /api/v1/models` CORS | Accessible cross-origin, no key required | -| `GET /api/v1/key` CORS | Accessible cross-origin | -| Error body shape | `{"error":{"message":"User not found.","code":401}}` | -| Exposed response headers | Only `content-type` and `cf-ray` | -| Catalog size | 430 models, 21 free, 18 free with `tools` support | - -Still unverified, because both require exhausting an account: the 402 (out of credits) and -429 (rate limited) mappings. - -### Verified live with a funded key - -| Check | Result | -| ---------------------------- | -------------------------------------------------------------------------- | -| SSE keepalive comments | `: OPENROUTER PROCESSING` lines do appear; `parseSseDataLine` ignores them | -| `data: [DONE]` sentinel | Present and handled | -| Invalid model slug | Returns **400**, not 404 — `"... is not a valid model ID"` | -| Tool calling on a free model | `openrouter/free` emits a real `propose_editor_update` call | -| Apply + undo round trip | Proposal applies to the editor tab and the undo action appears | - -## Current state - -The chat code is provider-neutral almost everywhere. The message normalization, tool-call -assembly, SSE parsing, and proposal/undo machinery are all plain OpenAI-shape handling that -carries over unchanged. What is GitHub-specific is narrow: the endpoint URL, two request -headers, the rate-limit header names, the hardcoded model list, and the fact that a single -GitHub PAT authorizes both repository writes and chat. - -Files in scope: - -| File | GitHub coupling | -| -------------------------------------------------- | ------------------------------------------------------------------------------- | -| `src/modules/github/api/constants.js` | Endpoint URL, default model, hardcoded model list | -| `src/modules/github/api/core.js` | `buildChatRequestHeaders`, `parseRateMetadata`, `parseErrorResponse` | -| `src/modules/github/api/chat.js` | Imports the above; otherwise provider-neutral | -| `src/modules/github/chat/drawer.js` | Model select population, token gating, status copy | -| `src/modules/github/chat/utils.js` | Model-access error string heuristics | -| `src/modules/github/chat/payload.js` | None (context assembly) | -| `src/modules/github/chat/active-tab-context.js` | None | -| `src/modules/github/chat/proposals.js` | None | -| `src/modules/github/chat/tab-target-resolver.js` | None | -| `src/modules/github/chat/tab-scoped-undo-state.js` | None | -| `src/modules/app-core/github-workflows.js` | Wires `getCurrentGitHubToken` and 11 `aiChat*` DOM handles into the chat drawer | -| `src/modules/app-core/github-workflows-setup.js` | Threads `githubAiContextState` through to chat | -| `src/modules/app-core/app-composition-options.js` | Passes `githubAiContextState` through GitHub-named plumbing | -| `src/modules/app-core/app-bindings-startup.js` | Calls `syncAiChatTokenVisibility` at startup | -| `src/modules/app-core/github-pr-context-ui.js` | `syncAiChatTokenVisibility` hides the chat toggle without a PAT | -| `src/app.js` | 11 `aiChat*` DOM handles, `githubAiContextState` | -| `src/index.html` | Chat drawer markup, model `` into a "Free" `` first and "Paid" second, so the zero-cost path is - the discoverable default. -3. Filter to models whose `supported_parameters` array includes `"tools"`. The editor - proposal flow depends on `tools` / `tool_choice`, and a model without tool support fails - silently rather than erroring. This drops the free set from 21 to 18 and the full - catalog to a far more navigable size. -4. Default selection is a specific free, tool-capable slug pinned as a constant rather than - inferred, so behavior is deterministic when the catalog fetch fails. Note that free slugs - churn — the pinned default needs a periodic sanity check, and an unknown-model 404 on the - default must fall back to the picker rather than dead-ending. -5. Static fallback list in `src/modules/chat/api/constants.js` for fetch failure, consistent - with the CDN fallback philosophy in `src/modules/cdn.js`. A catalog fetch failure must - not disable chat. - -## Phase 5 — Tests and docs - -### Playwright - -- Retarget all 11 `page.route` mocks in `playwright/github-byot-ai.spec.ts` from - `https://models.github.ai/inference/chat/completions` to - `https://openrouter.ai/api/v1/chat/completions`, and add a mock for - `https://openrouter.ai/api/v1/models`. -- Split the spec. Chat is no longer a GitHub feature, so the chat cases move to - `playwright/chat/` and `github-byot-ai.spec.ts` keeps only PR/BYOT coverage. -- `connectByotWithSingleRepo` in `playwright/helpers/app-test-helpers.ts` grows a sibling - helper for connecting an OpenRouter key, so specs can set up either credential - independently. -- The existing "chat stays hidden until token connect" case encodes the old coupling and is - rewritten, not patched. Replace it with coverage of all four cells of the gating matrix, - asserting the chat toggle is visible in every one. -- Assert the chat drawer stays closed until the toggle is clicked, in all four cells. -- Assert that deleting the GitHub PAT while the chat drawer is open leaves it open and - functional. -- Add local-mode chat specs with no PAT at all: send a message, apply a proposal to an - editor tab, and undo it, with no repository selected at any point. -- Add a spec asserting no request to `openrouter.ai` carries the PAT, and no request to - `api.github.com` carries the OpenRouter key. -- Add coverage for the free-model grouping, the 402 and 429 error messages, and graceful - degradation when the catalog fetch fails. -- Follow the repo's accessible-selector convention for the new key field: label it and - reach it with `getByLabel`, not a CSS locator. - -### Docs - -- New `docs/openrouter-byok.md` covering key creation, free-model limits, and the - browser-local storage guarantee. -- `docs/byot.md` narrows to the GitHub PAT and cross-links the new doc. -- `docs/ai-chat-context-and-payload-strategy.md` file paths updated for the move. -- `docs/localstorage-state.md` gains the new storage key. -- The in-app token info panel and the doc link in `src/index.html` updated to describe two - independent, optional-in-different-ways credentials. -- `README.md` chat section updated. - -## Risks - -| Risk | Status | Mitigation | -| ----------------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Browser CORS on `/chat/completions` | Resolved | Verified: preflight passes with `Authorization`, `Content-Type`, `Accept: text/event-stream` | -| Browser CORS on `/api/v1/models` | Resolved | Verified: accessible cross-origin, no key required | -| Free models lack tool support | Resolved | Verified: 18 of 21 free models advertise `tools` in `supported_parameters` | -| Rate-limit headers unreadable in browser | Resolved | Verified: only `content-type` and `cf-ray` exposed. Drop header parsing; use `/api/v1/key` if needed | -| SSE keepalive comments break the stream reader | Resolved | Verified live with a funded key; keepalive comments are ignored and stream completion is handled correctly | -| 402/404/429 mappings unconfirmed | Partial | 400 invalid model behavior is verified; 402 out-of-credits and 429 rate-limit remain to be validated against exhausted-account conditions | -| `syncAiChatTokenVisibility` split leaves gaps | Resolved | Chat visibility is decoupled from PAT gating; PR surface visibility remains PAT-scoped | -| Repository-independent chat hits untested paths | Partial | Core no-repository behavior is implemented and covered by focused tests; broader cross-browser matrix coverage remains | -| Pinned default free slug goes away | Open | 404 on default falls back to the picker; periodic sanity check | -| 50 req/day free limit feels broken to users | Open | Explicit 429 copy naming the limit and the credits threshold | -| Key in `localStorage` is XSS-exposed | Accepted | Same threat model as the existing PAT; document it, and note the OpenRouter key is scoped to inference spend only, unlike the PAT which can write repositories | -| Phase 1 rename churn hides regressions | Accepted | Keep Phase 1 as a pure move with no behavior change and lint/smoke before Phase 2 | - -## Out of scope - -- OAuth PKCE connect flow. Better UX than a paste field and worth revisiting, but it is a - larger change and the paste field matches the existing BYOT pattern. -- Microsoft Foundry support. The provider seam introduced in Phase 1 leaves room for a - second implementation, but nothing here should be generalized speculatively for it. -- Any change to the context assembly, proposal, or undo behavior. Those files move and are - otherwise untouched. - -## Approvals needed - -Per `AGENTS.md`, confirm before implementation starts: - -- Module relocation and identifier renames, which change file layout and documented paths. -- The gating change making chat independent of the GitHub PAT, which is user-visible - behavior documented in the README. -- No new dependencies are proposed; the migration is plain `fetch` throughout. diff --git a/src/modules/chat/api/models.js b/src/modules/chat/api/models.js index a2b3a83..f976799 100644 --- a/src/modules/chat/api/models.js +++ b/src/modules/chat/api/models.js @@ -50,15 +50,19 @@ const normalizeModelOptions = models => { }) } - const sortedModelIds = sortModelEntries(Array.from(byModelId.values())).map( - entry => entry.id, - ) + const sortedModelOptions = sortModelEntries(Array.from(byModelId.values())) - if (sortedModelIds.length === 0) { - return chatModelOptions + if (sortedModelOptions.length === 0) { + return chatModelOptions.map(id => ({ + id, + isFree: id === 'openrouter/free' || id.endsWith(':free'), + })) } - return [...new Set([defaultChatModel, ...sortedModelIds])] + return [ + { id: defaultChatModel, isFree: true }, + ...sortedModelOptions.filter(entry => entry.id !== defaultChatModel), + ] } const buildCatalogRequestHeaders = token => { diff --git a/src/modules/chat/model-picker.js b/src/modules/chat/model-picker.js index 3259dd2..78c99dc 100644 --- a/src/modules/chat/model-picker.js +++ b/src/modules/chat/model-picker.js @@ -18,35 +18,41 @@ export const createChatModelPicker = ({ modelSelect.disabled = isDisabled } - const replaceModelOptions = ({ modelIds, selectedModel }) => { + const replaceModelOptions = ({ modelOptions, selectedModel }) => { if (!(modelSelect instanceof HTMLSelectElement)) { return } const nextSelectedModel = toModelId(selectedModel) - const nextModelIds = [...new Set([defaultChatModel, ...modelIds])] - const freeModelIds = [] - const paidModelIds = [] - - for (const modelId of nextModelIds) { - if (isFreeChatModel(modelId)) { - freeModelIds.push(modelId) + 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 { - paidModelIds.push(modelId) + paidModelOptions.push(modelOption) } } modelSelect.replaceChildren() - const appendGroupedOptions = (label, ids) => { - if (ids.length === 0) { + const appendGroupedOptions = (label, options) => { + if (options.length === 0) { return } const group = document.createElement('optgroup') group.label = label - for (const modelId of ids) { + for (const { id: modelId } of options) { const option = document.createElement('option') option.value = modelId option.textContent = modelId @@ -57,10 +63,10 @@ export const createChatModelPicker = ({ modelSelect.append(group) } - appendGroupedOptions('Free', freeModelIds) - appendGroupedOptions('Paid', paidModelIds) + appendGroupedOptions('Free', freeModelOptions) + appendGroupedOptions('Paid', paidModelOptions) - if (!nextModelIds.includes(nextSelectedModel)) { + if (!nextModelOptions.some(option => option.id === nextSelectedModel)) { modelSelect.value = defaultChatModel } } @@ -75,7 +81,10 @@ export const createChatModelPicker = ({ const initializeModelOptions = () => { replaceModelOptions({ - modelIds: chatModelOptions, + modelOptions: chatModelOptions.map(id => ({ + id, + isFree: isFreeChatModel(id), + })), selectedModel: defaultChatModel, }) } @@ -103,10 +112,10 @@ export const createChatModelPicker = ({ const loadCatalog = async () => { try { - const modelIds = await fetchChatModelOptions({ token: normalizedToken }) + const modelOptions = await fetchChatModelOptions({ token: normalizedToken }) const selectedModel = getSelectedModel() replaceModelOptions({ - modelIds, + modelOptions, selectedModel, }) loadedCatalogToken = normalizedToken From 0da9c5331b862e10d708911b00c6143b561856ae Mon Sep 17 00:00:00 2001 From: KCM Date: Mon, 7 Sep 2026 14:37:39 -0500 Subject: [PATCH 8/8] ci: no playwright on chat. --- .github/workflows/playwright.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 6069f9d..62f58c0 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -4,7 +4,6 @@ on: pull_request: branches: - main - - chat types: - opened - synchronize