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..72ca275
--- /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/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.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/active-tab-context.js
+- src/modules/github/chat/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/proposals.js
+- src/modules/github/chat/tab-target-resolver.js
+- src/modules/github/chat/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.js
+- src/modules/github/chat/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/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.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.
diff --git a/playwright/github-byot-ai.spec.ts b/playwright/github-byot-ai.spec.ts
index 8b41a06..8d3c559 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,216 @@ 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 applies the correct proposal when unresolved targets are filtered out', async ({
+ page,
+}) => {
+ await page.route('https://models.github.ai/inference/chat/completions', async route => {
+ const body = route.request().postDataJSON() as ChatRequestBody | null
+
+ if (body?.stream) {
+ await route.fulfill({
+ status: 502,
+ contentType: 'application/json',
+ body: JSON.stringify({ message: 'stream intentionally disabled in this test' }),
+ })
+ return
+ }
+
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ choices: [
+ {
+ message: {
+ role: 'assistant',
+ content: 'Prepared updates for App tab.',
+ tool_calls: [
+ {
+ id: 'call_unresolved',
+ type: 'function',
+ function: {
+ name: 'propose_editor_update',
+ arguments: JSON.stringify({
+ target: 'src/components/missing.tsx',
+ content: 'const Missing = () => null',
+ }),
+ },
+ },
+ {
+ id: 'call_component',
+ type: 'function',
+ function: {
+ name: 'propose_editor_update',
+ arguments: JSON.stringify({
+ target: 'src/components/App.tsx',
+ content: 'const App = () =>
Resolved update
',
+ }),
+ },
+ },
+ ],
+ },
+ },
+ ],
+ }),
+ })
+ })
+
+ await waitForAppReady(page, `${appEntryPath}`)
+ await connectByotWithSingleRepo(page)
+ await setComponentEditorSource(page, 'const App = () => Before
')
+ await openWorkspaceTab(page, 'App.tsx')
+ await ensureAiChatDrawerOpen(page)
+
+ await page.getByLabel('Ask AI assistant').fill('Update App tab only.')
+ await page.getByRole('button', { name: 'Send' }).click()
+
+ await expect(
+ page.getByRole('button', { name: 'Apply update to App.tsx' }),
+ ).toBeVisible()
+ await page.getByRole('button', { name: 'Apply update to App.tsx' }).click()
+
+ await expect(
+ page.locator('.editor-panel[data-editor-kind="component"] .cm-content').first(),
+ ).toContainText('Resolved update')
+})
+
+test('AI chat renders a single apply action for multiple targets resolving to the same tab', async ({
+ page,
+}) => {
+ await page.route('https://models.github.ai/inference/chat/completions', async route => {
+ const body = route.request().postDataJSON() as ChatRequestBody | null
+
+ if (body?.stream) {
+ await route.fulfill({
+ status: 502,
+ contentType: 'application/json',
+ body: JSON.stringify({ message: 'stream intentionally disabled in this test' }),
+ })
+ return
+ }
+
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ choices: [
+ {
+ message: {
+ role: 'assistant',
+ content: 'Prepared updates for App tab.',
+ tool_calls: [
+ {
+ id: 'call_component_id',
+ type: 'function',
+ function: {
+ name: 'propose_editor_update',
+ arguments: JSON.stringify({
+ target: 'component',
+ content: 'const App = () => By id
',
+ }),
+ },
+ },
+ {
+ id: 'call_component_path',
+ type: 'function',
+ function: {
+ name: 'propose_editor_update',
+ arguments: JSON.stringify({
+ target: 'src/components/App.tsx',
+ content: 'const App = () => By path
',
+ }),
+ },
+ },
+ ],
+ },
+ },
+ ],
+ }),
+ })
+ })
+
+ await waitForAppReady(page, `${appEntryPath}`)
+ await connectByotWithSingleRepo(page)
+ await setComponentEditorSource(page, 'const App = () => Before
')
+ await openWorkspaceTab(page, 'App.tsx')
+ await ensureAiChatDrawerOpen(page)
+
+ await page.getByLabel('Ask AI assistant').fill('Update App tab once.')
+ await page.getByRole('button', { name: 'Send' }).click()
+
+ await expect(page.getByRole('button', { name: 'Apply update to App.tsx' })).toHaveCount(
+ 1,
+ )
+})
+
+test('AI chat sends the currently active tab when context is enabled', async ({
+ page,
+}) => {
+ let streamRequestBody: ChatRequestBody | undefined
+
+ await page.route('https://models.github.ai/inference/chat/completions', async route => {
+ streamRequestBody = route.request().postDataJSON() as ChatRequestBody
+
+ await route.fulfill({
+ status: 200,
+ contentType: 'text/event-stream',
+ body: [
+ 'data: {"choices":[{"delta":{"content":"ok"}}]}',
+ '',
+ 'data: [DONE]',
+ '',
+ ].join('\n'),
+ })
+ })
+
+ await waitForAppReady(page, `${appEntryPath}`)
+ await connectByotWithSingleRepo(page)
+ await setStylesEditorSource(page, '.button { color: red; }')
+ await ensureAiChatDrawerOpen(page)
+
+ await page.getByLabel('Ask AI assistant').fill('Use active tab context only.')
+ await page.getByRole('button', { name: 'Send' }).click()
+ await expect(
+ page.getByText('Response streamed from GitHub.', { exact: true }),
+ ).toHaveText('Response streamed from GitHub.')
+
+ const systemMessages = streamRequestBody?.messages?.filter(
+ (message: ChatRequestMessage) => message.role === 'system',
+ )
+ expect(
+ systemMessages?.some(
+ (message: ChatRequestMessage) =>
+ message.content?.includes('- Active tab:') &&
+ message.content?.includes('app.css'),
+ ),
+ ).toBe(true)
+ expect(
+ systemMessages?.some((message: ChatRequestMessage) =>
+ message.content?.includes('Active tab source:'),
+ ),
+ ).toBe(true)
+ expect(
+ systemMessages?.some((message: ChatRequestMessage) =>
+ message.content?.includes('Available tab targets (id and path):'),
+ ),
+ ).toBe(true)
})
test('AI chat streaming text still updates while latest undo actions are visible', async ({
@@ -546,7 +787,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 +837,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..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,
@@ -460,6 +461,7 @@ const setActiveWorkspaceRecordId = nextValue => {
let chatDrawerController = {
setOpen: () => {},
setSelectedRepository: () => {},
+ onActiveWorkspaceTabChange: () => {},
setToken: () => {},
dispose: () => {},
}
@@ -827,6 +829,7 @@ const {
getActiveWorkspaceTab,
onActiveWorkspaceTabChange: (_tab, { changed } = {}) => {
syncDiagnosticsDrawerLayout()
+ chatDrawerController.onActiveWorkspaceTabChange()
if (changed) {
clearDiagnosticsOnTabSwitch()
@@ -1039,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,
@@ -1216,24 +1231,7 @@ const githubWorkflows = createGitHubWorkflowsSetup({
confirmAction: options => confirmAction(options),
setStatus,
showAppToast,
- setComponentSource: value => {
- suppressEditorChangeSideEffects = true
- try {
- setJsxSource(value)
- } finally {
- suppressEditorChangeSideEffects = false
- }
- },
- setStylesSource: value => {
- suppressEditorChangeSideEffects = true
- try {
- setCssSource(value)
- } finally {
- suppressEditorChangeSideEffects = false
- }
- },
- getComponentSource: () => getJsxSource(),
- getStylesSource: () => getCssSource(),
+ ...githubChatWorkspaceActions,
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-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/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/proposals.js b/src/modules/github/chat-drawer/proposals.js
deleted file mode 100644
index 52800f6..0000000
--- a/src/modules/github/chat-drawer/proposals.js
+++ /dev/null
@@ -1,135 +0,0 @@
-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/chat/active-tab-context.js b/src/modules/github/chat/active-tab-context.js
new file mode 100644
index 0000000..12084a9
--- /dev/null
+++ b/src/modules/github/chat/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.js
similarity index 76%
rename from src/modules/github/chat-drawer/drawer.js
rename to src/modules/github/chat/drawer.js
index bb79a6d..b1243c0 100644
--- a/src/modules/github/chat-drawer/drawer.js
+++ b/src/modules/github/chat/drawer.js
@@ -12,9 +12,16 @@ import {
toModelId,
toRepositoryLabel,
toRepositoryUrl,
-} from './chat-utils.js'
+} from './utils.js'
+import {
+ buildActiveTabEditorContext,
+ normalizeWorkspaceTabContext,
+ normalizeWorkspaceTabContexts,
+} from './active-tab-context.js'
import { buildOutboundMessages as buildPayloadMessages } from './payload.js'
import { editorProposalTools, toMessageEditorProposals } from './proposals.js'
+import { resolveWorkspaceTabTarget } from './tab-target-resolver.js'
+import { createTabScopedUndoState } from './tab-scoped-undo-state.js'
const svgNamespace = 'http://www.w3.org/2000/svg'
@@ -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')
}
@@ -370,74 +387,61 @@ export const createGitHubChatDrawer = ({
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 resolvedProposals =
+ message.role === 'assistant' ? resolveMessageProposals(message) : []
+ 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 }) => {
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.proposalOriginalIndex = String(proposal.proposalOriginalIndex)
+ const tabLabel =
+ proposal.resolvedTab.name ||
+ proposal.resolvedTab.path ||
+ proposal.resolvedTab.id
+ button.textContent = `Apply update to ${tabLabel}`
+ button.setAttribute('aria-label', `Apply update to ${tabLabel}`)
if (pendingAbortController) {
button.disabled = true
}
return button
}
- if (
- componentProposal &&
- appliedTargets.component !== true &&
- !showCombinedApply
- ) {
- actions.append(buildApplyButton({ target: 'component', text: 'Apply update' }))
- }
+ const renderedApplyKeys = new Set()
- if (stylesProposal && appliedTargets.styles !== true && !showCombinedApply) {
- actions.append(buildApplyButton({ target: 'styles', text: 'Apply update' }))
- }
+ for (const proposal of resolvedProposals) {
+ if (!proposal?.appliedKey || appliedTargets[proposal.appliedKey] === true) {
+ continue
+ }
- 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
+ if (renderedApplyKeys.has(proposal.appliedKey)) {
+ continue
}
- actions.append(applyBothButton)
+
+ renderedApplyKeys.add(proposal.appliedKey)
+
+ actions.append(
+ buildApplyButton({
+ proposal,
+ }),
+ )
}
- item.append(actions)
+ if (actions.childElementCount > 0) {
+ item.append(actions)
+ }
}
if (message.role === 'assistant' && index === messages.length - 1) {
@@ -505,81 +509,119 @@ export const createGitHubChatDrawer = ({
return `${nextValue}\n`
}
- const applyProposalToEditor = ({ messageIndex, target }) => {
+ 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 false
+ return null
}
- const proposals = toMessageEditorProposals(message)
- const proposal = target === 'styles' ? proposals.styles : proposals.component
+ const proposals = toMessageEditorProposals(message, {
+ fallbackTarget: getFallbackProposalTarget(),
+ })
+ const proposal = proposals[proposalOriginalIndex]
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: resolvedTab.id,
+ 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 +655,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 +713,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 +930,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 +956,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 proposalOriginalIndex = Number(button.dataset.proposalOriginalIndex)
+ if (!Number.isFinite(proposalOriginalIndex) || proposalOriginalIndex < 0) {
+ return
}
- renderMessages()
- return
- }
- if (action === 'apply-both') {
- const appliedComponent = applyProposalToEditor({
- messageIndex,
- target: 'component',
- })
- const appliedStyles = applyProposalToEditor({
+ const applied = applyProposalToTab({
messageIndex,
- target: 'styles',
+ proposalOriginalIndex,
})
- 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 +1014,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/payload.js
similarity index 82%
rename from src/modules/github/chat-drawer/payload.js
rename to src/modules/github/chat/payload.js
index c44346b..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
@@ -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/proposals.js b/src/modules/github/chat/proposals.js
new file mode 100644
index 0000000..a6028d8
--- /dev/null
+++ b/src/modules/github/chat/proposals.js
@@ -0,0 +1,142 @@
+import { toChatText } from './utils.js'
+
+export const editorProposalTools = [
+ {
+ type: 'function',
+ function: {
+ name: 'propose_editor_update',
+ description:
+ 'Propose a single tab update with full replacement content for a target tab id or path.',
+ parameters: {
+ type: 'object',
+ properties: {
+ target: {
+ type: 'string',
+ description: 'Target tab id or tab path from the available tab list.',
+ },
+ content: {
+ type: 'string',
+ 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',
+ 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 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 []
+ }
+
+ 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 = toTargetValue(payload.target)
+ const content = toChatText(payload.content)
+ const language = toChatText(payload.language)
+ const rationale = toChatText(payload.rationale)
+ const proposalKey = toProposalKey(target)
+
+ if (!proposalKey || !content) {
+ continue
+ }
+
+ proposalsByKey.set(proposalKey, {
+ target,
+ source: 'tool',
+ content,
+ language,
+ rationale,
+ })
+ }
+
+ return Array.from(proposalsByKey.values())
+}
+
+const extractEditorProposalsFromMarkdown = ({ content, fallbackTarget }) => {
+ const target = toTargetValue(fallbackTarget)
+ if (!target) {
+ return []
+ }
+
+ if (typeof content !== 'string' || !content.trim()) {
+ return []
+ }
+
+ const blockRegex = /```([a-zA-Z0-9_-]+)?\n([\s\S]*?)```/g
+ const match = blockRegex.exec(content)
+ if (!match) {
+ return []
+ }
+
+ const language = toChatText(match[1]).toLowerCase()
+ const blockContent = toChatText(match[2])
+ if (!blockContent) {
+ return []
+ }
+
+ return [
+ {
+ target,
+ source: 'markdown',
+ content: blockContent,
+ language,
+ rationale: '',
+ },
+ ]
+}
+
+export const toMessageEditorProposals = (message, { fallbackTarget = '' } = {}) => {
+ const fromToolCalls = extractEditorProposalsFromToolCalls(message?.toolCalls)
+
+ if (fromToolCalls.length > 0) {
+ return fromToolCalls
+ }
+
+ return extractEditorProposalsFromMarkdown({
+ content: message?.content,
+ fallbackTarget,
+ })
+}
diff --git a/src/modules/github/chat/tab-scoped-undo-state.js b/src/modules/github/chat/tab-scoped-undo-state.js
new file mode 100644
index 0000000..efad338
--- /dev/null
+++ b/src/modules/github/chat/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/tab-target-resolver.js b/src/modules/github/chat/tab-target-resolver.js
new file mode 100644
index 0000000..a39ed2a
--- /dev/null
+++ b/src/modules/github/chat/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 }
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