diff --git a/README.md b/README.md
index a05972d..5fb3141 100644
--- a/README.md
+++ b/README.md
@@ -37,6 +37,10 @@ browser acts as the runtime host for render, lint, and typecheck flows.
- Live IDE: https://knightedcodemonkey.github.io/develop/
- Source repository: https://github.com/knightedcodemonkey/develop
+## BYOT Guide
+
+- GitHub PAT setup and usage: [docs/byot.md](docs/byot.md)
+
## License
MIT
diff --git a/docs/byot.md b/docs/byot.md
new file mode 100644
index 0000000..41a4c86
--- /dev/null
+++ b/docs/byot.md
@@ -0,0 +1,67 @@
+# BYOT Setup for GitHub in @knighted/develop
+
+This guide explains how to create and use a fine-grained GitHub Personal Access Token (PAT) for the BYOT flow in `@knighted/develop`.
+
+## What BYOT does in the app
+
+When the AI/BYOT feature is enabled, the token is used to:
+
+- authenticate GitHub API requests
+- load repositories where you have write access
+- let you choose which repository to work with
+
+As additional AI/PR features roll out, the same token is also used for model and repository operations that require the configured permissions.
+
+## Privacy and storage behavior
+
+- Your token is stored only in your browser `localStorage`.
+- The token is never sent to any service except the GitHub endpoints required by the feature.
+- You can remove it at any time using the delete button in the BYOT controls.
+
+## Enable the BYOT feature
+
+Use one of these options:
+
+1. Add `?feature-ai=true` to the app URL.
+2. Set `localStorage` key `knighted:develop:feature:ai-assistant` to `true`.
+
+## Create a fine-grained PAT
+
+Create a fine-grained PAT in GitHub settings and grant the permissions below.
+
+- 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)
+
+
+
+
+### Repository permissions
+
+- Contents: Read and write
+- Pull requests: Read and write
+- Metadata: Read-only (required)
+
+### Account permissions
+
+- Models: Read-only
+
+### Repository access scope
+
+Use either of these scopes depending on your needs:
+
+- Only select repositories
+- All repositories
+
+`@knighted/develop` will only show repositories where your token has write access.
+
+## Recommended setup flow
+
+1. Create token with the permissions above.
+2. Open `@knighted/develop` with `?feature-ai=true`.
+3. Paste token into the BYOT input and click add.
+4. Verify repository list loads.
+5. Select your target repository.
+
+## Screenshots
+
+The screenshots above show the recommended repository and account permission settings.
diff --git a/docs/media/byot-model-perms.png b/docs/media/byot-model-perms.png
new file mode 100644
index 0000000..e29ebf2
Binary files /dev/null and b/docs/media/byot-model-perms.png differ
diff --git a/docs/media/byot-repo-perms.png b/docs/media/byot-repo-perms.png
new file mode 100644
index 0000000..e9f4631
Binary files /dev/null and b/docs/media/byot-repo-perms.png differ
diff --git a/docs/next-steps.md b/docs/next-steps.md
index 9a32b0a..fd76952 100644
--- a/docs/next-steps.md
+++ b/docs/next-steps.md
@@ -18,3 +18,19 @@ Focused follow-up work for `@knighted/develop`.
- Ensure the deterministic lane still exercises the same user-facing flows (render, typecheck, lint, diagnostics drawer/button states), only swapping the source of runtime artifacts.
- 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:
+ - 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).
+ - 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."
diff --git a/playwright/app.spec.ts b/playwright/app.spec.ts
index d56316f..eb4fdb0 100644
--- a/playwright/app.spec.ts
+++ b/playwright/app.spec.ts
@@ -4,13 +4,17 @@ import type { Page } from '@playwright/test'
const webServerMode = process.env.PLAYWRIGHT_WEB_SERVER_MODE ?? 'dev'
const appEntryPath = webServerMode === 'preview' ? '/index.html' : '/src/index.html'
-const waitForInitialRender = async (page: Page) => {
- await page.goto(appEntryPath)
+const waitForAppReady = async (page: Page, path = appEntryPath) => {
+ await page.goto(path)
await expect(page.getByRole('heading', { name: '@knighted/develop' })).toBeVisible()
- await expect(page.locator('#status')).toHaveText('Rendered')
await expect(page.locator('#cdn-loading')).toHaveAttribute('hidden', '')
}
+const waitForInitialRender = async (page: Page) => {
+ await waitForAppReady(page)
+ await expect(page.locator('#status')).toHaveText('Rendered')
+}
+
const expectPreviewHasRenderedContent = async (page: Page) => {
const previewHost = page.locator('#preview-host')
await expect(previewHost.locator('pre')).toHaveCount(0)
@@ -126,6 +130,70 @@ const expectCollapseButtonState = async (
}
}
+test('BYOT controls stay hidden when feature flag is disabled', async ({ page }) => {
+ await waitForAppReady(page)
+
+ const byotControls = page.locator('#github-ai-controls')
+ await expect(byotControls).toHaveAttribute('hidden', '')
+ await expect(byotControls).toBeHidden()
+})
+
+test('BYOT controls render when feature flag is enabled by query param', async ({
+ page,
+}) => {
+ await waitForAppReady(page, `${appEntryPath}?feature-ai=true`)
+
+ const byotControls = page.locator('#github-ai-controls')
+ await expect(byotControls).toBeVisible()
+ await expect(page.locator('#github-token-input')).toBeVisible()
+ await expect(page.locator('#github-token-add')).toBeVisible()
+})
+
+test('BYOT remembers selected repository across reloads', async ({ page }) => {
+ await page.route('https://api.github.com/user/repos**', async route => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify([
+ {
+ id: 2,
+ owner: { login: 'knightedcodemonkey' },
+ name: 'develop',
+ full_name: 'knightedcodemonkey/develop',
+ default_branch: 'main',
+ permissions: { push: true },
+ },
+ {
+ id: 1,
+ owner: { login: 'knightedcodemonkey' },
+ name: 'css',
+ full_name: 'knightedcodemonkey/css',
+ default_branch: 'main',
+ permissions: { push: true },
+ },
+ ]),
+ })
+ })
+
+ await waitForAppReady(page, `${appEntryPath}?feature-ai=true`)
+
+ await page.locator('#github-token-input').fill('github_pat_fake_1234567890')
+ await page.locator('#github-token-add').click()
+
+ const repoSelect = page.locator('#github-repo-select')
+ await expect(repoSelect).toBeEnabled()
+ await expect(page.locator('#status')).toHaveText('Loaded 2 writable repositories')
+
+ await repoSelect.selectOption('knightedcodemonkey/develop')
+ await expect(repoSelect).toHaveValue('knightedcodemonkey/develop')
+
+ await page.reload()
+ await expect(page.getByRole('heading', { name: '@knighted/develop' })).toBeVisible()
+ await expect(page.locator('#github-token-add')).toBeHidden()
+ await expect(page.locator('#github-token-delete')).toBeVisible()
+ await expect(repoSelect).toHaveValue('knightedcodemonkey/develop')
+})
+
test('renders default playground preview', async ({ page }) => {
await waitForInitialRender(page)
diff --git a/src/app.js b/src/app.js
index 9926d41..03bd803 100644
--- a/src/app.js
+++ b/src/app.js
@@ -7,6 +7,8 @@ import {
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 { createGitHubByotControls } from './modules/github-byot-controls.js'
import { createLayoutThemeController } from './modules/layout-theme.js'
import { createLintDiagnosticsController } from './modules/lint-diagnostics.js'
import { createPreviewBackgroundController } from './modules/preview-background.js'
@@ -15,6 +17,13 @@ import { createTypeDiagnosticsController } from './modules/type-diagnostics.js'
const statusNode = document.getElementById('status')
const appGrid = document.querySelector('.app-grid')
+const githubAiControls = document.getElementById('github-ai-controls')
+const githubTokenInput = document.getElementById('github-token-input')
+const githubTokenInfo = document.getElementById('github-token-info')
+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 appGridLayoutButtons = document.querySelectorAll('[data-app-grid-layout]')
const appThemeButtons = document.querySelectorAll('[data-app-theme]')
const editorToolsButtons = document.querySelectorAll('[data-editor-tools-toggle]')
@@ -384,6 +393,21 @@ const {
updateUiIssueIndicators,
} = diagnosticsUi
+const aiAssistantFeatureEnabled = isAiAssistantFeatureEnabled()
+
+createGitHubByotControls({
+ featureEnabled: aiAssistantFeatureEnabled,
+ controlsRoot: githubAiControls,
+ tokenInput: githubTokenInput,
+ tokenInfoButton: githubTokenInfo,
+ tokenAddButton: githubTokenAdd,
+ tokenDeleteButton: githubTokenDelete,
+ repoSelect: githubRepoSelect,
+ repoWrap: githubRepoWrap,
+ onRepositoryChange: () => {},
+ setStatus,
+})
+
const getStyleEditorLanguage = mode => {
if (mode === 'less') return 'less'
if (mode === 'sass') return 'sass'
diff --git a/src/index.html b/src/index.html
index 9af6208..b5d4def 100644
--- a/src/index.html
+++ b/src/index.html
@@ -33,6 +33,78 @@
+
+
+
+
+
+
This token is stored only in your browser and is sent only to GitHub APIs
+ you invoke.
+
+
+
+
+
+
+
+
+
+