From f0f99dfc4dc09f576f1bd56390595ea9342c615e Mon Sep 17 00:00:00 2001 From: KCM Date: Tue, 31 Mar 2026 21:19:09 -0500 Subject: [PATCH 1/2] feat: chat editor integration. --- AGENTS.md | 1 + docs/next-steps.md | 26 +- playwright/github-byot-ai.spec.ts | 380 ++++++- playwright/helpers/app-test-helpers.ts | 9 + playwright/rendering-modes.spec.ts | 23 + src/app.js | 14 +- src/index.html | 19 +- src/modules/github-api.js | 200 +++- src/modules/github-chat-drawer.js | 691 ------------ src/modules/github-chat-drawer/chat-utils.js | 77 ++ src/modules/github-chat-drawer/drawer.js | 1050 ++++++++++++++++++ src/modules/github-chat-drawer/payload.js | 182 +++ src/modules/github-chat-drawer/proposals.js | 135 +++ src/modules/github-pr-drawer.js | 175 +-- src/modules/render-runtime.js | 4 + src/styles/ai-controls.css | 61 +- 16 files changed, 2228 insertions(+), 819 deletions(-) delete mode 100644 src/modules/github-chat-drawer.js create mode 100644 src/modules/github-chat-drawer/chat-utils.js create mode 100644 src/modules/github-chat-drawer/drawer.js create mode 100644 src/modules/github-chat-drawer/payload.js create mode 100644 src/modules/github-chat-drawer/proposals.js diff --git a/AGENTS.md b/AGENTS.md index 95dbc99..51531b0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,7 @@ Repository structure: ## Code style and conventions - Preserve current project formatting: single quotes, no semicolons, print width 90, arrowParens avoid. +- Do not use index files or barrel-file architecture; prefer explicit file names and explicit import paths. - Keep UI changes intentional and lightweight; avoid broad visual rewrites unless requested. - Keep runtime logic defensive for flaky/slow CDN conditions. - Preserve progressive loading behavior (lazy-load optional compilers/runtime pieces where possible). diff --git a/docs/next-steps.md b/docs/next-steps.md index deba9e3..feee14b 100644 --- a/docs/next-steps.md +++ b/docs/next-steps.md @@ -19,27 +19,7 @@ Focused follow-up work for `@knighted/develop`. - Suggested implementation prompt: - "Add a deterministic E2E execution mode for `@knighted/develop` that serves pinned runtime artifacts locally (instead of live CDN fetches) and wire it into CI as a required check on every PR. Keep a separate lightweight CDN-smoke E2E check for real-network coverage. Validate with `npm run lint`, deterministic Playwright PR checks, and one CDN-smoke Playwright run." -4. **Issue #18 continuation (finish remaining Phase 3 scope)** - - Current rollout status: - - Phase 0 complete: feature flag + scaffolding. - - Phase 1 complete: BYOT token flow, localStorage persistence, writable repo discovery/filtering. - - Phase 2 complete: separate AI chat drawer UX, streaming-first responses with non-stream fallback, selected repository context plumbing, and README fine-grained PAT setup links. - - Phase 3 partially complete: PR-prep filename/path groundwork landed via the Open PR drawer with repository-scoped persistence and stricter path validation. - - Phase 4 complete: open PR flow from editor content (branch creation, file upserts, PR creation), confirmation UX, loading/success states, and toast feedback. - - Post-implementation hardening complete: traversal/path validation edge cases, trailing-slash rejection, writable-repo select reset behavior during loading/error states, and a JS-driven Playwright readiness check. - - Implement the next slice (remaining Phase 3 assistant features): - - Add mode-aware recommendation behavior so the assistant strongly adapts suggestions to current render mode and style mode. - - Add an editor update workflow where the assistant can propose structured edits and the user can apply to Component and Styles editors with explicit confirmation. - - Keep behavior and constraints aligned with current implementation: - - Keep AI chat/assistant behavior behind the existing browser-only AI feature flag. - - Keep PR/BYOT controls available by default. - - Preserve BYOT token semantics (localStorage persistence until user deletes). - - Keep CDN-first runtime behavior and existing fallback model. - - Do not add dependencies without explicit approval. - - Remaining Phase 3 mini-spec (agent implementation prompt): - - "Continue Issue #18 in @knighted/develop from the current baseline where PR filename/path groundwork and Open PR flow are already shipped. Implement the two remaining Phase 3 assistant deliverables. (1) Add mode-aware assistant guidance: when collecting AI context, include explicit policy hints derived from render mode and style mode, and ensure recommendations avoid incompatible patterns (for example, avoid React hook/state guidance in DOM mode unless user explicitly asks for React migration). (2) Add assistant-to-editor apply flow: support structured assistant responses that can propose edits for component and/or styles editors; render these as reviewable actions in the chat drawer, require explicit user confirmation to apply, and support a one-step undo for last applied assistant edit per editor. Keep AI chat/assistant behavior behind the existing browser-only AI feature flag, keep PR/BYOT controls available by default, and preserve current token/repo persistence semantics. Do not add dependencies. Validate with npm run lint and targeted Playwright tests covering mode-aware recommendation constraints and apply/undo editor actions." - -5. **Phase 2 UX/UI continuation: fixed editor tabs first pass (Component, Styles, App)** +4. **Phase 2 UX/UI continuation: fixed editor tabs first pass (Component, Styles, App)** - Continue the tabs/editor UX work with a constrained first implementation that supports exactly three editor tabs: Component, Styles, and App. - Do not introduce arbitrary/custom tab names in this pass; treat custom naming as future scope after baseline tab behavior is stable. - Preserve existing runtime behavior and editor content semantics while adding tab switching, active tab indication, and predictable persistence/reset behavior consistent with current app patterns. @@ -47,7 +27,7 @@ Focused follow-up work for `@knighted/develop`. - Suggested implementation prompt: - "Implement Phase 2 UX/UI tab support in @knighted/develop with a fixed first-pass tab model: Component, Styles, and App only (no arbitrary tab names yet). Add a clear tab UI for switching editor panes, preserve existing editor behavior/content wiring, and keep render/lint/typecheck/diagnostics flows working with the selected tab context where relevant. Keep AI chat feature-flag behavior unchanged while keeping PR/BYOT controls available by default, maintain CDN-first runtime constraints, and do not add dependencies. Add targeted Playwright coverage for tab switching, default/active tab behavior, and interactions with existing render/style-mode flows. Validate with npm run lint and targeted Playwright tests." -6. **Document implicit App strict-flow behavior (auto render)** +5. **Document implicit App strict-flow behavior (auto render)** - Add a short behavior matrix in docs that explains when implicit App wrapping is allowed versus when users must define `App` explicitly. - Include concrete Component editor examples for each case so reviewer/user expectations are clear. - Suggested example cases to document: @@ -63,7 +43,7 @@ Focused follow-up work for `@knighted/develop`. - Suggested implementation prompt: - "Document the current implicit App behavior in @knighted/develop for auto-render mode using a compact behavior matrix and concrete component-editor snippets. Clearly distinguish supported implicit wrapping from cases that intentionally require an explicit App (such as top-level JSX mixed with imports/declarations). Keep docs concise, aligned with current runtime behavior, and include at least one positive and one explicit-error example." -7. **Evaluate GitHub file upsert request strategy (metadata-first vs optimistic PUT)** +6. **Evaluate GitHub file upsert request strategy (metadata-first vs optimistic PUT)** - Revisit the current metadata-first `upsertRepositoryFile` approach and compare it against an optimistic PUT + targeted retry-on-missing-sha flow. - Measure tradeoffs for latency, GitHub API request count/rate-limit impact, and browser-console signal quality during common PR flows. - If beneficial, introduce a configurable/hybrid strategy (for example, optimistic default with metadata fallback) without regressing current reliability. diff --git a/playwright/github-byot-ai.spec.ts b/playwright/github-byot-ai.spec.ts index 4518cb4..6277fd0 100644 --- a/playwright/github-byot-ai.spec.ts +++ b/playwright/github-byot-ai.spec.ts @@ -7,6 +7,8 @@ import { ensureAiChatDrawerOpen, ensureOpenPrDrawerOpen, mockRepositoryBranches, + setComponentEditorSource, + setStylesEditorSource, waitForAppReady, } from './helpers/app-test-helpers.js' @@ -201,23 +203,31 @@ test('AI chat prefers streaming responses when available', async ({ page }) => { await expect( page.getByText('Response streamed from GitHub.', { exact: true }), ).toHaveText('Response streamed from GitHub.') - await expect(page.getByText('Rate limit info unavailable', { exact: true })).toHaveText( - 'Rate limit info unavailable', - ) await expect(page.getByText('Summarize this repository.')).toBeVisible() await expect(page.getByText('Streaming response ready')).toBeVisible() expect(streamRequestBody?.metadata).toBeUndefined() expect(streamRequestBody?.model).toBe(defaultGitHubChatModel) - const systemMessage = streamRequestBody?.messages?.find( - (message: ChatRequestMessage) => message.role === 'system', + expect(streamRequestBody?.tool_choice).toBe('auto') + expect( + streamRequestBody?.tools?.some( + tool => tool.type === 'function' && tool.function?.name === 'propose_editor_update', + ), + ).toBe(true) + expect(streamRequestBody?.messages?.[0]?.role).toBe('system') + expect(streamRequestBody?.messages?.[0]?.content).toContain( + 'expert software development assistant focused on CSS dialects and JSX syntax', ) const systemMessages = streamRequestBody?.messages?.filter( (message: ChatRequestMessage) => message.role === 'system', ) - expect(systemMessage?.content).toContain('Selected repository context') - expect(systemMessage?.content).toContain('Repository: knightedcodemonkey/develop') - expect(systemMessage?.content).toContain( + 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( @@ -258,11 +268,9 @@ test('AI chat can disable editor context payload via checkbox', async ({ page }) await expect( page.getByText('Response streamed from GitHub.', { exact: true }), ).toHaveText('Response streamed from GitHub.') - await expect(page.getByText('Rate limit info unavailable', { exact: true })).toHaveText( - 'Rate limit info unavailable', - ) expect(streamRequestBody?.metadata).toBeUndefined() + expect(streamRequestBody?.tool_choice).toBe('none') const systemMessages = streamRequestBody?.messages?.filter( (message: ChatRequestMessage) => message.role === 'system', ) @@ -285,6 +293,295 @@ test('AI chat can disable editor context payload via checkbox', async ({ page }) ).toBe(false) }) +test('AI chat proposals can be confirmed, applied, and undone for component and styles editors', async ({ + page, +}) => { + await page.route('https://models.github.ai/inference/chat/completions', async route => { + const body = route.request().postDataJSON() as ChatRequestBody | null + + if (body?.stream) { + await route.fulfill({ + status: 502, + contentType: 'application/json', + body: JSON.stringify({ message: 'stream intentionally disabled in this test' }), + }) + return + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + choices: [ + { + message: { + role: 'assistant', + content: 'Prepared updates for both editors.', + tool_calls: [ + { + id: 'call_component', + type: 'function', + function: { + name: 'propose_editor_update', + arguments: JSON.stringify({ + target: 'component', + content: 'const App = () => ', + rationale: 'Use explicit App component output.', + }), + }, + }, + { + id: 'call_styles', + type: 'function', + function: { + name: 'propose_editor_update', + arguments: JSON.stringify({ + target: 'styles', + content: '.button { color: rgb(10 20 30); }', + rationale: 'Provide deterministic button styling.', + }), + }, + }, + ], + }, + }, + ], + }), + }) + }) + + await waitForAppReady(page, `${appEntryPath}?feature-ai=true`) + await connectByotWithSingleRepo(page) + await setComponentEditorSource(page, 'const App = () => ') + await setStylesEditorSource(page, '.button { color: red; }') + await ensureAiChatDrawerOpen(page) + + await page.getByLabel('Ask AI assistant').fill('Suggest updates for both editors.') + await page.getByRole('button', { name: 'Send' }).click() + + await expect( + page.getByText('Prepared updates for both editors.', { exact: true }), + ).toBeVisible() + + const assistantResponseMessage = page + .locator('.ai-chat-message--assistant') + .filter({ hasText: 'Prepared updates for both editors.' }) + .first() + + await expect( + page.getByRole('button', { name: 'Apply update to both editors' }), + ).toBeVisible() + await page.getByRole('button', { name: 'Apply update to both editors' }).click() + await expect( + page.getByRole('button', { name: 'Apply update to both editors' }), + ).toBeHidden() + await expect( + page.getByRole('button', { name: 'Apply update to Component editor' }), + ).toBeHidden() + await expect( + page.getByRole('button', { name: 'Apply update to Styles editor' }), + ).toBeHidden() + await expect( + assistantResponseMessage.getByRole('button', { name: 'Undo last Component apply' }), + ).toHaveCount(0) + await expect( + assistantResponseMessage.getByRole('button', { name: 'Undo last Styles apply' }), + ).toHaveCount(0) + await expect( + page.getByRole('button', { name: 'Undo last Component apply' }), + ).toBeVisible() + await expect(page.getByRole('button', { name: 'Undo last Styles apply' })).toBeVisible() + await expect(page.locator('.component-panel .cm-content').first()).toContainText( + 'Updated', + ) + await expect(page.locator('.styles-panel .cm-content').first()).toContainText( + 'rgb(10 20 30)', + ) + + await page.getByRole('button', { name: 'Undo last Component apply' }).click() + await expect(page.locator('.component-panel .cm-content').first()).toContainText( + 'Before', + ) + + await page.getByRole('button', { name: 'Undo last Styles apply' }).click() + await expect(page.locator('.styles-panel .cm-content').first()).toContainText('red') +}) + +test('AI chat shows a single apply action when both editor proposals are available', async ({ + page, +}) => { + await page.route('https://models.github.ai/inference/chat/completions', async route => { + const body = route.request().postDataJSON() as ChatRequestBody | null + + if (body?.stream) { + await route.fulfill({ + status: 502, + contentType: 'application/json', + body: JSON.stringify({ message: 'stream intentionally disabled in this test' }), + }) + return + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + choices: [ + { + message: { + role: 'assistant', + content: 'Prepared updates for both editors.', + tool_calls: [ + { + id: 'call_component', + type: 'function', + function: { + name: 'propose_editor_update', + arguments: JSON.stringify({ + target: 'component', + content: 'const App = () => ', + }), + }, + }, + { + id: 'call_styles', + type: 'function', + function: { + name: 'propose_editor_update', + arguments: JSON.stringify({ + target: 'styles', + content: '.button { color: rgb(10 20 30); }', + }), + }, + }, + ], + }, + }, + ], + }), + }) + }) + + await waitForAppReady(page, `${appEntryPath}?feature-ai=true`) + await connectByotWithSingleRepo(page) + await setComponentEditorSource(page, 'const App = () => ') + await setStylesEditorSource(page, '.button { color: red; }') + await ensureAiChatDrawerOpen(page) + + await page.getByLabel('Ask AI assistant').fill('Suggest updates for both editors.') + await page.getByRole('button', { name: 'Send' }).click() + + await expect( + page.getByText('Prepared updates for both editors.', { exact: true }), + ).toBeVisible() + + await expect( + page.getByRole('button', { name: 'Apply update to both editors' }), + ).toBeVisible() + await expect( + page.getByRole('button', { name: 'Apply update to Component editor' }), + ).toBeHidden() + await expect( + page.getByRole('button', { name: 'Apply update to Styles editor' }), + ).toBeHidden() +}) + +test('AI chat streaming text still updates while latest undo actions are visible', async ({ + page, +}) => { + let requestCount = 0 + + await page.route('https://models.github.ai/inference/chat/completions', async route => { + requestCount += 1 + const body = route.request().postDataJSON() as ChatRequestBody | null + + if (requestCount <= 2) { + if (body?.stream) { + await route.fulfill({ + status: 502, + contentType: 'application/json', + body: JSON.stringify({ message: 'force fallback for proposal setup' }), + }) + return + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + choices: [ + { + message: { + role: 'assistant', + content: 'Prepared updates for styles editor.', + tool_calls: [ + { + id: 'call_styles', + type: 'function', + function: { + name: 'propose_editor_update', + arguments: JSON.stringify({ + target: 'styles', + 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}?feature-ai=true`) + await connectByotWithSingleRepo(page) + await setStylesEditorSource(page, '.button { color: red; }') + await ensureAiChatDrawerOpen(page) + + await page.getByLabel('Ask AI assistant').fill('Suggest a styles update.') + await page.getByRole('button', { name: 'Send' }).click() + + await expect( + page.getByText('Prepared updates for styles editor.', { exact: true }), + ).toBeVisible() + await page.getByRole('button', { name: 'Apply update to Styles editor' }).click() + await expect(page.getByRole('button', { name: 'Undo last Styles apply' })).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, }) => { @@ -343,9 +640,6 @@ test('AI chat falls back to non-streaming response when streaming fails', async await expect(page.getByText('Fallback response loaded.', { exact: true })).toHaveText( 'Fallback response loaded.', ) - await expect( - page.getByText('Remaining 17, resets 00:00 UTC', { exact: true }), - ).toHaveText('Remaining 17, resets 00:00 UTC') await expect(page.getByText('Fallback response from JSON path.')).toBeVisible() expect(streamAttemptCount).toBeGreaterThan(0) expect(fallbackAttemptCount).toBeGreaterThan(0) @@ -353,6 +647,64 @@ test('AI chat falls back to non-streaming response when streaming fails', async expect(attemptedModels.every(model => model === selectedModel)).toBe(true) }) +test('clearing chat removes previous conversation context from new request', async ({ + page, +}) => { + const streamBodies: ChatRequestBody[] = [] + + await page.route('https://models.github.ai/inference/chat/completions', async route => { + const body = route.request().postDataJSON() as ChatRequestBody + if (body?.stream) { + streamBodies.push(body) + await route.fulfill({ + status: 200, + contentType: 'text/event-stream', + body: [ + 'data: {"choices":[{"delta":{"content":"ok"}}]}', + '', + 'data: [DONE]', + '', + ].join('\n'), + }) + return + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + choices: [{ message: { role: 'assistant', content: 'ok' } }], + }), + }) + }) + + await waitForAppReady(page, `${appEntryPath}?feature-ai=true`) + await connectByotWithSingleRepo(page) + await ensureAiChatDrawerOpen(page) + + await page.getByLabel('Ask AI assistant').fill('First conversation prompt') + await page.getByRole('button', { name: 'Send' }).click() + await expect( + page.getByText('Response streamed from GitHub.', { exact: true }), + ).toBeVisible() + + await page.getByRole('button', { name: 'Clear', exact: true }).click() + await expect(page.getByText('Chat cleared.', { exact: true })).toBeVisible() + + await page.getByLabel('Ask AI assistant').fill('Second conversation prompt') + await page.getByRole('button', { name: 'Send' }).click() + await expect( + page.getByText('Response streamed from GitHub.', { exact: true }), + ).toBeVisible() + + expect(streamBodies.length).toBeGreaterThanOrEqual(2) + const latestMessages = streamBodies[streamBodies.length - 1]?.messages ?? [] + const allLatestContent = latestMessages.map(message => message.content ?? '').join('\n') + + expect(allLatestContent).toContain('Second conversation prompt') + expect(allLatestContent).not.toContain('First conversation prompt') +}) + test('BYOT remembers selected repository across reloads', async ({ page }) => { test.setTimeout(90_000) diff --git a/playwright/helpers/app-test-helpers.ts b/playwright/helpers/app-test-helpers.ts index fd9c9af..14cb08f 100644 --- a/playwright/helpers/app-test-helpers.ts +++ b/playwright/helpers/app-test-helpers.ts @@ -16,6 +16,15 @@ export type ChatRequestBody = { messages?: ChatRequestMessage[] model?: string stream?: boolean + tool_choice?: 'auto' | 'required' | 'none' + tools?: Array<{ + type?: string + function?: { + name?: string + description?: string + parameters?: unknown + } + }> } export type CreateRefRequestBody = { diff --git a/playwright/rendering-modes.spec.ts b/playwright/rendering-modes.spec.ts index 7ba9563..2e237b9 100644 --- a/playwright/rendering-modes.spec.ts +++ b/playwright/rendering-modes.spec.ts @@ -463,6 +463,29 @@ test('supports export default App without redeclaration', async ({ page }) => { ).toContainText('export default App') }) +test('auto render supports export default named component without App redeclaration', async ({ + page, +}) => { + await waitForInitialRender(page) + + await ensurePanelToolsVisible(page, 'component') + await page.getByLabel('ShadowRoot').uncheck() + + await setComponentEditorSource( + page, + [ + 'const Button = () => ', + 'export default Button', + ].join('\n'), + ) + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') + await expect( + page.getByRole('region', { name: 'Preview output' }).getByRole('button').first(), + ).toContainText('export default Button') + await expect(page.locator('#preview-host pre')).toHaveCount(0) +}) + test('persists layout and theme across reload', async ({ page }) => { await waitForInitialRender(page) diff --git a/src/app.js b/src/app.js index f3dff8b..699891d 100644 --- a/src/app.js +++ b/src/app.js @@ -8,7 +8,7 @@ import { createCodeMirrorEditor } from './modules/editor-codemirror.js' import { defaultCss, defaultJsx, defaultReactJsx } from './modules/defaults.js' import { createDiagnosticsUiController } from './modules/diagnostics-ui.js' import { isAiAssistantFeatureEnabled } from './modules/feature-flags.js' -import { createGitHubChatDrawer } from './modules/github-chat-drawer.js' +import { createGitHubChatDrawer } from './modules/github-chat-drawer/drawer.js' import { createGitHubByotControls } from './modules/github-byot-controls.js' import { formatActivePrReference, @@ -41,7 +41,6 @@ const aiChatModel = document.getElementById('ai-chat-model') const aiChatIncludeEditors = document.getElementById('ai-chat-include-editors') const aiChatSend = document.getElementById('ai-chat-send') const aiChatStatus = document.getElementById('ai-chat-status') -const aiChatRate = document.getElementById('ai-chat-rate') const aiChatRepository = document.getElementById('ai-chat-repository') const aiChatMessages = document.getElementById('ai-chat-messages') const githubPrToggle = document.getElementById('github-pr-toggle') @@ -771,13 +770,22 @@ chatDrawerController = createGitHubChatDrawer({ sendButton: aiChatSend, clearButton: aiChatClear, statusNode: aiChatStatus, - rateNode: aiChatRate, repositoryNode: aiChatRepository, messagesNode: aiChatMessages, getToken: getCurrentGitHubToken, getSelectedRepository: getCurrentSelectedRepository, getComponentSource: () => getJsxSource(), + setComponentSource: value => setJsxSource(value), getStylesSource: () => getCssSource(), + setStylesSource: value => setCssSource(value), + scheduleRender: () => { + if ( + autoRenderToggle?.checked && + typeof renderRuntime?.scheduleRender === 'function' + ) { + renderRuntime.scheduleRender() + } + }, getRenderMode: () => renderMode.value, getStyleMode: () => styleMode.value, getDrawerSide: () => { diff --git a/src/index.html b/src/index.html index 0bd037b..ab7f00b 100644 --- a/src/index.html +++ b/src/index.html @@ -677,7 +677,23 @@

