From eaffbd608cdb742a315299776bda04390d82bce4 Mon Sep 17 00:00:00 2001 From: KCM Date: Sat, 21 Mar 2026 14:09:36 -0500 Subject: [PATCH 1/4] feat: ai chat drawer. --- README.md | 8 + playwright/app.spec.ts | 236 +++++++++++++++ src/app.js | 82 ++++- src/index.html | 69 +++++ src/modules/github-api.js | 349 +++++++++++++++++++++- src/modules/github-byot-controls.js | 13 + src/modules/github-chat-drawer.js | 445 ++++++++++++++++++++++++++++ src/styles/ai-controls.css | 230 ++++++++++++++ 8 files changed, 1423 insertions(+), 9 deletions(-) create mode 100644 src/modules/github-chat-drawer.js diff --git a/README.md b/README.md index 5fb3141..1443e24 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,14 @@ browser acts as the runtime host for render, lint, and typecheck flows. - GitHub PAT setup and usage: [docs/byot.md](docs/byot.md) +## Fine-Grained PAT Quick Setup + +For AI/BYOT flows, use a fine-grained GitHub PAT and follow the existing setup guide: + +- Full setup and behavior: [docs/byot.md](docs/byot.md) +- Repository permissions screenshot: [docs/media/byot-repo-perms.png](docs/media/byot-repo-perms.png) +- Models permission screenshot: [docs/media/byot-model-perms.png](docs/media/byot-model-perms.png) + ## License MIT diff --git a/playwright/app.spec.ts b/playwright/app.spec.ts index eb4fdb0..2b6be42 100644 --- a/playwright/app.spec.ts +++ b/playwright/app.spec.ts @@ -4,6 +4,16 @@ import type { Page } from '@playwright/test' const webServerMode = process.env.PLAYWRIGHT_WEB_SERVER_MODE ?? 'dev' const appEntryPath = webServerMode === 'preview' ? '/index.html' : '/src/index.html' +type ChatRequestMessage = { + role?: string + content?: string +} + +type ChatRequestBody = { + metadata?: unknown + messages?: ChatRequestMessage[] +} + const waitForAppReady = async (page: Page, path = appEntryPath) => { await page.goto(path) await expect(page.getByRole('heading', { name: '@knighted/develop' })).toBeVisible() @@ -100,6 +110,42 @@ const ensureDiagnosticsDrawerClosed = async (page: Page) => { await expect(page.locator('#diagnostics-drawer')).toBeHidden() } +const ensureAiChatDrawerOpen = async (page: Page) => { + const toggle = page.locator('#ai-chat-toggle') + const isExpanded = await toggle.getAttribute('aria-expanded') + + if (isExpanded !== 'true') { + await toggle.click() + } + + await expect(page.locator('#ai-chat-drawer')).toBeVisible() +} + +const connectByotWithSingleRepo = async (page: Page) => { + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 11, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: 'knightedcodemonkey/develop', + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await page.locator('#github-token-input').fill('github_pat_fake_chat_1234567890') + await page.locator('#github-token-add').click() + await expect(page.locator('#github-repo-select')).toHaveValue( + 'knightedcodemonkey/develop', + ) +} + const expectCollapseButtonState = async ( page: Page, panelName: 'component' | 'styles' | 'preview', @@ -136,6 +182,8 @@ test('BYOT controls stay hidden when feature flag is disabled', async ({ page }) const byotControls = page.locator('#github-ai-controls') await expect(byotControls).toHaveAttribute('hidden', '') await expect(byotControls).toBeHidden() + await expect(page.locator('#ai-chat-toggle')).toBeHidden() + await expect(page.locator('#ai-chat-drawer')).toBeHidden() }) test('BYOT controls render when feature flag is enabled by query param', async ({ @@ -147,6 +195,194 @@ test('BYOT controls render when feature flag is enabled by query param', async ( await expect(byotControls).toBeVisible() await expect(page.locator('#github-token-input')).toBeVisible() await expect(page.locator('#github-token-add')).toBeVisible() + await expect(page.locator('#github-ai-controls #ai-chat-toggle')).toBeHidden() +}) + +test('AI chat drawer opens and closes when feature flag is enabled', async ({ page }) => { + await waitForAppReady(page, `${appEntryPath}?feature-ai=true`) + await connectByotWithSingleRepo(page) + + const chatToggle = page.locator('#ai-chat-toggle') + const chatDrawer = page.locator('#ai-chat-drawer') + + await expect(chatToggle).toBeVisible() + await expect(chatToggle).toHaveAttribute('aria-expanded', 'false') + + await chatToggle.click() + await expect(chatDrawer).toBeVisible() + await expect(chatToggle).toHaveAttribute('aria-expanded', 'true') + + await page.locator('#ai-chat-close').click() + await expect(chatDrawer).toBeHidden() + await expect(chatToggle).toHaveAttribute('aria-expanded', 'false') +}) + +test('AI chat prefers streaming responses when available', async ({ page }) => { + let streamRequestBody: ChatRequestBody | undefined + + await page.route('https://models.github.ai/inference/chat/completions', async route => { + streamRequestBody = route.request().postDataJSON() as ChatRequestBody + + await route.fulfill({ + status: 200, + contentType: 'text/event-stream', + body: [ + 'data: {"choices":[{"delta":{"content":"Streaming "}}]}', + '', + 'data: {"choices":[{"delta":{"content":"response ready"}}]}', + '', + 'data: [DONE]', + '', + ].join('\n'), + }) + }) + + await waitForAppReady(page, `${appEntryPath}?feature-ai=true`) + await connectByotWithSingleRepo(page) + await ensureAiChatDrawerOpen(page) + + await page.locator('#ai-chat-prompt').fill('Summarize this repository.') + await page.locator('#ai-chat-send').click() + + await expect(page.locator('#ai-chat-status')).toHaveText( + 'Response streamed from GitHub.', + ) + await expect(page.locator('#ai-chat-rate')).toHaveText('Rate limit info unavailable') + await expect(page.locator('#ai-chat-messages')).toContainText( + 'Summarize this repository.', + ) + await expect(page.locator('#ai-chat-messages')).toContainText( + 'Streaming response ready', + ) + + expect(streamRequestBody?.metadata).toBeUndefined() + const systemMessage = streamRequestBody?.messages?.find( + (message: ChatRequestMessage) => message.role === 'system', + ) + const systemMessages = streamRequestBody?.messages?.filter( + (message: ChatRequestMessage) => message.role === 'system', + ) + expect(systemMessage?.content).toContain('Selected repository context') + expect(systemMessage?.content).toContain('Repository: knightedcodemonkey/develop') + expect(systemMessage?.content).toContain( + 'Repository URL: https://github.com/knightedcodemonkey/develop', + ) + expect( + systemMessages?.some((message: ChatRequestMessage) => + message.content?.includes('Editor context:'), + ), + ).toBe(true) +}) + +test('AI chat can disable editor context payload via checkbox', async ({ page }) => { + let streamRequestBody: ChatRequestBody | undefined + + await page.route('https://models.github.ai/inference/chat/completions', async route => { + streamRequestBody = route.request().postDataJSON() as ChatRequestBody + + await route.fulfill({ + status: 200, + contentType: 'text/event-stream', + body: [ + 'data: {"choices":[{"delta":{"content":"ok"}}]}', + '', + 'data: [DONE]', + '', + ].join('\n'), + }) + }) + + await waitForAppReady(page, `${appEntryPath}?feature-ai=true`) + await connectByotWithSingleRepo(page) + await ensureAiChatDrawerOpen(page) + + const includeEditorsToggle = page.locator('#ai-chat-include-editors') + await expect(includeEditorsToggle).toBeChecked() + await includeEditorsToggle.uncheck() + + await page.locator('#ai-chat-prompt').fill('No editor source this time.') + await page.locator('#ai-chat-send').click() + await expect(page.locator('#ai-chat-status')).toHaveText( + 'Response streamed from GitHub.', + ) + await expect(page.locator('#ai-chat-rate')).toHaveText('Rate limit info unavailable') + + expect(streamRequestBody?.metadata).toBeUndefined() + const systemMessages = streamRequestBody?.messages?.filter( + (message: ChatRequestMessage) => message.role === 'system', + ) + expect( + systemMessages?.some((message: ChatRequestMessage) => + message.content?.includes('Selected repository context'), + ), + ).toBe(true) + expect( + systemMessages?.some((message: ChatRequestMessage) => + message.content?.includes( + 'Repository URL: https://github.com/knightedcodemonkey/develop', + ), + ), + ).toBe(true) + expect( + systemMessages?.some((message: ChatRequestMessage) => + message.content?.includes('Editor context:'), + ), + ).toBe(false) +}) + +test('AI chat falls back to non-streaming response when streaming fails', async ({ + page, +}) => { + let streamAttemptCount = 0 + let fallbackAttemptCount = 0 + + await page.route('https://models.github.ai/inference/chat/completions', async route => { + const body = route.request().postDataJSON() as { stream?: boolean } | null + if (body?.stream) { + streamAttemptCount += 1 + await route.fulfill({ + status: 502, + contentType: 'application/json', + body: JSON.stringify({ message: 'stream failed' }), + }) + return + } + + fallbackAttemptCount += 1 + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + rate_limit: { + remaining: 17, + reset: 1704067200, + }, + choices: [ + { + message: { + role: 'assistant', + content: 'Fallback response from JSON path.', + }, + }, + ], + }), + }) + }) + + await waitForAppReady(page, `${appEntryPath}?feature-ai=true`) + await connectByotWithSingleRepo(page) + await ensureAiChatDrawerOpen(page) + + await page.locator('#ai-chat-prompt').fill('Use fallback path.') + await page.locator('#ai-chat-send').click() + + await expect(page.locator('#ai-chat-status')).toHaveText('Fallback response loaded.') + await expect(page.locator('#ai-chat-rate')).toHaveText('Remaining 17, resets 00:00 UTC') + await expect(page.locator('#ai-chat-messages')).toContainText( + 'Fallback response from JSON path.', + ) + expect(streamAttemptCount).toBeGreaterThan(0) + expect(fallbackAttemptCount).toBeGreaterThan(0) }) test('BYOT remembers selected repository across reloads', async ({ page }) => { diff --git a/src/app.js b/src/app.js index 03bd803..813dd51 100644 --- a/src/app.js +++ b/src/app.js @@ -8,6 +8,7 @@ import { createCodeMirrorEditor } from './modules/editor-codemirror.js' import { defaultCss, defaultJsx, defaultReactJsx } from './modules/defaults.js' import { createDiagnosticsUiController } from './modules/diagnostics-ui.js' import { isAiAssistantFeatureEnabled } from './modules/feature-flags.js' +import { createGitHubChatDrawer } from './modules/github-chat-drawer.js' import { createGitHubByotControls } from './modules/github-byot-controls.js' import { createLayoutThemeController } from './modules/layout-theme.js' import { createLintDiagnosticsController } from './modules/lint-diagnostics.js' @@ -24,6 +25,17 @@ const githubTokenAdd = document.getElementById('github-token-add') const githubTokenDelete = document.getElementById('github-token-delete') const githubRepoWrap = document.getElementById('github-repo-wrap') const githubRepoSelect = document.getElementById('github-repo-select') +const aiChatToggle = document.getElementById('ai-chat-toggle') +const aiChatDrawer = document.getElementById('ai-chat-drawer') +const aiChatClose = document.getElementById('ai-chat-close') +const aiChatClear = document.getElementById('ai-chat-clear') +const aiChatPrompt = document.getElementById('ai-chat-prompt') +const aiChatIncludeEditors = document.getElementById('ai-chat-include-editors') +const aiChatSend = document.getElementById('ai-chat-send') +const aiChatStatus = document.getElementById('ai-chat-status') +const aiChatRate = document.getElementById('ai-chat-rate') +const aiChatRepository = document.getElementById('ai-chat-repository') +const aiChatMessages = document.getElementById('ai-chat-messages') const appGridLayoutButtons = document.querySelectorAll('[data-app-grid-layout]') const appThemeButtons = document.querySelectorAll('[data-app-theme]') const editorToolsButtons = document.querySelectorAll('[data-editor-tools-toggle]') @@ -395,7 +407,31 @@ const { const aiAssistantFeatureEnabled = isAiAssistantFeatureEnabled() -createGitHubByotControls({ +const githubAiContextState = { + token: null, + selectedRepository: null, +} + +let chatDrawerController = { + setOpen: () => {}, + setSelectedRepository: () => {}, + dispose: () => {}, +} + +const syncAiChatTokenVisibility = token => { + const hasToken = typeof token === 'string' && token.trim().length > 0 + + if (hasToken) { + aiChatToggle?.removeAttribute('hidden') + return + } + + aiChatToggle?.setAttribute('hidden', '') + aiChatToggle?.setAttribute('aria-expanded', 'false') + chatDrawerController.setOpen(false) +} + +const byotControls = createGitHubByotControls({ featureEnabled: aiAssistantFeatureEnabled, controlsRoot: githubAiControls, tokenInput: githubTokenInput, @@ -404,10 +440,50 @@ createGitHubByotControls({ tokenDeleteButton: githubTokenDelete, repoSelect: githubRepoSelect, repoWrap: githubRepoWrap, - onRepositoryChange: () => {}, + onRepositoryChange: repository => { + githubAiContextState.selectedRepository = repository + chatDrawerController.setSelectedRepository(repository) + }, + onTokenChange: token => { + githubAiContextState.token = token + syncAiChatTokenVisibility(token) + }, setStatus, }) +githubAiContextState.selectedRepository = byotControls.getSelectedRepository() +githubAiContextState.token = byotControls.getToken() + +const getCurrentGitHubToken = () => githubAiContextState.token ?? byotControls.getToken() + +const getCurrentSelectedRepository = () => + githubAiContextState.selectedRepository ?? byotControls.getSelectedRepository() + +chatDrawerController = createGitHubChatDrawer({ + featureEnabled: aiAssistantFeatureEnabled, + toggleButton: aiChatToggle, + drawer: aiChatDrawer, + closeButton: aiChatClose, + promptInput: aiChatPrompt, + includeEditorsContextToggle: aiChatIncludeEditors, + sendButton: aiChatSend, + clearButton: aiChatClear, + statusNode: aiChatStatus, + rateNode: aiChatRate, + repositoryNode: aiChatRepository, + messagesNode: aiChatMessages, + getToken: getCurrentGitHubToken, + getSelectedRepository: getCurrentSelectedRepository, + getComponentSource: () => getJsxSource(), + getStylesSource: () => getCssSource(), + getRenderMode: () => renderMode.value, + getStyleMode: () => styleMode.value, + getDrawerSide: () => { + const layout = getCurrentLayout() + return layout === 'preview-left' ? 'left' : 'right' + }, +}) + const getStyleEditorLanguage = mode => { if (mode === 'less') return 'less' if (mode === 'sass') return 'sass' @@ -1048,12 +1124,14 @@ window.addEventListener('beforeunload', () => { clearComponentLintRecheckTimer() clearStylesLintRecheckTimer() lintDiagnostics.dispose() + chatDrawerController.dispose() }) applyAppGridLayout(getInitialAppGridLayout(), { persist: false }) applyTheme(getInitialTheme(), { persist: false }) applyEditorToolsVisibility() applyPanelCollapseState() +syncAiChatTokenVisibility(githubAiContextState.token) updateRenderButtonVisibility() renderDiagnosticsScope('component') diff --git a/src/index.html b/src/index.html index b5d4def..22c49d7 100644 --- a/src/index.html +++ b/src/index.html @@ -103,6 +103,27 @@

