Skip to content

Commit 1c8bc89

Browse files
feat: ai chat drawer. (#22)
1 parent f38a0dc commit 1c8bc89

10 files changed

Lines changed: 1934 additions & 118 deletions

File tree

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,14 @@ browser acts as the runtime host for render, lint, and typecheck flows.
4141

4242
- GitHub PAT setup and usage: [docs/byot.md](docs/byot.md)
4343

44+
## Fine-Grained PAT Quick Setup
45+
46+
For AI/BYOT flows, use a fine-grained GitHub PAT and follow the existing setup guide:
47+
48+
- Full setup and behavior: [docs/byot.md](docs/byot.md)
49+
- Repository permissions screenshot: [docs/media/byot-repo-perms.png](docs/media/byot-repo-perms.png)
50+
- Models permission screenshot: [docs/media/byot-model-perms.png](docs/media/byot-model-perms.png)
51+
4452
## License
4553

4654
MIT

docs/next-steps.md

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,18 +19,19 @@ Focused follow-up work for `@knighted/develop`.
1919
- Suggested implementation prompt:
2020
- "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."
2121

22-
4. **Issue #18 continuation (resume from Phase 2)**
23-
- Continue the GitHub AI assistant rollout after completed Phases 0-1:
22+
4. **Issue #18 continuation (resume from Phase 3)**
23+
- Current rollout status:
2424
- Phase 0 complete: feature flag + scaffolding.
2525
- Phase 1 complete: BYOT token flow, localStorage persistence, writable repo discovery/filtering.
26-
- Implement the next slice first:
27-
- Phase 2: chat drawer UX with streaming responses first, plus non-streaming fallback.
28-
- Add selected repository state plumbing now so Phase 4 (PR write flow) can reuse it.
29-
- Add README documentation for fine-grained PAT setup (reuse existing screenshots referenced in docs/byot.md).
26+
- 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.
27+
- Implement the next slice first (Phase 3):
28+
- Add mode-aware recommendation behavior so the assistant strongly adapts suggestions to current render mode and style mode.
29+
- 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.
30+
- Add filename groundwork for upcoming PR flows by allowing user-defined Component and Styles file names, persisted per selected repository.
3031
- Keep behavior and constraints aligned with current implementation:
3132
- Keep everything behind the existing browser-only AI feature flag.
3233
- Preserve BYOT token semantics (localStorage persistence until user deletes).
3334
- Keep CDN-first runtime behavior and existing fallback model.
3435
- Do not add dependencies without explicit approval.
35-
- Suggested implementation prompt:
36-
- "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."
36+
- Phase 3 mini-spec (agent implementation prompt):
37+
- "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."

playwright/app.spec.ts

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,16 @@ import type { Page } from '@playwright/test'
44
const webServerMode = process.env.PLAYWRIGHT_WEB_SERVER_MODE ?? 'dev'
55
const appEntryPath = webServerMode === 'preview' ? '/index.html' : '/src/index.html'
66

7+
type ChatRequestMessage = {
8+
role?: string
9+
content?: string
10+
}
11+
12+
type ChatRequestBody = {
13+
metadata?: unknown
14+
messages?: ChatRequestMessage[]
15+
}
16+
717
const waitForAppReady = async (page: Page, path = appEntryPath) => {
818
await page.goto(path)
919
await expect(page.getByRole('heading', { name: '@knighted/develop' })).toBeVisible()
@@ -100,6 +110,42 @@ const ensureDiagnosticsDrawerClosed = async (page: Page) => {
100110
await expect(page.locator('#diagnostics-drawer')).toBeHidden()
101111
}
102112

113+
const ensureAiChatDrawerOpen = async (page: Page) => {
114+
const toggle = page.locator('#ai-chat-toggle')
115+
const isExpanded = await toggle.getAttribute('aria-expanded')
116+
117+
if (isExpanded !== 'true') {
118+
await toggle.click()
119+
}
120+
121+
await expect(page.locator('#ai-chat-drawer')).toBeVisible()
122+
}
123+
124+
const connectByotWithSingleRepo = async (page: Page) => {
125+
await page.route('https://api.github.com/user/repos**', async route => {
126+
await route.fulfill({
127+
status: 200,
128+
contentType: 'application/json',
129+
body: JSON.stringify([
130+
{
131+
id: 11,
132+
owner: { login: 'knightedcodemonkey' },
133+
name: 'develop',
134+
full_name: 'knightedcodemonkey/develop',
135+
default_branch: 'main',
136+
permissions: { push: true },
137+
},
138+
]),
139+
})
140+
})
141+
142+
await page.locator('#github-token-input').fill('github_pat_fake_chat_1234567890')
143+
await page.locator('#github-token-add').click()
144+
await expect(page.locator('#github-repo-select')).toHaveValue(
145+
'knightedcodemonkey/develop',
146+
)
147+
}
148+
103149
const expectCollapseButtonState = async (
104150
page: Page,
105151
panelName: 'component' | 'styles' | 'preview',
@@ -136,6 +182,8 @@ test('BYOT controls stay hidden when feature flag is disabled', async ({ page })
136182
const byotControls = page.locator('#github-ai-controls')
137183
await expect(byotControls).toHaveAttribute('hidden', '')
138184
await expect(byotControls).toBeHidden()
185+
await expect(page.locator('#ai-chat-toggle')).toBeHidden()
186+
await expect(page.locator('#ai-chat-drawer')).toBeHidden()
139187
})
140188

141189
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 (
147195
await expect(byotControls).toBeVisible()
148196
await expect(page.locator('#github-token-input')).toBeVisible()
149197
await expect(page.locator('#github-token-add')).toBeVisible()
198+
await expect(page.locator('#github-ai-controls #ai-chat-toggle')).toBeHidden()
199+
})
200+
201+
test('AI chat drawer opens and closes when feature flag is enabled', async ({ page }) => {
202+
await waitForAppReady(page, `${appEntryPath}?feature-ai=true`)
203+
await connectByotWithSingleRepo(page)
204+
205+
const chatToggle = page.locator('#ai-chat-toggle')
206+
const chatDrawer = page.locator('#ai-chat-drawer')
207+
208+
await expect(chatToggle).toBeVisible()
209+
await expect(chatToggle).toHaveAttribute('aria-expanded', 'false')
210+
211+
await chatToggle.click()
212+
await expect(chatDrawer).toBeVisible()
213+
await expect(chatToggle).toHaveAttribute('aria-expanded', 'true')
214+
215+
await page.locator('#ai-chat-close').click()
216+
await expect(chatDrawer).toBeHidden()
217+
await expect(chatToggle).toHaveAttribute('aria-expanded', 'false')
218+
})
219+
220+
test('AI chat prefers streaming responses when available', async ({ page }) => {
221+
let streamRequestBody: ChatRequestBody | undefined
222+
223+
await page.route('https://models.github.ai/inference/chat/completions', async route => {
224+
streamRequestBody = route.request().postDataJSON() as ChatRequestBody
225+
226+
await route.fulfill({
227+
status: 200,
228+
contentType: 'text/event-stream',
229+
body: [
230+
'data: {"choices":[{"delta":{"content":"Streaming "}}]}',
231+
'',
232+
'data: {"choices":[{"delta":{"content":"response ready"}}]}',
233+
'',
234+
'data: [DONE]',
235+
'',
236+
].join('\n'),
237+
})
238+
})
239+
240+
await waitForAppReady(page, `${appEntryPath}?feature-ai=true`)
241+
await connectByotWithSingleRepo(page)
242+
await ensureAiChatDrawerOpen(page)
243+
244+
await page.locator('#ai-chat-prompt').fill('Summarize this repository.')
245+
await page.locator('#ai-chat-send').click()
246+
247+
await expect(page.locator('#ai-chat-status')).toHaveText(
248+
'Response streamed from GitHub.',
249+
)
250+
await expect(page.locator('#ai-chat-rate')).toHaveText('Rate limit info unavailable')
251+
await expect(page.locator('#ai-chat-messages')).toContainText(
252+
'Summarize this repository.',
253+
)
254+
await expect(page.locator('#ai-chat-messages')).toContainText(
255+
'Streaming response ready',
256+
)
257+
258+
expect(streamRequestBody?.metadata).toBeUndefined()
259+
const systemMessage = streamRequestBody?.messages?.find(
260+
(message: ChatRequestMessage) => message.role === 'system',
261+
)
262+
const systemMessages = streamRequestBody?.messages?.filter(
263+
(message: ChatRequestMessage) => message.role === 'system',
264+
)
265+
expect(systemMessage?.content).toContain('Selected repository context')
266+
expect(systemMessage?.content).toContain('Repository: knightedcodemonkey/develop')
267+
expect(systemMessage?.content).toContain(
268+
'Repository URL: https://github.com/knightedcodemonkey/develop',
269+
)
270+
expect(
271+
systemMessages?.some((message: ChatRequestMessage) =>
272+
message.content?.includes('Editor context:'),
273+
),
274+
).toBe(true)
275+
})
276+
277+
test('AI chat can disable editor context payload via checkbox', async ({ page }) => {
278+
let streamRequestBody: ChatRequestBody | undefined
279+
280+
await page.route('https://models.github.ai/inference/chat/completions', async route => {
281+
streamRequestBody = route.request().postDataJSON() as ChatRequestBody
282+
283+
await route.fulfill({
284+
status: 200,
285+
contentType: 'text/event-stream',
286+
body: [
287+
'data: {"choices":[{"delta":{"content":"ok"}}]}',
288+
'',
289+
'data: [DONE]',
290+
'',
291+
].join('\n'),
292+
})
293+
})
294+
295+
await waitForAppReady(page, `${appEntryPath}?feature-ai=true`)
296+
await connectByotWithSingleRepo(page)
297+
await ensureAiChatDrawerOpen(page)
298+
299+
const includeEditorsToggle = page.locator('#ai-chat-include-editors')
300+
await expect(includeEditorsToggle).toBeChecked()
301+
await includeEditorsToggle.uncheck()
302+
303+
await page.locator('#ai-chat-prompt').fill('No editor source this time.')
304+
await page.locator('#ai-chat-send').click()
305+
await expect(page.locator('#ai-chat-status')).toHaveText(
306+
'Response streamed from GitHub.',
307+
)
308+
await expect(page.locator('#ai-chat-rate')).toHaveText('Rate limit info unavailable')
309+
310+
expect(streamRequestBody?.metadata).toBeUndefined()
311+
const systemMessages = streamRequestBody?.messages?.filter(
312+
(message: ChatRequestMessage) => message.role === 'system',
313+
)
314+
expect(
315+
systemMessages?.some((message: ChatRequestMessage) =>
316+
message.content?.includes('Selected repository context'),
317+
),
318+
).toBe(true)
319+
expect(
320+
systemMessages?.some((message: ChatRequestMessage) =>
321+
message.content?.includes(
322+
'Repository URL: https://github.com/knightedcodemonkey/develop',
323+
),
324+
),
325+
).toBe(true)
326+
expect(
327+
systemMessages?.some((message: ChatRequestMessage) =>
328+
message.content?.includes('Editor context:'),
329+
),
330+
).toBe(false)
331+
})
332+
333+
test('AI chat falls back to non-streaming response when streaming fails', async ({
334+
page,
335+
}) => {
336+
let streamAttemptCount = 0
337+
let fallbackAttemptCount = 0
338+
339+
await page.route('https://models.github.ai/inference/chat/completions', async route => {
340+
const body = route.request().postDataJSON() as { stream?: boolean } | null
341+
if (body?.stream) {
342+
streamAttemptCount += 1
343+
await route.fulfill({
344+
status: 502,
345+
contentType: 'application/json',
346+
body: JSON.stringify({ message: 'stream failed' }),
347+
})
348+
return
349+
}
350+
351+
fallbackAttemptCount += 1
352+
await route.fulfill({
353+
status: 200,
354+
contentType: 'application/json',
355+
body: JSON.stringify({
356+
rate_limit: {
357+
remaining: 17,
358+
reset: 1704067200,
359+
},
360+
choices: [
361+
{
362+
message: {
363+
role: 'assistant',
364+
content: 'Fallback response from JSON path.',
365+
},
366+
},
367+
],
368+
}),
369+
})
370+
})
371+
372+
await waitForAppReady(page, `${appEntryPath}?feature-ai=true`)
373+
await connectByotWithSingleRepo(page)
374+
await ensureAiChatDrawerOpen(page)
375+
376+
await page.locator('#ai-chat-prompt').fill('Use fallback path.')
377+
await page.locator('#ai-chat-send').click()
378+
379+
await expect(page.locator('#ai-chat-status')).toHaveText('Fallback response loaded.')
380+
await expect(page.locator('#ai-chat-rate')).toHaveText('Remaining 17, resets 00:00 UTC')
381+
await expect(page.locator('#ai-chat-messages')).toContainText(
382+
'Fallback response from JSON path.',
383+
)
384+
expect(streamAttemptCount).toBeGreaterThan(0)
385+
expect(fallbackAttemptCount).toBeGreaterThan(0)
150386
})
151387

152388
test('BYOT remembers selected repository across reloads', async ({ page }) => {

0 commit comments

Comments
 (0)