From b544050a29a3ac45ee33fa33a0a163e49f0a744c Mon Sep 17 00:00:00 2001 From: KCM Date: Fri, 1 May 2026 19:40:59 -0500 Subject: [PATCH 1/4] feat: tab-scoped ai chat baseline. --- playwright/github-byot-ai.spec.ts | 184 +++++++-- src/app.js | 77 +++- src/index.html | 2 +- .../app-core/github-workflows-setup.js | 7 +- src/modules/app-core/github-workflows.js | 14 +- .../github/chat-drawer/active-tab-context.js | 136 ++++++ src/modules/github/chat-drawer/drawer.js | 387 +++++++++--------- src/modules/github/chat-drawer/payload.js | 28 ++ src/modules/github/chat-drawer/proposals.js | 107 ++--- .../chat-drawer/tab-scoped-undo-state.js | 49 +++ .../github/chat-drawer/tab-target-resolver.js | 82 ++++ 11 files changed, 753 insertions(+), 320 deletions(-) create mode 100644 src/modules/github/chat-drawer/active-tab-context.js create mode 100644 src/modules/github/chat-drawer/tab-scoped-undo-state.js create mode 100644 src/modules/github/chat-drawer/tab-target-resolver.js diff --git a/playwright/github-byot-ai.spec.ts b/playwright/github-byot-ai.spec.ts index 8b41a06..132f5ca 100644 --- a/playwright/github-byot-ai.spec.ts +++ b/playwright/github-byot-ai.spec.ts @@ -7,6 +7,7 @@ import { ensureAiChatDrawerOpen, ensureOpenPrDrawerOpen, mockRepositoryBranches, + openWorkspaceTab, setComponentEditorSource, setStylesEditorSource, waitForAppReady, @@ -241,6 +242,15 @@ test('AI chat prefers streaming responses when available', async ({ page }) => { expect(streamRequestBody?.messages?.[0]?.content).toContain( 'expert software development assistant focused on CSS dialects and JSX syntax', ) + expect(streamRequestBody?.messages?.[0]?.content).toContain( + 'JSX is compiled for @knighted/jsx DOM runtime', + ) + expect(streamRequestBody?.messages?.[0]?.content).toContain( + 'Do not suggest React imports, hooks, or React-only runtime APIs', + ) + expect(streamRequestBody?.messages?.[0]?.content).toContain( + 'Preserve the selected style dialect and avoid cross-dialect rewrites', + ) const systemMessages = streamRequestBody?.messages?.filter( (message: ChatRequestMessage) => message.role === 'system', ) @@ -258,6 +268,23 @@ test('AI chat prefers streaming responses when available', async ({ page }) => { message.content?.includes('Editor context:'), ), ).toBe(true) + expect( + systemMessages?.some( + (message: ChatRequestMessage) => + message.content?.includes('- Active tab:') && + message.content?.includes('App.tsx'), + ), + ).toBe(true) + expect( + systemMessages?.some((message: ChatRequestMessage) => + message.content?.includes('Available tab targets (id and path):'), + ), + ).toBe(true) + expect( + systemMessages?.some((message: ChatRequestMessage) => + message.content?.includes('Active tab source:'), + ), + ).toBe(true) }) test('AI chat can disable editor context payload via checkbox', async ({ page }) => { @@ -282,7 +309,7 @@ test('AI chat can disable editor context payload via checkbox', async ({ page }) await connectByotWithSingleRepo(page) await ensureAiChatDrawerOpen(page) - const includeEditorsToggle = page.getByLabel('Send JSX + CSS editor context') + const includeEditorsToggle = page.getByLabel('Send tab content') await expect(includeEditorsToggle).toBeChecked() await includeEditorsToggle.uncheck() @@ -316,7 +343,7 @@ 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 ({ +test('AI chat proposals can be confirmed, applied, and undone per active tab', async ({ page, }) => { await page.route('https://models.github.ai/inference/chat/completions', async route => { @@ -347,7 +374,7 @@ test('AI chat proposals can be confirmed, applied, and undone for component and function: { name: 'propose_editor_update', arguments: JSON.stringify({ - target: 'component', + target: 'src/components/App.tsx', content: 'const App = () => ', rationale: 'Use explicit App component output.', }), @@ -359,7 +386,7 @@ test('AI chat proposals can be confirmed, applied, and undone for component and function: { name: 'propose_editor_update', arguments: JSON.stringify({ - target: 'styles', + target: 'src/styles/app.css', content: '.button { color: rgb(10 20 30); }', rationale: 'Provide deterministic button styling.', }), @@ -377,6 +404,7 @@ test('AI chat proposals can be confirmed, applied, and undone for component and await connectByotWithSingleRepo(page) await setComponentEditorSource(page, 'const App = () => ') await setStylesEditorSource(page, '.button { color: red; }') + await openWorkspaceTab(page, 'App.tsx') await ensureAiChatDrawerOpen(page) await page.getByLabel('Ask AI assistant').fill('Suggest updates for both editors.') @@ -386,55 +414,65 @@ test('AI chat proposals can be confirmed, applied, and undone for component and 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 App.tsx' }), + ).toBeVisible() + await expect( + page.getByRole('button', { name: 'Apply update to app.css' }), + ).toBeVisible() + + await page.getByRole('button', { name: 'Apply update to App.tsx' }).click() + await expect(page.getByRole('button', { name: 'Apply update to App.tsx' })).toBeHidden() await expect( - page.getByRole('button', { name: 'Apply update to both editors' }), + page.getByRole('button', { name: 'Undo last apply for App.tsx' }), ).toBeVisible() - await page.getByRole('button', { name: 'Apply update to both editors' }).click() await expect( - page.getByRole('button', { name: 'Apply update to both editors' }), + page.getByRole('button', { name: 'Undo last apply for app.css' }), ).toBeHidden() await expect( - page.getByRole('button', { name: 'Apply update to Component editor' }), - ).toBeHidden() + page.locator('.editor-panel[data-editor-kind="component"] .cm-content').first(), + ).toContainText('Updated') + + await openWorkspaceTab(page, 'app.css') await expect( - page.getByRole('button', { name: 'Apply update to Styles editor' }), + page.getByRole('button', { name: 'Undo last apply for App.tsx' }), ).toBeHidden() await expect( - assistantResponseMessage.getByRole('button', { name: 'Undo last Component apply' }), - ).toHaveCount(0) + page.getByRole('button', { name: 'Apply update to app.css' }), + ).toBeVisible() + await page.getByRole('button', { name: 'Apply update to app.css' }).click() + await expect( - assistantResponseMessage.getByRole('button', { name: 'Undo last Styles apply' }), - ).toHaveCount(0) + page.locator('.editor-panel[data-editor-kind="styles"] .cm-content').first(), + ).toContainText('rgb(10 20 30)') await expect( - page.getByRole('button', { name: 'Undo last Component apply' }), + page.getByRole('button', { name: 'Undo last apply for app.css' }), ).toBeVisible() - await expect(page.getByRole('button', { name: 'Undo last Styles apply' })).toBeVisible() await expect( - page.locator('.editor-panel[data-editor-kind="component"] .cm-content').first(), - ).toContainText('Updated') + page.getByRole('button', { name: 'Undo last apply for App.tsx' }), + ).toBeHidden() + + await page.getByRole('button', { name: 'Undo last apply for app.css' }).click() await expect( page.locator('.editor-panel[data-editor-kind="styles"] .cm-content').first(), - ).toContainText('rgb(10 20 30)') + ).toContainText('red') - await page.getByRole('button', { name: 'Undo last Component apply' }).click() + await openWorkspaceTab(page, 'App.tsx') await expect( - page.locator('.editor-panel[data-editor-kind="component"] .cm-content').first(), - ).toContainText('Before') + page.getByRole('button', { name: 'Undo last apply for App.tsx' }), + ).toBeVisible() + await expect( + page.getByRole('button', { name: 'Undo last apply for app.css' }), + ).toBeHidden() - await page.getByRole('button', { name: 'Undo last Styles apply' }).click() + await page.getByRole('button', { name: 'Undo last apply for App.tsx' }).click() await expect( - page.locator('.editor-panel[data-editor-kind="styles"] .cm-content').first(), - ).toContainText('red') + page.locator('.editor-panel[data-editor-kind="component"] .cm-content').first(), + ).toContainText('Before') }) -test('AI chat shows a single apply action when both editor proposals are available', async ({ - page, -}) => { +test('AI chat apply actions resolve dynamic tab targets', async ({ page }) => { await page.route('https://models.github.ai/inference/chat/completions', async route => { const body = route.request().postDataJSON() as ChatRequestBody | null @@ -463,7 +501,7 @@ test('AI chat shows a single apply action when both editor proposals are availab function: { name: 'propose_editor_update', arguments: JSON.stringify({ - target: 'component', + target: 'src/components/App.tsx', content: 'const App = () => ', }), }, @@ -474,7 +512,7 @@ test('AI chat shows a single apply action when both editor proposals are availab function: { name: 'propose_editor_update', arguments: JSON.stringify({ - target: 'styles', + target: 'src/styles/app.css', content: '.button { color: rgb(10 20 30); }', }), }, @@ -491,6 +529,7 @@ test('AI chat shows a single apply action when both editor proposals are availab await connectByotWithSingleRepo(page) await setComponentEditorSource(page, 'const App = () => ') await setStylesEditorSource(page, '.button { color: red; }') + await openWorkspaceTab(page, 'App.tsx') await ensureAiChatDrawerOpen(page) await page.getByLabel('Ask AI assistant').fill('Suggest updates for both editors.') @@ -501,14 +540,73 @@ test('AI chat shows a single apply action when both editor proposals are availab ).toBeVisible() await expect( - page.getByRole('button', { name: 'Apply update to both editors' }), + page.getByRole('button', { name: 'Apply update to App.tsx' }), ).toBeVisible() await expect( - page.getByRole('button', { name: 'Apply update to Component editor' }), - ).toBeHidden() + page.getByRole('button', { name: 'Apply update to app.css' }), + ).toBeVisible() + + await openWorkspaceTab(page, 'app.css') + await expect( - page.getByRole('button', { name: 'Apply update to Styles editor' }), - ).toBeHidden() + page.getByRole('button', { name: 'Apply update to App.tsx' }), + ).toBeVisible() + await expect( + page.getByRole('button', { name: 'Apply update to app.css' }), + ).toBeVisible() +}) + +test('AI chat sends the currently active tab when context is enabled', async ({ + page, +}) => { + let streamRequestBody: ChatRequestBody | undefined + + await page.route('https://models.github.ai/inference/chat/completions', async route => { + streamRequestBody = route.request().postDataJSON() as ChatRequestBody + + await route.fulfill({ + status: 200, + contentType: 'text/event-stream', + body: [ + 'data: {"choices":[{"delta":{"content":"ok"}}]}', + '', + 'data: [DONE]', + '', + ].join('\n'), + }) + }) + + await waitForAppReady(page, `${appEntryPath}`) + await connectByotWithSingleRepo(page) + await setStylesEditorSource(page, '.button { color: red; }') + await ensureAiChatDrawerOpen(page) + + await page.getByLabel('Ask AI assistant').fill('Use active tab context only.') + await page.getByRole('button', { name: 'Send' }).click() + await expect( + page.getByText('Response streamed from GitHub.', { exact: true }), + ).toHaveText('Response streamed from GitHub.') + + const systemMessages = streamRequestBody?.messages?.filter( + (message: ChatRequestMessage) => message.role === 'system', + ) + expect( + systemMessages?.some( + (message: ChatRequestMessage) => + message.content?.includes('- Active tab:') && + message.content?.includes('app.css'), + ), + ).toBe(true) + expect( + systemMessages?.some((message: ChatRequestMessage) => + message.content?.includes('Active tab source:'), + ), + ).toBe(true) + expect( + systemMessages?.some((message: ChatRequestMessage) => + message.content?.includes('Available tab targets (id and path):'), + ), + ).toBe(true) }) test('AI chat streaming text still updates while latest undo actions are visible', async ({ @@ -546,7 +644,7 @@ test('AI chat streaming text still updates while latest undo actions are visible function: { name: 'propose_editor_update', arguments: JSON.stringify({ - target: 'styles', + target: 'src/styles/app.css', content: '.button { color: rgb(10 20 30); }', }), }, @@ -596,8 +694,10 @@ test('AI chat streaming text still updates while latest undo actions are visible 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.getByRole('button', { name: 'Apply update to app.css' }).click() + await expect( + page.getByRole('button', { name: 'Undo last apply for app.css' }), + ).toBeVisible() await page .getByLabel('Ask AI assistant') diff --git a/src/app.js b/src/app.js index 190ac1c..c2e9a2c 100644 --- a/src/app.js +++ b/src/app.js @@ -460,6 +460,7 @@ const setActiveWorkspaceRecordId = nextValue => { let chatDrawerController = { setOpen: () => {}, setSelectedRepository: () => {}, + onActiveWorkspaceTabChange: () => {}, setToken: () => {}, dispose: () => {}, } @@ -827,6 +828,7 @@ const { getActiveWorkspaceTab, onActiveWorkspaceTabChange: (_tab, { changed } = {}) => { syncDiagnosticsDrawerLayout() + chatDrawerController.onActiveWorkspaceTabChange() if (changed) { clearDiagnosticsOnTabSwitch() @@ -1216,24 +1218,71 @@ const githubWorkflows = createGitHubWorkflowsSetup({ confirmAction: options => confirmAction(options), setStatus, showAppToast, - setComponentSource: value => { - suppressEditorChangeSideEffects = true - try { - setJsxSource(value) - } finally { - suppressEditorChangeSideEffects = false + getActiveWorkspaceTabContext: () => { + const activeTab = getActiveWorkspaceTab() + if (!activeTab) { + return null + } + + const isStylesTab = isStyleWorkspaceTab(activeTab) + + return { + id: activeTab.id, + name: activeTab.name, + path: activeTab.path, + language: activeTab.language, + content: isStylesTab ? getCssSource() : getJsxSource(), + isActive: true, } }, - setStylesSource: value => { - suppressEditorChangeSideEffects = true - try { - setCssSource(value) - } finally { - suppressEditorChangeSideEffects = false + getWorkspaceTabContexts: () => { + const activeTabId = workspaceTabsState.getActiveTabId() + return workspaceTabsState.getTabs().map(tab => { + const isActive = tab.id === activeTabId + const isStylesTab = isStyleWorkspaceTab(tab) + return { + id: tab.id, + name: tab.name, + path: tab.path, + language: tab.language, + isActive, + content: isActive + ? isStylesTab + ? getCssSource() + : getJsxSource() + : tab.content, + } + }) + }, + applyWorkspaceTabContent: ({ tabId, content }) => { + const tab = workspaceTabsState.getTab(tabId) + if (!tab || typeof content !== 'string') { + return null + } + + const updatedTab = workspaceTabsState.upsertTab( + { + ...tab, + content, + isDirty: getDirtyStateForTabChange(tab, content), + lastModified: Date.now(), + isActive: tab.isActive, + }, + { emitReason: 'chatApplyTabContent' }, + ) + + if (!updatedTab) { + return null } + + if (updatedTab.isActive) { + loadWorkspaceTabIntoEditor(updatedTab) + } + + renderWorkspaceTabs() + queueWorkspaceSave() + return updatedTab }, - getComponentSource: () => getJsxSource(), - getStylesSource: () => getCssSource(), scheduleRender: () => { if ( autoRenderToggle?.checked && diff --git a/src/index.html b/src/index.html index 9dac925..5d33306 100644 --- a/src/index.html +++ b/src/index.html @@ -755,7 +755,7 @@

diff --git a/src/modules/app-core/github-workflows-setup.js b/src/modules/app-core/github-workflows-setup.js index 609a4ed..b4b8fa2 100644 --- a/src/modules/app-core/github-workflows-setup.js +++ b/src/modules/app-core/github-workflows-setup.js @@ -53,10 +53,9 @@ const createGitHubWorkflowsSetup = ({ confirmAction: actions.confirmAction, setStatus: actions.setStatus, showAppToast: actions.showAppToast, - setComponentSource: actions.setComponentSource, - setStylesSource: actions.setStylesSource, - getComponentSource: actions.getComponentSource, - getStylesSource: actions.getStylesSource, + getActiveWorkspaceTabContext: actions.getActiveWorkspaceTabContext, + getWorkspaceTabContexts: actions.getWorkspaceTabContexts, + applyWorkspaceTabContent: actions.applyWorkspaceTabContent, scheduleRender: actions.scheduleRender, }) diff --git a/src/modules/app-core/github-workflows.js b/src/modules/app-core/github-workflows.js index d9e5a7b..14592c9 100644 --- a/src/modules/app-core/github-workflows.js +++ b/src/modules/app-core/github-workflows.js @@ -81,10 +81,9 @@ const initializeGitHubWorkflows = ({ confirmAction, setStatus, showAppToast, - setComponentSource, - setStylesSource, - getComponentSource, - getStylesSource, + getActiveWorkspaceTabContext, + getWorkspaceTabContexts, + applyWorkspaceTabContent, scheduleRender, }) => { const getCurrentWritableRepositories = () => @@ -197,10 +196,9 @@ const initializeGitHubWorkflows = ({ messagesNode: aiChatMessages, getToken: getCurrentGitHubToken, getSelectedRepository: getCurrentSelectedRepository, - getComponentSource, - setComponentSource, - getStylesSource, - setStylesSource, + getActiveWorkspaceTabContext, + getWorkspaceTabContexts, + applyWorkspaceTabContent, scheduleRender, getRenderMode, getStyleMode, diff --git a/src/modules/github/chat-drawer/active-tab-context.js b/src/modules/github/chat-drawer/active-tab-context.js new file mode 100644 index 0000000..12084a9 --- /dev/null +++ b/src/modules/github/chat-drawer/active-tab-context.js @@ -0,0 +1,136 @@ +const toNonEmptyText = value => { + if (typeof value !== 'string') { + return '' + } + + return value.trim() +} + +const toCodeFenceLanguage = language => { + const normalizedLanguage = toNonEmptyText(language).toLowerCase() + + if (normalizedLanguage.includes('less')) { + return 'less' + } + + if (normalizedLanguage.includes('sass')) { + return 'sass' + } + + if (normalizedLanguage.includes('scss')) { + return 'scss' + } + + if (normalizedLanguage.includes('css')) { + return 'css' + } + + if (normalizedLanguage.includes('tsx')) { + return 'tsx' + } + + if (normalizedLanguage.includes('typescript')) { + return 'ts' + } + + if (normalizedLanguage.includes('jsx')) { + return 'jsx' + } + + if (normalizedLanguage.includes('javascript')) { + return 'js' + } + + return 'javascript' +} + +const normalizeWorkspaceTabContext = value => { + if (!value || typeof value !== 'object') { + return null + } + + const tabId = toNonEmptyText(value.id) + if (!tabId) { + return null + } + + return { + id: tabId, + name: toNonEmptyText(value.name), + path: toNonEmptyText(value.path), + language: toNonEmptyText(value.language), + content: typeof value.content === 'string' ? value.content : '', + isActive: Boolean(value.isActive), + } +} + +const normalizeWorkspaceTabContexts = values => { + if (!Array.isArray(values)) { + return [] + } + + const uniqueTabs = new Map() + + for (const tabValue of values) { + const normalizedTab = normalizeWorkspaceTabContext(tabValue) + if (!normalizedTab) { + continue + } + + uniqueTabs.set(normalizedTab.id, normalizedTab) + } + + return Array.from(uniqueTabs.values()) +} + +const buildTabReference = tabContext => { + const tabName = toNonEmptyText(tabContext?.name) + const tabPath = toNonEmptyText(tabContext?.path) + return [tabName, tabPath].filter(Boolean).join(' - ') || 'active tab' +} + +const buildActiveTabEditorContext = ({ + activeTabContext, + workspaceTabContexts, + renderMode, + styleMode, +}) => { + if (!activeTabContext) { + return null + } + + const tabReference = buildTabReference(activeTabContext) + const codeFenceLanguage = toCodeFenceLanguage(activeTabContext.language) + const visibleTabs = normalizeWorkspaceTabContexts(workspaceTabContexts) + .slice(0, 20) + .map(tab => { + const tabPath = toNonEmptyText(tab.path) || '(no-path)' + const tabName = toNonEmptyText(tab.name) || tab.id + const tabLanguage = toNonEmptyText(tab.language) || 'plaintext' + return `- id=${tab.id} | path=${tabPath} | name=${tabName} | language=${tabLanguage}` + }) + + return [ + 'Editor context:', + `- Render mode: ${toNonEmptyText(renderMode) || 'unknown'}`, + `- Style mode: ${toNonEmptyText(styleMode) || 'unknown'}`, + `- Active tab: ${tabReference}`, + '- If proposing concrete editor changes, prefer tool calls over plain text.', + '- For propose_editor_update, set target to a tab id or path from the available targets list.', + '- Use the optional language field when target text could match more than one tab.', + '', + 'Available tab targets (id and path):', + ...(visibleTabs.length > 0 ? visibleTabs : ['- (none)']), + '', + 'Active tab source:', + `\`\`\`${codeFenceLanguage}`, + activeTabContext.content || '(empty)', + '```', + ].join('\n') +} + +export { + buildActiveTabEditorContext, + normalizeWorkspaceTabContext, + normalizeWorkspaceTabContexts, +} diff --git a/src/modules/github/chat-drawer/drawer.js b/src/modules/github/chat-drawer/drawer.js index bb79a6d..222382d 100644 --- a/src/modules/github/chat-drawer/drawer.js +++ b/src/modules/github/chat-drawer/drawer.js @@ -13,8 +13,15 @@ import { toRepositoryLabel, toRepositoryUrl, } from './chat-utils.js' +import { + buildActiveTabEditorContext, + normalizeWorkspaceTabContext, + normalizeWorkspaceTabContexts, +} from './active-tab-context.js' import { buildOutboundMessages as buildPayloadMessages } from './payload.js' import { editorProposalTools, toMessageEditorProposals } from './proposals.js' +import { resolveWorkspaceTabTarget, toTargetKey } from './tab-target-resolver.js' +import { createTabScopedUndoState } from './tab-scoped-undo-state.js' const svgNamespace = 'http://www.w3.org/2000/svg' @@ -55,14 +62,13 @@ export const createGitHubChatDrawer = ({ includeEditorsContextToggle, getToken, getSelectedRepository, - getComponentSource, - setComponentSource, - getStylesSource, - setStylesSource, + getWorkspaceTabContexts, + applyWorkspaceTabContent, scheduleRender, getRenderMode, getStyleMode, getDrawerSide, + getActiveWorkspaceTabContext, }) => { let open = false let pendingAbortController = null @@ -76,15 +82,36 @@ export const createGitHubChatDrawer = ({ user: null, assistant: null, } - const lastAppliedEditorSnapshot = { - component: null, - styles: null, + const tabScopedUndoState = createTabScopedUndoState() + + const getActiveTabContext = () => { + if (typeof getActiveWorkspaceTabContext !== 'function') { + return null + } + + return normalizeWorkspaceTabContext(getActiveWorkspaceTabContext()) + } + + const getWorkspaceTabs = () => { + if (typeof getWorkspaceTabContexts !== 'function') { + return [] + } + + return normalizeWorkspaceTabContexts(getWorkspaceTabContexts()) + } + + const getFallbackProposalTarget = () => { + const activeTabContext = getActiveTabContext() + if (!activeTabContext) { + return '' + } + + return activeTabContext.path || activeTabContext.id } const resetChatContextState = () => { compactedConversationSummary = '' - lastAppliedEditorSnapshot.component = null - lastAppliedEditorSnapshot.styles = null + tabScopedUndoState.clearAll() for (const message of messages) { if (!message || typeof message !== 'object') { @@ -287,10 +314,13 @@ export const createGitHubChatDrawer = ({ undoNode.replaceChildren() - const hasComponentUndo = Boolean(lastAppliedEditorSnapshot.component) - const hasStylesUndo = Boolean(lastAppliedEditorSnapshot.styles) + const activeTabContext = getActiveTabContext() + const activeTabId = activeTabContext?.id + const activeTabUndoSnapshot = activeTabId + ? tabScopedUndoState.getSnapshot(activeTabId) + : null - if (!hasComponentUndo && !hasStylesUndo) { + if (!activeTabUndoSnapshot) { undoNode.setAttribute('hidden', '') return } @@ -300,27 +330,14 @@ export const createGitHubChatDrawer = ({ 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) - } + const undoButton = document.createElement('button') + undoButton.type = 'button' + undoButton.className = + 'render-button render-button--small ai-chat-drawer__undo-action' + undoButton.dataset.action = 'undo-tab-apply' + const tabName = activeTabContext?.name || activeTabUndoSnapshot.tabName || 'tab' + undoButton.textContent = `Undo last apply for ${tabName}` + undoNode.append(undoButton) undoNode.removeAttribute('hidden') } @@ -371,73 +388,80 @@ export const createGitHubChatDrawer = ({ item.append(body) const proposals = - message.role === 'assistant' ? toMessageEditorProposals(message) : null - const componentProposal = proposals?.component - const stylesProposal = proposals?.styles - const hasProposal = Boolean(componentProposal || stylesProposal) + message.role === 'assistant' + ? toMessageEditorProposals(message, { + fallbackTarget: getFallbackProposalTarget(), + }) + : [] + const workspaceTabs = getWorkspaceTabs() + const activeTabId = getActiveTabContext()?.id || '' + const resolvedProposals = proposals + .map(proposal => { + const resolvedTab = resolveWorkspaceTabTarget({ + target: proposal.target, + language: proposal.language, + tabs: workspaceTabs, + activeTabId, + }) + + if (!resolvedTab) { + return null + } + + const appliedKey = toTargetKey(proposal.target) + return { + ...proposal, + appliedKey, + resolvedTab, + } + }) + .filter(Boolean) + const hasProposal = resolvedProposals.length > 0 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 buildApplyButton = ({ proposal, proposalIndex }) => { 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', - ) + button.dataset.proposalIndex = String(proposalIndex) + const tabLabel = + proposal.resolvedTab.name || + proposal.resolvedTab.path || + proposal.resolvedTab.id + button.textContent = `Apply update to ${tabLabel}` + button.setAttribute('aria-label', `Apply update to ${tabLabel}`) if (pendingAbortController) { button.disabled = true } return button } - if ( - componentProposal && - appliedTargets.component !== true && - !showCombinedApply - ) { - actions.append(buildApplyButton({ target: 'component', text: 'Apply update' })) - } + for (const [proposalIndex, proposal] of resolvedProposals.entries()) { + if (!proposal?.appliedKey || appliedTargets[proposal.appliedKey] === true) { + continue + } - if (stylesProposal && appliedTargets.styles !== true && !showCombinedApply) { - actions.append(buildApplyButton({ target: 'styles', text: 'Apply update' })) + actions.append( + buildApplyButton({ + proposal, + proposalIndex, + }), + ) } - 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) + if (actions.childElementCount > 0) { + item.append(actions) } - - item.append(actions) } if (message.role === 'assistant' && index === messages.length - 1) { @@ -505,81 +529,89 @@ export const createGitHubChatDrawer = ({ return `${nextValue}\n` } - const applyProposalToEditor = ({ messageIndex, target }) => { + const applyProposalToTab = ({ messageIndex, proposalIndex }) => { const message = messages[messageIndex] if (!message || message.role !== 'assistant') { - return false + return null } - const proposals = toMessageEditorProposals(message) - const proposal = target === 'styles' ? proposals.styles : proposals.component + const proposals = toMessageEditorProposals(message, { + fallbackTarget: getFallbackProposalTarget(), + }) + const proposal = proposals[proposalIndex] if (!proposal) { - return false + return null } - 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 - } + const activeTabContext = getActiveTabContext() + const workspaceTabs = getWorkspaceTabs() + const resolvedTab = resolveWorkspaceTabTarget({ + target: proposal.target, + language: proposal.language, + tabs: workspaceTabs, + activeTabId: activeTabContext?.id || '', + }) - if (typeof setStylesSource !== 'function' || typeof getStylesSource !== 'function') { - return false + if (!resolvedTab || typeof applyWorkspaceTabContent !== 'function') { + return null } - const previousValue = getStylesSource() + const previousValue = + typeof resolvedTab.content === 'string' ? resolvedTab.content : '' const nextValue = preserveTrailingNewlineIfNeeded({ previousValue, nextValue: proposal.content, }) - setStylesSource(nextValue) - lastAppliedEditorSnapshot.styles = { - previousValue, + + const updatedTab = applyWorkspaceTabContent({ + tabId: resolvedTab.id, + content: nextValue, + }) + if (!updatedTab) { + return null } + + tabScopedUndoState.setSnapshot({ + tabId: resolvedTab.id, + snapshot: { + previousValue, + tabName: resolvedTab.name, + }, + }) + scheduleRenderAfterEditorUpdate() - setChatStatus('Applied assistant proposal to Styles editor.', 'ok') - return true + const tabLabel = resolvedTab.name || resolvedTab.path || resolvedTab.id + setChatStatus(`Applied assistant proposal to ${tabLabel}.`, 'ok') + return { + appliedKey: toTargetKey(proposal.target), + tabId: resolvedTab.id, + } } - const undoEditorApply = target => { - if (target === 'component') { - const snapshot = lastAppliedEditorSnapshot.component - if (!snapshot || typeof setComponentSource !== 'function') { - return false - } + const undoActiveTabApply = () => { + const activeTabContext = getActiveTabContext() + const activeTabId = activeTabContext?.id + if (!activeTabId || typeof applyWorkspaceTabContent !== 'function') { + return false + } - setComponentSource(snapshot.previousValue) - lastAppliedEditorSnapshot.component = null - scheduleRenderAfterEditorUpdate() - setChatStatus('Reverted last Component editor apply.', 'neutral') - return true + const snapshot = tabScopedUndoState.getSnapshot(activeTabId) + if (!snapshot) { + return false } - const snapshot = lastAppliedEditorSnapshot.styles - if (!snapshot || typeof setStylesSource !== 'function') { + const restored = applyWorkspaceTabContent({ + tabId: activeTabId, + content: snapshot.previousValue, + }) + if (!restored) { return false } - setStylesSource(snapshot.previousValue) - lastAppliedEditorSnapshot.styles = null + tabScopedUndoState.clearSnapshot(activeTabId) scheduleRenderAfterEditorUpdate() - setChatStatus('Reverted last Styles editor apply.', 'neutral') + const tabLabel = activeTabContext?.name || snapshot.tabName || 'active tab' + setChatStatus(`Reverted last apply for ${tabLabel}.`, 'neutral') return true } @@ -613,35 +645,26 @@ export const createGitHubChatDrawer = ({ return null } - const componentSource = - typeof getComponentSource === 'function' ? toChatText(getComponentSource()) : '' - const stylesSource = - typeof getStylesSource === 'function' ? toChatText(getStylesSource()) : '' - - if (!componentSource && !stylesSource) { + const activeTabContext = getActiveTabContext() + if (!activeTabContext) { return null } + const workspaceTabs = getWorkspaceTabs() + const renderMode = typeof getRenderMode === 'function' ? toChatText(getRenderMode()) : '' const styleMode = typeof getStyleMode === 'function' ? toChatText(getStyleMode()) : '' - return [ - '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') + return buildActiveTabEditorContext({ + activeTabContext: { + ...activeTabContext, + content: toChatText(activeTabContext.content), + }, + workspaceTabContexts: workspaceTabs, + renderMode, + styleMode, + }) } const setPendingState = isPending => { @@ -680,7 +703,7 @@ export const createGitHubChatDrawer = ({ lastMessage.content = hasContent || normalizedToolCalls.length === 0 ? normalizedContent - : 'Proposed editor update is ready. Review and apply below.' + : 'Proposed editor update is ready. Apply below.' lastMessage.toolCalls = normalizedToolCalls if (typeof model === 'string' && model.trim()) { @@ -897,12 +920,11 @@ export const createGitHubChatDrawer = ({ } const action = button.dataset.action - const targetEditor = button.dataset.targetEditor === 'styles' ? 'styles' : 'component' - if (action === 'undo-editor-apply') { - const undone = undoEditorApply(targetEditor) + if (action === 'undo-tab-apply') { + const undone = undoActiveTabApply() if (!undone) { - setChatStatus('No editor apply action is available to undo.', 'error') + setChatStatus('No tab apply action is available to undo.', 'error') } renderMessages() return @@ -924,66 +946,26 @@ export const createGitHubChatDrawer = ({ } 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, - } + const proposalIndex = Number(button.dataset.proposalIndex) + if (!Number.isFinite(proposalIndex) || proposalIndex < 0) { + return } - renderMessages() - return - } - if (action === 'apply-both') { - const appliedComponent = applyProposalToEditor({ + const applied = applyProposalToTab({ messageIndex, - target: 'component', + proposalIndex, }) - 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) { + if (!applied) { + setChatStatus('Could not apply proposal to tab.', 'error') + } else { message.appliedTargets = { ...(message.appliedTargets && typeof message.appliedTargets === 'object' ? message.appliedTargets : {}), - styles: true, + [applied.appliedKey]: true, } } - - if (appliedComponent && appliedStyles) { - setChatStatus( - 'Applied assistant proposals to Component and Styles editors.', - 'ok', - ) - } - renderMessages() return } @@ -1022,6 +1004,9 @@ export const createGitHubChatDrawer = ({ setSelectedRepository: () => { syncRepositoryLabel() }, + onActiveWorkspaceTabChange: () => { + renderMessages() + }, setToken: token => { syncModelSelectionForToken(token) }, diff --git a/src/modules/github/chat-drawer/payload.js b/src/modules/github/chat-drawer/payload.js index c44346b..1010185 100644 --- a/src/modules/github/chat-drawer/payload.js +++ b/src/modules/github/chat-drawer/payload.js @@ -75,15 +75,22 @@ const collectModePolicyContext = ({ renderMode, styleMode }) => { 'Mode-aware policy:', `- Render mode: ${renderModeText}`, `- Style mode: ${styleModeText}`, + '- Preserve the selected style dialect and avoid cross-dialect rewrites unless the user explicitly asks for conversion.', ] if (renderModeKey === 'dom') { policyLines.push( '- In DOM mode, avoid React hook/state guidance unless the user explicitly asks for React migration.', ) + policyLines.push( + '- In DOM mode, JSX is compiled for @knighted/jsx DOM runtime and should not be treated as a React application by default.', + ) policyLines.push( '- Prefer native DOM APIs, event listeners, and direct browser-compatible patterns.', ) + policyLines.push( + '- Do not suggest React imports, hooks, or React-only runtime APIs unless the user explicitly requests React mode or migration.', + ) } if (renderModeKey === 'react') { @@ -96,6 +103,27 @@ const collectModePolicyContext = ({ renderMode, styleMode }) => { ) } + if (styleModeKey === 'module') { + policyLines.push( + '- In CSS modules mode, keep class names module-scoped and preserve CSS module semantics.', + ) + policyLines.push( + '- Avoid converting CSS modules files to global CSS unless the user explicitly asks.', + ) + } + + if (styleModeKey === 'less') { + policyLines.push( + '- In Less mode, prefer Less-compatible syntax and avoid Sass-specific directives/features.', + ) + } + + if (styleModeKey === 'sass') { + policyLines.push( + '- In Sass mode, prefer Sass/SCSS-compatible syntax and avoid Less-specific directives/features.', + ) + } + return policyLines.join('\n') } diff --git a/src/modules/github/chat-drawer/proposals.js b/src/modules/github/chat-drawer/proposals.js index 52800f6..5b337f8 100644 --- a/src/modules/github/chat-drawer/proposals.js +++ b/src/modules/github/chat-drawer/proposals.js @@ -6,17 +6,21 @@ export const editorProposalTools = [ function: { name: 'propose_editor_update', description: - 'Propose a single editor update for component or styles with full replacement content.', + 'Propose a single tab update with full replacement content for a target tab id or path.', parameters: { type: 'object', properties: { target: { type: 'string', - enum: ['component', 'styles'], + description: 'Target tab id or tab path from the available tab list.', }, content: { type: 'string', - description: 'Full replacement text for the target editor.', + description: 'Full replacement text for the target tab.', + }, + language: { + type: 'string', + description: 'Optional tab language hint such as javascript-jsx or css.', }, rationale: { type: 'string', @@ -42,14 +46,21 @@ const parseJsonSafe = value => { } } -const extractEditorProposalsFromToolCalls = toolCalls => { - const proposals = { - component: null, - styles: null, +const toTargetValue = value => { + if (typeof value !== 'string') { + return '' } + return value.trim() +} + +const toProposalKey = value => toTargetValue(value).toLowerCase() + +const extractEditorProposalsFromToolCalls = toolCalls => { + const proposalsByKey = new Map() + if (!Array.isArray(toolCalls)) { - return proposals + return [] } for (const toolCall of toolCalls) { @@ -62,74 +73,70 @@ const extractEditorProposalsFromToolCalls = toolCalls => { continue } - const target = - payload.target === 'component' || payload.target === 'styles' - ? payload.target - : null + const target = toTargetValue(payload.target) const content = toChatText(payload.content) + const language = toChatText(payload.language) const rationale = toChatText(payload.rationale) + const proposalKey = toProposalKey(target) - if (!target || !content) { + if (!proposalKey || !content) { continue } - proposals[target] = { + proposalsByKey.set(proposalKey, { + target, source: 'tool', content, + language, rationale, - } + }) } - return proposals + return Array.from(proposalsByKey.values()) } -const extractEditorProposalsFromMarkdown = content => { - const proposals = { - component: null, - styles: null, +const extractEditorProposalsFromMarkdown = ({ content, fallbackTarget }) => { + const target = toTargetValue(fallbackTarget) + if (!target) { + return [] } if (typeof content !== 'string' || !content.trim()) { - return proposals + return [] } - 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: '', - } - } - } + const blockRegex = /```([a-zA-Z0-9_-]+)?\n([\s\S]*?)```/g + const match = blockRegex.exec(content) + if (!match) { + return [] + } - match = blockRegex.exec(content) + const language = toChatText(match[1]).toLowerCase() + const blockContent = toChatText(match[2]) + if (!blockContent) { + return [] } - return proposals + return [ + { + target, + source: 'markdown', + content: blockContent, + language, + rationale: '', + }, + ] } -export const toMessageEditorProposals = message => { +export const toMessageEditorProposals = (message, { fallbackTarget = '' } = {}) => { const fromToolCalls = extractEditorProposalsFromToolCalls(message?.toolCalls) - if (fromToolCalls.component || fromToolCalls.styles) { + if (fromToolCalls.length > 0) { return fromToolCalls } - return extractEditorProposalsFromMarkdown(message?.content) + return extractEditorProposalsFromMarkdown({ + content: message?.content, + fallbackTarget, + }) } diff --git a/src/modules/github/chat-drawer/tab-scoped-undo-state.js b/src/modules/github/chat-drawer/tab-scoped-undo-state.js new file mode 100644 index 0000000..efad338 --- /dev/null +++ b/src/modules/github/chat-drawer/tab-scoped-undo-state.js @@ -0,0 +1,49 @@ +const toTabId = value => { + if (typeof value !== 'string') { + return '' + } + + return value.trim() +} + +const createTabScopedUndoState = () => { + const snapshotsByTabId = new Map() + + const getSnapshot = tabId => { + const normalizedTabId = toTabId(tabId) + if (!normalizedTabId) { + return null + } + + return snapshotsByTabId.get(normalizedTabId) ?? null + } + + const setSnapshot = ({ tabId, snapshot }) => { + const normalizedTabId = toTabId(tabId) + if (!normalizedTabId) { + return + } + + snapshotsByTabId.set(normalizedTabId, snapshot) + } + + const clearSnapshot = tabId => { + const normalizedTabId = toTabId(tabId) + if (!normalizedTabId) { + return + } + + snapshotsByTabId.delete(normalizedTabId) + } + + return { + clearAll: () => { + snapshotsByTabId.clear() + }, + getSnapshot, + setSnapshot, + clearSnapshot, + } +} + +export { createTabScopedUndoState } diff --git a/src/modules/github/chat-drawer/tab-target-resolver.js b/src/modules/github/chat-drawer/tab-target-resolver.js new file mode 100644 index 0000000..a39ed2a --- /dev/null +++ b/src/modules/github/chat-drawer/tab-target-resolver.js @@ -0,0 +1,82 @@ +const toNonEmptyText = value => { + if (typeof value !== 'string') { + return '' + } + + return value.trim() +} + +const normalizePath = value => + toNonEmptyText(value).replace(/\\/g, '/').replace(/\/+/g, '/') + +const normalizeProposalTarget = value => toNonEmptyText(value) + +const normalizeTabLanguage = value => toNonEmptyText(value).toLowerCase() + +const toTargetKey = value => normalizeProposalTarget(value).toLowerCase() + +const getCandidateTabsForTarget = ({ target, tabs }) => { + const normalizedTarget = normalizeProposalTarget(target) + const normalizedTargetKey = toTargetKey(target) + const normalizedTargetPath = normalizePath(target) + + if (!normalizedTarget || !Array.isArray(tabs)) { + return [] + } + + const byId = tabs.filter(tab => toTargetKey(tab.id) === normalizedTargetKey) + if (byId.length > 0) { + return byId + } + + const byPath = tabs.filter(tab => normalizePath(tab.path) === normalizedTargetPath) + if (byPath.length > 0) { + return byPath + } + + return tabs.filter(tab => toTargetKey(tab.name) === normalizedTargetKey) +} + +const resolveWorkspaceTabTarget = ({ target, language, tabs, activeTabId }) => { + const normalizedTarget = normalizeProposalTarget(target) + if (!normalizedTarget) { + return null + } + + const availableTabs = Array.isArray(tabs) ? tabs : [] + const activeTab = availableTabs.find(tab => tab.id === activeTabId) ?? null + + if (toTargetKey(normalizedTarget) === 'active') { + return activeTab + } + + const candidates = getCandidateTabsForTarget({ + target: normalizedTarget, + tabs: availableTabs, + }) + + if (candidates.length === 0) { + return null + } + + const normalizedLanguage = normalizeTabLanguage(language) + const languageMatches = normalizedLanguage + ? candidates.filter(tab => normalizeTabLanguage(tab.language) === normalizedLanguage) + : candidates + + const scopedCandidates = languageMatches.length > 0 ? languageMatches : candidates + if (scopedCandidates.length === 1) { + return scopedCandidates[0] + } + + if (activeTab) { + const activeCandidate = scopedCandidates.find(tab => tab.id === activeTab.id) + if (activeCandidate) { + return activeCandidate + } + } + + return scopedCandidates[0] +} + +export { resolveWorkspaceTabTarget, toTargetKey } From 9afd83b43749ac956336cbdf0fbfd411a6175493 Mon Sep 17 00:00:00 2001 From: KCM Date: Fri, 1 May 2026 19:52:23 -0500 Subject: [PATCH 2/4] docs: summarize ai chat context strategy. --- docs/ai-chat-context-and-payload-strategy.md | 184 +++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 docs/ai-chat-context-and-payload-strategy.md diff --git a/docs/ai-chat-context-and-payload-strategy.md b/docs/ai-chat-context-and-payload-strategy.md new file mode 100644 index 0000000..25f2a9e --- /dev/null +++ b/docs/ai-chat-context-and-payload-strategy.md @@ -0,0 +1,184 @@ +# AI Chat Context and Payload Strategy + +This document describes the current AI chat request construction approach in @knighted/develop, including context shaping, tool usage, payload-size controls, and known improvement opportunities. + +## Current Approach + +### 1. System prompt and mode-aware policy + +Each request includes a system prompt with policy guidance, then augments that prompt with mode-aware constraints: + +- Render mode guidance (DOM vs React) +- Style mode guidance (css, module, less, sass) +- DOM-mode JSX guidance for @knighted/jsx runtime +- Explicit React-avoidance in DOM mode unless migration is requested +- Dialect preservation guidance (avoid cross-dialect rewrites unless requested) + +Primary implementation: + +- src/modules/github/chat-drawer/payload.js + +### 2. Repository context + +Each request includes repository targeting context as a dedicated system message: + +- Selected repository full name +- Repository URL +- Default branch +- Policy to treat selected repository as default unless overridden + +Primary implementation: + +- src/modules/github/chat-drawer/drawer.js + +### 3. Editor context (Send tab content) + +When enabled, the drawer includes active tab context as a system message: + +- Render mode and style mode +- Active tab label/path +- Available tab targets list (id/path/name/language), currently capped to 20 +- Active tab source code block + +This context is designed to support dynamic proposal targeting by tab id/path and reduce ambiguity. + +Primary implementation: + +- src/modules/github/chat-drawer/active-tab-context.js +- src/modules/github/chat-drawer/drawer.js + +### 4. Tooling model + +AI proposal actions currently use a function tool: + +- propose_editor_update + +Contract: + +- target: tab id or path +- content: full replacement tab content +- language: optional disambiguation hint +- rationale: optional explanation + +Primary implementation: + +- src/modules/github/chat-drawer/proposals.js +- src/modules/github/chat-drawer/tab-target-resolver.js +- src/modules/github/chat-drawer/drawer.js + +### 5. Apply and undo behavior + +- Apply is proposal-driven and tab-target-aware (id/path resolution) +- Undo is scoped per tab (latest snapshot per tab) +- Undo UI is visible for active tab snapshot only + +Primary implementation: + +- src/modules/github/chat-drawer/drawer.js +- src/modules/github/chat-drawer/tab-scoped-undo-state.js + +### 6. Payload size controls and summary strategy + +The payload builder includes bounded-conversation controls: + +- Hard byte budget: 120_000 bytes +- Direct conversation retention cap: latest 14 chat messages +- Summary cap: 3_600 characters +- Older dropped conversation turns are compacted into a rolling system summary + +Primary implementation: + +- src/modules/github/chat-drawer/payload.js + +### 7. Fallback and transport behavior + +- Streaming request path is attempted first +- Non-stream fallback is attempted on streaming failure +- Model access errors are surfaced with tailored status text + +Primary implementation: + +- src/modules/github/chat-drawer/drawer.js +- src/modules/github/api/chat.js + +## Why this approach + +- Keeps active-tab workflows lightweight and responsive +- Supports explicit user review before applying generated edits +- Preserves model guidance quality with mode/dialect policy constraints +- Reduces request-size growth with bounded message history and rolling summaries + +## Possible Areas for Improvement + +### 1. Hard-fit protection when system context alone is large + +Current shrinking behavior primarily trims conversation turns. Add a final hard-fit step that can selectively trim editor context sections when total payload still exceeds budget. + +Potential ideas: + +- Trim available tab target list length adaptively +- Clip active tab source with clear truncation markers +- Retry once on 413 with reduced context envelope + +### 2. Create-tab capability + +Add a dedicated tool for creating workspace tabs so requests like "create a new styles tab" can be completed in one interaction. + +Potential tool: + +- create_workspace_tab(path, language, initialContent?, activate?) + +### 3. Cross-tab source access + +Support workflows where the user references a non-active tab. + +Potential options: + +- Add Send all tabs mode with explicit byte budgeting +- Add read_workspace_tab tool for targeted lookup + +### 4. Better summary fidelity + +Current summary is compact and bounded, but can lose nuanced intent over long sessions. + +Potential ideas: + +- Structured summary sections (goals, constraints, pending asks) +- Weighted retention for user constraints and accepted decisions + +### 5. Context observability in UI + +Provide optional diagnostics showing what context is being sent in the next request. + +Potential ideas: + +- "Preview outgoing context" drawer section +- Approximate byte-count indicator before send + +### 6. Tool-call UX clarity + +Continue improving copy and actions so users understand what is proposed versus what is already applied. + +Potential ideas: + +- Show target tab path in each action +- Add optional diff preview before apply + +### 7. Optional stricter policy profiles + +Allow policy strictness presets depending on user goals. + +Potential ideas: + +- Conservative mode: fewer tool proposals, stronger minimal-change bias +- Refactor mode: broader architectural proposal tolerance + +## Validation status + +Current strategy has focused Playwright coverage for the chat drawer behavior and context policy assertions in: + +- playwright/github-byot-ai.spec.ts + +## Scope note + +This document is intentionally implementation-oriented. It describes current behavior and practical next improvements without locking future UX or API contracts. From ea52ed250ac76525bd2fa09290342e1dc4500e5d Mon Sep 17 00:00:00 2001 From: KCM Date: Fri, 1 May 2026 20:16:19 -0500 Subject: [PATCH 3/4] refactor: github chat. --- docs/ai-chat-context-and-payload-strategy.md | 22 ++--- src/app.js | 81 ++++-------------- .../app-core/github-chat-workspace-actions.js | 85 +++++++++++++++++++ .../active-tab-context.js | 0 .../github/{chat-drawer => chat}/drawer.js | 2 +- .../github/{chat-drawer => chat}/payload.js | 2 +- .../github/{chat-drawer => chat}/proposals.js | 2 +- .../tab-scoped-undo-state.js | 0 .../tab-target-resolver.js | 0 .../chat-utils.js => chat/utils.js} | 0 10 files changed, 114 insertions(+), 80 deletions(-) create mode 100644 src/modules/app-core/github-chat-workspace-actions.js rename src/modules/github/{chat-drawer => chat}/active-tab-context.js (100%) rename src/modules/github/{chat-drawer => chat}/drawer.js (99%) rename src/modules/github/{chat-drawer => chat}/payload.js (99%) rename src/modules/github/{chat-drawer => chat}/proposals.js (98%) rename src/modules/github/{chat-drawer => chat}/tab-scoped-undo-state.js (100%) rename src/modules/github/{chat-drawer => chat}/tab-target-resolver.js (100%) rename src/modules/github/{chat-drawer/chat-utils.js => chat/utils.js} (100%) diff --git a/docs/ai-chat-context-and-payload-strategy.md b/docs/ai-chat-context-and-payload-strategy.md index 25f2a9e..72ca275 100644 --- a/docs/ai-chat-context-and-payload-strategy.md +++ b/docs/ai-chat-context-and-payload-strategy.md @@ -16,7 +16,7 @@ Each request includes a system prompt with policy guidance, then augments that p Primary implementation: -- src/modules/github/chat-drawer/payload.js +- src/modules/github/chat/payload.js ### 2. Repository context @@ -29,7 +29,7 @@ Each request includes repository targeting context as a dedicated system message Primary implementation: -- src/modules/github/chat-drawer/drawer.js +- src/modules/github/chat/drawer.js ### 3. Editor context (Send tab content) @@ -44,8 +44,8 @@ This context is designed to support dynamic proposal targeting by tab id/path an Primary implementation: -- src/modules/github/chat-drawer/active-tab-context.js -- src/modules/github/chat-drawer/drawer.js +- src/modules/github/chat/active-tab-context.js +- src/modules/github/chat/drawer.js ### 4. Tooling model @@ -62,9 +62,9 @@ Contract: Primary implementation: -- src/modules/github/chat-drawer/proposals.js -- src/modules/github/chat-drawer/tab-target-resolver.js -- src/modules/github/chat-drawer/drawer.js +- src/modules/github/chat/proposals.js +- src/modules/github/chat/tab-target-resolver.js +- src/modules/github/chat/drawer.js ### 5. Apply and undo behavior @@ -74,8 +74,8 @@ Primary implementation: Primary implementation: -- src/modules/github/chat-drawer/drawer.js -- src/modules/github/chat-drawer/tab-scoped-undo-state.js +- src/modules/github/chat/drawer.js +- src/modules/github/chat/tab-scoped-undo-state.js ### 6. Payload size controls and summary strategy @@ -88,7 +88,7 @@ The payload builder includes bounded-conversation controls: Primary implementation: -- src/modules/github/chat-drawer/payload.js +- src/modules/github/chat/payload.js ### 7. Fallback and transport behavior @@ -98,7 +98,7 @@ Primary implementation: Primary implementation: -- src/modules/github/chat-drawer/drawer.js +- src/modules/github/chat/drawer.js - src/modules/github/api/chat.js ## Why this approach diff --git a/src/app.js b/src/app.js index c2e9a2c..d8a0d67 100644 --- a/src/app.js +++ b/src/app.js @@ -51,8 +51,9 @@ import { persistClosedPrContextRecords } from './modules/app-core/pr-context-rec import { createPrContextStateChangeHandler } from './modules/app-core/pr-context-state-change-handler.js' import { createWorkspaceContextStatusController } from './modules/app-core/workspace-context-status-controller.js' import { createWorkspaceRecordAppliedHandler } from './modules/app-core/workspace-record-applied-handler.js' +import { createGitHubChatWorkspaceActions } from './modules/app-core/github-chat-workspace-actions.js' import { createDiagnosticsUiController } from './modules/diagnostics/diagnostics-ui.js' -import { createGitHubChatDrawer } from './modules/github/chat-drawer/drawer.js' +import { createGitHubChatDrawer } from './modules/github/chat/drawer.js' import { createGitHubByotControls } from './modules/github/byot-controls.js' import { formatActivePrReference, @@ -1041,6 +1042,18 @@ const onPrContextStateChange = createPrContextStateChangeHandler({ editedIndicatorVisibilityController, }) +const githubChatWorkspaceActions = createGitHubChatWorkspaceActions({ + getActiveWorkspaceTab, + isStyleWorkspaceTab, + getCssSource: () => getCssSource(), + getJsxSource: () => getJsxSource(), + workspaceTabsState, + getDirtyStateForTabChange, + loadWorkspaceTabIntoEditor, + renderWorkspaceTabs, + queueWorkspaceSave: () => queueWorkspaceSave(), +}) + const githubWorkflows = createGitHubWorkflowsSetup({ factories: { createGitHubPrEditorSyncController, @@ -1218,71 +1231,7 @@ const githubWorkflows = createGitHubWorkflowsSetup({ confirmAction: options => confirmAction(options), setStatus, showAppToast, - getActiveWorkspaceTabContext: () => { - const activeTab = getActiveWorkspaceTab() - if (!activeTab) { - return null - } - - const isStylesTab = isStyleWorkspaceTab(activeTab) - - return { - id: activeTab.id, - name: activeTab.name, - path: activeTab.path, - language: activeTab.language, - content: isStylesTab ? getCssSource() : getJsxSource(), - isActive: true, - } - }, - getWorkspaceTabContexts: () => { - const activeTabId = workspaceTabsState.getActiveTabId() - return workspaceTabsState.getTabs().map(tab => { - const isActive = tab.id === activeTabId - const isStylesTab = isStyleWorkspaceTab(tab) - return { - id: tab.id, - name: tab.name, - path: tab.path, - language: tab.language, - isActive, - content: isActive - ? isStylesTab - ? getCssSource() - : getJsxSource() - : tab.content, - } - }) - }, - applyWorkspaceTabContent: ({ tabId, content }) => { - const tab = workspaceTabsState.getTab(tabId) - if (!tab || typeof content !== 'string') { - return null - } - - const updatedTab = workspaceTabsState.upsertTab( - { - ...tab, - content, - isDirty: getDirtyStateForTabChange(tab, content), - lastModified: Date.now(), - isActive: tab.isActive, - }, - { emitReason: 'chatApplyTabContent' }, - ) - - if (!updatedTab) { - return null - } - - if (updatedTab.isActive) { - loadWorkspaceTabIntoEditor(updatedTab) - } - - renderWorkspaceTabs() - queueWorkspaceSave() - return updatedTab - }, + ...githubChatWorkspaceActions, scheduleRender: () => { if ( autoRenderToggle?.checked && diff --git a/src/modules/app-core/github-chat-workspace-actions.js b/src/modules/app-core/github-chat-workspace-actions.js new file mode 100644 index 0000000..3a5613d --- /dev/null +++ b/src/modules/app-core/github-chat-workspace-actions.js @@ -0,0 +1,85 @@ +const createGitHubChatWorkspaceActions = ({ + getActiveWorkspaceTab, + isStyleWorkspaceTab, + getCssSource, + getJsxSource, + workspaceTabsState, + getDirtyStateForTabChange, + loadWorkspaceTabIntoEditor, + renderWorkspaceTabs, + queueWorkspaceSave, +}) => { + const getActiveWorkspaceTabContext = () => { + const activeTab = getActiveWorkspaceTab() + if (!activeTab) { + return null + } + + const isStylesTab = isStyleWorkspaceTab(activeTab) + + return { + id: activeTab.id, + name: activeTab.name, + path: activeTab.path, + language: activeTab.language, + content: isStylesTab ? getCssSource() : getJsxSource(), + isActive: true, + } + } + + const getWorkspaceTabContexts = () => { + const activeTabId = workspaceTabsState.getActiveTabId() + + return workspaceTabsState.getTabs().map(tab => { + const isActive = tab.id === activeTabId + const isStylesTab = isStyleWorkspaceTab(tab) + + return { + id: tab.id, + name: tab.name, + path: tab.path, + language: tab.language, + isActive, + content: isActive ? (isStylesTab ? getCssSource() : getJsxSource()) : tab.content, + } + }) + } + + const applyWorkspaceTabContent = ({ tabId, content }) => { + const tab = workspaceTabsState.getTab(tabId) + if (!tab || typeof content !== 'string') { + return null + } + + const updatedTab = workspaceTabsState.upsertTab( + { + ...tab, + content, + isDirty: getDirtyStateForTabChange(tab, content), + lastModified: Date.now(), + isActive: tab.isActive, + }, + { emitReason: 'chatApplyTabContent' }, + ) + + if (!updatedTab) { + return null + } + + if (updatedTab.isActive) { + loadWorkspaceTabIntoEditor(updatedTab) + } + + renderWorkspaceTabs() + queueWorkspaceSave() + return updatedTab + } + + return { + getActiveWorkspaceTabContext, + getWorkspaceTabContexts, + applyWorkspaceTabContent, + } +} + +export { createGitHubChatWorkspaceActions } diff --git a/src/modules/github/chat-drawer/active-tab-context.js b/src/modules/github/chat/active-tab-context.js similarity index 100% rename from src/modules/github/chat-drawer/active-tab-context.js rename to src/modules/github/chat/active-tab-context.js diff --git a/src/modules/github/chat-drawer/drawer.js b/src/modules/github/chat/drawer.js similarity index 99% rename from src/modules/github/chat-drawer/drawer.js rename to src/modules/github/chat/drawer.js index 222382d..363b4e8 100644 --- a/src/modules/github/chat-drawer/drawer.js +++ b/src/modules/github/chat/drawer.js @@ -12,7 +12,7 @@ import { toModelId, toRepositoryLabel, toRepositoryUrl, -} from './chat-utils.js' +} from './utils.js' import { buildActiveTabEditorContext, normalizeWorkspaceTabContext, diff --git a/src/modules/github/chat-drawer/payload.js b/src/modules/github/chat/payload.js similarity index 99% rename from src/modules/github/chat-drawer/payload.js rename to src/modules/github/chat/payload.js index 1010185..d3228db 100644 --- a/src/modules/github/chat-drawer/payload.js +++ b/src/modules/github/chat/payload.js @@ -1,4 +1,4 @@ -import { toChatText } from './chat-utils.js' +import { toChatText } from './utils.js' const chatByteBudget = 120_000 const chatMaxSummaryChars = 3_600 diff --git a/src/modules/github/chat-drawer/proposals.js b/src/modules/github/chat/proposals.js similarity index 98% rename from src/modules/github/chat-drawer/proposals.js rename to src/modules/github/chat/proposals.js index 5b337f8..a6028d8 100644 --- a/src/modules/github/chat-drawer/proposals.js +++ b/src/modules/github/chat/proposals.js @@ -1,4 +1,4 @@ -import { toChatText } from './chat-utils.js' +import { toChatText } from './utils.js' export const editorProposalTools = [ { diff --git a/src/modules/github/chat-drawer/tab-scoped-undo-state.js b/src/modules/github/chat/tab-scoped-undo-state.js similarity index 100% rename from src/modules/github/chat-drawer/tab-scoped-undo-state.js rename to src/modules/github/chat/tab-scoped-undo-state.js diff --git a/src/modules/github/chat-drawer/tab-target-resolver.js b/src/modules/github/chat/tab-target-resolver.js similarity index 100% rename from src/modules/github/chat-drawer/tab-target-resolver.js rename to src/modules/github/chat/tab-target-resolver.js diff --git a/src/modules/github/chat-drawer/chat-utils.js b/src/modules/github/chat/utils.js similarity index 100% rename from src/modules/github/chat-drawer/chat-utils.js rename to src/modules/github/chat/utils.js From 563ad737a8e6cc79f81224746bb70a5b249b2ef2 Mon Sep 17 00:00:00 2001 From: KCM Date: Fri, 1 May 2026 20:42:49 -0500 Subject: [PATCH 4/4] refactor: address pr comments. --- playwright/github-byot-ai.spec.ts | 143 ++++++++++++++++++++++++++++++ src/modules/github/chat/drawer.js | 90 ++++++++++--------- 2 files changed, 193 insertions(+), 40 deletions(-) diff --git a/playwright/github-byot-ai.spec.ts b/playwright/github-byot-ai.spec.ts index 132f5ca..8d3c559 100644 --- a/playwright/github-byot-ai.spec.ts +++ b/playwright/github-byot-ai.spec.ts @@ -556,6 +556,149 @@ test('AI chat apply actions resolve dynamic tab targets', async ({ page }) => { ).toBeVisible() }) +test('AI chat applies the correct proposal when unresolved targets are filtered out', async ({ + page, +}) => { + await page.route('https://models.github.ai/inference/chat/completions', async route => { + const body = route.request().postDataJSON() as ChatRequestBody | null + + if (body?.stream) { + await route.fulfill({ + status: 502, + contentType: 'application/json', + body: JSON.stringify({ message: 'stream intentionally disabled in this test' }), + }) + return + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + choices: [ + { + message: { + role: 'assistant', + content: 'Prepared updates for App tab.', + tool_calls: [ + { + id: 'call_unresolved', + type: 'function', + function: { + name: 'propose_editor_update', + arguments: JSON.stringify({ + target: 'src/components/missing.tsx', + content: 'const Missing = () => null', + }), + }, + }, + { + id: 'call_component', + type: 'function', + function: { + name: 'propose_editor_update', + arguments: JSON.stringify({ + target: 'src/components/App.tsx', + content: 'const App = () =>

Resolved update

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

Before

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

By id

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

By path

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

Before

') + await openWorkspaceTab(page, 'App.tsx') + await ensureAiChatDrawerOpen(page) + + await page.getByLabel('Ask AI assistant').fill('Update App tab once.') + await page.getByRole('button', { name: 'Send' }).click() + + await expect(page.getByRole('button', { name: 'Apply update to App.tsx' })).toHaveCount( + 1, + ) +}) + test('AI chat sends the currently active tab when context is enabled', async ({ page, }) => { diff --git a/src/modules/github/chat/drawer.js b/src/modules/github/chat/drawer.js index 363b4e8..b1243c0 100644 --- a/src/modules/github/chat/drawer.js +++ b/src/modules/github/chat/drawer.js @@ -20,7 +20,7 @@ import { } from './active-tab-context.js' import { buildOutboundMessages as buildPayloadMessages } from './payload.js' import { editorProposalTools, toMessageEditorProposals } from './proposals.js' -import { resolveWorkspaceTabTarget, toTargetKey } from './tab-target-resolver.js' +import { resolveWorkspaceTabTarget } from './tab-target-resolver.js' import { createTabScopedUndoState } from './tab-scoped-undo-state.js' const svgNamespace = 'http://www.w3.org/2000/svg' @@ -387,35 +387,8 @@ export const createGitHubChatDrawer = ({ body.textContent = message.content item.append(body) - const proposals = - message.role === 'assistant' - ? toMessageEditorProposals(message, { - fallbackTarget: getFallbackProposalTarget(), - }) - : [] - const workspaceTabs = getWorkspaceTabs() - const activeTabId = getActiveTabContext()?.id || '' - const resolvedProposals = proposals - .map(proposal => { - const resolvedTab = resolveWorkspaceTabTarget({ - target: proposal.target, - language: proposal.language, - tabs: workspaceTabs, - activeTabId, - }) - - if (!resolvedTab) { - return null - } - - const appliedKey = toTargetKey(proposal.target) - return { - ...proposal, - appliedKey, - resolvedTab, - } - }) - .filter(Boolean) + const resolvedProposals = + message.role === 'assistant' ? resolveMessageProposals(message) : [] const hasProposal = resolvedProposals.length > 0 const appliedTargets = message && typeof message.appliedTargets === 'object' && message.appliedTargets @@ -427,13 +400,13 @@ export const createGitHubChatDrawer = ({ actions.className = 'ai-chat-message__actions' actions.dataset.messageIndex = String(index) - const buildApplyButton = ({ proposal, proposalIndex }) => { + const buildApplyButton = ({ proposal }) => { const button = document.createElement('button') button.type = 'button' button.className = 'render-button render-button--small ai-chat-message__action' button.dataset.action = 'request-apply' button.dataset.messageIndex = String(index) - button.dataset.proposalIndex = String(proposalIndex) + button.dataset.proposalOriginalIndex = String(proposal.proposalOriginalIndex) const tabLabel = proposal.resolvedTab.name || proposal.resolvedTab.path || @@ -446,15 +419,22 @@ export const createGitHubChatDrawer = ({ return button } - for (const [proposalIndex, proposal] of resolvedProposals.entries()) { + const renderedApplyKeys = new Set() + + for (const proposal of resolvedProposals) { if (!proposal?.appliedKey || appliedTargets[proposal.appliedKey] === true) { continue } + if (renderedApplyKeys.has(proposal.appliedKey)) { + continue + } + + renderedApplyKeys.add(proposal.appliedKey) + actions.append( buildApplyButton({ proposal, - proposalIndex, }), ) } @@ -529,7 +509,37 @@ export const createGitHubChatDrawer = ({ return `${nextValue}\n` } - const applyProposalToTab = ({ messageIndex, proposalIndex }) => { + const resolveMessageProposals = message => { + const proposals = toMessageEditorProposals(message, { + fallbackTarget: getFallbackProposalTarget(), + }) + const workspaceTabs = getWorkspaceTabs() + const activeTabId = getActiveTabContext()?.id || '' + + return proposals + .map((proposal, proposalOriginalIndex) => { + const resolvedTab = resolveWorkspaceTabTarget({ + target: proposal.target, + language: proposal.language, + tabs: workspaceTabs, + activeTabId, + }) + + if (!resolvedTab) { + return null + } + + return { + ...proposal, + proposalOriginalIndex, + appliedKey: resolvedTab.id, + resolvedTab, + } + }) + .filter(Boolean) + } + + const applyProposalToTab = ({ messageIndex, proposalOriginalIndex }) => { const message = messages[messageIndex] if (!message || message.role !== 'assistant') { return null @@ -538,7 +548,7 @@ export const createGitHubChatDrawer = ({ const proposals = toMessageEditorProposals(message, { fallbackTarget: getFallbackProposalTarget(), }) - const proposal = proposals[proposalIndex] + const proposal = proposals[proposalOriginalIndex] if (!proposal) { return null } @@ -583,7 +593,7 @@ export const createGitHubChatDrawer = ({ const tabLabel = resolvedTab.name || resolvedTab.path || resolvedTab.id setChatStatus(`Applied assistant proposal to ${tabLabel}.`, 'ok') return { - appliedKey: toTargetKey(proposal.target), + appliedKey: resolvedTab.id, tabId: resolvedTab.id, } } @@ -946,14 +956,14 @@ export const createGitHubChatDrawer = ({ } if (action === 'request-apply') { - const proposalIndex = Number(button.dataset.proposalIndex) - if (!Number.isFinite(proposalIndex) || proposalIndex < 0) { + const proposalOriginalIndex = Number(button.dataset.proposalOriginalIndex) + if (!Number.isFinite(proposalOriginalIndex) || proposalOriginalIndex < 0) { return } const applied = applyProposalToTab({ messageIndex, - proposalIndex, + proposalOriginalIndex, }) if (!applied) {