+ +
@@ -467,6 +488,54 @@

Styles

+ +
diff --git a/src/modules/github-api.js b/src/modules/github-api.js index f9e34cb..bb905e4 100644 --- a/src/modules/github-api.js +++ b/src/modules/github-api.js @@ -1,4 +1,5 @@ const githubApiBaseUrl = 'https://api.github.com' +const githubModelsApiUrl = 'https://models.github.ai/inference/chat/completions' const parseNextPageUrlFromLinkHeader = linkHeader => { if (typeof linkHeader !== 'string' || !linkHeader.trim()) { @@ -65,17 +66,223 @@ const buildRequestHeaders = token => ({ 'X-GitHub-Api-Version': '2022-11-28', }) -const parseErrorMessage = async response => { - try { - const body = await response.json() - if (body && typeof body.message === 'string' && body.message.trim()) { - return body.message +const buildChatRequestHeaders = ({ token, stream }) => ({ + Accept: stream ? 'text/event-stream' : 'application/json', + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + 'X-GitHub-Api-Version': '2022-11-28', +}) + +const toFiniteNumber = value => { + if (value === null || value === undefined) { + return null + } + + if (typeof value === 'string' && value.trim().length === 0) { + return null + } + + const numberValue = Number(value) + return Number.isFinite(numberValue) ? numberValue : null +} + +const parseRateMetadataFromHeaders = headers => { + if (!headers || typeof headers.get !== 'function') { + return { + remaining: null, + resetEpochSeconds: null, } + } + + const remaining = + toFiniteNumber(headers.get('x-ratelimit-remaining')) ?? + toFiniteNumber(headers.get('ratelimit-remaining')) + + const resetEpochSeconds = + toFiniteNumber(headers.get('x-ratelimit-reset')) ?? + toFiniteNumber(headers.get('ratelimit-reset')) + + return { + remaining, + resetEpochSeconds, + } +} + +const parseRateMetadataFromBody = body => { + if (!body || typeof body !== 'object') { + return { + remaining: null, + resetEpochSeconds: null, + } + } + + const rateLimit = body.rate_limit ?? body.rateLimit ?? null + + const remaining = + toFiniteNumber(rateLimit?.remaining) ?? toFiniteNumber(body.remaining) ?? null + + const resetEpochSeconds = + toFiniteNumber(rateLimit?.reset) ?? + toFiniteNumber(rateLimit?.reset_epoch_seconds) ?? + toFiniteNumber(rateLimit?.resetEpochSeconds) ?? + toFiniteNumber(body.reset) ?? + null + + return { + remaining, + resetEpochSeconds, + } +} + +const mergeRateMetadata = (primary, fallback) => ({ + remaining: primary.remaining ?? fallback.remaining ?? null, + resetEpochSeconds: primary.resetEpochSeconds ?? fallback.resetEpochSeconds ?? null, +}) + +const parseRateMetadata = ({ headers, body }) => { + const fromHeaders = parseRateMetadataFromHeaders(headers) + const fromBody = parseRateMetadataFromBody(body) + return mergeRateMetadata(fromHeaders, fromBody) +} + +const toApiError = ({ message, rateLimit }) => { + const error = new Error(message) + error.rateLimit = rateLimit + return error +} + +const normalizeChatMessage = message => { + if (!message || typeof message !== 'object') { + return null + } + + const role = + message.role === 'system' || message.role === 'assistant' ? message.role : 'user' + const content = typeof message.content === 'string' ? message.content.trim() : '' + + if (!content) { + return null + } + + return { role, content } +} + +const normalizeChatMessages = messages => { + if (!Array.isArray(messages)) { + return [] + } + + return messages.map(normalizeChatMessage).filter(Boolean) +} + +const buildChatBody = ({ model, messages, stream }) => { + const normalizedMessages = normalizeChatMessages(messages) + + return { + model, + messages: normalizedMessages, + stream, + } +} + +const extractContentFromMessage = message => { + if (!message || typeof message !== 'object') { + return '' + } + + if (typeof message.content === 'string') { + return message.content + } + + if (!Array.isArray(message.content)) { + return '' + } + + return message.content + .map(part => { + if (typeof part === 'string') { + return part + } + + if ( + part && + typeof part === 'object' && + part.type === 'text' && + typeof part.text === 'string' + ) { + return part.text + } + + return '' + }) + .join('') +} + +const extractChatCompletionText = body => { + const firstChoice = Array.isArray(body?.choices) ? body.choices[0] : null + + if (!firstChoice || typeof firstChoice !== 'object') { + return '' + } + + const message = firstChoice.message + return extractContentFromMessage(message).trim() +} + +const extractStreamingDeltaText = body => { + const firstChoice = Array.isArray(body?.choices) ? body.choices[0] : null + + if (!firstChoice || typeof firstChoice !== 'object') { + return '' + } + + if (typeof firstChoice.delta?.content === 'string') { + return firstChoice.delta.content + } + + return '' +} + +const parseSseDataLine = line => { + if (typeof line !== 'string') { + return null + } + + const trimmedLine = line.trim() + if (!trimmedLine.startsWith('data:')) { + return null + } + + const payload = trimmedLine.slice(5).trim() + if (!payload || payload === '[DONE]') { + return null + } + + try { + return JSON.parse(payload) + } catch { + return null + } +} + +const parseErrorResponse = async response => { + let body = null + + try { + body = await response.json() } catch { /* noop */ } - return `GitHub API request failed with status ${response.status}` + const message = + body && typeof body.message === 'string' && body.message.trim() + ? body.message + : `GitHub API request failed with status ${response.status}` + + return { + message, + rateLimit: parseRateMetadata({ headers: response.headers, body }), + } } const fetchJson = async ({ token, url, signal }) => { @@ -86,7 +293,8 @@ const fetchJson = async ({ token, url, signal }) => { }) if (!response.ok) { - throw new Error(await parseErrorMessage(response)) + const { message, rateLimit } = await parseErrorResponse(response) + throw toApiError({ message, rateLimit }) } return { @@ -138,3 +346,130 @@ export const listWritableRepositories = async ({ token, signal }) => { return writableRepos } + +export const streamGitHubChatCompletion = async ({ + token, + messages, + signal, + onToken, + model = 'openai/gpt-4.1-mini', +}) => { + if (typeof token !== 'string' || token.trim().length === 0) { + throw new Error('A GitHub token is required to start a chat request.') + } + + const normalizedMessages = normalizeChatMessages(messages) + if (normalizedMessages.length === 0) { + throw new Error('At least one message is required to start a chat request.') + } + + const response = await fetch(githubModelsApiUrl, { + method: 'POST', + headers: buildChatRequestHeaders({ token, stream: true }), + body: JSON.stringify( + buildChatBody({ model, messages: normalizedMessages, stream: true }), + ), + signal, + }) + + if (!response.ok) { + const { message, rateLimit } = await parseErrorResponse(response) + throw toApiError({ message, rateLimit }) + } + + if (!response.body) { + throw new Error('Streaming response body is not available in this browser.') + } + + const decoder = new TextDecoder() + const reader = response.body.getReader() + let buffered = '' + let combined = '' + + while (true) { + // eslint-disable-next-line no-await-in-loop + const { done, value } = await reader.read() + if (done) { + break + } + + buffered += decoder.decode(value, { stream: true }) + const lines = buffered.split('\n') + buffered = lines.pop() ?? '' + + for (const line of lines) { + const body = parseSseDataLine(line) + if (!body) { + continue + } + + const chunk = extractStreamingDeltaText(body) + if (!chunk) { + continue + } + + combined += chunk + onToken?.(chunk) + } + } + + if (buffered.trim()) { + const body = parseSseDataLine(buffered) + const chunk = body ? extractStreamingDeltaText(body) : '' + if (chunk) { + combined += chunk + onToken?.(chunk) + } + } + + if (!combined.trim()) { + throw new Error('Streaming response did not include assistant content.') + } + + return { + content: combined, + rateLimit: parseRateMetadata({ headers: response.headers, body: null }), + } +} + +export const requestGitHubChatCompletion = async ({ + token, + messages, + signal, + model = 'openai/gpt-4.1-mini', +}) => { + if (typeof token !== 'string' || token.trim().length === 0) { + throw new Error('A GitHub token is required to start a chat request.') + } + + const normalizedMessages = normalizeChatMessages(messages) + if (normalizedMessages.length === 0) { + throw new Error('At least one message is required to start a chat request.') + } + + const response = await fetch(githubModelsApiUrl, { + method: 'POST', + headers: buildChatRequestHeaders({ token, stream: false }), + body: JSON.stringify( + buildChatBody({ model, messages: normalizedMessages, stream: false }), + ), + signal, + }) + + if (!response.ok) { + const { message, rateLimit } = await parseErrorResponse(response) + throw toApiError({ message, rateLimit }) + } + + const body = await response.json() + const content = extractChatCompletionText(body) + + if (!content) { + throw new Error('GitHub chat response did not include assistant content.') + } + + return { + content, + rateLimit: parseRateMetadata({ headers: response.headers, body }), + } +} diff --git a/src/modules/github-byot-controls.js b/src/modules/github-byot-controls.js index ad00c3e..18264d0 100644 --- a/src/modules/github-byot-controls.js +++ b/src/modules/github-byot-controls.js @@ -56,6 +56,7 @@ export const createGitHubByotControls = ({ repoSelect, repoWrap, onRepositoryChange, + onTokenChange, setStatus, }) => { if (!featureEnabled) { @@ -93,6 +94,12 @@ export const createGitHubByotControls = ({ } } + const emitTokenChange = () => { + if (typeof onTokenChange === 'function') { + onTokenChange(savedToken) + } + } + const setTokenFieldLockState = isLocked => { if (!tokenInput) { return @@ -214,6 +221,7 @@ export const createGitHubByotControls = ({ option.dataset.owner = repo.owner option.dataset.name = repo.name option.dataset.defaultBranch = repo.defaultBranch + option.dataset.htmlUrl = repo.htmlUrl ?? '' option.selected = repo.fullName === selectedRepositoryFullName return option }) @@ -254,6 +262,7 @@ export const createGitHubByotControls = ({ owner: selectedOption.dataset.owner ?? '', name: selectedOption.dataset.name ?? '', defaultBranch: selectedOption.dataset.defaultBranch ?? 'main', + htmlUrl: selectedOption.dataset.htmlUrl ?? '', }) } @@ -383,6 +392,7 @@ export const createGitHubByotControls = ({ } savedToken = trimmedToken + emitTokenChange() setTokenFieldToMasked({ locked: true }) } @@ -412,6 +422,7 @@ export const createGitHubByotControls = ({ clearSelectedRepository() lastSelectedRepository = null clearGitHubToken() + emitTokenChange() onRepositoryChange?.(null) syncSavedTokenUi() setStatus('GitHub token removed', 'neutral') @@ -429,6 +440,7 @@ export const createGitHubByotControls = ({ controlsRoot?.removeAttribute('hidden') syncSavedTokenUi() + emitTokenChange() if (savedToken) { setTokenFieldToMasked({ locked: true }) @@ -436,6 +448,7 @@ export const createGitHubByotControls = ({ if (!result.ok) { savedToken = null clearGitHubToken() + emitTokenChange() syncSavedTokenUi() } }) diff --git a/src/modules/github-chat-drawer.js b/src/modules/github-chat-drawer.js new file mode 100644 index 0000000..70c2688 --- /dev/null +++ b/src/modules/github-chat-drawer.js @@ -0,0 +1,445 @@ +import { requestGitHubChatCompletion, streamGitHubChatCompletion } from './github-api.js' + +const toChatText = value => { + if (typeof value !== 'string') { + return '' + } + + return value.trim() +} + +const toRepositoryLabel = repository => { + if (!repository || typeof repository !== 'object') { + return 'No repository selected' + } + + if (typeof repository.fullName === 'string' && repository.fullName.trim()) { + return repository.fullName + } + + return 'No repository selected' +} + +const toRepositoryUrl = repository => { + if (!repository || typeof repository !== 'object') { + return '' + } + + if (typeof repository.htmlUrl === 'string' && repository.htmlUrl.trim()) { + return repository.htmlUrl + } + + if (typeof repository.fullName === 'string' && repository.fullName.trim()) { + return `https://github.com/${repository.fullName}` + } + + return '' +} + +export const createGitHubChatDrawer = ({ + featureEnabled, + toggleButton, + drawer, + closeButton, + promptInput, + sendButton, + clearButton, + statusNode, + rateNode, + repositoryNode, + messagesNode, + includeEditorsContextToggle, + getToken, + getSelectedRepository, + getComponentSource, + getStylesSource, + getRenderMode, + getStyleMode, + getDrawerSide, +}) => { + if (!featureEnabled) { + toggleButton?.setAttribute('hidden', '') + drawer?.setAttribute('hidden', '') + + return { + setOpen: () => {}, + isOpen: () => false, + setSelectedRepository: () => {}, + dispose: () => {}, + } + } + + let open = false + let pendingAbortController = null + const messages = [] + + const stopPendingRequest = () => { + pendingAbortController?.abort() + pendingAbortController = null + } + + const setOpen = nextOpen => { + open = nextOpen === true + + if (!toggleButton || !drawer) { + return + } + + const preferredSide = getDrawerSide?.() === 'left' ? 'left' : 'right' + drawer.classList.toggle('ai-chat-drawer--left', preferredSide === 'left') + drawer.classList.toggle('ai-chat-drawer--right', preferredSide !== 'left') + + toggleButton.setAttribute('aria-expanded', open ? 'true' : 'false') + drawer.toggleAttribute('hidden', !open) + + if (open && promptInput instanceof HTMLTextAreaElement) { + promptInput.focus() + } + } + + const setChatStatus = (text, level = 'neutral') => { + if (!statusNode) { + return + } + + statusNode.textContent = text + statusNode.dataset.level = level + } + + const formatResetTime = resetEpochSeconds => { + if (!Number.isFinite(resetEpochSeconds)) { + return '' + } + + const resetDate = new Date(Number(resetEpochSeconds) * 1000) + if (Number.isNaN(resetDate.getTime())) { + return '' + } + + return `${resetDate.toISOString().slice(11, 16)} UTC` + } + + const setRateMetadata = rateLimit => { + if (!rateNode) { + return + } + + const remaining = Number.isFinite(rateLimit?.remaining) ? rateLimit.remaining : null + const resetText = formatResetTime(rateLimit?.resetEpochSeconds) + + if (remaining === null) { + rateNode.textContent = 'Rate limit info unavailable' + return + } + + if (resetText) { + rateNode.textContent = `Remaining ${remaining}, resets ${resetText}` + return + } + + rateNode.textContent = `Remaining ${remaining}` + } + + const syncRepositoryLabel = () => { + if (!repositoryNode) { + return + } + + repositoryNode.textContent = toRepositoryLabel(getSelectedRepository?.()) + } + + const renderMessages = () => { + if (!messagesNode) { + return + } + + messagesNode.replaceChildren() + + if (messages.length === 0) { + const emptyNode = document.createElement('p') + emptyNode.className = 'ai-chat-empty' + emptyNode.textContent = 'Ask a question about your selected repository.' + messagesNode.append(emptyNode) + return + } + + for (const message of messages) { + const item = document.createElement('article') + item.className = `ai-chat-message ai-chat-message--${message.role}` + + const label = document.createElement('h3') + label.className = 'ai-chat-message__label' + label.textContent = message.role === 'assistant' ? 'Assistant' : 'You' + item.append(label) + + const body = document.createElement('p') + body.className = 'ai-chat-message__body' + body.textContent = message.content + item.append(body) + + if (message.level === 'error') { + item.classList.add('ai-chat-message--error') + } + + messagesNode.append(item) + } + + messagesNode.scrollTop = messagesNode.scrollHeight + } + + const appendMessage = message => { + messages.push(message) + renderMessages() + } + + const updateLastAssistantMessage = content => { + const lastMessage = messages[messages.length - 1] + if (!lastMessage || lastMessage.role !== 'assistant') { + return + } + + lastMessage.content = content + renderMessages() + } + + const collectConversation = () => { + return messages + .filter(message => message.role === 'user' || message.role === 'assistant') + .map(message => ({ + role: message.role, + content: message.content, + })) + } + + const collectRepositoryContext = () => { + const repository = getSelectedRepository?.() + + const repositoryLabel = toRepositoryLabel(repository) + const repositoryUrl = toRepositoryUrl(repository) + const defaultBranch = + repository && typeof repository.defaultBranch === 'string' + ? repository.defaultBranch + : 'unknown' + + const contextLines = [ + 'Selected repository context:', + `- Repository: ${repositoryLabel}`, + ...(repositoryUrl ? [`- Repository URL: ${repositoryUrl}`] : []), + `- Default branch: ${defaultBranch}`, + 'Use this repository as the default target for the user request unless they explicitly override it.', + ] + + return contextLines.join('\n') + } + + const collectEditorContext = () => { + if (!(includeEditorsContextToggle instanceof HTMLInputElement)) { + return null + } + + if (!includeEditorsContextToggle.checked) { + return null + } + + const componentSource = + typeof getComponentSource === 'function' ? toChatText(getComponentSource()) : '' + const stylesSource = + typeof getStylesSource === 'function' ? toChatText(getStylesSource()) : '' + + if (!componentSource && !stylesSource) { + return null + } + + const renderMode = + typeof getRenderMode === 'function' ? toChatText(getRenderMode()) : '' + const styleMode = typeof getStyleMode === 'function' ? toChatText(getStyleMode()) : '' + + return [ + 'Editor context:', + `- Render mode: ${renderMode || 'unknown'}`, + `- Style mode: ${styleMode || 'unknown'}`, + '', + 'Component editor source (JSX/TSX):', + '```jsx', + componentSource || '(empty)', + '```', + '', + 'Styles editor source:', + '```css', + stylesSource || '(empty)', + '```', + ].join('\n') + } + + const setPendingState = isPending => { + if (sendButton instanceof HTMLButtonElement) { + sendButton.disabled = isPending + } + + if (promptInput instanceof HTMLTextAreaElement) { + promptInput.disabled = isPending + } + } + + const runChatRequest = async () => { + const prompt = toChatText(promptInput?.value) + + if (!prompt) { + setChatStatus('Enter a prompt before sending.', 'error') + return + } + + const token = getToken?.() + if (!token) { + setChatStatus('Add a GitHub token before starting chat.', 'error') + return + } + + const repository = getSelectedRepository?.() + if (!repository?.fullName) { + setChatStatus('Select a writable repository before starting chat.', 'error') + return + } + + stopPendingRequest() + pendingAbortController = new AbortController() + + appendMessage({ role: 'user', content: prompt }) + appendMessage({ role: 'assistant', content: '' }) + if (promptInput instanceof HTMLTextAreaElement) { + promptInput.value = '' + } + + setPendingState(true) + setChatStatus('Streaming response from GitHub...', 'pending') + + const repositoryContext = collectRepositoryContext() + const editorContext = collectEditorContext() + const outboundMessages = [ + { role: 'system', content: repositoryContext }, + ...(editorContext ? [{ role: 'system', content: editorContext }] : []), + ...collectConversation(), + ] + + let streamedContent = '' + let streamSucceeded = false + + try { + const streamResult = await streamGitHubChatCompletion({ + token, + messages: outboundMessages, + signal: pendingAbortController.signal, + onToken: tokenChunk => { + streamedContent += tokenChunk + updateLastAssistantMessage(streamedContent) + }, + }) + + streamSucceeded = true + setChatStatus('Response streamed from GitHub.', 'ok') + setRateMetadata(streamResult?.rateLimit) + } catch (streamError) { + setRateMetadata(streamError?.rateLimit) + if (pendingAbortController.signal.aborted) { + setChatStatus('Chat request canceled.', 'neutral') + pendingAbortController = null + setPendingState(false) + return + } + + setChatStatus( + 'Streaming unavailable. Retrying with fallback response...', + 'pending', + ) + } + + if (streamSucceeded) { + pendingAbortController = null + setPendingState(false) + return + } + + try { + const fallbackResult = await requestGitHubChatCompletion({ + token, + messages: outboundMessages, + signal: pendingAbortController.signal, + }) + + updateLastAssistantMessage(fallbackResult.content) + setChatStatus('Fallback response loaded.', 'ok') + setRateMetadata(fallbackResult.rateLimit) + } catch (fallbackError) { + const fallbackMessage = + fallbackError instanceof Error ? fallbackError.message : 'Chat request failed.' + + setRateMetadata(fallbackError?.rateLimit) + + updateLastAssistantMessage(fallbackMessage) + const lastMessage = messages[messages.length - 1] + if (lastMessage) { + lastMessage.level = 'error' + } + renderMessages() + setChatStatus(`Chat request failed: ${fallbackMessage}`, 'error') + } finally { + pendingAbortController = null + setPendingState(false) + } + } + + toggleButton?.setAttribute('aria-expanded', 'false') + drawer?.setAttribute('hidden', '') + syncRepositoryLabel() + renderMessages() + setChatStatus('Idle', 'neutral') + setRateMetadata(null) + + toggleButton?.addEventListener('click', () => { + setOpen(!open) + }) + + closeButton?.addEventListener('click', () => { + setOpen(false) + }) + + clearButton?.addEventListener('click', () => { + messages.length = 0 + renderMessages() + setChatStatus('Chat cleared.', 'neutral') + }) + + sendButton?.addEventListener('click', () => { + void runChatRequest() + }) + + promptInput?.addEventListener('keydown', event => { + if (event.key !== 'Enter' || (!event.metaKey && !event.ctrlKey)) { + return + } + + event.preventDefault() + void runChatRequest() + }) + + const onDocumentKeydown = event => { + if (event.key === 'Escape' && open) { + setOpen(false) + } + } + + document.addEventListener('keydown', onDocumentKeydown) + + return { + setOpen, + isOpen: () => open, + setSelectedRepository: () => { + syncRepositoryLabel() + }, + dispose: () => { + stopPendingRequest() + setPendingState(false) + document.removeEventListener('keydown', onDocumentKeydown) + }, + } +} diff --git a/src/styles/ai-controls.css b/src/styles/ai-controls.css index 56a82b3..69f24a3 100644 --- a/src/styles/ai-controls.css +++ b/src/styles/ai-controls.css @@ -160,6 +160,218 @@ color: var(--select-option-disabled); } +.ai-chat-toggle { + margin-left: 2px; + display: inline-flex; + align-items: center; + gap: 6px; +} + +.ai-chat-toggle[hidden] { + display: none !important; +} + +.ai-chat-toggle__emoji--light { + display: none; +} + +.ai-chat-toggle__emoji { + font-size: 1.25em; + line-height: 1; +} + +:root[data-theme='light'] .ai-chat-toggle__emoji--dark { + display: none; +} + +:root[data-theme='light'] .ai-chat-toggle__emoji--light { + display: inline; +} + +.ai-chat-drawer { + position: fixed; + top: 82px; + left: auto; + right: 18px; + width: min(560px, calc(100vw - 24px)); + max-height: min(78vh, 820px); + padding: 14px; + border: 1px solid var(--border-subtle); + border-radius: 14px; + background: color-mix(in srgb, var(--surface-panel) 92%, transparent); + box-shadow: 0 20px 40px var(--shadow-elev-1); + backdrop-filter: blur(8px); + overflow: hidden; + display: grid; + grid-template-rows: auto auto auto minmax(120px, 1fr) auto auto; + gap: 10px; + z-index: 95; +} + +.ai-chat-drawer.ai-chat-drawer--right { + right: 18px; + left: auto; +} + +.ai-chat-drawer.ai-chat-drawer--left { + left: 18px; + right: auto; +} + +.ai-chat-drawer[hidden] { + display: none !important; +} + +.ai-chat-drawer__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.ai-chat-drawer__header h2 { + margin: 0; + font-size: 1rem; +} + +.ai-chat-drawer__meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + border-top: 1px solid var(--border-subtle); + border-bottom: 1px solid var(--border-subtle); + padding: 8px 0; +} + +.ai-chat-drawer__repo, +.ai-chat-drawer__status { + margin: 0; + font-size: 0.78rem; +} + +.ai-chat-drawer__repo { + color: var(--text-muted); + max-width: 66%; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +} + +.ai-chat-drawer__status { + color: var(--text-subtle); + text-align: right; +} + +.ai-chat-drawer__status[data-level='pending'] { + color: color-mix(in srgb, var(--panel-text) 72%, var(--accent)); +} + +.ai-chat-drawer__status[data-level='ok'] { + color: color-mix(in srgb, #22c55e 80%, var(--panel-text)); +} + +.ai-chat-drawer__status[data-level='error'] { + color: color-mix(in srgb, rgb(var(--danger-rgb)) 85%, var(--panel-text)); +} + +.ai-chat-drawer__rate { + margin: -2px 0 0; + font-size: 0.74rem; + color: var(--text-muted); + align-self: start; +} + +.ai-chat-messages { + border: 1px solid var(--border-subtle); + border-radius: 10px; + padding: 10px; + overflow: auto; + min-height: 140px; + background: color-mix(in srgb, var(--surface-panel-header) 72%, transparent); +} + +.ai-chat-empty { + margin: 0; + color: var(--text-muted); + font-size: 0.84rem; +} + +.ai-chat-message { + margin: 0; + padding: 8px 9px; + border-radius: 8px; + background: color-mix(in srgb, var(--surface-control) 80%, transparent); +} + +.ai-chat-message + .ai-chat-message { + margin-top: 8px; +} + +.ai-chat-message--assistant { + border: 1px solid color-mix(in srgb, var(--accent) 30%, var(--border-subtle)); +} + +.ai-chat-message--user { + border: 1px solid var(--border-subtle); +} + +.ai-chat-message--error { + border-color: color-mix(in srgb, rgb(var(--danger-rgb)) 50%, var(--border-subtle)); +} + +.ai-chat-message__label { + margin: 0; + font-size: 0.72rem; + letter-spacing: 0.02em; + text-transform: uppercase; + color: var(--text-muted); +} + +.ai-chat-message__body { + margin: 4px 0 0; + font-size: 0.85rem; + white-space: pre-wrap; + word-break: break-word; +} + +.ai-chat-prompt { + resize: vertical; + min-height: 72px; + max-height: 220px; + border: 1px solid var(--border-control); + border-radius: 10px; + background: color-mix(in srgb, var(--surface-select) 82%, transparent); + color: var(--panel-text); + padding: 10px; + font-size: 0.86rem; + font-family: inherit; +} + +.ai-chat-prompt:focus-visible { + outline: 2px solid var(--focus-ring); + outline-offset: 1px; +} + +.ai-chat-drawer__actions { + display: flex; + gap: 8px; +} + +.ai-chat-context-toggle { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 0.8rem; + color: var(--text-muted); +} + +.ai-chat-context-toggle input[type='checkbox'] { + width: 14px; + height: 14px; + accent-color: var(--accent); +} + @media (max-width: 900px) { .app-grid-ai-controls { margin-right: 8px; @@ -178,4 +390,22 @@ min-width: min(84vw, 320px); max-width: min(84vw, 320px); } + + .ai-chat-drawer { + top: auto; + right: 12px; + left: 12px; + bottom: 12px; + width: auto; + max-height: 68vh; + } + + .ai-chat-drawer__meta { + flex-direction: column; + align-items: flex-start; + } + + .ai-chat-drawer__repo { + max-width: 100%; + } } From 287a4b552c5508845e173ff0c6465bc10effee11 Mon Sep 17 00:00:00 2001 From: KCM Date: Sat, 21 Mar 2026 14:56:38 -0500 Subject: [PATCH 2/4] refactor: responsiveness. --- src/app.js | 146 +++++++++++++++++++++++++- src/index.html | 202 +++++++++++++++++++++--------------- src/styles/ai-controls.css | 71 +++++++++++-- src/styles/diagnostics.css | 8 ++ src/styles/layout-shell.css | 112 +++++++++++++++++--- 5 files changed, 433 insertions(+), 106 deletions(-) diff --git a/src/app.js b/src/app.js index 813dd51..18c3f5d 100644 --- a/src/app.js +++ b/src/app.js @@ -36,6 +36,9 @@ const aiChatStatus = document.getElementById('ai-chat-status') const aiChatRate = document.getElementById('ai-chat-rate') const aiChatRepository = document.getElementById('ai-chat-repository') const aiChatMessages = document.getElementById('ai-chat-messages') +const viewControlsToggle = document.getElementById('view-controls-toggle') +const viewControlsDrawer = document.getElementById('view-controls-drawer') +const aiControlsToggle = document.getElementById('ai-controls-toggle') const appGridLayoutButtons = document.querySelectorAll('[data-app-grid-layout]') const appThemeButtons = document.querySelectorAll('[data-app-theme]') const editorToolsButtons = document.querySelectorAll('[data-editor-tools-toggle]') @@ -84,6 +87,7 @@ let pendingClearAction = null let suppressEditorChangeSideEffects = false let hasAppliedReactModeDefault = false const clipboardSupported = Boolean(navigator.clipboard?.writeText) +const aiAssistantFeatureEnabled = isAiAssistantFeatureEnabled() const previewBackground = createPreviewBackgroundController({ previewBgColorInput, @@ -102,6 +106,66 @@ const { applyAppGridLayout, applyTheme, getInitialAppGridLayout, getInitialTheme layoutTheme const compactViewportMediaQuery = window.matchMedia('(max-width: 900px)') +const stackedRailMediaQuery = window.matchMedia('(max-width: 1090px)') +let stackedRailViewControlsOpen = false +let compactAiControlsOpen = false + +const isStackedRailViewport = () => stackedRailMediaQuery.matches + +const setStackedRailViewControlsOpen = isOpen => { + if (!(viewControlsToggle instanceof HTMLButtonElement) || !viewControlsDrawer) { + return + } + + if (!isStackedRailViewport()) { + stackedRailViewControlsOpen = false + viewControlsToggle.setAttribute('aria-expanded', 'false') + viewControlsDrawer.removeAttribute('hidden') + return + } + + stackedRailViewControlsOpen = Boolean(isOpen) + viewControlsToggle.setAttribute( + 'aria-expanded', + stackedRailViewControlsOpen ? 'true' : 'false', + ) + + if (stackedRailViewControlsOpen) { + viewControlsDrawer.removeAttribute('hidden') + return + } + + viewControlsDrawer.setAttribute('hidden', '') +} + +const setCompactAiControlsOpen = isOpen => { + if (!(aiControlsToggle instanceof HTMLButtonElement) || !githubAiControls) { + return + } + + if (!aiAssistantFeatureEnabled) { + compactAiControlsOpen = false + aiControlsToggle.setAttribute('hidden', '') + aiControlsToggle.setAttribute('aria-expanded', 'false') + githubAiControls.removeAttribute('data-compact-open') + githubAiControls.setAttribute('hidden', '') + return + } + + aiControlsToggle.removeAttribute('hidden') + + if (!isCompactViewport()) { + compactAiControlsOpen = false + aiControlsToggle.setAttribute('aria-expanded', 'false') + githubAiControls.removeAttribute('data-compact-open') + githubAiControls.removeAttribute('hidden') + return + } + + compactAiControlsOpen = Boolean(isOpen) + aiControlsToggle.setAttribute('aria-expanded', compactAiControlsOpen ? 'true' : 'false') + githubAiControls.dataset.compactOpen = compactAiControlsOpen ? 'true' : 'false' +} const getCurrentLayout = () => { if (appGrid.classList.contains('app-grid--preview-right')) { @@ -405,8 +469,6 @@ const { updateUiIssueIndicators, } = diagnosticsUi -const aiAssistantFeatureEnabled = isAiAssistantFeatureEnabled() - const githubAiContextState = { token: null, selectedRepository: null, @@ -1074,6 +1136,10 @@ for (const button of appGridLayoutButtons) { } applyAppGridLayout(nextLayout) applyPanelCollapseState() + + if (isStackedRailViewport()) { + setStackedRailViewControlsOpen(false) + } }) } @@ -1084,9 +1150,72 @@ for (const button of appThemeButtons) { return } applyTheme(nextTheme) + + if (isStackedRailViewport()) { + setStackedRailViewControlsOpen(false) + } }) } +if (viewControlsToggle instanceof HTMLButtonElement) { + viewControlsToggle.addEventListener('click', () => { + if (!isStackedRailViewport()) { + return + } + + if (isCompactViewport()) { + setCompactAiControlsOpen(false) + } + + setStackedRailViewControlsOpen(!stackedRailViewControlsOpen) + }) +} + +if (aiControlsToggle instanceof HTMLButtonElement) { + aiControlsToggle.addEventListener('click', () => { + if (!isCompactViewport()) { + return + } + + setStackedRailViewControlsOpen(false) + setCompactAiControlsOpen(!compactAiControlsOpen) + }) +} + +document.addEventListener('click', event => { + const clickTarget = event.target + if (!(clickTarget instanceof Node)) { + return + } + + if (isStackedRailViewport() && stackedRailViewControlsOpen) { + if ( + !viewControlsDrawer?.contains(clickTarget) && + !viewControlsToggle?.contains(clickTarget) + ) { + setStackedRailViewControlsOpen(false) + } + } + + if (isCompactViewport() && compactAiControlsOpen) { + if ( + !githubAiControls.contains(clickTarget) && + !aiControlsToggle?.contains(clickTarget) + ) { + setCompactAiControlsOpen(false) + } + } +}) + +document.addEventListener('keydown', event => { + if (event.key !== 'Escape') { + return + } + + setStackedRailViewControlsOpen(false) + setCompactAiControlsOpen(false) +}) + for (const button of editorToolsButtons) { button.addEventListener('click', () => { const panelName = button.dataset.editorToolsToggle @@ -1112,6 +1241,11 @@ for (const button of panelCollapseButtons) { const handleCompactViewportChange = () => { applyPanelCollapseState() + setCompactAiControlsOpen(false) +} + +const handleStackedRailViewportChange = () => { + setStackedRailViewControlsOpen(false) } if (typeof compactViewportMediaQuery.addEventListener === 'function') { @@ -1120,6 +1254,12 @@ if (typeof compactViewportMediaQuery.addEventListener === 'function') { compactViewportMediaQuery.onchange = handleCompactViewportChange } +if (typeof stackedRailMediaQuery.addEventListener === 'function') { + stackedRailMediaQuery.addEventListener('change', handleStackedRailViewportChange) +} else { + stackedRailMediaQuery.onchange = handleStackedRailViewportChange +} + window.addEventListener('beforeunload', () => { clearComponentLintRecheckTimer() clearStylesLintRecheckTimer() @@ -1131,6 +1271,8 @@ applyAppGridLayout(getInitialAppGridLayout(), { persist: false }) applyTheme(getInitialTheme(), { persist: false }) applyEditorToolsVisibility() applyPanelCollapseState() +setStackedRailViewControlsOpen(false) +setCompactAiControlsOpen(false) syncAiChatTokenVisibility(githubAiContextState.token) updateRenderButtonVisibility() diff --git a/src/index.html b/src/index.html index 22c49d7..3a8159e 100644 --- a/src/index.html +++ b/src/index.html @@ -126,100 +126,136 @@

-
- -
- - - - + + -
+
+ +
+
+ + + +
+ +
+ + +
+
diff --git a/src/styles/ai-controls.css b/src/styles/ai-controls.css index 69f24a3..75ecbb7 100644 --- a/src/styles/ai-controls.css +++ b/src/styles/ai-controls.css @@ -372,23 +372,78 @@ accent-color: var(--accent); } +@media (max-width: 1090px) { + .app-grid-layout-controls { + flex-wrap: nowrap; + } + + .github-token-input { + width: 136px; + } + + .github-repo-wrap select { + min-width: 220px; + max-width: 260px; + } + + .app-grid-ai-controls { + margin-right: 0; + padding-right: 0; + border-right: none; + } +} + @media (max-width: 900px) { + .app-grid-layout-controls { + flex-wrap: nowrap; + } + .app-grid-ai-controls { - margin-right: 8px; - padding-right: 8px; - border-right: 1px solid var(--border-subtle); - width: 100%; + position: absolute; + top: calc(100% + 10px); + left: 12px; + right: 12px; + display: none; + margin-right: 0; + padding-right: 0; + border-right: none; + width: auto; + max-width: none; justify-content: flex-start; - order: 3; + flex-wrap: nowrap; + border: 1px solid var(--border-subtle); + border-radius: 12px; + padding: 10px 12px; + background: color-mix(in srgb, var(--surface-panel-header) 88%, transparent); + box-shadow: 0 14px 30px var(--shadow-elev-1); + backdrop-filter: blur(6px); + overflow-x: auto; + overflow-y: hidden; + z-index: 60; + } + + .github-token-info.shadow-hint::after { + position: fixed; + top: auto; + right: 12px; + left: 12px; + bottom: 12px; + width: auto; + max-width: none; + z-index: 120; + } + + .app-grid-ai-controls[data-compact-open='true'] { + display: inline-flex; } .github-token-input { - width: min(56vw, 220px); + width: min(34vw, 180px); } .github-repo-wrap select { - min-width: min(84vw, 320px); - max-width: min(84vw, 320px); + min-width: min(58vw, 260px); + max-width: min(58vw, 260px); } .ai-chat-drawer { diff --git a/src/styles/diagnostics.css b/src/styles/diagnostics.css index ee8e1e2..b8c17f0 100644 --- a/src/styles/diagnostics.css +++ b/src/styles/diagnostics.css @@ -158,6 +158,14 @@ z-index: 90; } +@media (max-width: 1090px) { + .app-grid-diagnostics-controls { + margin-right: 0; + padding-right: 0; + border-right: none; + } +} + .diagnostics-drawer__header { display: flex; align-items: center; diff --git a/src/styles/layout-shell.css b/src/styles/layout-shell.css index 4022e3d..51a59a4 100644 --- a/src/styles/layout-shell.css +++ b/src/styles/layout-shell.css @@ -216,6 +216,33 @@ align-items: center; justify-self: end; gap: 10px; + position: relative; +} + +.app-grid-view-controls { + display: inline-flex; + align-items: center; + gap: 10px; +} + +.app-grid-layout-top-controls { + display: inline-flex; + align-items: center; + gap: 10px; +} + +.app-grid-layout-toggle-controls { + display: inline-flex; + align-items: center; + gap: 10px; +} + +.app-grid-view-toggle { + display: none; +} + +.app-grid-ai-toggle { + display: none; } .app-grid-theme-controls { @@ -234,6 +261,74 @@ border-right: 1px solid var(--border-subtle); } +@media (max-width: 1090px) { + .app-grid-layout-controls { + display: inline-flex; + flex-direction: row; + align-items: center; + justify-content: flex-end; + justify-self: end; + flex-wrap: nowrap; + gap: 10px; + width: auto; + } + + .app-grid-layout-top-controls { + justify-content: flex-end; + flex-wrap: nowrap; + } + + .app-grid-diagnostics-controls { + margin-right: 0; + padding-right: 0; + border-right: none; + } + + .app-grid-view-toggle { + display: inline-flex; + } + + .app-grid-view-controls { + position: absolute; + top: calc(100% + 10px); + right: 0; + display: grid; + grid-template-columns: max-content max-content; + align-items: center; + gap: 10px; + padding: 10px 12px; + border: 1px solid var(--border-subtle); + border-radius: 12px; + background: color-mix(in srgb, var(--surface-panel-header) 88%, transparent); + box-shadow: 0 14px 30px var(--shadow-elev-1); + backdrop-filter: blur(6px); + z-index: 60; + } + + .app-grid-view-controls[hidden] { + display: none !important; + } + + .app-grid-theme-controls { + margin-left: 0; + padding-left: 10px; + } + + .app-grid-ai-controls { + justify-self: auto; + align-self: auto; + margin-right: 0; + padding-right: 0; + border-right: none; + } +} + +@media (max-width: 900px) { + .app-grid-ai-toggle { + display: inline-flex; + } +} + .layout-toggle { display: inline-grid; place-content: center; @@ -388,18 +483,9 @@ } .app-grid-layout-controls { - justify-self: start; - } - - .app-grid-theme-controls { - margin-left: 0; - padding-left: 0; - border-left: none; - } - - .app-grid-diagnostics-controls { - margin-right: 0; - padding-right: 0; - border-right: none; + display: flex; + justify-self: stretch; + justify-content: flex-end; + width: 100%; } } From 2581515637da8b1cff0a47c0046c0117d4b56472 Mon Sep 17 00:00:00 2001 From: KCM Date: Sat, 21 Mar 2026 15:08:07 -0500 Subject: [PATCH 3/4] docs: next-steps. --- docs/next-steps.md | 17 +++++++++-------- src/styles/layout-shell.css | 2 +- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/docs/next-steps.md b/docs/next-steps.md index fd76952..de4148e 100644 --- a/docs/next-steps.md +++ b/docs/next-steps.md @@ -19,18 +19,19 @@ Focused follow-up work for `@knighted/develop`. - Suggested implementation prompt: - "Add a deterministic E2E execution mode for `@knighted/develop` that serves pinned runtime artifacts locally (instead of live CDN fetches) and wire it into CI as a required check on every PR. Keep a separate lightweight CDN-smoke E2E check for real-network coverage. Validate with `npm run lint`, deterministic Playwright PR checks, and one CDN-smoke Playwright run." -4. **Issue #18 continuation (resume from Phase 2)** - - Continue the GitHub AI assistant rollout after completed Phases 0-1: +4. **Issue #18 continuation (resume from Phase 3)** + - Current rollout status: - Phase 0 complete: feature flag + scaffolding. - Phase 1 complete: BYOT token flow, localStorage persistence, writable repo discovery/filtering. - - Implement the next slice first: - - Phase 2: chat drawer UX with streaming responses first, plus non-streaming fallback. - - Add selected repository state plumbing now so Phase 4 (PR write flow) can reuse it. - - Add README documentation for fine-grained PAT setup (reuse existing screenshots referenced in docs/byot.md). + - Phase 2 complete: separate AI chat drawer UX, streaming-first responses with non-stream fallback, selected repository context plumbing, and README fine-grained PAT setup links. + - Implement the next slice first (Phase 3): + - Add mode-aware recommendation behavior so the assistant strongly adapts suggestions to current render mode and style mode. + - Add an editor update workflow where the assistant can propose structured edits and the user can apply to Component and Styles editors with explicit confirmation. + - Add filename groundwork for upcoming PR flows by allowing user-defined Component and Styles file names, persisted per selected repository. - Keep behavior and constraints aligned with current implementation: - Keep everything behind the existing browser-only AI feature flag. - Preserve BYOT token semantics (localStorage persistence until user deletes). - Keep CDN-first runtime behavior and existing fallback model. - Do not add dependencies without explicit approval. - - Suggested implementation prompt: - - "Continue Issue #18 in @knighted/develop from the current Phase 1 baseline. Implement Phase 2 by adding a separate AI chat drawer with streaming response rendering (primary) and a non-streaming fallback path. Wire selected repository state as shared app state for upcoming Phase 4 PR actions. Update README with a concise fine-grained PAT setup section that links to existing BYOT screenshot assets/docs. Keep all AI/BYOT UI and behavior behind the existing browser-only feature flag, preserve current token persistence and repo filtering behavior, and validate with npm run lint plus targeted Playwright coverage for chat drawer visibility, streaming/fallback behavior, and repo-context selection plumbing." + - Phase 3 mini-spec (agent implementation prompt): + - "Continue Issue #18 in @knighted/develop from the current Phase 2 baseline. Implement Phase 3 with three deliverables. (1) Add mode-aware assistant guidance: when collecting AI context, include explicit policy hints derived from render mode and style mode, and ensure recommendations avoid incompatible patterns (for example, avoid React hook/state guidance in DOM mode unless user explicitly asks for React migration). (2) Add assistant-to-editor apply flow: support structured assistant responses that can propose edits for component and/or styles editors; render these as reviewable actions in the chat drawer, require explicit user confirmation to apply, and support a one-step undo for last applied assistant edit per editor. (3) Add PR-prep filename metadata: introduce user-editable fields for Component filename and Styles filename in AI controls, validate simple safe filename format, and persist/reload values scoped to selected repository so Phase 4 PR write flow can reuse them. Keep all AI/BYOT behavior behind the existing browser-only AI feature flag and preserve current token/repo persistence semantics. Do not add dependencies. Validate with npm run lint and targeted Playwright tests covering: mode-aware recommendation constraints, apply/undo editor actions, and repository-scoped filename persistence." diff --git a/src/styles/layout-shell.css b/src/styles/layout-shell.css index 51a59a4..5bfd045 100644 --- a/src/styles/layout-shell.css +++ b/src/styles/layout-shell.css @@ -7,7 +7,7 @@ background: var(--surface-app-header); position: sticky; top: 0; - z-index: 10; + z-index: 200; backdrop-filter: blur(8px); } From da2ec56476e104ffa4ac0e6ee5d5178dd9fc1ab7 Mon Sep 17 00:00:00 2001 From: KCM Date: Sat, 21 Mar 2026 15:24:02 -0500 Subject: [PATCH 4/4] refactor: address comments. --- src/index.html | 1 + src/modules/github-chat-drawer.js | 105 ++++++++++++++++++++++++++---- src/styles/diagnostics.css | 8 --- 3 files changed, 94 insertions(+), 20 deletions(-) diff --git a/src/index.html b/src/index.html index 3a8159e..4bad76f 100644 --- a/src/index.html +++ b/src/index.html @@ -170,6 +170,7 @@

id="view-controls-drawer" role="group" aria-label="Layout and theme controls" + hidden >