Styles

hidden >
-

AI Chat

+

+ AI Chat + +

-

Rate limit info unavailable

diff --git a/src/modules/github-api.js b/src/modules/github-api.js index 7eec205..c6ce879 100644 --- a/src/modules/github-api.js +++ b/src/modules/github-api.js @@ -242,14 +242,107 @@ const normalizeChatMessages = messages => { return messages.map(normalizeChatMessage).filter(Boolean) } -const buildChatBody = ({ model, messages, stream }) => { - const normalizedMessages = normalizeChatMessages(messages) +const normalizeToolChoice = toolChoice => { + if (toolChoice === 'required' || toolChoice === 'none') { + return toolChoice + } + + return 'auto' +} + +const normalizeToolDefinition = tool => { + if (!tool || typeof tool !== 'object') { + return null + } + + if (tool.type !== 'function') { + return null + } + + const fn = tool.function + if (!fn || typeof fn !== 'object') { + return null + } + + const name = typeof fn.name === 'string' ? fn.name.trim() : '' + if (!name) { + return null + } + + const description = + typeof fn.description === 'string' && fn.description.trim() + ? fn.description.trim() + : undefined + const parameters = + fn.parameters && typeof fn.parameters === 'object' ? fn.parameters : undefined return { + type: 'function', + function: { + name, + ...(description ? { description } : {}), + ...(parameters ? { parameters } : {}), + }, + } +} + +const normalizeToolDefinitions = tools => { + if (!Array.isArray(tools)) { + return [] + } + + return tools.map(normalizeToolDefinition).filter(Boolean) +} + +const buildChatBody = ({ model, messages, stream, tools, toolChoice }) => { + const normalizedMessages = normalizeChatMessages(messages) + const normalizedTools = normalizeToolDefinitions(tools) + + const body = { model, messages: normalizedMessages, stream, } + + if (normalizedTools.length > 0) { + body.tools = normalizedTools + body.tool_choice = normalizeToolChoice(toolChoice) + } + + return body +} + +const normalizeToolCall = toolCall => { + if (!toolCall || typeof toolCall !== 'object') { + return null + } + + const fn = toolCall.function + const name = typeof fn?.name === 'string' ? fn.name.trim() : '' + if (!name) { + return null + } + + const argumentsText = + typeof fn?.arguments === 'string' && fn.arguments.trim() ? fn.arguments : '{}' + + return { + id: typeof toolCall.id === 'string' ? toolCall.id : '', + name, + arguments: argumentsText, + } +} + +const extractToolCallsFromMessage = message => { + if (!message || typeof message !== 'object') { + return [] + } + + if (!Array.isArray(message.tool_calls)) { + return [] + } + + return message.tool_calls.map(normalizeToolCall).filter(Boolean) } const extractContentFromMessage = message => { @@ -296,6 +389,16 @@ const extractChatCompletionText = body => { return extractContentFromMessage(message).trim() } +const extractChatCompletionToolCalls = body => { + const firstChoice = Array.isArray(body?.choices) ? body.choices[0] : null + + if (!firstChoice || typeof firstChoice !== 'object') { + return [] + } + + return extractToolCallsFromMessage(firstChoice.message) +} + const extractStreamingDeltaText = body => { const firstChoice = Array.isArray(body?.choices) ? body.choices[0] : null @@ -310,6 +413,57 @@ const extractStreamingDeltaText = body => { return '' } +const collectStreamingToolCalls = ({ body, callsByIndex, orderedCalls }) => { + const firstChoice = Array.isArray(body?.choices) ? body.choices[0] : null + const deltas = Array.isArray(firstChoice?.delta?.tool_calls) + ? firstChoice.delta.tool_calls + : [] + + for (const delta of deltas) { + const index = Number.isFinite(delta?.index) ? delta.index : orderedCalls.length + const key = String(index) + const existing = callsByIndex.get(key) ?? { + id: '', + name: '', + arguments: '', + } + + if (typeof delta?.id === 'string' && delta.id.trim()) { + existing.id = delta.id + } + + const fn = delta?.function + if (typeof fn?.name === 'string' && fn.name.trim()) { + existing.name = fn.name + } + + if (typeof fn?.arguments === 'string') { + existing.arguments += fn.arguments + } + + callsByIndex.set(key, existing) + } + + orderedCalls.length = 0 + const sortedEntries = [...callsByIndex.entries()].sort( + (left, right) => Number(left[0]) - Number(right[0]), + ) + + for (const [, call] of sortedEntries) { + const normalizedCall = normalizeToolCall({ + id: call.id, + function: { + name: call.name, + arguments: call.arguments, + }, + }) + + if (normalizedCall) { + orderedCalls.push(normalizedCall) + } + } +} + const parseSseDataLine = line => { if (typeof line !== 'string') { return null @@ -478,6 +632,8 @@ export const streamGitHubChatCompletion = async ({ signal, onToken, model = defaultGitHubChatModel, + tools, + toolChoice, }) => { if (typeof token !== 'string' || token.trim().length === 0) { throw new Error('A GitHub token is required to start a chat request.') @@ -492,7 +648,13 @@ export const streamGitHubChatCompletion = async ({ method: 'POST', headers: buildChatRequestHeaders({ token, stream: true }), body: JSON.stringify( - buildChatBody({ model, messages: normalizedMessages, stream: true }), + buildChatBody({ + model, + messages: normalizedMessages, + stream: true, + tools, + toolChoice, + }), ), signal, }) @@ -511,6 +673,8 @@ export const streamGitHubChatCompletion = async ({ let buffered = '' let combined = '' let responseModel = '' + const streamingToolCallsByIndex = new Map() + const streamingToolCalls = [] while (true) { // eslint-disable-next-line no-await-in-loop @@ -533,6 +697,12 @@ export const streamGitHubChatCompletion = async ({ responseModel = body.model } + collectStreamingToolCalls({ + body, + callsByIndex: streamingToolCallsByIndex, + orderedCalls: streamingToolCalls, + }) + const chunk = extractStreamingDeltaText(body) if (!chunk) { continue @@ -548,6 +718,13 @@ export const streamGitHubChatCompletion = async ({ if (body && !responseModel && typeof body.model === 'string') { responseModel = body.model } + if (body) { + collectStreamingToolCalls({ + body, + callsByIndex: streamingToolCallsByIndex, + orderedCalls: streamingToolCalls, + }) + } const chunk = body ? extractStreamingDeltaText(body) : '' if (chunk) { combined += chunk @@ -555,12 +732,13 @@ export const streamGitHubChatCompletion = async ({ } } - if (!combined.trim()) { + if (!combined.trim() && streamingToolCalls.length === 0) { throw new Error('Streaming response did not include assistant content.') } return { content: combined, + toolCalls: streamingToolCalls, model: responseModel || model, rateLimit: parseRateMetadata({ headers: response.headers, body: null }), } @@ -571,6 +749,8 @@ export const requestGitHubChatCompletion = async ({ messages, signal, model = defaultGitHubChatModel, + tools, + toolChoice, }) => { if (typeof token !== 'string' || token.trim().length === 0) { throw new Error('A GitHub token is required to start a chat request.') @@ -585,7 +765,13 @@ export const requestGitHubChatCompletion = async ({ method: 'POST', headers: buildChatRequestHeaders({ token, stream: false }), body: JSON.stringify( - buildChatBody({ model, messages: normalizedMessages, stream: false }), + buildChatBody({ + model, + messages: normalizedMessages, + stream: false, + tools, + toolChoice, + }), ), signal, }) @@ -597,13 +783,15 @@ export const requestGitHubChatCompletion = async ({ const body = await response.json() const content = extractChatCompletionText(body) + const toolCalls = extractChatCompletionToolCalls(body) - if (!content) { + if (!content && toolCalls.length === 0) { throw new Error('GitHub chat response did not include assistant content.') } return { content, + toolCalls, model: typeof body?.model === 'string' && body.model ? body.model : model, rateLimit: parseRateMetadata({ headers: response.headers, body }), } diff --git a/src/modules/github-chat-drawer.js b/src/modules/github-chat-drawer.js deleted file mode 100644 index 2f764cd..0000000 --- a/src/modules/github-chat-drawer.js +++ /dev/null @@ -1,691 +0,0 @@ -import { - defaultGitHubChatModel, - githubChatModelOptions, - requestGitHubChatCompletion, - streamGitHubChatCompletion, -} from './github-api.js' - -const toChatText = value => { - if (typeof value !== 'string') { - return '' - } - - return value.trim() -} - -const toModelId = value => { - if (typeof value !== 'string') { - return defaultGitHubChatModel - } - - const model = value.trim() - return model || defaultGitHubChatModel -} - -const isModelAccessError = error => { - const message = error instanceof Error ? error.message.toLowerCase() : '' - if (!message) { - return false - } - - 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('forbidden') && message.includes('model')) - ) -} - -const formatModelAccessErrorMessage = selectedModel => { - const model = toModelId(selectedModel) - return `Selected model "${model}" is not available for this token. Choose a different model.` -} - -const isModelAccessStatusMessage = value => { - if (typeof value !== 'string') { - return false - } - - return ( - value.startsWith('Selected model "') && value.includes('not available for this token') - ) -} - -const toRepositoryLabel = repository => { - if (!repository || typeof repository !== 'object') { - return 'No repository selected' - } - - if (typeof repository.fullName === 'string' && repository.fullName.trim()) { - return repository.fullName - } - - return 'No repository selected' -} - -const toRepositoryUrl = repository => { - if (!repository || typeof repository !== 'object') { - return '' - } - - if (typeof repository.htmlUrl === 'string' && repository.htmlUrl.trim()) { - return repository.htmlUrl - } - - if (typeof repository.fullName === 'string' && repository.fullName.trim()) { - return `https://github.com/${repository.fullName}` - } - - return '' -} - -export const createGitHubChatDrawer = ({ - featureEnabled, - toggleButton, - drawer, - closeButton, - promptInput, - modelSelect, - sendButton, - clearButton, - statusNode, - rateNode, - repositoryNode, - messagesNode, - includeEditorsContextToggle, - getToken, - getSelectedRepository, - getComponentSource, - getStylesSource, - getRenderMode, - getStyleMode, - getDrawerSide, -}) => { - if (!featureEnabled) { - toggleButton?.setAttribute('hidden', '') - drawer?.setAttribute('hidden', '') - - return { - setOpen: () => {}, - isOpen: () => false, - setSelectedRepository: () => {}, - setToken: () => {}, - dispose: () => {}, - } - } - - let open = false - let pendingAbortController = null - const messages = [] - let lastAssistantBodyNode = null - let pendingAssistantBodyText = null - let pendingAssistantFrameId = null - - const cancelPendingAssistantBodyUpdate = () => { - if (pendingAssistantFrameId === null) { - return - } - - cancelAnimationFrame(pendingAssistantFrameId) - pendingAssistantFrameId = null - } - - const flushPendingAssistantBodyUpdate = () => { - pendingAssistantFrameId = null - - if (pendingAssistantBodyText === null) { - return - } - - if (lastAssistantBodyNode) { - lastAssistantBodyNode.textContent = pendingAssistantBodyText - if (messagesNode) { - messagesNode.scrollTop = messagesNode.scrollHeight - } - pendingAssistantBodyText = null - return - } - - const nextText = pendingAssistantBodyText - pendingAssistantBodyText = null - updateLastAssistantMessage(nextText) - } - - const scheduleAssistantBodyUpdate = content => { - pendingAssistantBodyText = content - - if (pendingAssistantFrameId !== null) { - return - } - - pendingAssistantFrameId = requestAnimationFrame(() => { - flushPendingAssistantBodyUpdate() - }) - } - - const stopPendingRequest = () => { - pendingAbortController?.abort() - pendingAbortController = null - } - - const setModelSelectDisabled = isDisabled => { - if (!(modelSelect instanceof HTMLSelectElement)) { - return - } - - modelSelect.disabled = isDisabled - } - - const replaceModelOptions = ({ modelIds, selectedModel }) => { - if (!(modelSelect instanceof HTMLSelectElement)) { - return - } - - const nextSelectedModel = toModelId(selectedModel) - const nextModelIds = [...new Set([defaultGitHubChatModel, ...modelIds])] - - modelSelect.replaceChildren() - - for (const modelId of nextModelIds) { - const option = document.createElement('option') - option.value = modelId - option.textContent = modelId - option.selected = modelId === nextSelectedModel - modelSelect.append(option) - } - - if (!nextModelIds.includes(nextSelectedModel)) { - modelSelect.value = defaultGitHubChatModel - } - } - - const getSelectedModel = () => { - if (!(modelSelect instanceof HTMLSelectElement)) { - return defaultGitHubChatModel - } - - return toModelId(modelSelect.value) - } - - const initializeModelOptions = () => { - replaceModelOptions({ - modelIds: githubChatModelOptions, - selectedModel: defaultGitHubChatModel, - }) - } - - const syncModelSelectionForToken = token => { - const hasToken = typeof token === 'string' && token.trim().length > 0 - - setModelSelectDisabled(!hasToken) - - if (!hasToken && modelSelect instanceof HTMLSelectElement) { - modelSelect.value = defaultGitHubChatModel - } - - if (hasToken && isModelAccessStatusMessage(statusNode?.textContent)) { - setChatStatus('Idle', 'neutral') - } - } - - const setOpen = nextOpen => { - open = nextOpen === true - - if (!toggleButton || !drawer) { - return - } - - const preferredSide = getDrawerSide?.() === 'left' ? 'left' : 'right' - drawer.classList.toggle('ai-chat-drawer--left', preferredSide === 'left') - drawer.classList.toggle('ai-chat-drawer--right', preferredSide !== 'left') - - toggleButton.setAttribute('aria-expanded', open ? 'true' : 'false') - drawer.toggleAttribute('hidden', !open) - - if (open && promptInput instanceof HTMLTextAreaElement) { - promptInput.focus() - } - } - - const setChatStatus = (text, level = 'neutral') => { - if (!statusNode) { - return - } - - statusNode.textContent = text - statusNode.dataset.level = level - } - - const formatResetTime = resetEpochSeconds => { - if (!Number.isFinite(resetEpochSeconds)) { - return '' - } - - const resetDate = new Date(Number(resetEpochSeconds) * 1000) - if (Number.isNaN(resetDate.getTime())) { - return '' - } - - return `${resetDate.toISOString().slice(11, 16)} UTC` - } - - const setRateMetadata = rateLimit => { - if (!rateNode) { - return - } - - const remaining = Number.isFinite(rateLimit?.remaining) ? rateLimit.remaining : null - const resetText = formatResetTime(rateLimit?.resetEpochSeconds) - - if (remaining === null) { - rateNode.textContent = 'Rate limit info unavailable' - return - } - - if (resetText) { - rateNode.textContent = `Remaining ${remaining}, resets ${resetText}` - return - } - - rateNode.textContent = `Remaining ${remaining}` - } - - const syncRepositoryLabel = () => { - if (!repositoryNode) { - return - } - - repositoryNode.textContent = toRepositoryLabel(getSelectedRepository?.()) - } - - 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) - 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' - label.textContent = message.role === 'assistant' ? 'Assistant' : 'You' - - item.append(label) - - const body = document.createElement('p') - body.className = 'ai-chat-message__body' - body.textContent = message.content - item.append(body) - - 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 - } - - const appendMessage = message => { - messages.push(message) - renderMessages() - } - - const updateLastAssistantMessage = content => { - const lastMessage = messages[messages.length - 1] - if (!lastMessage || lastMessage.role !== 'assistant') { - return - } - - lastMessage.content = content - - if (lastAssistantBodyNode) { - scheduleAssistantBodyUpdate(content) - return - } - - renderMessages() - } - - const collectConversation = () => { - return messages - .filter(message => message.role === 'user' || message.role === 'assistant') - .map(message => ({ - role: message.role, - content: message.content, - })) - } - - const collectRepositoryContext = () => { - const repository = getSelectedRepository?.() - - const repositoryLabel = toRepositoryLabel(repository) - const repositoryUrl = toRepositoryUrl(repository) - const defaultBranch = - repository && typeof repository.defaultBranch === 'string' - ? repository.defaultBranch - : 'unknown' - - const contextLines = [ - 'Selected repository context:', - `- Repository: ${repositoryLabel}`, - ...(repositoryUrl ? [`- Repository URL: ${repositoryUrl}`] : []), - `- Default branch: ${defaultBranch}`, - 'Use this repository as the default target for the user request unless they explicitly override it.', - ] - - return contextLines.join('\n') - } - - const collectEditorContext = () => { - if (!(includeEditorsContextToggle instanceof HTMLInputElement)) { - return null - } - - if (!includeEditorsContextToggle.checked) { - return null - } - - const componentSource = - typeof getComponentSource === 'function' ? toChatText(getComponentSource()) : '' - const stylesSource = - typeof getStylesSource === 'function' ? toChatText(getStylesSource()) : '' - - if (!componentSource && !stylesSource) { - return null - } - - const renderMode = - typeof getRenderMode === 'function' ? toChatText(getRenderMode()) : '' - const styleMode = typeof getStyleMode === 'function' ? toChatText(getStyleMode()) : '' - - return [ - 'Editor context:', - `- Render mode: ${renderMode || 'unknown'}`, - `- Style mode: ${styleMode || 'unknown'}`, - '', - 'Component editor source (JSX/TSX):', - '```jsx', - componentSource || '(empty)', - '```', - '', - 'Styles editor source:', - '```css', - stylesSource || '(empty)', - '```', - ].join('\n') - } - - const setPendingState = isPending => { - if (sendButton instanceof HTMLButtonElement) { - sendButton.disabled = isPending - } - - if (promptInput instanceof HTMLTextAreaElement) { - promptInput.disabled = isPending - } - - if (modelSelect instanceof HTMLSelectElement) { - if (isPending) { - modelSelect.disabled = true - } else { - const token = getToken?.() - const hasToken = typeof token === 'string' && token.trim().length > 0 - modelSelect.disabled = !hasToken - } - } - } - - const runChatRequest = async () => { - const prompt = toChatText(promptInput?.value) - - if (!prompt) { - setChatStatus('Enter a prompt before sending.', 'error') - return - } - - const token = getToken?.() - if (!token) { - setChatStatus('Add a GitHub token before starting chat.', 'error') - return - } - - const repository = getSelectedRepository?.() - if (!repository?.fullName) { - setChatStatus('Select a writable repository before starting chat.', 'error') - return - } - - const selectedModel = getSelectedModel() - - stopPendingRequest() - const requestAbortController = new AbortController() - const requestSignal = requestAbortController.signal - pendingAbortController = requestAbortController - - appendMessage({ role: 'user', content: prompt }) - appendMessage({ role: 'assistant', content: '', model: selectedModel }) - if (promptInput instanceof HTMLTextAreaElement) { - promptInput.value = '' - } - - setPendingState(true) - setChatStatus('Streaming response from GitHub...', 'pending') - - const repositoryContext = collectRepositoryContext() - const editorContext = collectEditorContext() - const outboundMessages = [ - { role: 'system', content: repositoryContext }, - ...(editorContext ? [{ role: 'system', content: editorContext }] : []), - ...collectConversation(), - ] - - let streamedContent = '' - let streamSucceeded = false - - try { - const streamResult = await streamGitHubChatCompletion({ - token, - messages: outboundMessages, - model: selectedModel, - signal: requestSignal, - onToken: tokenChunk => { - streamedContent += tokenChunk - updateLastAssistantMessage(streamedContent) - }, - }) - - streamSucceeded = true - const streamedModel = toChatText(streamResult?.model) - if (streamedModel) { - const lastMessage = messages[messages.length - 1] - if (lastMessage?.role === 'assistant' && lastMessage.model !== streamedModel) { - lastMessage.model = streamedModel - renderMessages() - } - } - setChatStatus('Response streamed from GitHub.', 'ok') - setRateMetadata(streamResult?.rateLimit) - } catch (streamError) { - setRateMetadata(streamError?.rateLimit) - if (requestSignal.aborted) { - if (pendingAbortController === requestAbortController) { - setChatStatus('Chat request canceled.', 'neutral') - pendingAbortController = null - setPendingState(false) - } - return - } - - if (isModelAccessError(streamError)) { - const modelAccessMessage = formatModelAccessErrorMessage(selectedModel) - - updateLastAssistantMessage(modelAccessMessage) - const lastMessage = messages[messages.length - 1] - if (lastMessage) { - lastMessage.level = 'error' - } - renderMessages() - setChatStatus(modelAccessMessage, 'error') - - if (pendingAbortController === requestAbortController) { - pendingAbortController = null - setPendingState(false) - } - return - } - - setChatStatus( - 'Streaming unavailable. Retrying with fallback response...', - 'pending', - ) - } - - if (streamSucceeded) { - if (pendingAbortController === requestAbortController) { - pendingAbortController = null - setPendingState(false) - } - return - } - - try { - const fallbackResult = await requestGitHubChatCompletion({ - token, - messages: outboundMessages, - model: selectedModel, - signal: requestSignal, - }) - - updateLastAssistantMessage(fallbackResult.content) - 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') - setRateMetadata(fallbackResult.rateLimit) - } 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.' - - setRateMetadata(fallbackError?.rateLimit) - - updateLastAssistantMessage(fallbackMessage) - const lastMessage = messages[messages.length - 1] - if (lastMessage) { - lastMessage.level = 'error' - } - renderMessages() - setChatStatus(`Chat request failed: ${fallbackMessage}`, 'error') - } finally { - if (pendingAbortController === requestAbortController) { - pendingAbortController = null - setPendingState(false) - } - } - } - - toggleButton?.setAttribute('aria-expanded', 'false') - drawer?.setAttribute('hidden', '') - initializeModelOptions() - syncModelSelectionForToken(getToken?.()) - syncRepositoryLabel() - renderMessages() - setChatStatus('Idle', 'neutral') - setRateMetadata(null) - - toggleButton?.addEventListener('click', () => { - setOpen(!open) - }) - - closeButton?.addEventListener('click', () => { - setOpen(false) - }) - - clearButton?.addEventListener('click', () => { - stopPendingRequest() - setPendingState(false) - cancelPendingAssistantBodyUpdate() - pendingAssistantBodyText = null - messages.length = 0 - renderMessages() - setRateMetadata(null) - setChatStatus('Chat cleared.', 'neutral') - }) - - sendButton?.addEventListener('click', () => { - void runChatRequest() - }) - - promptInput?.addEventListener('keydown', event => { - if (event.key !== 'Enter' || (!event.metaKey && !event.ctrlKey)) { - return - } - - event.preventDefault() - void runChatRequest() - }) - - const onDocumentKeydown = event => { - if (event.key === 'Escape' && open) { - setOpen(false) - } - } - - document.addEventListener('keydown', onDocumentKeydown) - - return { - setOpen, - isOpen: () => open, - setSelectedRepository: () => { - syncRepositoryLabel() - }, - setToken: token => { - syncModelSelectionForToken(token) - }, - dispose: () => { - stopPendingRequest() - setPendingState(false) - cancelPendingAssistantBodyUpdate() - pendingAssistantBodyText = null - document.removeEventListener('keydown', onDocumentKeydown) - }, - } -} diff --git a/src/modules/github-chat-drawer/chat-utils.js b/src/modules/github-chat-drawer/chat-utils.js new file mode 100644 index 0000000..7a98cfa --- /dev/null +++ b/src/modules/github-chat-drawer/chat-utils.js @@ -0,0 +1,77 @@ +import { defaultGitHubChatModel } from '../github-api.js' + +export const toChatText = value => { + if (typeof value !== 'string') { + return '' + } + + return value.trim() +} + +export const toModelId = value => { + if (typeof value !== 'string') { + return defaultGitHubChatModel + } + + const model = value.trim() + return model || defaultGitHubChatModel +} + +export const isModelAccessError = error => { + const message = error instanceof Error ? error.message.toLowerCase() : '' + if (!message) { + return false + } + + 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('forbidden') && message.includes('model')) + ) +} + +export const formatModelAccessErrorMessage = selectedModel => { + const model = toModelId(selectedModel) + return `Selected model "${model}" is not available for this token. Choose a different model.` +} + +export const isModelAccessStatusMessage = value => { + if (typeof value !== 'string') { + return false + } + + return ( + value.startsWith('Selected model "') && value.includes('not available for this token') + ) +} + +export const toRepositoryLabel = repository => { + if (!repository || typeof repository !== 'object') { + return 'No repository selected' + } + + if (typeof repository.fullName === 'string' && repository.fullName.trim()) { + return repository.fullName + } + + return 'No repository selected' +} + +export const toRepositoryUrl = repository => { + if (!repository || typeof repository !== 'object') { + return '' + } + + if (typeof repository.htmlUrl === 'string' && repository.htmlUrl.trim()) { + return repository.htmlUrl + } + + if (typeof repository.fullName === 'string' && repository.fullName.trim()) { + return `https://github.com/${repository.fullName}` + } + + return '' +} diff --git a/src/modules/github-chat-drawer/drawer.js b/src/modules/github-chat-drawer/drawer.js new file mode 100644 index 0000000..93f857c --- /dev/null +++ b/src/modules/github-chat-drawer/drawer.js @@ -0,0 +1,1050 @@ +import { + defaultGitHubChatModel, + githubChatModelOptions, + requestGitHubChatCompletion, + streamGitHubChatCompletion, +} from '../github-api.js' +import { + formatModelAccessErrorMessage, + isModelAccessError, + isModelAccessStatusMessage, + toChatText, + toModelId, + toRepositoryLabel, + toRepositoryUrl, +} from './chat-utils.js' +import { buildOutboundMessages as buildPayloadMessages } from './payload.js' +import { editorProposalTools, toMessageEditorProposals } from './proposals.js' + +const svgNamespace = 'http://www.w3.org/2000/svg' + +const createMessageLabelIconTemplate = role => { + const iconPathByRole = { + user: 'M1 2.75C1 1.784 1.784 1 2.75 1h10.5c.966 0 1.75.784 1.75 1.75v7.5A1.75 1.75 0 0 1 13.25 12H9.06l-2.573 2.573A1.458 1.458 0 0 1 4 13.543V12H2.75A1.75 1.75 0 0 1 1 10.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h4.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z', + assistant: + 'M7.75 1a.75.75 0 0 1 0 1.5h-5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h2c.199 0 .39.079.53.22.141.14.22.331.22.53v2.19l2.72-2.72a.747.747 0 0 1 .53-.22h4.5a.25.25 0 0 0 .25-.25v-2a.75.75 0 0 1 1.5 0v2c0 .464-.184.909-.513 1.237A1.746 1.746 0 0 1 13.25 12H9.06l-2.573 2.573A1.457 1.457 0 0 1 4 13.543V12H2.75A1.75 1.75 0 0 1 1 10.25v-7.5C1 1.784 1.784 1 2.75 1h5Zm4.519-.837a.248.248 0 0 1 .466 0l.238.648a3.726 3.726 0 0 0 2.218 2.219l.649.238a.249.249 0 0 1 0 .467l-.649.238a3.725 3.725 0 0 0-2.218 2.218l-.238.649a.248.248 0 0 1-.466 0l-.239-.649a3.725 3.725 0 0 0-2.218-2.218l-.649-.238a.249.249 0 0 1 0-.467l.649-.238A3.726 3.726 0 0 0 12.03.811l.239-.648Z', + } + + const pathData = role === 'assistant' ? iconPathByRole.assistant : iconPathByRole.user + const svg = document.createElementNS(svgNamespace, 'svg') + svg.setAttribute('xmlns', svgNamespace) + svg.setAttribute('viewBox', '0 0 16 16') + svg.setAttribute('width', '16') + svg.setAttribute('height', '16') + svg.setAttribute('aria-hidden', 'true') + svg.classList.add('ai-chat-message__label-icon') + + const path = document.createElementNS(svgNamespace, 'path') + path.setAttribute('d', pathData) + svg.append(path) + + return svg +} + +export const createGitHubChatDrawer = ({ + featureEnabled, + toggleButton, + drawer, + closeButton, + promptInput, + modelSelect, + sendButton, + clearButton, + statusNode, + repositoryNode, + messagesNode, + includeEditorsContextToggle, + getToken, + getSelectedRepository, + getComponentSource, + setComponentSource, + getStylesSource, + setStylesSource, + scheduleRender, + getRenderMode, + getStyleMode, + getDrawerSide, +}) => { + if (!featureEnabled) { + toggleButton?.setAttribute('hidden', '') + drawer?.setAttribute('hidden', '') + + return { + setOpen: () => {}, + isOpen: () => false, + setSelectedRepository: () => {}, + setToken: () => {}, + dispose: () => {}, + } + } + + 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 lastAppliedEditorSnapshot = { + component: null, + styles: null, + } + + const resetChatContextState = () => { + compactedConversationSummary = '' + lastAppliedEditorSnapshot.component = null + lastAppliedEditorSnapshot.styles = null + + for (const message of messages) { + if (!message || typeof message !== 'object') { + continue + } + + message.confirmTarget = null + message.appliedTargets = null + } + } + + const cancelPendingAssistantBodyUpdate = () => { + if (pendingAssistantFrameId === null) { + return + } + + cancelAnimationFrame(pendingAssistantFrameId) + pendingAssistantFrameId = null + } + + const flushPendingAssistantBodyUpdate = () => { + pendingAssistantFrameId = null + + if (pendingAssistantBodyText === null) { + return + } + + if (lastAssistantBodyNode) { + lastAssistantBodyNode.textContent = pendingAssistantBodyText + if (messagesNode) { + messagesNode.scrollTop = messagesNode.scrollHeight + } + pendingAssistantBodyText = null + return + } + + const nextText = pendingAssistantBodyText + pendingAssistantBodyText = null + updateLastAssistantMessage(nextText) + } + + const scheduleAssistantBodyUpdate = content => { + pendingAssistantBodyText = content + + if (pendingAssistantFrameId !== null) { + return + } + + pendingAssistantFrameId = requestAnimationFrame(() => { + flushPendingAssistantBodyUpdate() + }) + } + + const stopPendingRequest = () => { + pendingAbortController?.abort() + pendingAbortController = null + } + + const setModelSelectDisabled = isDisabled => { + if (!(modelSelect instanceof HTMLSelectElement)) { + return + } + + modelSelect.disabled = isDisabled + } + + const replaceModelOptions = ({ modelIds, selectedModel }) => { + if (!(modelSelect instanceof HTMLSelectElement)) { + return + } + + const nextSelectedModel = toModelId(selectedModel) + const nextModelIds = [...new Set([defaultGitHubChatModel, ...modelIds])] + + modelSelect.replaceChildren() + + for (const modelId of nextModelIds) { + const option = document.createElement('option') + option.value = modelId + option.textContent = modelId + option.selected = modelId === nextSelectedModel + modelSelect.append(option) + } + + if (!nextModelIds.includes(nextSelectedModel)) { + modelSelect.value = defaultGitHubChatModel + } + } + + const getSelectedModel = () => { + if (!(modelSelect instanceof HTMLSelectElement)) { + return defaultGitHubChatModel + } + + return toModelId(modelSelect.value) + } + + const initializeModelOptions = () => { + replaceModelOptions({ + modelIds: githubChatModelOptions, + selectedModel: defaultGitHubChatModel, + }) + } + + const syncModelSelectionForToken = token => { + const hasToken = typeof token === 'string' && token.trim().length > 0 + + setModelSelectDisabled(!hasToken) + + if (!hasToken && modelSelect instanceof HTMLSelectElement) { + modelSelect.value = defaultGitHubChatModel + } + + if (hasToken && isModelAccessStatusMessage(statusNode?.textContent)) { + setChatStatus('Idle', 'neutral') + } + } + + const setOpen = nextOpen => { + open = nextOpen === true + + if (!toggleButton || !drawer) { + return + } + + const preferredSide = getDrawerSide?.() === 'left' ? 'left' : 'right' + drawer.classList.toggle('ai-chat-drawer--left', preferredSide === 'left') + drawer.classList.toggle('ai-chat-drawer--right', preferredSide !== 'left') + + toggleButton.setAttribute('aria-expanded', open ? 'true' : 'false') + drawer.toggleAttribute('hidden', !open) + + if (open && promptInput instanceof HTMLTextAreaElement) { + promptInput.focus() + } + } + + const setChatStatus = (text, level = 'neutral') => { + if (!statusNode) { + return + } + + statusNode.textContent = text + statusNode.dataset.level = level + } + + const syncRepositoryLabel = () => { + if (!repositoryNode) { + return + } + + repositoryNode.textContent = toRepositoryLabel(getSelectedRepository?.()) + } + + const buildRequestMessages = ({ repositoryContext, editorContext }) => { + const renderMode = + typeof getRenderMode === 'function' ? toChatText(getRenderMode()) : 'unknown' + const styleMode = + typeof getStyleMode === 'function' ? toChatText(getStyleMode()) : 'unknown' + + const { outboundMessages, nextSummary } = buildPayloadMessages({ + messages, + repositoryContext, + editorContext, + renderMode, + styleMode, + existingSummary: compactedConversationSummary, + }) + + compactedConversationSummary = nextSummary + return outboundMessages + } + + const ensureUndoActionsNode = () => { + if (undoActionsNode) { + return undoActionsNode + } + + if (!(messagesNode instanceof HTMLElement)) { + return null + } + + const parentNode = messagesNode.parentElement + if (!(parentNode instanceof HTMLElement)) { + return null + } + + undoActionsNode = document.createElement('div') + undoActionsNode.className = 'ai-chat-drawer__undo-actions' + undoActionsNode.setAttribute('hidden', '') + messagesNode.insertAdjacentElement('afterend', undoActionsNode) + + return undoActionsNode + } + + const renderUndoActions = () => { + const undoNode = ensureUndoActionsNode() + if (!undoNode) { + return + } + + undoNode.replaceChildren() + + const hasComponentUndo = Boolean(lastAppliedEditorSnapshot.component) + const hasStylesUndo = Boolean(lastAppliedEditorSnapshot.styles) + + if (!hasComponentUndo && !hasStylesUndo) { + undoNode.setAttribute('hidden', '') + return + } + + const label = document.createElement('p') + label.className = 'ai-chat-drawer__undo-label' + label.textContent = 'Latest applied changes' + undoNode.append(label) + + if (hasComponentUndo) { + const undoComponentButton = document.createElement('button') + undoComponentButton.type = 'button' + undoComponentButton.className = + 'render-button render-button--small ai-chat-drawer__undo-action' + undoComponentButton.dataset.action = 'undo-editor-apply' + undoComponentButton.dataset.targetEditor = 'component' + undoComponentButton.textContent = 'Undo last Component apply' + undoNode.append(undoComponentButton) + } + + if (hasStylesUndo) { + const undoStylesButton = document.createElement('button') + undoStylesButton.type = 'button' + undoStylesButton.className = + 'render-button render-button--small ai-chat-drawer__undo-action' + undoStylesButton.dataset.action = 'undo-editor-apply' + undoStylesButton.dataset.targetEditor = 'styles' + undoStylesButton.textContent = 'Undo last Styles apply' + undoNode.append(undoStylesButton) + } + + 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 proposals = + message.role === 'assistant' ? toMessageEditorProposals(message) : null + const componentProposal = proposals?.component + const stylesProposal = proposals?.styles + const hasProposal = Boolean(componentProposal || stylesProposal) + const appliedTargets = + message && typeof message.appliedTargets === 'object' && message.appliedTargets + ? message.appliedTargets + : {} + const showCombinedApply = + componentProposal && + stylesProposal && + appliedTargets.component !== true && + appliedTargets.styles !== true + + if (hasProposal) { + const actions = document.createElement('div') + actions.className = 'ai-chat-message__actions' + actions.dataset.messageIndex = String(index) + + const buildApplyButton = ({ target, text }) => { + 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.targetEditor = target + button.dataset.messageIndex = String(index) + button.textContent = text + button.setAttribute( + 'aria-label', + target === 'styles' + ? 'Apply update to Styles editor' + : 'Apply update to Component editor', + ) + if (pendingAbortController) { + button.disabled = true + } + return button + } + + if ( + componentProposal && + appliedTargets.component !== true && + !showCombinedApply + ) { + actions.append(buildApplyButton({ target: 'component', text: 'Apply update' })) + } + + if (stylesProposal && appliedTargets.styles !== true && !showCombinedApply) { + actions.append(buildApplyButton({ target: 'styles', text: 'Apply update' })) + } + + if (showCombinedApply) { + const applyBothButton = document.createElement('button') + applyBothButton.type = 'button' + applyBothButton.className = + 'render-button render-button--small ai-chat-message__action' + applyBothButton.dataset.action = 'apply-both' + applyBothButton.dataset.messageIndex = String(index) + applyBothButton.textContent = 'Apply update' + applyBothButton.setAttribute('aria-label', 'Apply update to both editors') + if (pendingAbortController) { + applyBothButton.disabled = true + } + actions.append(applyBothButton) + } + + item.append(actions) + } + + if (message.role === 'assistant' && index === messages.length - 1) { + lastAssistantBodyNode = body + } + + if (message.level === 'error') { + item.classList.add('ai-chat-message--error') + } + + messagesNode.append(item) + } + + messagesNode.scrollTop = messagesNode.scrollHeight + renderUndoActions() + } + + const appendMessage = message => { + messages.push(message) + renderMessages() + } + + const updateLastAssistantMessage = content => { + const lastMessage = messages[messages.length - 1] + if (!lastMessage || lastMessage.role !== 'assistant') { + return + } + + lastMessage.content = content + + if (lastAssistantBodyNode) { + scheduleAssistantBodyUpdate(content) + return + } + + renderMessages() + } + + const scheduleRenderAfterEditorUpdate = () => { + if (typeof scheduleRender !== 'function') { + return + } + + if (typeof requestAnimationFrame === 'function') { + requestAnimationFrame(() => { + scheduleRender() + }) + return + } + + setTimeout(() => { + scheduleRender() + }, 0) + } + + const preserveTrailingNewlineIfNeeded = ({ previousValue, nextValue }) => { + if (typeof previousValue !== 'string' || typeof nextValue !== 'string') { + return nextValue + } + + if (!previousValue.endsWith('\n') || nextValue.endsWith('\n')) { + return nextValue + } + + return `${nextValue}\n` + } + + const applyProposalToEditor = ({ messageIndex, target }) => { + const message = messages[messageIndex] + if (!message || message.role !== 'assistant') { + return false + } + + const proposals = toMessageEditorProposals(message) + const proposal = target === 'styles' ? proposals.styles : proposals.component + if (!proposal) { + return false + } + + if (target === 'component') { + if ( + typeof setComponentSource !== 'function' || + typeof getComponentSource !== 'function' + ) { + return false + } + + const previousValue = getComponentSource() + const nextValue = preserveTrailingNewlineIfNeeded({ + previousValue, + nextValue: proposal.content, + }) + setComponentSource(nextValue) + lastAppliedEditorSnapshot.component = { + previousValue, + } + scheduleRenderAfterEditorUpdate() + setChatStatus('Applied assistant proposal to Component editor.', 'ok') + return true + } + + if (typeof setStylesSource !== 'function' || typeof getStylesSource !== 'function') { + return false + } + + const previousValue = getStylesSource() + const nextValue = preserveTrailingNewlineIfNeeded({ + previousValue, + nextValue: proposal.content, + }) + setStylesSource(nextValue) + lastAppliedEditorSnapshot.styles = { + previousValue, + } + scheduleRenderAfterEditorUpdate() + setChatStatus('Applied assistant proposal to Styles editor.', 'ok') + return true + } + + const undoEditorApply = target => { + if (target === 'component') { + const snapshot = lastAppliedEditorSnapshot.component + if (!snapshot || typeof setComponentSource !== 'function') { + return false + } + + setComponentSource(snapshot.previousValue) + lastAppliedEditorSnapshot.component = null + scheduleRenderAfterEditorUpdate() + setChatStatus('Reverted last Component editor apply.', 'neutral') + return true + } + + const snapshot = lastAppliedEditorSnapshot.styles + if (!snapshot || typeof setStylesSource !== 'function') { + return false + } + + setStylesSource(snapshot.previousValue) + lastAppliedEditorSnapshot.styles = null + scheduleRenderAfterEditorUpdate() + setChatStatus('Reverted last Styles editor apply.', 'neutral') + return true + } + + const collectRepositoryContext = () => { + const repository = getSelectedRepository?.() + + const repositoryLabel = toRepositoryLabel(repository) + const repositoryUrl = toRepositoryUrl(repository) + const defaultBranch = + repository && typeof repository.defaultBranch === 'string' + ? repository.defaultBranch + : 'unknown' + + const contextLines = [ + 'Selected repository context:', + `- Repository: ${repositoryLabel}`, + ...(repositoryUrl ? [`- Repository URL: ${repositoryUrl}`] : []), + `- Default branch: ${defaultBranch}`, + 'Use this repository as the default target for the user request unless they explicitly override it.', + ] + + return contextLines.join('\n') + } + + const collectEditorContext = () => { + if (!(includeEditorsContextToggle instanceof HTMLInputElement)) { + return null + } + + if (!includeEditorsContextToggle.checked) { + return null + } + + const componentSource = + typeof getComponentSource === 'function' ? toChatText(getComponentSource()) : '' + const stylesSource = + typeof getStylesSource === 'function' ? toChatText(getStylesSource()) : '' + + if (!componentSource && !stylesSource) { + return null + } + + const renderMode = + typeof getRenderMode === 'function' ? toChatText(getRenderMode()) : '' + const styleMode = typeof getStyleMode === 'function' ? toChatText(getStyleMode()) : '' + + return [ + 'Editor context:', + `- Render mode: ${renderMode || 'unknown'}`, + `- Style mode: ${styleMode || 'unknown'}`, + '- If proposing concrete editor changes, prefer tool calls over plain text.', + '', + 'Component editor source (JSX/TSX):', + '```jsx', + componentSource || '(empty)', + '```', + '', + 'Styles editor source:', + '```css', + stylesSource || '(empty)', + '```', + ].join('\n') + } + + const setPendingState = isPending => { + if (sendButton instanceof HTMLButtonElement) { + sendButton.disabled = isPending + } + + if (promptInput instanceof HTMLTextAreaElement) { + promptInput.disabled = isPending + } + + if (modelSelect instanceof HTMLSelectElement) { + if (isPending) { + modelSelect.disabled = true + } else { + const token = getToken?.() + const hasToken = typeof token === 'string' && token.trim().length > 0 + modelSelect.disabled = !hasToken + } + } + + renderMessages() + } + + const attachAssistantResponseMetadata = ({ content, toolCalls, model, level }) => { + const lastMessage = messages[messages.length - 1] + if (!lastMessage || lastMessage.role !== 'assistant') { + return + } + + const normalizedToolCalls = Array.isArray(toolCalls) ? toolCalls : [] + const normalizedContent = typeof content === 'string' ? content : lastMessage.content + const hasContent = + typeof normalizedContent === 'string' && normalizedContent.trim().length > 0 + + lastMessage.content = + hasContent || normalizedToolCalls.length === 0 + ? normalizedContent + : 'Proposed editor update is ready. Review and apply below.' + lastMessage.toolCalls = normalizedToolCalls + + if (typeof model === 'string' && model.trim()) { + lastMessage.model = model + } + + if (level) { + lastMessage.level = level + } else { + delete lastMessage.level + } + + renderMessages() + } + + const runChatRequest = async () => { + const prompt = toChatText(promptInput?.value) + + if (!prompt) { + setChatStatus('Enter a prompt before sending.', 'error') + return + } + + const token = getToken?.() + if (!token) { + setChatStatus('Add a GitHub token before starting chat.', 'error') + return + } + + const repository = getSelectedRepository?.() + if (!repository?.fullName) { + setChatStatus('Select a writable repository before starting chat.', 'error') + return + } + + const selectedModel = getSelectedModel() + + stopPendingRequest() + const requestAbortController = new AbortController() + const requestSignal = requestAbortController.signal + pendingAbortController = requestAbortController + + appendMessage({ role: 'user', content: prompt }) + appendMessage({ role: 'assistant', content: '', model: selectedModel }) + if (promptInput instanceof HTMLTextAreaElement) { + promptInput.value = '' + } + + setPendingState(true) + setChatStatus('Streaming response from GitHub...', 'pending') + + const repositoryContext = collectRepositoryContext() + const editorContext = collectEditorContext() + const outboundMessages = buildRequestMessages({ repositoryContext, editorContext }) + const toolChoice = includeEditorsContextToggle?.checked ? 'auto' : 'none' + + let streamedContent = '' + let streamSucceeded = false + + try { + const streamResult = await streamGitHubChatCompletion({ + token, + messages: outboundMessages, + model: selectedModel, + tools: editorProposalTools, + toolChoice, + signal: requestSignal, + onToken: tokenChunk => { + streamedContent += tokenChunk + updateLastAssistantMessage(streamedContent) + }, + }) + + streamSucceeded = true + const streamedModel = toChatText(streamResult?.model) + const streamContent = toChatText(streamResult?.content) + attachAssistantResponseMetadata({ + content: streamContent, + toolCalls: streamResult?.toolCalls, + model: streamedModel, + }) + setChatStatus('Response streamed from GitHub.', 'ok') + } catch (streamError) { + if (requestSignal.aborted) { + if (pendingAbortController === requestAbortController) { + setChatStatus('Chat request canceled.', 'neutral') + pendingAbortController = null + setPendingState(false) + } + return + } + + if (isModelAccessError(streamError)) { + const modelAccessMessage = formatModelAccessErrorMessage(selectedModel) + + updateLastAssistantMessage(modelAccessMessage) + const lastMessage = messages[messages.length - 1] + if (lastMessage) { + lastMessage.level = 'error' + } + renderMessages() + setChatStatus(modelAccessMessage, 'error') + + if (pendingAbortController === requestAbortController) { + pendingAbortController = null + setPendingState(false) + } + return + } + + setChatStatus( + 'Streaming unavailable. Retrying with fallback response...', + 'pending', + ) + } + + if (streamSucceeded) { + if (pendingAbortController === requestAbortController) { + pendingAbortController = null + setPendingState(false) + } + return + } + + try { + const fallbackResult = await requestGitHubChatCompletion({ + token, + messages: outboundMessages, + model: selectedModel, + tools: editorProposalTools, + toolChoice, + signal: requestSignal, + }) + + attachAssistantResponseMetadata({ + content: toChatText(fallbackResult.content), + toolCalls: fallbackResult?.toolCalls, + }) + const fallbackModel = toChatText(fallbackResult.model) + if (fallbackModel) { + const lastMessage = messages[messages.length - 1] + if (lastMessage?.role === 'assistant' && lastMessage.model !== fallbackModel) { + lastMessage.model = fallbackModel + renderMessages() + } + } + setChatStatus('Fallback response loaded.', 'ok') + } catch (fallbackError) { + if (requestSignal.aborted) { + if (pendingAbortController === requestAbortController) { + setChatStatus('Chat request canceled.', 'neutral') + } + return + } + + const fallbackMessage = isModelAccessError(fallbackError) + ? formatModelAccessErrorMessage(selectedModel) + : fallbackError instanceof Error + ? fallbackError.message + : 'Chat request failed.' + + updateLastAssistantMessage(fallbackMessage) + const lastMessage = messages[messages.length - 1] + if (lastMessage) { + lastMessage.level = 'error' + } + renderMessages() + setChatStatus(`Chat request failed: ${fallbackMessage}`, 'error') + } finally { + if (pendingAbortController === requestAbortController) { + pendingAbortController = null + setPendingState(false) + } + } + } + + toggleButton?.setAttribute('aria-expanded', 'false') + drawer?.setAttribute('hidden', '') + initializeModelOptions() + syncModelSelectionForToken(getToken?.()) + syncRepositoryLabel() + ensureUndoActionsNode() + renderMessages() + setChatStatus('Idle', 'neutral') + + toggleButton?.addEventListener('click', () => { + setOpen(!open) + }) + + closeButton?.addEventListener('click', () => { + setOpen(false) + }) + + clearButton?.addEventListener('click', () => { + stopPendingRequest() + setPendingState(false) + cancelPendingAssistantBodyUpdate() + pendingAssistantBodyText = null + resetChatContextState() + messages.length = 0 + renderMessages() + setChatStatus('Chat cleared.', 'neutral') + }) + + drawer?.addEventListener('click', 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 + const targetEditor = button.dataset.targetEditor === 'styles' ? 'styles' : 'component' + + if (action === 'undo-editor-apply') { + const undone = undoEditorApply(targetEditor) + if (!undone) { + setChatStatus('No editor 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 applied = applyProposalToEditor({ + messageIndex, + target: targetEditor, + }) + + if (!applied) { + setChatStatus('Could not apply proposal to editor.', 'error') + } else { + message.appliedTargets = { + ...(message.appliedTargets && typeof message.appliedTargets === 'object' + ? message.appliedTargets + : {}), + [targetEditor]: true, + } + } + renderMessages() + return + } + + if (action === 'apply-both') { + const appliedComponent = applyProposalToEditor({ + messageIndex, + target: 'component', + }) + const appliedStyles = applyProposalToEditor({ + messageIndex, + target: 'styles', + }) + + if (!appliedComponent && !appliedStyles) { + setChatStatus('Could not apply proposals to either editor.', 'error') + renderMessages() + return + } + + if (appliedComponent) { + message.appliedTargets = { + ...(message.appliedTargets && typeof message.appliedTargets === 'object' + ? message.appliedTargets + : {}), + component: true, + } + } + + if (appliedStyles) { + message.appliedTargets = { + ...(message.appliedTargets && typeof message.appliedTargets === 'object' + ? message.appliedTargets + : {}), + styles: true, + } + } + + if (appliedComponent && appliedStyles) { + setChatStatus( + 'Applied assistant proposals to Component and Styles editors.', + 'ok', + ) + } + + renderMessages() + return + } + }) + + sendButton?.addEventListener('click', () => { + void runChatRequest() + }) + + promptInput?.addEventListener('keydown', event => { + if (event.key !== 'Enter' || (!event.metaKey && !event.ctrlKey)) { + return + } + + event.preventDefault() + void runChatRequest() + }) + + const onDocumentKeydown = event => { + if (event.key === 'Escape' && open) { + setOpen(false) + } + } + + document.addEventListener('keydown', onDocumentKeydown) + + return { + setOpen, + isOpen: () => open, + setSelectedRepository: () => { + syncRepositoryLabel() + }, + setToken: token => { + syncModelSelectionForToken(token) + }, + dispose: () => { + stopPendingRequest() + setPendingState(false) + cancelPendingAssistantBodyUpdate() + pendingAssistantBodyText = null + resetChatContextState() + if (undoActionsNode) { + undoActionsNode.remove() + undoActionsNode = null + } + document.removeEventListener('keydown', onDocumentKeydown) + }, + } +} diff --git a/src/modules/github-chat-drawer/payload.js b/src/modules/github-chat-drawer/payload.js new file mode 100644 index 0000000..e1ae459 --- /dev/null +++ b/src/modules/github-chat-drawer/payload.js @@ -0,0 +1,182 @@ +import { toChatText } from './chat-utils.js' + +const chatByteBudget = 120_000 +const chatMaxSummaryChars = 3_600 +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.', + 'Do not assume framework migrations unless the user asks.', +].join(' ') + +const toUtf8ByteLength = value => { + const text = typeof value === 'string' ? value : '' + return new TextEncoder().encode(text).length +} + +const summarizeConversationSlice = messages => { + if (!Array.isArray(messages) || messages.length === 0) { + return '' + } + + const lines = [] + for (const message of messages) { + const role = message.role === 'assistant' ? 'Assistant' : 'User' + const content = toChatText(message.content) + if (!content) { + continue + } + + const clipped = content.length > 280 ? `${content.slice(0, 280)}...` : content + lines.push(`- ${role}: ${clipped}`) + } + + const summary = lines.join('\n').trim() + if (!summary) { + return '' + } + + if (summary.length <= chatMaxSummaryChars) { + return summary + } + + return `${summary.slice(0, chatMaxSummaryChars)}...` +} + +const mergeConversationSummary = ({ existingSummary, droppedMessages }) => { + const droppedSummary = summarizeConversationSlice(droppedMessages) + if (!droppedSummary) { + return existingSummary + } + + const merged = [existingSummary, droppedSummary].filter(Boolean).join('\n') + if (merged.length <= chatMaxSummaryChars) { + return merged + } + + return `${merged.slice(0, chatMaxSummaryChars)}...` +} + +const collectModePolicyContext = ({ renderMode, styleMode }) => { + const policyLines = [ + 'Mode-aware policy:', + `- Render mode: ${renderMode || 'unknown'}`, + `- Style mode: ${styleMode || 'unknown'}`, + ] + + if (renderMode.toLowerCase() === 'dom') { + policyLines.push( + '- In DOM mode, avoid React hook/state guidance unless the user explicitly asks for React migration.', + ) + policyLines.push( + '- Prefer native DOM APIs, event listeners, and direct browser-compatible patterns.', + ) + } + + if (renderMode.toLowerCase() === 'react') { + policyLines.push('- In React mode, prefer component-based React guidance.') + } + + if (styleMode.toLowerCase() === 'css') { + policyLines.push( + '- Keep style advice compatible with plain CSS unless user asks for a preprocessor.', + ) + } + + return policyLines.join('\n') +} + +const collectSystemRolePrompt = ({ renderMode, styleMode }) => { + return [systemPromptMessage, collectModePolicyContext({ renderMode, styleMode })].join( + '\n\n', + ) +} + +const collectConversation = messages => { + return messages + .filter(message => message.role === 'user' || message.role === 'assistant') + .map(message => ({ + role: message.role, + content: toChatText(message.content), + })) + .filter(message => Boolean(message.content)) +} + +export const buildOutboundMessages = ({ + messages, + repositoryContext, + editorContext, + renderMode, + styleMode, + existingSummary, +}) => { + const systemMessages = [ + { + role: 'system', + content: collectSystemRolePrompt({ renderMode, styleMode }), + }, + { role: 'system', content: repositoryContext }, + ...(editorContext ? [{ role: 'system', content: editorContext }] : []), + ] + const conversation = collectConversation(messages) + + let retainedConversation = conversation.slice(-chatMaxConversationMessages) + let droppedConversation = conversation.slice( + 0, + Math.max(0, conversation.length - retainedConversation.length), + ) + let nextSummary = existingSummary + + if (droppedConversation.length > 0) { + nextSummary = mergeConversationSummary({ + existingSummary: nextSummary, + droppedMessages: droppedConversation, + }) + } + + let payloadMessages = [ + ...systemMessages, + ...(nextSummary + ? [ + { + role: 'system', + content: `Conversation summary of earlier turns:\n${nextSummary}`, + }, + ] + : []), + ...retainedConversation, + ] + + while ( + toUtf8ByteLength(JSON.stringify({ messages: payloadMessages })) > chatByteBudget && + retainedConversation.length > 2 + ) { + droppedConversation = [...droppedConversation, retainedConversation.shift()] + if (droppedConversation.length > 0) { + nextSummary = mergeConversationSummary({ + existingSummary: nextSummary, + droppedMessages: droppedConversation, + }) + droppedConversation = [] + } + + payloadMessages = [ + ...systemMessages, + ...(nextSummary + ? [ + { + role: 'system', + content: `Conversation summary of earlier turns:\n${nextSummary}`, + }, + ] + : []), + ...retainedConversation, + ] + } + + return { + outboundMessages: payloadMessages, + nextSummary, + } +} diff --git a/src/modules/github-chat-drawer/proposals.js b/src/modules/github-chat-drawer/proposals.js new file mode 100644 index 0000000..52800f6 --- /dev/null +++ b/src/modules/github-chat-drawer/proposals.js @@ -0,0 +1,135 @@ +import { toChatText } from './chat-utils.js' + +export const editorProposalTools = [ + { + type: 'function', + function: { + name: 'propose_editor_update', + description: + 'Propose a single editor update for component or styles with full replacement content.', + parameters: { + type: 'object', + properties: { + target: { + type: 'string', + enum: ['component', 'styles'], + }, + content: { + type: 'string', + description: 'Full replacement text for the target editor.', + }, + rationale: { + type: 'string', + description: 'Short explanation for the proposed change.', + }, + }, + required: ['target', 'content'], + additionalProperties: false, + }, + }, + }, +] + +const parseJsonSafe = value => { + if (typeof value !== 'string' || !value.trim()) { + return null + } + + try { + return JSON.parse(value) + } catch { + return null + } +} + +const extractEditorProposalsFromToolCalls = toolCalls => { + const proposals = { + component: null, + styles: null, + } + + if (!Array.isArray(toolCalls)) { + return proposals + } + + for (const toolCall of toolCalls) { + if (!toolCall || toolCall.name !== 'propose_editor_update') { + continue + } + + const payload = parseJsonSafe(toolCall.arguments) + if (!payload || typeof payload !== 'object') { + continue + } + + const target = + payload.target === 'component' || payload.target === 'styles' + ? payload.target + : null + const content = toChatText(payload.content) + const rationale = toChatText(payload.rationale) + + if (!target || !content) { + continue + } + + proposals[target] = { + source: 'tool', + content, + rationale, + } + } + + return proposals +} + +const extractEditorProposalsFromMarkdown = content => { + const proposals = { + component: null, + styles: null, + } + + if (typeof content !== 'string' || !content.trim()) { + return proposals + } + + const blockRegex = /```(jsx|tsx|css)\n([\s\S]*?)```/gi + let match = blockRegex.exec(content) + + while (match) { + const language = match[1]?.toLowerCase() + const blockContent = toChatText(match[2]) + + if (blockContent) { + if ((language === 'jsx' || language === 'tsx') && !proposals.component) { + proposals.component = { + source: 'markdown', + content: blockContent, + rationale: '', + } + } + + if (language === 'css' && !proposals.styles) { + proposals.styles = { + source: 'markdown', + content: blockContent, + rationale: '', + } + } + } + + match = blockRegex.exec(content) + } + + return proposals +} + +export const toMessageEditorProposals = message => { + const fromToolCalls = extractEditorProposalsFromToolCalls(message?.toolCalls) + + if (fromToolCalls.component || fromToolCalls.styles) { + return fromToolCalls + } + + return extractEditorProposalsFromMarkdown(message?.content) +} diff --git a/src/modules/github-pr-drawer.js b/src/modules/github-pr-drawer.js index 090cdeb..4ed0516 100644 --- a/src/modules/github-pr-drawer.js +++ b/src/modules/github-pr-drawer.js @@ -439,6 +439,8 @@ export const createGitHubPrDrawer = ({ let pendingActiveContentSyncAbortController = null let pendingBranchesRequestKey = '' let pendingBranchesPromise = null + let pendingContextVerifyRequestKey = '' + let pendingContextVerifyPromise = null let lastSyncedRepositoryFullName = '' let lastActiveContentSyncKey = '' const baseBranchesByRepository = new Map() @@ -645,6 +647,8 @@ export const createGitHubPrDrawer = ({ const abortPendingContextVerifyRequest = () => { pendingContextVerifyAbortController?.abort() pendingContextVerifyAbortController = null + pendingContextVerifyRequestKey = '' + pendingContextVerifyPromise = null } const abortPendingActiveContentSyncRequest = () => { @@ -735,100 +739,127 @@ export const createGitHubPrDrawer = ({ return } + const requestKey = [ + repositoryFullName, + String(pullRequestNumberFromConfig || ''), + headBranch, + toSafeText(savedConfig.baseBranch), + ].join('|') + + if (pendingContextVerifyPromise && pendingContextVerifyRequestKey === requestKey) { + await pendingContextVerifyPromise + return + } + abortPendingContextVerifyRequest() const abortController = new AbortController() pendingContextVerifyAbortController = abortController - try { - let resolvedPullRequest = null - let pullRequestClosedByNumber = false - - if (pullRequestNumberFromConfig) { - const pullRequest = await getRepositoryPullRequest({ - token, - owner, - repo, - pullRequestNumber: pullRequestNumberFromConfig, - signal: abortController.signal, - }) + const runVerifyRequest = async () => { + try { + let resolvedPullRequest = null + let pullRequestClosedByNumber = false - if (pullRequest?.isOpen) { - resolvedPullRequest = pullRequest - } else if (pullRequest) { - pullRequestClosedByNumber = true + if (pullRequestNumberFromConfig) { + const pullRequest = await getRepositoryPullRequest({ + token, + owner, + repo, + pullRequestNumber: pullRequestNumberFromConfig, + signal: abortController.signal, + }) + + if (pullRequest?.isOpen) { + resolvedPullRequest = pullRequest + } else if (pullRequest) { + pullRequestClosedByNumber = true + } } - } - if (!resolvedPullRequest && !pullRequestClosedByNumber) { - resolvedPullRequest = await findOpenRepositoryPullRequestByHead({ - token, - owner, - repo, - headOwner: owner, - headBranch, - baseBranch: toSafeText(savedConfig.baseBranch), - signal: abortController.signal, - }) - } + if (!resolvedPullRequest && !pullRequestClosedByNumber) { + resolvedPullRequest = await findOpenRepositoryPullRequestByHead({ + token, + owner, + repo, + headOwner: owner, + headBranch, + baseBranch: toSafeText(savedConfig.baseBranch), + signal: abortController.signal, + }) + } - if (pendingContextVerifyAbortController !== abortController) { - return - } + if (pendingContextVerifyAbortController !== abortController) { + return + } - if (resolvedPullRequest?.isOpen) { - const nextHeadBranch = - sanitizeBranchPart(resolvedPullRequest.headRef) || headBranch - const nextBaseBranch = - toSafeText(resolvedPullRequest.baseRef) || toSafeText(savedConfig.baseBranch) + if (resolvedPullRequest?.isOpen) { + const nextHeadBranch = + sanitizeBranchPart(resolvedPullRequest.headRef) || headBranch + const nextBaseBranch = + toSafeText(resolvedPullRequest.baseRef) || toSafeText(savedConfig.baseBranch) + + saveRepositoryPrConfig({ + repositoryFullName, + config: { + ...savedConfig, + isActivePr: true, + renderMode: normalizeRenderMode(savedConfig.renderMode), + styleMode: normalizeStyleMode(savedConfig.styleMode), + headBranch: nextHeadBranch, + baseBranch: nextBaseBranch, + pullRequestNumber: resolvedPullRequest.number, + pullRequestUrl: resolvedPullRequest.htmlUrl, + prTitle: + toSafeText(savedConfig.prTitle) || toSafeText(resolvedPullRequest.title), + }, + }) + syncFormForRepository({ resetBranch: true }) + setSubmitButtonLabel() + emitActivePrContextChange() + void syncActivePrEditorContent() + return + } saveRepositoryPrConfig({ repositoryFullName, config: { ...savedConfig, - isActivePr: true, - renderMode: normalizeRenderMode(savedConfig.renderMode), - styleMode: normalizeStyleMode(savedConfig.styleMode), - headBranch: nextHeadBranch, - baseBranch: nextBaseBranch, - pullRequestNumber: resolvedPullRequest.number, - pullRequestUrl: resolvedPullRequest.htmlUrl, - prTitle: - toSafeText(savedConfig.prTitle) || toSafeText(resolvedPullRequest.title), + isActivePr: false, }, }) - syncFormForRepository({ resetBranch: true }) setSubmitButtonLabel() emitActivePrContextChange() - void syncActivePrEditorContent() - return - } + lastActiveContentSyncKey = '' + abortPendingActiveContentSyncRequest() + setStatus( + 'Saved pull request context is not open on GitHub. Open PR mode restored.', + 'neutral', + ) + } catch (error) { + if (abortController.signal.aborted) { + return + } - saveRepositoryPrConfig({ - repositoryFullName, - config: { - ...savedConfig, - isActivePr: false, - }, - }) - setSubmitButtonLabel() - emitActivePrContextChange() - lastActiveContentSyncKey = '' - abortPendingActiveContentSyncRequest() - setStatus( - 'Saved pull request context is not open on GitHub. Open PR mode restored.', - 'neutral', - ) - } catch (error) { - if (abortController.signal.aborted) { - return + const message = + error instanceof Error ? error.message : 'Failed to verify pull request state.' + setStatus(`Could not verify saved pull request state: ${message}`, 'error') + } finally { + if (pendingContextVerifyAbortController === abortController) { + pendingContextVerifyAbortController = null + } } + } - const message = - error instanceof Error ? error.message : 'Failed to verify pull request state.' - setStatus(`Could not verify saved pull request state: ${message}`, 'error') + const requestPromise = runVerifyRequest() + pendingContextVerifyRequestKey = requestKey + pendingContextVerifyPromise = requestPromise + + try { + await requestPromise } finally { - if (pendingContextVerifyAbortController === abortController) { - pendingContextVerifyAbortController = null + if (pendingContextVerifyPromise === requestPromise) { + pendingContextVerifyPromise = null + pendingContextVerifyRequestKey = '' } } } diff --git a/src/modules/render-runtime.js b/src/modules/render-runtime.js index a95d9a6..db27dc8 100644 --- a/src/modules/render-runtime.js +++ b/src/modules/render-runtime.js @@ -460,6 +460,10 @@ export const createRenderRuntimeController = ({ return source } + if (/^\s*export\s+default\b/m.test(source)) { + return source + } + const { declarations, importCount, diff --git a/src/styles/ai-controls.css b/src/styles/ai-controls.css index 6c89ea1..dc141ff 100644 --- a/src/styles/ai-controls.css +++ b/src/styles/ai-controls.css @@ -678,6 +678,19 @@ font-size: 1rem; } +.ai-chat-title { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.ai-chat-title svg { + width: 1.05rem; + height: 1.05rem; + color: color-mix(in srgb, var(--accent) 58%, var(--panel-text)); + fill: currentColor; +} + .ai-chat-drawer__meta { display: flex; align-items: center; @@ -710,14 +723,6 @@ color: color-mix(in srgb, rgb(var(--danger-rgb)) 85%, var(--panel-text)); } -.ai-chat-drawer__rate { - margin: -2px 0 0; - font-size: 0.74rem; - font-style: italic; - color: var(--text-muted); - align-self: start; -} - .ai-chat-messages { border: 1px solid var(--border-subtle); border-radius: 10px; @@ -759,11 +764,20 @@ .ai-chat-message__label { margin: 0; font-size: 0.72rem; + display: inline-flex; + align-items: center; + gap: 6px; letter-spacing: 0.02em; text-transform: uppercase; color: var(--text-muted); } +.ai-chat-message__label-icon { + width: 0.86rem; + height: 0.86rem; + fill: currentColor; +} + .ai-chat-message__body { margin: 4px 0 0; font-size: 0.85rem; @@ -771,6 +785,37 @@ word-break: break-word; } +.ai-chat-message__actions { + margin-top: 8px; + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.ai-chat-message__action { + font-size: 0.74rem; + padding: 5px 8px; +} + +.ai-chat-drawer__undo-actions { + margin-top: 8px; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; +} + +.ai-chat-drawer__undo-label { + margin: 0; + font-size: 0.74rem; + color: var(--text-muted); +} + +.ai-chat-drawer__undo-action { + font-size: 0.74rem; + padding: 5px 8px; +} + .ai-chat-prompt { resize: vertical; width: 100%; From fb5be70780aa6ac4ebdaa641bf472a8c6c709574 Mon Sep 17 00:00:00 2001 From: KCM Date: Tue, 31 Mar 2026 21:30:42 -0500 Subject: [PATCH 2/2] refactor: address comments. --- src/modules/github-chat-drawer/drawer.js | 37 +++++++++++++++-------- src/modules/github-chat-drawer/payload.js | 22 +++++++++++--- 2 files changed, 41 insertions(+), 18 deletions(-) diff --git a/src/modules/github-chat-drawer/drawer.js b/src/modules/github-chat-drawer/drawer.js index 93f857c..f4b92dd 100644 --- a/src/modules/github-chat-drawer/drawer.js +++ b/src/modules/github-chat-drawer/drawer.js @@ -105,7 +105,6 @@ export const createGitHubChatDrawer = ({ continue } - message.confirmTarget = null message.appliedTargets = null } } @@ -881,15 +880,15 @@ export const createGitHubChatDrawer = ({ renderMessages() setChatStatus('Idle', 'neutral') - toggleButton?.addEventListener('click', () => { + const onToggleButtonClick = () => { setOpen(!open) - }) + } - closeButton?.addEventListener('click', () => { + const onCloseButtonClick = () => { setOpen(false) - }) + } - clearButton?.addEventListener('click', () => { + const onClearButtonClick = () => { stopPendingRequest() setPendingState(false) cancelPendingAssistantBodyUpdate() @@ -898,9 +897,9 @@ export const createGitHubChatDrawer = ({ messages.length = 0 renderMessages() setChatStatus('Chat cleared.', 'neutral') - }) + } - drawer?.addEventListener('click', event => { + const onDrawerClick = event => { const target = event.target if (!(target instanceof HTMLElement)) { return @@ -1002,20 +1001,20 @@ export const createGitHubChatDrawer = ({ renderMessages() return } - }) + } - sendButton?.addEventListener('click', () => { + const onSendButtonClick = () => { void runChatRequest() - }) + } - promptInput?.addEventListener('keydown', event => { + const onPromptInputKeydown = event => { if (event.key !== 'Enter' || (!event.metaKey && !event.ctrlKey)) { return } event.preventDefault() void runChatRequest() - }) + } const onDocumentKeydown = event => { if (event.key === 'Escape' && open) { @@ -1023,6 +1022,12 @@ export const createGitHubChatDrawer = ({ } } + 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 { @@ -1044,6 +1049,12 @@ export const createGitHubChatDrawer = ({ undoActionsNode.remove() undoActionsNode = null } + toggleButton?.removeEventListener('click', onToggleButtonClick) + closeButton?.removeEventListener('click', onCloseButtonClick) + clearButton?.removeEventListener('click', onClearButtonClick) + drawer?.removeEventListener('click', onDrawerClick) + sendButton?.removeEventListener('click', onSendButtonClick) + promptInput?.removeEventListener('keydown', onPromptInputKeydown) document.removeEventListener('keydown', onDocumentKeydown) }, } diff --git a/src/modules/github-chat-drawer/payload.js b/src/modules/github-chat-drawer/payload.js index e1ae459..c44346b 100644 --- a/src/modules/github-chat-drawer/payload.js +++ b/src/modules/github-chat-drawer/payload.js @@ -58,14 +58,26 @@ const mergeConversationSummary = ({ existingSummary, droppedMessages }) => { return `${merged.slice(0, chatMaxSummaryChars)}...` } +const toModeDisplayText = value => { + const mode = toChatText(value) + return mode || 'unknown' +} + +const toModeKey = value => toChatText(value).toLowerCase() + const collectModePolicyContext = ({ renderMode, styleMode }) => { + const renderModeText = toModeDisplayText(renderMode) + const styleModeText = toModeDisplayText(styleMode) + const renderModeKey = toModeKey(renderMode) + const styleModeKey = toModeKey(styleMode) + const policyLines = [ 'Mode-aware policy:', - `- Render mode: ${renderMode || 'unknown'}`, - `- Style mode: ${styleMode || 'unknown'}`, + `- Render mode: ${renderModeText}`, + `- Style mode: ${styleModeText}`, ] - if (renderMode.toLowerCase() === 'dom') { + if (renderModeKey === 'dom') { policyLines.push( '- In DOM mode, avoid React hook/state guidance unless the user explicitly asks for React migration.', ) @@ -74,11 +86,11 @@ const collectModePolicyContext = ({ renderMode, styleMode }) => { ) } - if (renderMode.toLowerCase() === 'react') { + if (renderModeKey === 'react') { policyLines.push('- In React mode, prefer component-based React guidance.') } - if (styleMode.toLowerCase() === 'css') { + if (styleModeKey === 'css') { policyLines.push( '- Keep style advice compatible with plain CSS unless user asks for a preprocessor.', )