From 186e4c07383e8be43be565eeb33bfbe64d597fdb Mon Sep 17 00:00:00 2001 From: KCM Date: Sun, 3 May 2026 11:14:52 -0500 Subject: [PATCH 1/2] feat: workspaces without pat. --- playwright/diagnostics.spec.ts | 36 +++ playwright/github-byot-ai.spec.ts | 258 +++++++++++++++++- playwright/rendering-modes/core.spec.ts | 38 ++- src/app.js | 5 +- src/index.html | 52 ++-- src/modules/app-core/github-pr-context-ui.js | 18 -- .../app-core/workspace-context-controller.js | 32 +++ .../app-core/workspace-controllers-setup.js | 2 + src/modules/diagnostics/type-diagnostics.js | 4 + src/modules/preview/render-runtime.js | 35 +-- .../workspace/workspaces-drawer/drawer.js | 15 + src/styles/ai-controls.css | 39 ++- src/styles/layout-shell.css | 14 +- 13 files changed, 453 insertions(+), 95 deletions(-) diff --git a/playwright/diagnostics.spec.ts b/playwright/diagnostics.spec.ts index f8ec32c..3d30005 100644 --- a/playwright/diagnostics.spec.ts +++ b/playwright/diagnostics.spec.ts @@ -247,6 +247,42 @@ test('typecheck does not report TS2307 for stylesheet side-effect imports', asyn expect(diagnosticsText).not.toContain("Cannot find module '../styles/app.css'") }) +test('typecheck recognizes css module class maps in React mode', async ({ page }) => { + await waitForInitialRender(page) + + await ensurePanelToolsVisible(page, 'component') + await addWorkspaceTab(page, { type: 'style' }) + await page.getByRole('button', { name: 'Rename tab module.css' }).click() + const renameInput = page.getByLabel('Rename module.css') + await renameInput.fill('app.module.css') + await renameInput.press('Enter') + + await setWorkspaceTabSource(page, { + fileName: 'app.module.css', + kind: 'styles', + source: ['.btn {', ' color: #fff;', '}'].join('\n'), + }) + + await setComponentEditorSource( + page, + [ + "import styles from '../styles/app.module.css'", + '', + 'const App = () => ', + '', + ].join('\n'), + ) + + await runTypecheck(page) + await ensureDiagnosticsDrawerOpen(page) + await expect(page.locator('#diagnostics-component')).toContainText( + 'No TypeScript errors found.', + ) + + const diagnosticsText = await page.locator('#diagnostics-component').innerText() + expect(diagnosticsText).not.toContain("Property 'btn' does not exist on type 'string'") +}) + test('component diagnostics rows navigate editor to reported line', async ({ page }) => { await waitForInitialRender(page) diff --git a/playwright/github-byot-ai.spec.ts b/playwright/github-byot-ai.spec.ts index 8d3c559..0c81f30 100644 --- a/playwright/github-byot-ai.spec.ts +++ b/playwright/github-byot-ai.spec.ts @@ -4,6 +4,7 @@ import type { ChatRequestBody, ChatRequestMessage } from './helpers/app-test-hel import { appEntryPath, connectByotWithSingleRepo, + ensureWorkspacesDrawerClosed, ensureAiChatDrawerOpen, ensureOpenPrDrawerOpen, mockRepositoryBranches, @@ -12,6 +13,10 @@ import { setStylesEditorSource, waitForAppReady, } from './helpers/app-test-helpers.js' +import { + getAllWorkspaceRecords, + seedLocalWorkspaceContexts, +} from './github-pr-drawer/github-pr-drawer.helpers.js' test('PR/BYOT controls are visible and chat stays hidden until token connect', async ({ page, @@ -37,7 +42,256 @@ test('PR/BYOT controls are visible and chat stays hidden until token connect', a await expect(prToggle).toHaveCount(1) await expect(prToggle).toBeHidden() await expect(workspacesToggle).toHaveCount(1) - await expect(workspacesToggle).toBeHidden() + await expect(workspacesToggle).toBeVisible() +}) + +test('Workspaces repository filter is local-only and read-only without PAT', async ({ + page, +}) => { + await waitForAppReady(page) + + const workspacesToggle = page.getByRole('button', { + name: 'Workspaces', + exact: true, + }) + await expect(workspacesToggle).toBeVisible() + + await workspacesToggle.click() + + const repositoryFilter = page.getByRole('combobox', { + name: 'Workspace repository filter', + }) + await expect(repositoryFilter).toBeDisabled() + await expect(repositoryFilter).toHaveValue('__local__') + await expect(repositoryFilter.locator('option')).toHaveCount(1) + await expect(repositoryFilter.locator('option')).toHaveText(['Local']) +}) + +test('No-PAT startup restores Local workspace from mixed stored contexts', async ({ + page, +}) => { + const localWorkspaceId = 'local_no_pat_restore_target' + const localHead = 'feat/local-no-pat-restore' + const localMarker = 'Local restore marker content' + const repositoryMarker = 'Repository restore marker content' + + await waitForAppReady(page) + + await page.evaluate(async () => { + const request = indexedDB.open('knighted-develop-workspaces') + const db = await new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error) + request.onblocked = () => reject(new Error('Could not open IndexedDB.')) + }) + + try { + const tx = db.transaction('prWorkspaces', 'readwrite') + const store = tx.objectStore('prWorkspaces') + const clearRequest = store.clear() + + await new Promise((resolve, reject) => { + clearRequest.onsuccess = () => resolve() + clearRequest.onerror = () => reject(clearRequest.error) + }) + + await new Promise((resolve, reject) => { + tx.oncomplete = () => resolve() + tx.onerror = () => reject(tx.error) + tx.onabort = () => reject(tx.error) + }) + } finally { + db.close() + } + }) + + await seedLocalWorkspaceContexts(page, [ + { + id: localWorkspaceId, + repo: '', + workspaceScope: 'local', + base: 'main', + head: localHead, + prTitle: 'Local restore target', + prContextState: 'inactive', + prNumber: null, + tabs: [ + { + id: 'entry', + name: 'App.tsx', + path: 'src/components/App.tsx', + language: 'javascript-jsx', + role: 'entry', + isActive: true, + content: `export const App = () =>
${localMarker}
`, + }, + ], + activeTabId: 'entry', + createdAt: Date.now() - 5000, + lastModified: Date.now() - 5000, + }, + { + id: 'repo_no_pat_restore_should_not_apply', + repo: 'knightedcodemonkey/develop', + workspaceScope: 'repository', + base: 'main', + head: 'feat/repo-should-not-restore-without-pat', + prTitle: 'Repository active context', + prContextState: 'active', + prNumber: 107, + tabs: [ + { + id: 'entry', + name: 'App.tsx', + path: 'src/components/App.tsx', + language: 'javascript-jsx', + role: 'entry', + isActive: true, + content: `export const App = () =>
${repositoryMarker}
`, + }, + ], + activeTabId: 'entry', + createdAt: Date.now() + 5000, + lastModified: Date.now() + 5000, + }, + ]) + + await page.reload() + await waitForAppReady(page) + + await expect(page.locator('#github-pr-head-branch')).toHaveValue(localHead) + await expect( + page.getByRole('textbox', { name: 'Component source editor' }), + ).toContainText(localMarker) + await expect( + page.getByRole('textbox', { name: 'Component source editor' }), + ).not.toContainText(repositoryMarker) + + const workspacesToggle = page.getByRole('button', { + name: 'Workspaces', + exact: true, + }) + await workspacesToggle.click() + + await expect(page.locator('#workspaces-repository')).toBeDisabled() + await expect(page.locator('#workspaces-select')).toHaveValue(localWorkspaceId) + await expect(page.getByRole('button', { name: 'Remove', exact: true })).toBeDisabled() +}) + +test('PAT connect after Local-only session preserves Local records and enables repository workflows', async ({ + page, +}) => { + const localWorkspaceId = 'local_pat_connect_preserve' + const localHead = 'feat/local-before-pat-connect' + + await waitForAppReady(page) + + await page.evaluate(async () => { + const request = indexedDB.open('knighted-develop-workspaces') + const db = await new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error) + request.onblocked = () => reject(new Error('Could not open IndexedDB.')) + }) + + try { + const tx = db.transaction('prWorkspaces', 'readwrite') + const store = tx.objectStore('prWorkspaces') + const clearRequest = store.clear() + + await new Promise((resolve, reject) => { + clearRequest.onsuccess = () => resolve() + clearRequest.onerror = () => reject(clearRequest.error) + }) + + await new Promise((resolve, reject) => { + tx.oncomplete = () => resolve() + tx.onerror = () => reject(tx.error) + tx.onabort = () => reject(tx.error) + }) + } finally { + db.close() + } + }) + + await seedLocalWorkspaceContexts(page, [ + { + id: localWorkspaceId, + repo: '', + workspaceScope: 'local', + base: 'main', + head: localHead, + prTitle: 'Local only workspace before PAT', + prContextState: 'inactive', + prNumber: null, + createdAt: Date.now() - 1000, + lastModified: Date.now() - 1000, + }, + ]) + + await page.reload() + await waitForAppReady(page) + + const workspacesToggle = page.getByRole('button', { + name: 'Workspaces', + exact: true, + }) + await workspacesToggle.click() + + const repositoryFilter = page.getByLabel('Workspace repository filter') + await expect(repositoryFilter).toBeDisabled() + await expect(repositoryFilter).toHaveValue('__local__') + await expect(page.locator('#workspaces-select')).toHaveValue(localWorkspaceId) + + await ensureWorkspacesDrawerClosed(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 mockRepositoryBranches(page, { + 'knightedcodemonkey/develop': ['main', 'release'], + }) + + await page + .getByRole('textbox', { name: 'GitHub token' }) + .fill('github_pat_fake_transition_1234567890') + await page.getByRole('button', { name: 'Add GitHub token' }).click() + + await expect(page.getByRole('button', { name: 'Open pull request' })).toBeVisible() + + await workspacesToggle.click() + await expect(repositoryFilter).toBeEnabled() + await expect(repositoryFilter.locator('option')).toHaveCount(2) + await expect(repositoryFilter.locator('option')).toHaveText([ + 'Local', + 'knightedcodemonkey/develop', + ]) + + await repositoryFilter.selectOption('knightedcodemonkey/develop') + await expect(repositoryFilter).toHaveValue('knightedcodemonkey/develop') + + const records = await getAllWorkspaceRecords(page) + const localRecord = records.find(record => record?.id === localWorkspaceId) + + expect(localRecord).toBeTruthy() + expect(typeof localRecord?.repo === 'string' ? localRecord.repo : '').toBe('') + expect( + typeof localRecord?.workspaceScope === 'string' ? localRecord.workspaceScope : '', + ).toBe('local') }) test('chat becomes available after token connect', async ({ page }) => { @@ -80,7 +334,7 @@ test('BYOT controls render with default app entry', async ({ page }) => { await expect(prToggle).toHaveCount(1) await expect(prToggle).toBeHidden() await expect(workspacesToggle).toHaveCount(1) - await expect(workspacesToggle).toBeHidden() + await expect(workspacesToggle).toBeVisible() }) test('GitHub token info panel reflects missing and present token states', async ({ diff --git a/playwright/rendering-modes/core.spec.ts b/playwright/rendering-modes/core.spec.ts index 717e085..b4f3489 100644 --- a/playwright/rendering-modes/core.spec.ts +++ b/playwright/rendering-modes/core.spec.ts @@ -217,6 +217,14 @@ test('css module imports expose class map for module tabs', async ({ page }) => '.item {', ' color: rgb(10, 20, 30);', '}', + '', + '.item:hover {', + ' color: rgb(30, 40, 50);', + '}', + '', + '.item:active {', + ' color: rgb(60, 70, 80);', + '}', ].join('\n'), }) @@ -283,6 +291,19 @@ test('css module imports expose class map for module tabs', async ({ page }) => 'color', 'rgb(10, 20, 30)', ) + + await expect + .poll(async () => readPreviewUserStyleText(page)) + .toEqual(expect.stringMatching(/\.[A-Za-z0-9_-]+_item:hover\s*\{/)) + await expect + .poll(async () => readPreviewUserStyleText(page)) + .toEqual(expect.stringMatching(/\.[A-Za-z0-9_-]+_item:active\s*\{/)) + await expect + .poll(async () => readPreviewUserStyleText(page)) + .not.toContain('.item:hover') + await expect + .poll(async () => readPreviewUserStyleText(page)) + .not.toContain('.item:active') }) test('preview styles require explicit import from entry graph', async ({ page }) => { @@ -562,16 +583,15 @@ test('config patch keeps preview style order stable around app head styles', asy } }) - if (!resolvedOrderBeforePatch) { - throw new Error('Expected app-injected head style to exist before config patch.') + expect(resolvedOrderBeforePatch).not.toBeNull() + const orderBeforePatch = resolvedOrderBeforePatch as { + baseIndex: number + userIndex: number + appIndex: number } - expect(resolvedOrderBeforePatch.baseIndex).toBeLessThan( - resolvedOrderBeforePatch.userIndex, - ) - expect(resolvedOrderBeforePatch.userIndex).toBeLessThan( - resolvedOrderBeforePatch.appIndex, - ) + expect(orderBeforePatch.baseIndex).toBeLessThan(orderBeforePatch.userIndex) + expect(orderBeforePatch.userIndex).toBeLessThan(orderBeforePatch.appIndex) await page.getByLabel('Background').fill('#456789') @@ -600,7 +620,7 @@ test('config patch keeps preview style order stable around app head styles', asy } }) }) - .toEqual(resolvedOrderBeforePatch) + .toEqual(orderBeforePatch) }) test('nested module imports can bring styles into preview graph', async ({ page }) => { diff --git a/src/app.js b/src/app.js index 3fd1a38..4001887 100644 --- a/src/app.js +++ b/src/app.js @@ -490,7 +490,6 @@ const prContextUi = createGitHubPrContextUiController({ stylesPrSyncIconPath, githubPrContextClose, aiChatToggle, - workspacesToggle, githubPrOpenIcon, githubPrPushCommitIcon, closeChatDrawer: () => { @@ -499,7 +498,6 @@ const prContextUi = createGitHubPrContextUiController({ closePrDrawer: () => { prDrawerController.setOpen(false) }, - closeWorkspacesDrawer: () => workspacesDrawerController?.setOpen(false), }) const editedIndicatorVisibilityController = createEditedIndicatorVisibilityController({ @@ -583,6 +581,8 @@ githubAiContextState.token = byotControls.getToken() githubAiContextState.writableRepositories = byotControls.getWritableRepositories() const getCurrentGitHubToken = () => githubAiContextState.token ?? byotControls.getToken() +const hasCurrentGitHubToken = () => + typeof getCurrentGitHubToken() === 'string' && getCurrentGitHubToken().trim().length > 0 const getCurrentSelectedRepository = () => githubAiContextState.selectedRepository ?? byotControls.getSelectedRepository() @@ -799,6 +799,7 @@ const { } = createWorkspaceControllersSetup({ createDebouncedWorkspaceSaver, workspaceStorage, + getHasGitHubToken: () => hasCurrentGitHubToken(), getWorkspacesDrawerController: () => workspacesDrawerController, toNonEmptyWorkspaceText, buildWorkspaceRecordSnapshot, diff --git a/src/index.html b/src/index.html index 3f95a62..6d17e18 100644 --- a/src/index.html +++ b/src/index.html @@ -139,24 +139,6 @@

- - + +
+ +