From e95b5d353b70e2925eb9267a288604e1561f4b1e Mon Sep 17 00:00:00 2001
From: KCM
Date: Sun, 26 Apr 2026 14:00:43 -0500
Subject: [PATCH 1/5] feat: improved workspace ux baseline.
---
docs/idb-workspace-state.md | 4 +-
docs/issue-99-workspaces-drawer-plan.md | 71 ++++
docs/localstorage-state.md | 2 +-
docs/pr-context-storage-matrix.md | 30 +-
playwright/github-byot-ai.spec.ts | 13 +-
.../active-context-switch.spec.ts | 358 ------------------
.../github-pr-drawer.helpers.ts | 46 +--
.../github-pr-drawer/open-pr-create.spec.ts | 51 ++-
playwright/helpers/app-test-helpers.ts | 32 +-
src/app.js | 20 +-
src/index.html | 55 +--
src/modules/app-core/github-pr-context-ui.js | 6 -
.../app-core/github-workflows-setup.js | 2 -
src/modules/app-core/github-workflows.js | 93 ++---
src/modules/constants.js | 1 -
src/modules/github/pr/drawer/common.js | 6 +-
.../pr/drawer/controller/public-actions.js | 19 -
.../workspace/workspaces-drawer/drawer.js | 116 +++---
src/styles/ai-controls.css | 78 ++--
19 files changed, 321 insertions(+), 682 deletions(-)
create mode 100644 docs/issue-99-workspaces-drawer-plan.md
delete mode 100644 src/modules/constants.js
diff --git a/docs/idb-workspace-state.md b/docs/idb-workspace-state.md
index 029a7ce..e361499 100644
--- a/docs/idb-workspace-state.md
+++ b/docs/idb-workspace-state.md
@@ -23,7 +23,7 @@ Each workspace record may include:
- `head`
- `prTitle`
- `prNumber`
- - `prContextState` (`inactive` | `active` | `disconnected` | `closed`)
+ - `prContextState` (`inactive` | `active` | `closed`)
- Runtime/editor state:
- `renderMode`
- `activeTabId`
@@ -37,7 +37,7 @@ IDB supports that by storing:
- Full workspace snapshots
- Repo-scoped context records
-- Historical transitions such as disconnected or closed PR context
+- Historical transitions such as closed PR context
## Design Rule
diff --git a/docs/issue-99-workspaces-drawer-plan.md b/docs/issue-99-workspaces-drawer-plan.md
new file mode 100644
index 0000000..4656b19
--- /dev/null
+++ b/docs/issue-99-workspaces-drawer-plan.md
@@ -0,0 +1,71 @@
+# Issue #99 + Workspaces Drawer UX Plan
+
+## Goal
+
+Simplify PR/workspace lifecycle and the Workspaces drawer UX by:
+
+- removing disconnected state and Disconnect action paths
+- using workspace terminology in the drawer (not context)
+- separating new workspace initialization from workspace selection
+- preserving strict explicit intent semantics (no implicit apply/mutation from select changes)
+
+## Decisions
+
+- New workspace is an explicit direct action via a `New workspace` button.
+- `New workspace` must work for both repository scopes and `Local`.
+- `Open` remains the explicit action for applying an existing stored workspace.
+- If the selected repository has no stored workspaces, hide the workspace select and show the new-workspace path.
+
+## Implementation Steps
+
+1. Remove disconnected model paths (Issue #99)
+
+- Remove Disconnect control from UI.
+- Remove disconnected event wiring and runtime callbacks.
+- Remove disconnected public action paths.
+- Normalize legacy `disconnected` records to `inactive` during restore/normalization.
+
+2. Redesign drawer flow
+
+- Replace starter option-in-select behavior with a dedicated `New workspace` button adjacent to repository select.
+- Keep workspace select for stored workspaces only.
+- Hide workspace select when no stored workspaces exist for the selected scope.
+- Keep strict explicit selection semantics (no auto-apply from select/filter changes).
+
+3. Update copy and accessibility
+
+- Replace "Stored contexts" and related "context" wording with "Workspace" wording.
+- Update status and aria labels consistently.
+
+4. Remove obsolete code paths
+
+- Remove starter prefix constants and parsing.
+- Remove disconnected-only logic and stale styling/branches.
+
+5. Update tests
+
+- Remove/replace disconnected-focused scenarios.
+- Update helpers/selectors to new workspace labels and `New workspace` action.
+- Add/adjust scenarios for empty repository scope (select hidden) and explicit Open behavior for existing workspaces.
+
+6. Update docs
+
+- Update storage/state docs to remove disconnected semantics.
+- Update drawer UX docs to reflect repository row + new workspace action flow.
+
+## Verification
+
+1. `npm run lint`
+2. Targeted Playwright (Chromium first):
+
+- `playwright/github-pr-drawer/active-context-switch.spec.ts`
+- `playwright/github-pr-drawer/open-pr-create.spec.ts`
+
+3. Broader Playwright run for workspace/PR drawer flows.
+4. Manual verification in dev server for:
+
+- repository + new workspace row
+- local new workspace creation
+- hidden workspace select when no stored entries
+- explicit Open required for existing entries
+- no Disconnect control
diff --git a/docs/localstorage-state.md b/docs/localstorage-state.md
index a9af9ba..90eac89 100644
--- a/docs/localstorage-state.md
+++ b/docs/localstorage-state.md
@@ -19,7 +19,7 @@ Do not store pull request context in `localStorage`.
Examples that must stay out of `localStorage`:
- Selected repository preference (`owner/repo`)
-- PR context state (`active`, `disconnected`, `closed`, `inactive`)
+- PR context state (`active`, `closed`, `inactive`)
- PR number and URL
- PR base/head/title/body
- PR drawer repository-scoped workflow state
diff --git a/docs/pr-context-storage-matrix.md b/docs/pr-context-storage-matrix.md
index 8a068fc..bee056a 100644
--- a/docs/pr-context-storage-matrix.md
+++ b/docs/pr-context-storage-matrix.md
@@ -20,7 +20,7 @@ See the full storage ownership docs for non-PR keys:
- Database: `knighted-develop-workspaces`
- Object store: `prWorkspaces`
- Relevant fields in each workspace record:
- - `prContextState`: `inactive` | `active` | `disconnected` | `closed`
+ - `prContextState`: `inactive` | `active` | `closed`
- `prNumber`: `number | null`
- `prTitle`, `base`, `head`
- `repo`
@@ -34,12 +34,11 @@ See the full storage ownership docs for non-PR keys:
Use this matrix as the source of truth when debugging UI/storage mismatch.
-| Scenario | IDB `prContextState` | IDB `prNumber` | localStorage PR fields | Notes |
-| --------------------------------------------- | -------------------- | --------------------------------- | ---------------------- | -------------------------------------------------------------------------------------------------------------- |
-| A. Local workspace only, no PR context | `inactive` | `null` | none | No connected PR context. |
-| B. Workspace is for an active, open PR | `active` | PR number | none | Push mode in PR controls. |
-| C. Workspace is for a disconnected PR context | `disconnected` | last known PR number if available | none | Opening this workspace from Workspaces restores PR runtime context and verifies open/closed state with GitHub. |
-| D. Workspace is for a PR closed on GitHub | `closed` | closed PR number | none | Historical context retained for debugging/reference. |
+| Scenario | IDB `prContextState` | IDB `prNumber` | localStorage PR fields | Notes |
+| ----------------------------------------- | -------------------- | ---------------- | ---------------------- | ---------------------------------------------------- |
+| A. Local workspace only, no PR context | `inactive` | `null` | none | No connected PR context. |
+| B. Workspace is for an active, open PR | `active` | PR number | none | Push mode in PR controls. |
+| C. Workspace is for a PR closed on GitHub | `closed` | closed PR number | none | Historical context retained for debugging/reference. |
## Current Workspace Selection On Load
@@ -82,9 +81,8 @@ When the UI does not match expected PR state:
- `prNumber`
- `repo`, `head`, `prTitle`
2. Compare against the matrix above.
-3. If scenario C is expected, open that workspace from Workspaces to restore runtime PR context.
-4. If the PR is still open on GitHub, expect PR controls to return to Push mode and the workspace record to transition back to `active`.
-5. If the PR is no longer open, expect Open PR mode to remain and status messaging to explain verification results.
+3. If the PR is still open on GitHub, expect PR controls to return to Push mode and the workspace record to transition back to `active`.
+4. If the PR is no longer open, expect Open PR mode to remain and status messaging to explain verification results.
## Console Snippets
@@ -99,19 +97,13 @@ indexedDB.open('knighted-develop-workspaces').onsuccess = event => {
}
```
-## Reconnect Behavior
-
-Reconnect behavior from the Workspaces drawer is implemented.
-
-Opening a `disconnected` workspace record restores active PR runtime context for that repository and reinitializes editor state from the selected workspace record.
-
## End-Of-Session Behavior
-`Disconnect` and `Close` are treated as end-of-session actions for PR-linked workspaces.
+`Close` is the end-of-session action for PR-linked workspaces.
-When either action is confirmed:
+When close is confirmed:
-1. The current workspace is archived as historical (`disconnected` or `closed`).
+1. The current workspace is archived as historical (`closed`).
2. The app immediately switches to a fresh local workspace (`inactive`) with a single empty entry tab.
3. Status messaging guides the user to continue locally or reopen a stored workspace from Workspaces.
diff --git a/playwright/github-byot-ai.spec.ts b/playwright/github-byot-ai.spec.ts
index e5f61f4..577f9e9 100644
--- a/playwright/github-byot-ai.spec.ts
+++ b/playwright/github-byot-ai.spec.ts
@@ -768,21 +768,16 @@ test('BYOT remembers selected repository across reloads', async ({ page }) => {
await page.getByRole('button', { name: 'Workspaces' }).click()
const workspaceRepositoryFilter = page.getByLabel('Workspace repository filter')
- const storedContextsSelect = page.getByLabel('Stored local editor contexts')
- const openStoredContextButton = page.getByRole('button', {
- name: 'Open',
+ const newWorkspaceButton = page.getByRole('button', {
+ name: 'New workspace',
exact: true,
})
await expect(workspaceRepositoryFilter).toBeVisible()
await workspaceRepositoryFilter.selectOption('knightedcodemonkey/develop')
await expect(workspaceRepositoryFilter).toHaveValue('knightedcodemonkey/develop')
- await expect(storedContextsSelect).toBeVisible()
- await storedContextsSelect.selectOption({
- label: 'Start new context for knightedcodemonkey/develop',
- })
- await expect(openStoredContextButton).toBeEnabled()
- await openStoredContextButton.click()
+ await expect(newWorkspaceButton).toBeVisible()
+ await newWorkspaceButton.click()
await page.getByRole('button', { name: 'Close workspaces drawer' }).click()
await ensureOpenPrDrawerOpen(page)
diff --git a/playwright/github-pr-drawer/active-context-switch.spec.ts b/playwright/github-pr-drawer/active-context-switch.spec.ts
index b786b1d..e35e3ba 100644
--- a/playwright/github-pr-drawer/active-context-switch.spec.ts
+++ b/playwright/github-pr-drawer/active-context-switch.spec.ts
@@ -9,7 +9,6 @@ import {
mockRepositoryBranches,
openMostRecentStoredWorkspaceContext,
openStoredWorkspaceContextByHead,
- openStoredWorkspaceContextById,
removeSavedGitHubToken,
runActiveWorkspaceCrossRepoSwitchIntegrityScenario,
runActiveWorkspaceSwitchIntegrityScenario,
@@ -21,343 +20,6 @@ import {
waitForAppReady,
} from './github-pr-drawer.helpers.js'
-test('Active PR context disconnect uses local-only confirmation flow', async ({
- page,
-}) => {
- let closePullRequestRequestCount = 0
-
- 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.route(
- 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/2',
- async route => {
- if (route.request().method() === 'PATCH') {
- closePullRequestRequestCount += 1
- await route.fulfill({
- status: 200,
- contentType: 'application/json',
- body: JSON.stringify({
- number: 2,
- state: 'closed',
- title: 'Existing PR context from storage',
- html_url: 'https://github.com/knightedcodemonkey/develop/pull/2',
- head: { ref: 'develop/open-pr-test' },
- base: { ref: 'main' },
- }),
- })
- return
- }
-
- await route.fulfill({
- status: 200,
- contentType: 'application/json',
- body: JSON.stringify({
- number: 2,
- state: 'open',
- title: 'Existing PR context from storage',
- html_url: 'https://github.com/knightedcodemonkey/develop/pull/2',
- head: { ref: 'develop/open-pr-test' },
- base: { ref: 'main' },
- }),
- })
- },
- )
-
- await page.route(
- 'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**',
- async route => {
- await route.fulfill({
- status: 200,
- contentType: 'application/json',
- body: JSON.stringify({
- ref: 'refs/heads/develop/open-pr-test',
- object: { type: 'commit', sha: 'existing-head-sha' },
- }),
- })
- },
- )
-
- await waitForAppReady(page, `${appEntryPath}`)
-
- await seedActivePrWorkspaceContext(page, {
- repositoryFullName: 'knightedcodemonkey/develop',
- headBranch: 'develop/open-pr-test',
- prTitle: 'Existing PR context from storage',
- prNumber: 2,
- renderMode: 'react',
- })
-
- await connectByotWithSingleRepo(page)
- await openMostRecentStoredWorkspaceContext(page)
-
- await expect(
- page.getByRole('button', { name: 'Disconnect active pull request context' }),
- ).toBeVisible()
-
- await page
- .getByRole('button', { name: 'Disconnect active pull request context' })
- .click()
-
- const dialog = page.getByRole('dialog')
- await expect(dialog).toBeVisible()
- await expect(dialog).toContainText('Disconnect PR context?')
- await expect(dialog).toContainText(
- 'This will disconnect the active pull request context in this app only.',
- )
- await expect(dialog).toContainText('Your pull request will stay open on GitHub.')
- await expect(dialog).toContainText(
- 'Your GitHub token and selected repository will stay connected.',
- )
-
- await dialog.getByRole('button', { name: 'Cancel' }).click()
-
- await expect(
- page.getByRole('button', { name: 'Push commit to active pull request branch' }),
- ).toBeVisible()
-
- const recordAfterCancel = await getWorkspaceTabsRecord(page, {
- headBranch: 'develop/open-pr-test',
- })
- expect(recordAfterCancel?.prContextState).toBe('active')
-
- await page
- .getByRole('button', { name: 'Disconnect active pull request context' })
- .click()
- await dialog.getByRole('button', { name: 'Disconnect' }).click()
-
- await expect(page.getByRole('button', { name: 'Open pull request' })).toBeVisible()
- await expect(
- page.getByRole('button', { name: 'Disconnect active pull request context' }),
- ).toBeHidden()
- await expect(
- page.getByRole('listitem', { name: 'Workspace tab App.tsx' }),
- ).toBeVisible()
- await expect(
- page.getByRole('list', { name: 'Workspace editor tabs' }).getByRole('listitem'),
- ).toHaveCount(1)
- await expect(page.locator('#preview-host iframe')).toHaveCount(0)
-
- const recordAfterDisconnect = await getWorkspaceTabsRecord(page, {
- headBranch: 'develop/open-pr-test',
- })
- expect(recordAfterDisconnect?.prContextState).toBe('disconnected')
- expect(recordAfterDisconnect?.prNumber).toBe(2)
- await expect
- .poll(async () => {
- const records = await getAllWorkspaceRecords(page)
- return records.filter(
- record =>
- record?.repo === 'knightedcodemonkey/develop' &&
- record?.prContextState === 'active' &&
- record?.prNumber === 2,
- ).length
- })
- .toBe(0)
- await expect
- .poll(async () => {
- const records = await getAllWorkspaceRecords(page)
- const localRecord = records.find(
- record =>
- typeof record?.id === 'string' &&
- record.id.startsWith('ws_') &&
- record?.prContextState === 'inactive',
- )
- return Boolean(localRecord)
- })
- .toBe(true)
- expect(closePullRequestRequestCount).toBe(0)
-
- await waitForAppReady(page, `${appEntryPath}`)
-
- await expect(page.getByRole('button', { name: 'Open pull request' })).toBeVisible()
- await expect(
- page.getByRole('button', { name: 'Disconnect active pull request context' }),
- ).toBeHidden()
-
- const recordAfterReload = await getWorkspaceTabsRecord(page, {
- headBranch: 'develop/open-pr-test',
- })
- expect(recordAfterReload?.prContextState).toBe('disconnected')
- expect(recordAfterReload?.prNumber).toBe(2)
-})
-
-test('Reopening a disconnected workspace from Workspaces restores active PR controls and editor state', async ({
- page,
-}) => {
- const repositoryFullName = 'knightedcodemonkey/develop'
- const activeHeadBranch = 'develop/open-pr-test'
- const inactiveHeadBranch = 'feat/fallback-workspace'
- const activeWorkspaceId = buildWorkspaceRecordId({
- repositoryFullName,
- headBranch: activeHeadBranch,
- })
- const inactiveWorkspaceId = buildWorkspaceRecordId({
- repositoryFullName,
- headBranch: inactiveHeadBranch,
- })
-
- 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: repositoryFullName,
- default_branch: 'main',
- permissions: { push: true },
- },
- ]),
- })
- })
-
- await mockRepositoryBranches(page, {
- [repositoryFullName]: ['main', 'release', activeHeadBranch, inactiveHeadBranch],
- })
-
- await page.route(
- 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/2',
- async route => {
- await route.fulfill({
- status: 200,
- contentType: 'application/json',
- body: JSON.stringify({
- number: 2,
- state: 'open',
- title: 'Existing PR context from storage',
- html_url: 'https://github.com/knightedcodemonkey/develop/pull/2',
- head: { ref: activeHeadBranch },
- base: { ref: 'main' },
- }),
- })
- },
- )
-
- await page.route(
- 'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**',
- async route => {
- await route.fulfill({
- status: 200,
- contentType: 'application/json',
- body: JSON.stringify({
- ref: `refs/heads/${activeHeadBranch}`,
- object: { type: 'commit', sha: 'existing-head-sha' },
- }),
- })
- },
- )
-
- await waitForAppReady(page, `${appEntryPath}`)
-
- await seedActivePrWorkspaceContext(page, {
- repositoryFullName,
- headBranch: activeHeadBranch,
- prTitle: 'Existing PR context from storage',
- prNumber: 2,
- renderMode: 'react',
- })
-
- await seedLocalWorkspaceContexts(page, [
- {
- id: inactiveWorkspaceId,
- repo: repositoryFullName,
- base: 'main',
- head: inactiveHeadBranch,
- prTitle: '',
- prNumber: null,
- prContextState: 'inactive',
- renderMode: 'dom',
- tabs: [
- {
- id: 'component',
- name: 'App.tsx',
- path: 'src/components/App.tsx',
- language: 'javascript-jsx',
- role: 'entry',
- isActive: true,
- content: 'export const App = () => Fallback workspace view',
- },
- {
- id: 'styles',
- name: 'app.css',
- path: 'src/styles/app.css',
- language: 'css',
- role: 'module',
- isActive: false,
- content: 'main { color: #333; }',
- },
- ],
- activeTabId: 'component',
- createdAt: Date.now() - 120_000,
- lastModified: Date.now() - 120_000,
- },
- ])
-
- await connectByotWithSingleRepo(page)
- await openStoredWorkspaceContextById(page, activeWorkspaceId)
-
- await expect(
- page.getByRole('button', { name: 'Push commit to active pull request branch' }),
- ).toBeVisible()
-
- await page
- .getByRole('button', { name: 'Disconnect active pull request context' })
- .click()
- await page.getByRole('dialog').getByRole('button', { name: 'Disconnect' }).click()
-
- await expect(page.getByRole('button', { name: 'Open pull request' })).toBeVisible()
- const disconnectedRecord = await getWorkspaceTabsRecord(page, {
- headBranch: activeHeadBranch,
- })
- expect(disconnectedRecord?.prContextState).toBe('disconnected')
-
- await openStoredWorkspaceContextById(page, inactiveWorkspaceId)
-
- await expect(page.getByRole('button', { name: 'Open pull request' })).toBeVisible()
- await expect(
- page.locator('.editor-panel[data-editor-kind="component"] .cm-content').first(),
- ).toContainText('Fallback workspace view')
-
- await openStoredWorkspaceContextById(page, activeWorkspaceId)
-
- await expect(
- page.getByRole('button', { name: 'Push commit to active pull request branch' }),
- ).toBeVisible()
- await expect(
- page.getByRole('button', { name: 'Disconnect active pull request context' }),
- ).toBeVisible()
- await expect(
- page.locator('.editor-panel[data-editor-kind="component"] .cm-content').first(),
- ).toContainText('Hello from Knighted')
-
- const reactivatedRecord = await getWorkspaceTabsRecord(page, {
- headBranch: activeHeadBranch,
- })
- expect(reactivatedRecord?.prContextState).toBe('active')
- expect(reactivatedRecord?.prNumber).toBe(2)
-})
-
test('Switching active workspace to inactive preserves switched-from record integrity', async ({
page,
}) => {
@@ -368,16 +30,6 @@ test('Switching active workspace to inactive preserves switched-from record inte
await expect(page.getByRole('status', { name: 'App status' })).toContainText('Rendered')
})
-test('Switching active workspace to disconnected preserves switched-from record integrity', async ({
- page,
-}) => {
- await runActiveWorkspaceSwitchIntegrityScenario({
- page,
- targetState: 'disconnected',
- })
- await expect(page.getByRole('status', { name: 'App status' })).toContainText('Rendered')
-})
-
test('Switching active workspace to closed preserves switched-from record integrity', async ({
page,
}) => {
@@ -398,16 +50,6 @@ test('Switching active workspace to cross-repo inactive preserves switched-from
await expect(page.getByRole('status', { name: 'App status' })).toContainText('Rendered')
})
-test('Switching active workspace to cross-repo disconnected preserves switched-from record integrity', async ({
- page,
-}) => {
- await runActiveWorkspaceCrossRepoSwitchIntegrityScenario({
- page,
- targetState: 'disconnected',
- })
- await expect(page.getByRole('status', { name: 'App status' })).toContainText('Rendered')
-})
-
test('Switching from one active context in source repo to target repo does not overwrite sibling active source context', async ({
page,
}) => {
diff --git a/playwright/github-pr-drawer/github-pr-drawer.helpers.ts b/playwright/github-pr-drawer/github-pr-drawer.helpers.ts
index 150dab3..feb826a 100644
--- a/playwright/github-pr-drawer/github-pr-drawer.helpers.ts
+++ b/playwright/github-pr-drawer/github-pr-drawer.helpers.ts
@@ -128,9 +128,9 @@ export const removeSavedGitHubToken = async (page: Page) => {
}
export const ensureWorkspacesDrawerOpen = async (page: Page) => {
- const select = page.getByLabel('Stored local editor contexts')
+ const drawer = page.getByRole('complementary', { name: 'Workspaces' })
- if (await select.isVisible()) {
+ if (await drawer.isVisible()) {
return
}
@@ -142,7 +142,7 @@ export const ensureWorkspacesDrawerOpen = async (page: Page) => {
}
await page.getByRole('button', { name: 'Workspaces' }).click()
- await expect(select).toBeVisible()
+ await expect(drawer).toBeVisible()
}
export const getWorkspaceRecordId = (
@@ -183,7 +183,7 @@ export const openStoredWorkspaceContextById = async (
repositoryFilter?: string
} = {},
) => {
- const select = page.getByLabel('Stored local editor contexts')
+ const select = page.getByLabel('Stored workspace')
const openButton = page.locator('#workspaces-open')
const resolveRepositoryFilterForWorkspace = async () => {
@@ -433,7 +433,7 @@ export const seedLocalWorkspaceContexts = async (
head: string
prTitle: string
prNumber?: number | null
- prContextState?: 'inactive' | 'active' | 'disconnected' | 'closed'
+ prContextState?: 'inactive' | 'active' | 'closed'
renderMode?: 'dom' | 'react'
tabs?: Array>
activeTabId?: string | null
@@ -585,7 +585,7 @@ export const seedActivePrWorkspaceContext = async (
export const getLocalContextOptionLabels = async (page: Page) => {
return page
- .getByLabel('Stored local editor contexts')
+ .getByLabel('Stored workspace')
.locator('option')
.evaluateAll(nodes => nodes.map(node => node.textContent?.trim() || ''))
}
@@ -725,7 +725,7 @@ export const runActiveWorkspaceSwitchIntegrityScenario = async ({
targetState,
}: {
page: Page
- targetState: 'inactive' | 'disconnected' | 'closed'
+ targetState: 'inactive' | 'closed'
}) => {
const repositoryFullName = 'knightedcodemonkey/develop'
const activeHeadBranch = 'develop/issue-97-active-a'
@@ -742,11 +742,8 @@ export const runActiveWorkspaceSwitchIntegrityScenario = async ({
targetState === 'inactive' ? '' : `Target ${targetState} workspace`
const targetPrNumber = targetState === 'inactive' ? null : 9
const usesPromotedSourceSnapshot =
- targetState === 'inactive' ||
- targetState === 'disconnected' ||
- targetState === 'closed'
- const expectedTargetPrContextState =
- targetState === 'disconnected' ? 'active' : targetState
+ targetState === 'inactive' || targetState === 'closed'
+ const expectedTargetPrContextState = targetState
await page.route('https://api.github.com/user/repos**', async route => {
await route.fulfill({
@@ -937,27 +934,11 @@ export const runActiveWorkspaceSwitchIntegrityScenario = async ({
}
}
- if (targetState !== 'disconnected') {
- await expect
- .poll(async () => {
- return readSnapshot()
- })
- .toEqual(usesPromotedSourceSnapshot ? promotedSnapshot : originalSnapshot)
- return
- }
-
- const toSnapshotKey = (value: unknown) => JSON.stringify(value)
-
await expect
.poll(async () => {
- const snapshot = await readSnapshot()
- const snapshotKey = toSnapshotKey(snapshot)
- return (
- snapshotKey === toSnapshotKey(promotedSnapshot) ||
- snapshotKey === toSnapshotKey(originalSnapshot)
- )
+ return readSnapshot()
})
- .toBe(true)
+ .toEqual(usesPromotedSourceSnapshot ? promotedSnapshot : originalSnapshot)
}
export const runActiveWorkspaceCrossRepoSwitchIntegrityScenario = async ({
@@ -965,7 +946,7 @@ export const runActiveWorkspaceCrossRepoSwitchIntegrityScenario = async ({
targetState,
}: {
page: Page
- targetState: 'inactive' | 'disconnected' | 'closed'
+ targetState: 'inactive' | 'closed'
}) => {
const sourceRepositoryFullName = 'knightedcodemonkey/develop'
const targetRepositoryFullName = 'knightedcodemonkey/css'
@@ -982,8 +963,7 @@ export const runActiveWorkspaceCrossRepoSwitchIntegrityScenario = async ({
const targetPrTitle =
targetState === 'inactive' ? '' : `Cross target ${targetState} workspace`
const targetPrNumber = 9
- const expectedTargetPrContextState =
- targetState === 'disconnected' ? 'active' : targetState
+ const expectedTargetPrContextState = targetState
await page.route('https://api.github.com/user/repos**', async route => {
await route.fulfill({
diff --git a/playwright/github-pr-drawer/open-pr-create.spec.ts b/playwright/github-pr-drawer/open-pr-create.spec.ts
index 02e0fc8..482d93c 100644
--- a/playwright/github-pr-drawer/open-pr-create.spec.ts
+++ b/playwright/github-pr-drawer/open-pr-create.spec.ts
@@ -509,15 +509,60 @@ test('Workspaces repository selector filters contexts and keeps local-only conte
await selectWorkspacesRepositoryFilter(page, 'knightedcodemonkey/develop')
const developLabels = await getLocalContextOptionLabels(page)
- expect(developLabels).toEqual(['Select a stored local context', 'Alpha active context'])
+ expect(developLabels).toEqual(['Select a stored workspace', 'Alpha active context'])
await selectWorkspacesRepositoryFilter(page, '__local__')
const localLabels = await getLocalContextOptionLabels(page)
- expect(localLabels).toContain('Select a stored local context')
+ expect(localLabels).toContain('Select a stored workspace')
expect(localLabels).toContain('local:Alpha local context')
expect(localLabels).not.toContain('Alpha active context')
})
+test('Workspaces repository with no stored entries hides Workspace select and supports New workspace', async ({
+ page,
+}) => {
+ await waitForAppReady(page, `${appEntryPath}`)
+
+ 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 },
+ },
+ ]),
+ })
+ })
+
+ await page
+ .getByRole('textbox', { name: 'GitHub token' })
+ .fill('github_pat_fake_1234567890')
+ await page.getByRole('button', { name: 'Add GitHub token' }).click()
+
+ await selectWorkspacesRepositoryFilter(page, 'knightedcodemonkey/develop')
+ await expect(page.getByLabel('Stored workspace')).toBeHidden()
+ await expect(page.getByRole('button', { name: 'Open', exact: true })).toBeHidden()
+ await expect(page.getByRole('button', { name: 'Remove', exact: true })).toBeHidden()
+
+ const newWorkspaceButton = page.getByRole('button', {
+ name: 'New workspace',
+ exact: true,
+ })
+ await expect(newWorkspaceButton).toBeVisible()
+ await newWorkspaceButton.click()
+
+ await ensureOpenPrDrawerOpen(page)
+ await expect(page.getByLabel('Pull request repository')).toHaveValue(
+ 'knightedcodemonkey/develop',
+ )
+})
+
test('Switching Workspaces repository scope to Local keeps inactive record repo and shows it as local in drawer', async ({
page,
}) => {
@@ -779,7 +824,7 @@ test('Changing head updates current workspace without creating a new record', as
})
})
-for (const prContextState of ['inactive', 'disconnected', 'closed'] as const) {
+for (const prContextState of ['inactive', 'closed'] as const) {
test(`Head stays fixed across repository changes for ${prContextState} workspace context`, async ({
page,
browserName,
diff --git a/playwright/helpers/app-test-helpers.ts b/playwright/helpers/app-test-helpers.ts
index f4e8c3e..c2e9b17 100644
--- a/playwright/helpers/app-test-helpers.ts
+++ b/playwright/helpers/app-test-helpers.ts
@@ -412,8 +412,7 @@ export const ensureWorkspacesDrawerClosed = async (page: Page) => {
const toggle = page.locator('#workspaces-toggle')
await expect(toggle).toBeVisible()
- const isExpanded = await toggle.getAttribute('aria-expanded')
- if (isExpanded === 'true') {
+ const requestClose = async () => {
const closeButton = page.locator('#workspaces-close')
if (await closeButton.isVisible()) {
await closeButton.evaluate(element => {
@@ -421,15 +420,32 @@ export const ensureWorkspacesDrawerClosed = async (page: Page) => {
element.click()
}
})
- } else {
- await toggle.evaluate(element => {
- if (element instanceof HTMLButtonElement) {
- element.click()
- }
- })
+ return
}
+
+ await toggle.evaluate(element => {
+ if (element instanceof HTMLButtonElement) {
+ element.click()
+ }
+ })
}
+ const isExpanded = await toggle.getAttribute('aria-expanded')
+ if (isExpanded === 'true') {
+ await requestClose()
+ }
+
+ await expect
+ .poll(async () => {
+ const expanded = await toggle.getAttribute('aria-expanded')
+ if (expanded === 'true') {
+ await requestClose()
+ }
+
+ return expanded
+ })
+ .toBe('false')
+
await expect(toggle).toHaveAttribute('aria-expanded', 'false')
await expect(page.getByRole('complementary', { name: 'Workspaces' })).toBeHidden()
}
diff --git a/src/app.js b/src/app.js
index 3601cdb..939e91b 100644
--- a/src/app.js
+++ b/src/app.js
@@ -123,7 +123,6 @@ const githubPrToggleLabel = document.getElementById('github-pr-toggle-label')
const githubPrToggleIcon = document.getElementById('github-pr-toggle-icon')
const githubPrToggleIconPath = document.getElementById('github-pr-toggle-icon-path')
const githubPrContextClose = document.getElementById('github-pr-context-close')
-const githubPrContextDisconnect = document.getElementById('github-pr-context-disconnect')
const githubPrDrawer = document.getElementById('github-pr-drawer')
const openPrTitle = document.getElementById('open-pr-title')
const githubPrClose = document.getElementById('github-pr-close')
@@ -141,6 +140,7 @@ const workspacesDrawer = document.getElementById('workspaces-drawer')
const workspacesClose = document.getElementById('workspaces-close')
const workspacesStatus = document.getElementById('workspaces-status')
const workspacesRepository = document.getElementById('workspaces-repository')
+const workspacesNew = document.getElementById('workspaces-new')
const workspacesSelect = document.getElementById('workspaces-select')
const workspacesOpen = document.getElementById('workspaces-open')
const workspacesRemove = document.getElementById('workspaces-remove')
@@ -458,7 +458,6 @@ const prContextUi = createGitHubPrContextUiController({
stylesPrSyncIcon,
stylesPrSyncIconPath,
githubPrContextClose,
- githubPrContextDisconnect,
aiChatToggle,
workspacesToggle,
githubPrOpenIcon,
@@ -830,7 +829,7 @@ const {
getWorkspaceTabByKind,
makeUniqueTabPath,
createWorkspaceTabId,
- onWorkspaceRecordApplied: (workspace, options = {}) => {
+ onWorkspaceRecordApplied: workspace => {
if (!workspace || typeof workspace !== 'object') {
return
}
@@ -844,14 +843,11 @@ const {
prDrawerController.clearSelectedRepositoryActivePrContext({ resetForm: false })
- const isSilentRestore = options?.silent === true
-
const state =
typeof workspace.prContextState === 'string'
? workspace.prContextState.trim().toLowerCase()
: ''
- const shouldHydratePrContext =
- state === 'active' || (state === 'disconnected' && !isSilentRestore)
+ const shouldHydratePrContext = state === 'active'
if (!shouldHydratePrContext) {
return
}
@@ -1065,6 +1061,7 @@ const githubWorkflows = createGitHubWorkflowsSetup({
workspacesClose,
workspacesStatus,
workspacesRepository,
+ workspacesNew,
workspacesSelect,
workspacesOpen,
workspacesRemove,
@@ -1123,14 +1120,6 @@ const githubWorkflows = createGitHubWorkflowsSetup({
'PR context closed. Open Workspaces to load a saved workspace or continue with this local workspace.',
})
},
- onPrContextDisconnected: result => {
- archivePrSessionAndStartFreshLocal({
- result,
- archivedState: 'disconnected',
- statusMessage:
- 'PR context disconnected. Open Workspaces to load a saved workspace or continue with this local workspace.',
- })
- },
getPersistedActivePrContext,
getTokenForVisibility: () => githubAiContextState.token,
closeWorkspacesDrawer: () => {
@@ -1148,7 +1137,6 @@ const githubWorkflows = createGitHubWorkflowsSetup({
},
formatActivePrReference,
githubPrContextClose,
- githubPrContextDisconnect,
},
actions: {
applyRenderMode,
diff --git a/src/index.html b/src/index.html
index 98aceef..d510fa9 100644
--- a/src/index.html
+++ b/src/index.html
@@ -195,30 +195,6 @@
Close
-
-
-
+
+
+
+
diff --git a/src/modules/app-core/github-pr-context-ui.js b/src/modules/app-core/github-pr-context-ui.js
index 5536e7d..a047410 100644
--- a/src/modules/app-core/github-pr-context-ui.js
+++ b/src/modules/app-core/github-pr-context-ui.js
@@ -10,7 +10,6 @@ export const createGitHubPrContextUiController = ({
stylesPrSyncIcon,
stylesPrSyncIconPath,
githubPrContextClose,
- githubPrContextDisconnect,
aiChatToggle,
workspacesToggle,
githubPrOpenIcon,
@@ -85,12 +84,10 @@ export const createGitHubPrContextUiController = ({
if (!hasActiveContext) {
githubPrContextClose?.setAttribute('hidden', '')
- githubPrContextDisconnect?.setAttribute('hidden', '')
return
}
githubPrContextClose?.removeAttribute('hidden')
- githubPrContextDisconnect?.removeAttribute('hidden')
}
const markActivePrEditorContentSynced = () => {
@@ -126,10 +123,8 @@ export const createGitHubPrContextUiController = ({
if (contextState.activePrContext) {
githubPrContextClose?.removeAttribute('hidden')
- githubPrContextDisconnect?.removeAttribute('hidden')
} else {
githubPrContextClose?.setAttribute('hidden', '')
- githubPrContextDisconnect?.setAttribute('hidden', '')
}
return
}
@@ -155,7 +150,6 @@ export const createGitHubPrContextUiController = ({
}
workspacesToggle?.setAttribute('aria-expanded', 'false')
githubPrContextClose?.setAttribute('hidden', '')
- githubPrContextDisconnect?.setAttribute('hidden', '')
closeChatDrawer?.()
closePrDrawer?.()
void closeWorkspacesDrawer?.()
diff --git a/src/modules/app-core/github-workflows-setup.js b/src/modules/app-core/github-workflows-setup.js
index 6ebc4b6..90d210f 100644
--- a/src/modules/app-core/github-workflows-setup.js
+++ b/src/modules/app-core/github-workflows-setup.js
@@ -40,7 +40,6 @@ const createGitHubWorkflowsSetup = ({
onPrContextStateChange: runtime.onPrContextStateChange,
onPrContextVerifiedClosed: runtime.onPrContextVerifiedClosed,
onPrContextClosed: runtime.onPrContextClosed,
- onPrContextDisconnected: runtime.onPrContextDisconnected,
getTokenForVisibility: runtime.getTokenForVisibility,
closeWorkspacesDrawer: runtime.closeWorkspacesDrawer,
getActivePrEditorSyncKey: runtime.getActivePrEditorSyncKey,
@@ -49,7 +48,6 @@ const createGitHubWorkflowsSetup = ({
applyStyleMode: actions.applyStyleMode,
formatActivePrReference: runtime.formatActivePrReference,
githubPrContextClose: runtime.githubPrContextClose,
- githubPrContextDisconnect: runtime.githubPrContextDisconnect,
confirmAction: actions.confirmAction,
setStatus: actions.setStatus,
showAppToast: actions.showAppToast,
diff --git a/src/modules/app-core/github-workflows.js b/src/modules/app-core/github-workflows.js
index 472c647..add666a 100644
--- a/src/modules/app-core/github-workflows.js
+++ b/src/modules/app-core/github-workflows.js
@@ -1,5 +1,3 @@
-import { repositoryStarterSelectionIdPrefix } from '../constants.js'
-
const initializeGitHubWorkflows = ({
createGitHubPrEditorSyncController,
createGitHubChatDrawer,
@@ -42,6 +40,7 @@ const initializeGitHubWorkflows = ({
workspacesClose,
workspacesStatus,
workspacesRepository,
+ workspacesNew,
workspacesSelect,
workspacesOpen,
workspacesRemove,
@@ -66,7 +65,6 @@ const initializeGitHubWorkflows = ({
onPrContextStateChange,
onPrContextVerifiedClosed,
onPrContextClosed,
- onPrContextDisconnected,
getTokenForVisibility,
closeWorkspacesDrawer,
getActivePrEditorSyncKey,
@@ -75,7 +73,6 @@ const initializeGitHubWorkflows = ({
applyStyleMode,
formatActivePrReference,
githubPrContextClose,
- githubPrContextDisconnect,
confirmAction,
setStatus,
showAppToast,
@@ -102,18 +99,6 @@ const initializeGitHubWorkflows = ({
return collectTopLevelDeclarations({ source, transformJsxSource })
}
- const parseRepositoryStarterSelectionId = value => {
- const normalizedValue = typeof value === 'string' ? value.trim() : ''
- if (!normalizedValue.startsWith(repositoryStarterSelectionIdPrefix)) {
- return ''
- }
-
- const repositoryFullName = normalizedValue.slice(
- repositoryStarterSelectionIdPrefix.length,
- )
- return typeof repositoryFullName === 'string' ? repositoryFullName.trim() : ''
- }
-
const shouldReconcileWorkspaceUpdatesForRepository = repositoryFullName => {
const normalizedActiveRepository =
typeof githubAiContextState.activePrContext?.repositoryFullName === 'string'
@@ -315,6 +300,7 @@ const initializeGitHubWorkflows = ({
closeButton: workspacesClose,
statusNode: workspacesStatus,
repositorySelect: workspacesRepository,
+ newButton: workspacesNew,
selectInput: workspacesSelect,
openButton: workspacesOpen,
removeButton: workspacesRemove,
@@ -348,25 +334,38 @@ const initializeGitHubWorkflows = ({
return 'right'
},
onRefreshRequested: listLocalContextRecords,
- onOpenSelected: async workspaceId => {
+ onCreateWorkspace: async repositoryFilter => {
+ const normalizedFilter =
+ typeof repositoryFilter === 'string' ? repositoryFilter.trim() : ''
+ const repositoryFullName =
+ normalizedFilter && normalizedFilter !== '__local__' ? normalizedFilter : ''
+
try {
- const starterRepositoryFullName = parseRepositoryStarterSelectionId(workspaceId)
- if (starterRepositoryFullName) {
- setCurrentSelectedRepository?.(starterRepositoryFullName)
- await syncActiveWorkspaceRepositoryScope?.(starterRepositoryFullName, {
- rekeyRecord: true,
- })
- await refreshLocalContextOptions()
- prDrawerController.resetStatus?.()
- prDrawerController.syncRepositories()
- return true
+ if (!repositoryFullName) {
+ clearCurrentSelectedRepository?.()
+ } else {
+ setCurrentSelectedRepository?.(repositoryFullName)
}
+ await syncActiveWorkspaceRepositoryScope?.(repositoryFullName, {
+ rekeyRecord: true,
+ })
+ await refreshLocalContextOptions()
+ prDrawerController.resetStatus?.()
+ prDrawerController.syncRepositories()
+ return true
+ } catch {
+ workspacesDrawerController?.setStatus('Could not create workspace.', 'error')
+ return false
+ }
+ },
+ onOpenSelected: async workspaceId => {
+ try {
const record = await workspaceStorage.getWorkspaceById(workspaceId)
if (!record) {
await refreshLocalContextOptions()
workspacesDrawerController?.setStatus(
- 'Stored local context no longer exists.',
+ 'Stored workspace no longer exists.',
'error',
)
return false
@@ -381,7 +380,7 @@ const initializeGitHubWorkflows = ({
return applied
} catch {
workspacesDrawerController?.setStatus(
- 'Could not load selected local context.',
+ 'Could not load selected workspace.',
'error',
)
return false
@@ -389,7 +388,7 @@ const initializeGitHubWorkflows = ({
},
onRemoveSelected: async workspaceId => {
confirmAction({
- title: 'Remove stored local context?',
+ title: 'Remove stored workspace?',
copy: 'This removes only local workspace metadata and editor content from this browser.',
confirmButtonText: 'Remove',
onConfirm: () => {
@@ -403,13 +402,13 @@ const initializeGitHubWorkflows = ({
await refreshLocalContextOptions()
workspacesDrawerController?.setStatus(
- 'Removed stored local context.',
+ 'Removed stored workspace.',
'neutral',
)
})
.catch(() => {
workspacesDrawerController?.setStatus(
- 'Could not remove stored local context.',
+ 'Could not remove stored workspace.',
'error',
)
})
@@ -474,36 +473,6 @@ const initializeGitHubWorkflows = ({
})
})
- githubPrContextDisconnect?.addEventListener('click', () => {
- if (!githubAiContextState.activePrContext) {
- return
- }
-
- const activePrReference = formatActivePrReference(
- githubAiContextState.activePrContext,
- )
- const referenceLine = activePrReference ? `PR: ${activePrReference}\n` : ''
-
- confirmAction({
- title: 'Disconnect PR context?',
- copy: `${referenceLine}This will disconnect the active pull request context in this app only.\nYour pull request will stay open on GitHub.\nYour GitHub token and selected repository will stay connected.`,
- confirmButtonText: 'Disconnect',
- onConfirm: () => {
- const result = prDrawerController.disconnectActivePrContext()
- const reference = result?.reference
- setStatus(
- reference
- ? `Disconnected PR context (${reference}). Pull request remains open on GitHub.`
- : 'Disconnected PR context. Pull request remains open on GitHub.',
- 'neutral',
- )
- if (typeof onPrContextDisconnected === 'function') {
- onPrContextDisconnected(result)
- }
- },
- })
- })
-
return {
chatDrawerController,
prDrawerController,
diff --git a/src/modules/constants.js b/src/modules/constants.js
deleted file mode 100644
index 624e03a..0000000
--- a/src/modules/constants.js
+++ /dev/null
@@ -1 +0,0 @@
-export const repositoryStarterSelectionIdPrefix = '__create_repository_context__:'
diff --git a/src/modules/github/pr/drawer/common.js b/src/modules/github/pr/drawer/common.js
index 4578483..3bc542c 100644
--- a/src/modules/github/pr/drawer/common.js
+++ b/src/modules/github/pr/drawer/common.js
@@ -2,7 +2,7 @@ const defaultCommitMessage = 'chore: sync editor updates from @knighted/develop'
const supportedRenderModes = new Set(['dom', 'react'])
const supportedStyleModes = new Set(['css', 'module', 'less', 'sass'])
-const supportedPrContextStates = new Set(['inactive', 'active', 'disconnected', 'closed'])
+const supportedPrContextStates = new Set(['inactive', 'active', 'closed'])
const toSafeText = value => (typeof value === 'string' ? value.trim() : '')
@@ -26,6 +26,10 @@ const normalizeStyleMode = value => {
const normalizePrContextState = value => {
const state = toSafeText(value).toLowerCase()
+ if (state === 'disconnected') {
+ return 'inactive'
+ }
+
return supportedPrContextStates.has(state) ? state : 'inactive'
}
diff --git a/src/modules/github/pr/drawer/controller/public-actions.js b/src/modules/github/pr/drawer/controller/public-actions.js
index 9daca47..acca1b7 100644
--- a/src/modules/github/pr/drawer/controller/public-actions.js
+++ b/src/modules/github/pr/drawer/controller/public-actions.js
@@ -44,25 +44,6 @@ export const createPublicActions = ({
}
return {
- disconnectActivePrContext: () => {
- const repository = getSelectedRepositoryObject()
- const repositoryFullName = getRepositoryFullName(repository)
- if (!repositoryFullName) {
- return { reference: '' }
- }
-
- const activeContext = getCurrentActivePrContext()
- clearSelectedRepositoryActivePrContext()
-
- return {
- reference: formatActivePrReference(activeContext),
- pullRequestNumber:
- typeof activeContext?.pullRequestNumber === 'number' &&
- Number.isFinite(activeContext.pullRequestNumber)
- ? activeContext.pullRequestNumber
- : null,
- }
- },
clearActivePrContext: () => {
clearSelectedRepositoryActivePrContext({ resetForm: true })
},
diff --git a/src/modules/workspace/workspaces-drawer/drawer.js b/src/modules/workspace/workspaces-drawer/drawer.js
index 03c219e..c776d80 100644
--- a/src/modules/workspace/workspaces-drawer/drawer.js
+++ b/src/modules/workspace/workspaces-drawer/drawer.js
@@ -1,21 +1,7 @@
-import { repositoryStarterSelectionIdPrefix } from '../../constants.js'
-
const toSafeText = value => (typeof value === 'string' ? value.trim() : '')
const localRepositoryFilterValue = '__local__'
-const toRepositoryStarterSelectionId = repositoryFullName => {
- const repository = toSafeText(repositoryFullName)
- if (!repository || repository === localRepositoryFilterValue) {
- return ''
- }
-
- return `${repositoryStarterSelectionIdPrefix}${repository}`
-}
-
-const isRepositoryStarterSelectionId = value =>
- toSafeText(value).startsWith(repositoryStarterSelectionIdPrefix)
-
const isLocalWorkspaceEntry = workspace => {
const repository = toSafeText(workspace?.repo)
return !repository
@@ -50,6 +36,7 @@ export const createWorkspacesDrawer = ({
closeButton,
statusNode,
repositorySelect,
+ newButton,
selectInput,
openButton,
removeButton,
@@ -58,6 +45,7 @@ export const createWorkspacesDrawer = ({
getSelectedRepositoryFilter,
onRepositoryFilterChange,
onRefreshRequested,
+ onCreateWorkspace,
onOpenSelected,
onRemoveSelected,
} = {}) => {
@@ -66,6 +54,7 @@ export const createWorkspacesDrawer = ({
let selectedId = ''
let selectedRepositoryFilter = localRepositoryFilterValue
let hasUserSelectedRepositoryFilter = false
+ let hasStoredWorkspacesInScope = false
const getNormalizedRepositoryFilter = value => {
const normalized = toSafeText(value)
@@ -107,14 +96,21 @@ export const createWorkspacesDrawer = ({
const updateActions = () => {
const normalizedSelectedId = toSafeText(selectedId)
const hasSelection = normalizedSelectedId.length > 0
- const isStarterSelection = isRepositoryStarterSelectionId(normalizedSelectedId)
+
+ if (newButton instanceof HTMLButtonElement) {
+ newButton.disabled = typeof onCreateWorkspace !== 'function'
+ }
if (openButton instanceof HTMLButtonElement) {
+ openButton.toggleAttribute('hidden', !hasStoredWorkspacesInScope)
+ openButton.style.display = hasStoredWorkspacesInScope ? '' : 'none'
openButton.disabled = !hasSelection
}
if (removeButton instanceof HTMLButtonElement) {
- removeButton.disabled = !hasSelection || isStarterSelection
+ removeButton.toggleAttribute('hidden', !hasStoredWorkspacesInScope)
+ removeButton.style.display = hasStoredWorkspacesInScope ? '' : 'none'
+ removeButton.disabled = !hasSelection
}
}
@@ -125,37 +121,29 @@ export const createWorkspacesDrawer = ({
const repositoryFilteredEntries = getFilteredEntriesByRepository()
const filteredEntries = repositoryFilteredEntries
- const normalizedRepositoryFilter = getNormalizedRepositoryFilter(
- selectedRepositoryFilter,
- )
- const starterSelectionId =
- filteredEntries.length === 0
- ? toRepositoryStarterSelectionId(normalizedRepositoryFilter)
- : ''
- const hasStarterSelection = Boolean(starterSelectionId)
+ const workspaceField = selectInput.closest('label')
+ const hasStoredWorkspaces = filteredEntries.length > 0
+ hasStoredWorkspacesInScope = hasStoredWorkspaces
+
+ if (workspaceField instanceof HTMLElement) {
+ workspaceField.toggleAttribute('hidden', !hasStoredWorkspaces)
+ }
+
+ if (!hasStoredWorkspaces) {
+ selectedId = ''
+ updateActions()
+ return
+ }
selectInput.replaceChildren()
const placeholder = document.createElement('option')
placeholder.value = ''
- placeholder.textContent =
- repositoryFilteredEntries.length === 0
- ? hasStarterSelection
- ? 'Select to start a new local context'
- : 'No saved local contexts'
- : 'Select a stored local context'
- placeholder.disabled = filteredEntries.length > 0 || hasStarterSelection
+ placeholder.textContent = 'Select a stored workspace'
+ placeholder.disabled = true
placeholder.selected = !filteredEntries.some(entry => entry.id === selectedId)
selectInput.append(placeholder)
- if (hasStarterSelection) {
- const starterOption = document.createElement('option')
- starterOption.value = starterSelectionId
- starterOption.textContent = `Start new context for ${normalizedRepositoryFilter}`
- starterOption.selected = selectedId === starterSelectionId
- selectInput.append(starterOption)
- }
-
for (const entry of filteredEntries) {
const option = document.createElement('option')
option.value = toSafeText(entry.id)
@@ -167,11 +155,9 @@ export const createWorkspacesDrawer = ({
const hasSelectedFilteredEntry = filteredEntries.some(
entry => entry.id === selectedId,
)
- const hasSelectedStarterEntry =
- hasStarterSelection && selectedId === starterSelectionId
- if (!hasSelectedFilteredEntry && !hasSelectedStarterEntry) {
- selectedId = hasStarterSelection ? starterSelectionId : ''
+ if (!hasSelectedFilteredEntry) {
+ selectedId = ''
selectInput.value = selectedId
}
@@ -247,7 +233,7 @@ export const createWorkspacesDrawer = ({
} catch {
entries = []
selectedId = ''
- setStatus('Could not refresh stored local contexts.', 'error')
+ setStatus('Could not refresh stored workspaces.', 'error')
renderOptions()
return entries
}
@@ -294,11 +280,17 @@ export const createWorkspacesDrawer = ({
open = false
toggleButton.setAttribute('aria-expanded', 'false')
drawer.toggleAttribute('hidden', true)
- setStatus('Could not open local workspaces drawer.', 'error')
+ setStatus('Could not open workspaces drawer.', 'error')
return
}
- selectInput?.focus()
+ const workspaceField = selectInput?.closest('label')
+ if (workspaceField instanceof HTMLElement && !workspaceField.hasAttribute('hidden')) {
+ selectInput?.focus()
+ return
+ }
+
+ newButton?.focus()
}
toggleButton?.addEventListener('click', () => {
@@ -325,6 +317,30 @@ export const createWorkspacesDrawer = ({
updateActions()
})
+ newButton?.addEventListener('click', async () => {
+ if (typeof onCreateWorkspace !== 'function') {
+ return
+ }
+
+ let created = false
+ try {
+ created = await onCreateWorkspace(
+ getNormalizedRepositoryFilter(selectedRepositoryFilter),
+ )
+ } catch {
+ setStatus('Could not create workspace.', 'error')
+ return
+ }
+
+ if (!created) {
+ return
+ }
+
+ selectedId = ''
+ setStatus('Created workspace.', 'neutral')
+ await refresh({ preserveSelection: false })
+ })
+
openButton?.addEventListener('click', async () => {
const id = toSafeText(selectedId)
if (!id || typeof onOpenSelected !== 'function') {
@@ -337,7 +353,7 @@ export const createWorkspacesDrawer = ({
try {
opened = await onOpenSelected(id)
} catch {
- setStatus('Could not load selected local context.', 'error')
+ setStatus('Could not load selected workspace.', 'error')
return
}
@@ -345,7 +361,7 @@ export const createWorkspacesDrawer = ({
return
}
- setStatus('Loaded local workspace context.', 'neutral')
+ setStatus('Loaded workspace.', 'neutral')
void refresh({ preserveSelection: true })
})
@@ -359,7 +375,7 @@ export const createWorkspacesDrawer = ({
try {
removed = await onRemoveSelected(id)
} catch {
- setStatus('Could not remove selected local context.', 'error')
+ setStatus('Could not remove selected workspace.', 'error')
return
}
@@ -368,7 +384,7 @@ export const createWorkspacesDrawer = ({
}
selectedId = ''
- setStatus('Removed stored local context.', 'neutral')
+ setStatus('Removed stored workspace.', 'neutral')
await refresh({ preserveSelection: false })
})
diff --git a/src/styles/ai-controls.css b/src/styles/ai-controls.css
index 09d3d15..222f3f7 100644
--- a/src/styles/ai-controls.css
+++ b/src/styles/ai-controls.css
@@ -430,61 +430,6 @@
display: none;
}
-.diagnostics-toggle.github-pr-context-disconnect {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- --github-pr-disconnect-icon-color: color-mix(
- in srgb,
- var(--accent) 78%,
- var(--panel-text)
- );
- --github-pr-disconnect-text-color: var(--shell-text);
- --github-pr-disconnect-text-color-hover: color-mix(
- in srgb,
- var(--accent) 78%,
- var(--panel-text)
- );
-
- color: var(--github-pr-disconnect-text-color);
-}
-
-:root[data-theme='light'] .diagnostics-toggle.github-pr-context-disconnect {
- --github-pr-disconnect-icon-color: color-mix(in srgb, #6d28d9 84%, var(--panel-text));
- --github-pr-disconnect-text-color-hover: color-mix(
- in srgb,
- #6d28d9 88%,
- var(--panel-text)
- );
-}
-
-.github-pr-context-disconnect__icon {
- width: 14px;
- height: 14px;
- fill: var(--github-pr-disconnect-icon-color);
-}
-
-.github-pr-context-disconnect__icon path {
- fill: var(--github-pr-disconnect-icon-color);
-}
-
-.diagnostics-toggle.github-pr-context-disconnect:hover:not(:disabled) {
- color: var(--github-pr-disconnect-text-color-hover);
-}
-
-.github-pr-context-disconnect__label {
- color: inherit;
-}
-
-.github-pr-context-disconnect:focus-visible:not(:disabled) {
- outline: 2px solid var(--focus-ring);
- outline-offset: 1px;
-}
-
-.github-pr-context-disconnect[hidden] {
- display: none;
-}
-
.ai-chat-drawer {
position: fixed;
top: 82px;
@@ -623,12 +568,35 @@
padding: 2px;
}
+.workspaces-drawer__repository-row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 8px;
+ align-items: end;
+}
+
+.workspaces-drawer__repository-row .github-pr-field--full {
+ grid-column: auto;
+}
+
+.workspaces-drawer__repository-row .render-button {
+ height: 36px;
+ width: auto;
+ min-width: fit-content;
+ padding-inline: 12px;
+ white-space: nowrap;
+}
+
.workspaces-drawer__actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
+.workspaces-drawer__actions[hidden] {
+ display: none;
+}
+
.github-pr-drawer__header {
display: flex;
align-items: center;
From 5ef284db7a34bf5f4642ee50e984399c333fbfd5 Mon Sep 17 00:00:00 2001
From: KCM
Date: Wed, 29 Apr 2026 14:05:21 -0500
Subject: [PATCH 2/5] fix: workspace identity on changing for same repo.
---
docs/idb-workspace-state.md | 7 +
docs/localstorage-state.md | 4 +
docs/workspaces-behavior-algorithm.md | 96 ++
playwright/github-byot-ai.spec.ts | 9 +-
...-context-switch-debounce-essential.spec.ts | 341 +++++++
.../active-context-switch.spec.ts | 485 +++++++++-
.../active-context-sync.spec.ts | 748 +++++++++++++++-
.../github-pr-drawer.helpers.ts | 26 +-
.../github-pr-drawer/open-pr-create.spec.ts | 831 +++++++++++++++++-
playwright/helpers/app-test-helpers.ts | 39 +-
src/app.js | 83 +-
src/index.html | 48 +-
src/modules/app-core/app-bindings-startup.js | 14 -
.../app-core/github-workflows-setup.js | 3 +-
src/modules/app-core/github-workflows.js | 118 ++-
.../app-core/workspace-context-controller.js | 33 +-
.../app-core/workspace-controllers-setup.js | 17 +
.../app-core/workspace-editor-helpers.js | 28 +-
...workspace-pr-session-handoff-controller.js | 7 +
.../app-core/workspace-save-controller.js | 199 ++++-
.../app-core/workspace-scope-fork-actions.js | 183 ++++
.../app-core/workspace-sync-controller.js | 89 +-
src/modules/github/api/editor-content.js | 124 ++-
.../pr/drawer/controller/context-sync.js | 33 +-
.../pr/drawer/controller/create-controller.js | 2 +
.../github/pr/drawer/controller/run-submit.js | 28 +-
src/modules/github/pr/editor-sync.js | 169 ++--
src/modules/workspace/workspace-storage.js | 16 +
.../workspace/workspaces-drawer/drawer.js | 201 ++++-
src/styles/ai-controls.css | 6 +
30 files changed, 3508 insertions(+), 479 deletions(-)
create mode 100644 docs/workspaces-behavior-algorithm.md
create mode 100644 playwright/github-pr-drawer/active-context-switch-debounce-essential.spec.ts
create mode 100644 src/modules/app-core/workspace-scope-fork-actions.js
diff --git a/docs/idb-workspace-state.md b/docs/idb-workspace-state.md
index e361499..752afa4 100644
--- a/docs/idb-workspace-state.md
+++ b/docs/idb-workspace-state.md
@@ -17,6 +17,7 @@ Each workspace record may include:
- `id`
- `createdAt`
- `lastModified`
+ - `workspaceScope` (`local` | `repository`)
- Repository and PR context:
- `repo`
- `base`
@@ -44,3 +45,9 @@ IDB supports that by storing:
If a value is required to accurately restore PR/workspace behavior after reload, it must live in IDB records.
`localStorage` should only mirror user preferences and lightweight bootstrap values.
+
+## Behavioral Spec
+
+For action-level drawer semantics and state machine behavior, see:
+
+- `docs/workspaces-behavior-algorithm.md`
diff --git a/docs/localstorage-state.md b/docs/localstorage-state.md
index 90eac89..ae273a2 100644
--- a/docs/localstorage-state.md
+++ b/docs/localstorage-state.md
@@ -32,3 +32,7 @@ Examples that must stay out of `localStorage`:
If data is needed to restore workspace or pull request workflow state, it belongs in IndexedDB workspace records.
Repository selection is derived from in-memory BYOT controls and IndexedDB-backed workspace records, not from a dedicated localStorage key.
+
+For the Workspaces drawer action/state algorithm, see:
+
+- `docs/workspaces-behavior-algorithm.md`
diff --git a/docs/workspaces-behavior-algorithm.md b/docs/workspaces-behavior-algorithm.md
new file mode 100644
index 0000000..f34bd7a
--- /dev/null
+++ b/docs/workspaces-behavior-algorithm.md
@@ -0,0 +1,96 @@
+# Workspaces Drawer Behavior Algorithm
+
+This document locks in the intended behavior for Workspaces drawer actions.
+
+## Goals
+
+- Keep action semantics explicit and predictable.
+- Keep button visibility state-driven and mutually exclusive.
+- Preserve workspace restore behavior by persisting state in IndexedDB.
+
+## Core Terms
+
+- `Local` scope: Workspaces whose `workspaceScope` is `local`.
+- `Repository` scope: Workspaces whose `workspaceScope` is `repository` and whose `repo` matches the selected repository filter.
+- `workspaceKey`: Derived identity key from repository + head branch. Used for matching/preference logic, not for UI policy by itself.
+
+## Required Invariants
+
+1. `Initialize` and `New workspace` must never be visible at the same time.
+2. `Local` scope never shows `Initialize`.
+3. `Initialize` for non-Local empty scope updates the active workspace in place (no fork).
+4. `New workspace` always forks from current editor/runtime state into a new record id.
+5. Fork creation must generate a fresh head branch suffix so `workspaceKey` and visible labels are distinct.
+6. Any workspace created via `New workspace` must persist with `prContextState = "inactive"`.
+7. For `New workspace`, `workspaceScope` is target-dependent:
+ - `local` when repository filter is `__local__`
+ - `repository` when repository filter is a non-local `owner/repo`
+
+## UI State Machine
+
+State is derived from:
+
+- Selected repository filter (`__local__` vs non-local `owner/repo`)
+- Presence of stored workspaces in the selected scope
+
+States:
+
+1. `local-empty`
+ - Show: `New workspace`
+ - Hide: `Initialize`, workspace select, `Open`, `Remove`
+2. `local-with-workspaces`
+ - Show: `New workspace`, workspace select, `Open`, `Remove`
+ - Hide: `Initialize`
+3. `repository-empty`
+ - Show: `Initialize`
+ - Hide: `New workspace`, workspace select, `Open`, `Remove`
+4. `repository-with-workspaces`
+ - Show: `New workspace`, workspace select, `Open`, `Remove`
+ - Hide: `Initialize`
+
+## Action Semantics
+
+### A) Local + New workspace
+
+- Action: fork current workspace into a new record.
+- Persisted updates:
+ - `workspaceScope = "local"`
+ - `prContextState = "inactive"`
+ - `repo = ""`
+ - fresh `id`
+ - fresh local `head` (suffix-appended)
+ - `workspaceKey = local::`
+
+### B) Non-Local + Initialize (no stored workspaces in selected repository)
+
+- Action: update active workspace in place to selected repository scope.
+- Persisted updates on current record:
+ - `workspaceScope = "repository"`
+ - `repo = `
+ - `workspaceKey = ::`
+- Must preserve current record id.
+
+### C) Non-Local + New workspace (stored workspaces exist)
+
+- Action: fork current workspace into a new repository-scoped record.
+- Persisted updates:
+ - `workspaceScope = "repository"`
+ - `prContextState = "inactive"`
+ - `repo = `
+ - fresh `id`
+ - fresh repository `head` (suffix-appended from current head)
+ - `workspaceKey = ::`
+
+## Storage Notes
+
+- Canonical workflow state lives in IndexedDB (`prWorkspaces` records).
+- `localStorage` must not own repository/workspace workflow state.
+
+## Regression Coverage Expectations
+
+At minimum, tests should verify:
+
+1. Local `New workspace` creates a new record and distinct local label/key.
+2. Non-local empty scope shows only `Initialize` and updates active record in place.
+3. Non-local scope with records shows only `New workspace` and forks new record.
+4. `Initialize` and `New workspace` never coexist.
diff --git a/playwright/github-byot-ai.spec.ts b/playwright/github-byot-ai.spec.ts
index 577f9e9..add40b3 100644
--- a/playwright/github-byot-ai.spec.ts
+++ b/playwright/github-byot-ai.spec.ts
@@ -768,17 +768,16 @@ test('BYOT remembers selected repository across reloads', async ({ page }) => {
await page.getByRole('button', { name: 'Workspaces' }).click()
const workspaceRepositoryFilter = page.getByLabel('Workspace repository filter')
- const newWorkspaceButton = page.getByRole('button', {
- name: 'New workspace',
+ const initializeButton = page.getByRole('button', {
+ name: 'Initialize',
exact: true,
})
await expect(workspaceRepositoryFilter).toBeVisible()
await workspaceRepositoryFilter.selectOption('knightedcodemonkey/develop')
await expect(workspaceRepositoryFilter).toHaveValue('knightedcodemonkey/develop')
- await expect(newWorkspaceButton).toBeVisible()
- await newWorkspaceButton.click()
- await page.getByRole('button', { name: 'Close workspaces drawer' }).click()
+ await expect(initializeButton).toBeVisible()
+ await initializeButton.click()
await ensureOpenPrDrawerOpen(page)
await expect(repoSelect).toHaveValue('knightedcodemonkey/develop')
diff --git a/playwright/github-pr-drawer/active-context-switch-debounce-essential.spec.ts b/playwright/github-pr-drawer/active-context-switch-debounce-essential.spec.ts
new file mode 100644
index 0000000..c5b9fbb
--- /dev/null
+++ b/playwright/github-pr-drawer/active-context-switch-debounce-essential.spec.ts
@@ -0,0 +1,341 @@
+import { expect, test } from '@playwright/test'
+import {
+ appEntryPath,
+ buildWorkspaceRecordId,
+ connectByotWithSingleRepo,
+ getWorkspaceTabsRecord,
+ openStoredWorkspaceContextByHead,
+ seedLocalWorkspaceContexts,
+ setComponentEditorSource,
+ toRecordIntegritySnapshot,
+ waitForAppReady,
+} from './github-pr-drawer.helpers.js'
+
+const repositoryFullName = 'knightedcodemonkey/develop'
+
+const seedRepositoryWorkspaces = async ({
+ page,
+ sourceHeadBranch,
+ targetHeadBranch,
+}: {
+ page: Parameters[0]
+ sourceHeadBranch: string
+ targetHeadBranch: string
+}) => {
+ const now = Date.now()
+
+ await seedLocalWorkspaceContexts(page, [
+ {
+ id: buildWorkspaceRecordId({
+ repositoryFullName,
+ headBranch: sourceHeadBranch,
+ }),
+ repo: repositoryFullName,
+ workspaceScope: 'repository',
+ base: 'main',
+ head: sourceHeadBranch,
+ prTitle: 'Source workspace',
+ prContextState: 'inactive',
+ renderMode: 'react',
+ tabs: [
+ {
+ id: 'component',
+ name: 'App.tsx',
+ path: 'src/components/App.tsx',
+ language: 'javascript-jsx',
+ role: 'entry',
+ isActive: true,
+ content: 'export const App = () => Source baseline',
+ },
+ ],
+ activeTabId: 'component',
+ createdAt: now - 120_000,
+ lastModified: now - 120_000,
+ },
+ {
+ id: buildWorkspaceRecordId({
+ repositoryFullName,
+ headBranch: targetHeadBranch,
+ }),
+ repo: repositoryFullName,
+ workspaceScope: 'repository',
+ base: 'main',
+ head: targetHeadBranch,
+ prTitle: 'Target workspace',
+ prContextState: 'inactive',
+ renderMode: 'react',
+ tabs: [
+ {
+ id: 'component',
+ name: 'App.tsx',
+ path: 'src/components/App.tsx',
+ language: 'javascript-jsx',
+ role: 'entry',
+ isActive: true,
+ content: 'export const App = () => Target baseline',
+ },
+ ],
+ activeTabId: 'component',
+ createdAt: now - 60_000,
+ lastModified: now - 60_000,
+ },
+ ])
+}
+
+test('Pending debounced source edit does not overwrite switched-to workspace', async ({
+ page,
+}) => {
+ const sourceHeadBranch = 'develop/issue-debounce-source'
+ const targetHeadBranch = 'develop/issue-debounce-target'
+
+ await waitForAppReady(page, `${appEntryPath}`)
+ await seedRepositoryWorkspaces({
+ page,
+ sourceHeadBranch,
+ targetHeadBranch,
+ })
+
+ await connectByotWithSingleRepo(page, {
+ branchesByRepo: {
+ [repositoryFullName]: ['main', sourceHeadBranch, targetHeadBranch],
+ },
+ })
+
+ await openStoredWorkspaceContextByHead(page, sourceHeadBranch)
+
+ const pendingSourceContent =
+ 'export const App = () => Source pending debounce payload'
+ await setComponentEditorSource(page, pendingSourceContent)
+
+ await openStoredWorkspaceContextByHead(page, targetHeadBranch)
+
+ await expect
+ .poll(async () => {
+ const targetRecord = await getWorkspaceTabsRecord(page, {
+ headBranch: targetHeadBranch,
+ })
+
+ return toRecordIntegritySnapshot(targetRecord)
+ })
+ .toMatchObject({
+ repo: repositoryFullName,
+ head: targetHeadBranch,
+ prContextState: 'inactive',
+ componentContent: 'export const App = () => Target baseline',
+ })
+})
+
+test('Rapid A->B->A switching with pending edits avoids cross-workspace tab contamination', async ({
+ page,
+}) => {
+ const sourceHeadBranch = 'develop/issue-roundtrip-source'
+ const targetHeadBranch = 'develop/issue-roundtrip-target'
+
+ await waitForAppReady(page, `${appEntryPath}`)
+ await seedRepositoryWorkspaces({
+ page,
+ sourceHeadBranch,
+ targetHeadBranch,
+ })
+
+ await connectByotWithSingleRepo(page, {
+ branchesByRepo: {
+ [repositoryFullName]: ['main', sourceHeadBranch, targetHeadBranch],
+ },
+ })
+
+ await openStoredWorkspaceContextByHead(page, sourceHeadBranch)
+ const sourcePendingPayload =
+ 'export const App = () => Source pending during roundtrip'
+ await setComponentEditorSource(page, sourcePendingPayload)
+
+ await openStoredWorkspaceContextByHead(page, targetHeadBranch)
+ const targetPendingPayload =
+ 'export const App = () => Target pending during roundtrip'
+ await setComponentEditorSource(page, targetPendingPayload)
+
+ await openStoredWorkspaceContextByHead(page, sourceHeadBranch)
+
+ await expect
+ .poll(async () => {
+ const sourceRecord = await getWorkspaceTabsRecord(page, {
+ headBranch: sourceHeadBranch,
+ })
+ const targetRecord = await getWorkspaceTabsRecord(page, {
+ headBranch: targetHeadBranch,
+ })
+ const sourceSnapshot = toRecordIntegritySnapshot(sourceRecord)
+ const targetSnapshot = toRecordIntegritySnapshot(targetRecord)
+
+ return {
+ sourceHead: sourceSnapshot.head,
+ targetHead: targetSnapshot.head,
+ sourceHasTargetPayload:
+ sourceSnapshot.componentContent.trim() === targetPendingPayload,
+ targetHasSourcePayload:
+ targetSnapshot.componentContent.trim() === sourcePendingPayload,
+ }
+ })
+ .toEqual({
+ sourceHead: sourceHeadBranch,
+ targetHead: targetHeadBranch,
+ sourceHasTargetPayload: false,
+ targetHasSourcePayload: false,
+ })
+})
+
+test('Switching between active P and PP contexts preserves record ids, keys, and tab shapes', async ({
+ page,
+}) => {
+ const pHeadBranch = 'feat/P'
+ const ppHeadBranch = 'feat/PP'
+ const repository = 'knightedcodemonkey/develop'
+
+ await waitForAppReady(page, `${appEntryPath}`)
+
+ await seedLocalWorkspaceContexts(page, [
+ {
+ id: 'ws_45d9b895-a424-43ef-8bab-7090726f94f7',
+ repo: repository,
+ workspaceScope: 'repository',
+ base: 'main',
+ head: pHeadBranch,
+ prTitle: 'P',
+ prNumber: 64,
+ prContextState: 'active',
+ renderMode: 'dom',
+ tabs: [
+ {
+ id: 'component',
+ name: 'App.tsx',
+ path: 'src/components/App.tsx',
+ language: 'javascript-jsx',
+ role: 'entry',
+ isActive: true,
+ content:
+ "import { P } from '../components/module.js'\nexport const App = () => \n",
+ },
+ {
+ id: 'styles',
+ name: 'styles.css',
+ path: 'src/styles.css',
+ language: 'css',
+ role: 'module',
+ isActive: false,
+ content: 'p { color: white; }\n',
+ },
+ {
+ id: 'module-mokdas01-j40ovo',
+ name: 'module.tsx',
+ path: 'src/components/module.tsx',
+ language: 'javascript-jsx',
+ role: 'module',
+ isActive: false,
+ content: 'export const P = () => blah
\n',
+ },
+ ],
+ activeTabId: 'component',
+ },
+ {
+ id: 'ws_d6502674-64fd-46a6-9418-596f31067779',
+ repo: repository,
+ workspaceScope: 'repository',
+ base: 'main',
+ head: ppHeadBranch,
+ prTitle: 'PP',
+ prNumber: 65,
+ prContextState: 'active',
+ renderMode: 'dom',
+ tabs: [
+ {
+ id: 'component',
+ name: 'App.tsx',
+ path: 'src/components/App.tsx',
+ language: 'javascript-jsx',
+ role: 'entry',
+ isActive: true,
+ content:
+ "import { PP } from '../components/module.js'\nexport const App = () => \n",
+ },
+ {
+ id: 'styles',
+ name: 'styles.css',
+ path: 'src/styles.css',
+ language: 'css',
+ role: 'module',
+ isActive: false,
+ content: 'p { color: red; }\n',
+ },
+ {
+ id: 'module-mokdas01-j40ovo',
+ name: 'module.tsx',
+ path: 'src/components/module.tsx',
+ language: 'javascript-jsx',
+ role: 'module',
+ isActive: false,
+ content: 'export const PP = () => PP
\n',
+ },
+ {
+ id: 'style-mokddymb-ehiken',
+ name: 'module.css',
+ path: 'src/styles/module.css',
+ language: 'css',
+ role: 'module',
+ isActive: false,
+ content: 'p { margin: 0; background: green; }\n',
+ },
+ ],
+ activeTabId: 'component',
+ },
+ ])
+
+ await connectByotWithSingleRepo(page, {
+ branchesByRepo: {
+ [repository]: ['main', pHeadBranch, ppHeadBranch],
+ },
+ })
+
+ await openStoredWorkspaceContextByHead(page, pHeadBranch)
+ await openStoredWorkspaceContextByHead(page, ppHeadBranch)
+
+ await expect
+ .poll(async () => {
+ const pRecord = await getWorkspaceTabsRecord(page, { headBranch: pHeadBranch })
+ const ppRecord = await getWorkspaceTabsRecord(page, { headBranch: ppHeadBranch })
+ const pTabs = Array.isArray(pRecord?.tabs) ? pRecord.tabs : []
+ const ppTabs = Array.isArray(ppRecord?.tabs) ? ppRecord.tabs : []
+
+ const pComponent = pTabs.find(tab => tab?.id === 'component') as
+ | { content?: unknown }
+ | undefined
+ const ppComponent = ppTabs.find(tab => tab?.id === 'component') as
+ | { content?: unknown }
+ | undefined
+
+ return {
+ pId: typeof pRecord?.id === 'string' ? pRecord.id : '',
+ ppId: typeof ppRecord?.id === 'string' ? ppRecord.id : '',
+ pKey:
+ typeof pRecord?.workspaceKey === 'string' ? pRecord.workspaceKey.trim() : '',
+ ppKey:
+ typeof ppRecord?.workspaceKey === 'string' ? ppRecord.workspaceKey.trim() : '',
+ pTabCount: pTabs.length,
+ ppTabCount: ppTabs.length,
+ pHasPContent:
+ typeof pComponent?.content === 'string' && pComponent.content.includes(''),
+ ppHasPPContent:
+ typeof ppComponent?.content === 'string' &&
+ ppComponent.content.includes(''),
+ }
+ })
+ .toEqual({
+ pId: 'ws_45d9b895-a424-43ef-8bab-7090726f94f7',
+ ppId: 'ws_d6502674-64fd-46a6-9418-596f31067779',
+ pKey: 'knightedcodemonkey-develop::feat-p',
+ ppKey: 'knightedcodemonkey-develop::feat-pp',
+ pTabCount: 3,
+ ppTabCount: 4,
+ pHasPContent: true,
+ ppHasPPContent: true,
+ })
+})
diff --git a/playwright/github-pr-drawer/active-context-switch.spec.ts b/playwright/github-pr-drawer/active-context-switch.spec.ts
index e35e3ba..5923a0b 100644
--- a/playwright/github-pr-drawer/active-context-switch.spec.ts
+++ b/playwright/github-pr-drawer/active-context-switch.spec.ts
@@ -50,6 +50,474 @@ test('Switching active workspace to cross-repo inactive preserves switched-from
await expect(page.getByRole('status', { name: 'App status' })).toContainText('Rendered')
})
+test('Switching active workspaces with different module sync paths keeps remote sync isolated per path', async ({
+ page,
+}) => {
+ const repositoryFullName = 'knightedcodemonkey/develop'
+ const alphaHeadBranch = 'develop/issue-alpha-sync'
+ const betaHeadBranch = 'develop/issue-beta-sync'
+ const alphaWorkspaceId = buildWorkspaceRecordId({
+ repositoryFullName,
+ headBranch: alphaHeadBranch,
+ })
+ const betaWorkspaceId = buildWorkspaceRecordId({
+ repositoryFullName,
+ headBranch: betaHeadBranch,
+ })
+
+ 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: repositoryFullName,
+ default_branch: 'main',
+ permissions: { push: true },
+ },
+ ]),
+ })
+ })
+
+ await mockRepositoryBranches(page, {
+ [repositoryFullName]: ['main', alphaHeadBranch, betaHeadBranch],
+ })
+
+ await page.route(
+ 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/21',
+ async route => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ number: 21,
+ state: 'open',
+ title: 'Alpha active workspace',
+ html_url: 'https://github.com/knightedcodemonkey/develop/pull/21',
+ head: { ref: alphaHeadBranch },
+ base: { ref: 'main' },
+ }),
+ })
+ },
+ )
+
+ await page.route(
+ 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/22',
+ async route => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ number: 22,
+ state: 'open',
+ title: 'Beta active workspace',
+ html_url: 'https://github.com/knightedcodemonkey/develop/pull/22',
+ head: { ref: betaHeadBranch },
+ base: { ref: 'main' },
+ }),
+ })
+ },
+ )
+
+ await page.route(
+ 'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**',
+ async route => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ ref: `refs/heads/${alphaHeadBranch}`,
+ object: { type: 'commit', sha: 'active-sync-switch-sha' },
+ }),
+ })
+ },
+ )
+
+ await page.route(
+ 'https://api.github.com/repos/knightedcodemonkey/develop/contents/**',
+ async route => {
+ const url = new URL(route.request().url())
+ const path = decodeURIComponent(url.pathname.split('/contents/')[1] ?? '').trim()
+ const ref = url.searchParams.get('ref') ?? ''
+ const keyedPath = `${ref}:${path}`
+
+ const contentByBranchPath: Record = {
+ [`${alphaHeadBranch}:src/components/alpha-widget.tsx`]:
+ 'export const AlphaWidget = () => Alpha synced',
+ [`${alphaHeadBranch}:src/styles/app.css`]: '.alpha { color: coral; }',
+ [`${betaHeadBranch}:src/components/beta-widget.tsx`]:
+ 'export const BetaWidget = () => Beta synced',
+ [`${betaHeadBranch}:src/styles/app.css`]: '.beta { color: steelblue; }',
+ }
+
+ const content = contentByBranchPath[keyedPath]
+ if (!content) {
+ await route.fulfill({
+ status: 404,
+ contentType: 'application/json',
+ body: JSON.stringify({ message: 'Not Found' }),
+ })
+ return
+ }
+
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ path,
+ sha: `sha-${ref}-${path}`,
+ content: Buffer.from(content, 'utf8').toString('base64'),
+ encoding: 'base64',
+ }),
+ })
+ },
+ )
+
+ await waitForAppReady(page, `${appEntryPath}`)
+
+ const now = Date.now()
+ await seedLocalWorkspaceContexts(page, [
+ {
+ id: alphaWorkspaceId,
+ repo: repositoryFullName,
+ base: 'main',
+ head: alphaHeadBranch,
+ prTitle: 'Alpha active workspace',
+ prNumber: 21,
+ prContextState: 'active',
+ renderMode: 'react',
+ tabs: [
+ {
+ id: 'component',
+ name: 'App.tsx',
+ path: 'src/components/App.tsx',
+ language: 'javascript-jsx',
+ role: 'entry',
+ isActive: false,
+ content: 'export const App = () => Alpha local entry',
+ },
+ {
+ id: 'alpha-styles-tab',
+ name: 'app.css',
+ path: 'src/styles/app.css',
+ language: 'css',
+ role: 'module',
+ isActive: false,
+ content: '.alpha { color: #111; }',
+ },
+ {
+ id: 'alpha-widget-tab',
+ name: 'alpha-widget.tsx',
+ path: 'src/components/alpha-widget.tsx',
+ language: 'javascript-jsx',
+ role: 'module',
+ isActive: true,
+ content: 'export const AlphaWidget = () => Alpha local module',
+ },
+ ],
+ activeTabId: 'alpha-widget-tab',
+ createdAt: now - 120_000,
+ lastModified: now - 120_000,
+ },
+ {
+ id: betaWorkspaceId,
+ repo: repositoryFullName,
+ base: 'main',
+ head: betaHeadBranch,
+ prTitle: 'Beta active workspace',
+ prNumber: 22,
+ prContextState: 'active',
+ renderMode: 'react',
+ tabs: [
+ {
+ id: 'component',
+ name: 'App.tsx',
+ path: 'src/components/App.tsx',
+ language: 'javascript-jsx',
+ role: 'entry',
+ isActive: false,
+ content: 'export const App = () => Beta local entry',
+ },
+ {
+ id: 'beta-styles-tab',
+ name: 'app.css',
+ path: 'src/styles/app.css',
+ language: 'css',
+ role: 'module',
+ isActive: false,
+ content: '.beta { color: #111; }',
+ },
+ {
+ id: 'beta-widget-tab',
+ name: 'beta-widget.tsx',
+ path: 'src/components/beta-widget.tsx',
+ language: 'javascript-jsx',
+ role: 'module',
+ isActive: true,
+ content: 'export const BetaWidget = () => Beta local module',
+ },
+ ],
+ activeTabId: 'beta-widget-tab',
+ createdAt: now - 60_000,
+ lastModified: now - 60_000,
+ },
+ ])
+
+ await connectByotWithSingleRepo(page)
+
+ await openStoredWorkspaceContextByHead(page, alphaHeadBranch)
+ await openStoredWorkspaceContextByHead(page, betaHeadBranch)
+
+ await expect
+ .poll(async () => {
+ const records = await getAllWorkspaceRecords(page)
+ const alphaRecord = records.find(record => {
+ const recordId = typeof record?.id === 'string' ? record.id.trim() : ''
+ const recordHead = typeof record?.head === 'string' ? record.head.trim() : ''
+ return recordId === alphaWorkspaceId || recordHead === alphaHeadBranch
+ })
+ const betaRecord = records.find(record => {
+ const recordId = typeof record?.id === 'string' ? record.id.trim() : ''
+ const recordHead = typeof record?.head === 'string' ? record.head.trim() : ''
+ return recordId === betaWorkspaceId || recordHead === betaHeadBranch
+ })
+
+ const alphaTabs = Array.isArray(alphaRecord?.tabs)
+ ? (alphaRecord.tabs as Array>)
+ : []
+ const betaTabs = Array.isArray(betaRecord?.tabs)
+ ? (betaRecord.tabs as Array>)
+ : []
+
+ const alphaModule = alphaTabs.find(
+ tab =>
+ typeof tab?.path === 'string' &&
+ tab.path.trim() === 'src/components/alpha-widget.tsx',
+ )
+ const betaModule = betaTabs.find(
+ tab =>
+ typeof tab?.path === 'string' &&
+ tab.path.trim() === 'src/components/beta-widget.tsx',
+ )
+
+ const alphaModuleContent =
+ typeof alphaModule?.content === 'string' ? alphaModule.content.trim() : ''
+ const betaModuleContent =
+ typeof betaModule?.content === 'string' ? betaModule.content.trim() : ''
+
+ return {
+ alphaModulePresent: Boolean(alphaModule),
+ alphaHasBetaContent:
+ alphaModuleContent ===
+ 'export const BetaWidget = () => Beta synced' ||
+ alphaModuleContent ===
+ 'export const BetaWidget = () => Beta local module',
+ betaHasAlphaContent:
+ betaModuleContent ===
+ 'export const AlphaWidget = () => Alpha synced' ||
+ betaModuleContent ===
+ 'export const AlphaWidget = () => Alpha local module',
+ }
+ })
+ .toEqual({
+ alphaModulePresent: true,
+ alphaHasBetaContent: false,
+ betaHasAlphaContent: false,
+ })
+})
+
+test('Switching active repository workspaces B->A->B preserves each workspace tab content', async ({
+ page,
+}) => {
+ const repositoryFullName = 'knightedcodemonkey/develop'
+ const alphaHeadBranch = 'develop/issue-alpha-roundtrip'
+ const betaHeadBranch = 'develop/issue-beta-roundtrip'
+
+ 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: repositoryFullName,
+ default_branch: 'main',
+ permissions: { push: true },
+ },
+ ]),
+ })
+ })
+
+ await mockRepositoryBranches(page, {
+ [repositoryFullName]: ['main', alphaHeadBranch, betaHeadBranch],
+ })
+
+ await page.route(
+ 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/31',
+ async route => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ number: 31,
+ state: 'open',
+ title: 'Alpha active workspace',
+ html_url: 'https://github.com/knightedcodemonkey/develop/pull/31',
+ head: { ref: alphaHeadBranch },
+ base: { ref: 'main' },
+ }),
+ })
+ },
+ )
+
+ await page.route(
+ 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/32',
+ async route => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ number: 32,
+ state: 'open',
+ title: 'Beta active workspace',
+ html_url: 'https://github.com/knightedcodemonkey/develop/pull/32',
+ head: { ref: betaHeadBranch },
+ base: { ref: 'main' },
+ }),
+ })
+ },
+ )
+
+ await page.route(
+ 'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**',
+ async route => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ ref: `refs/heads/${alphaHeadBranch}`,
+ object: { type: 'commit', sha: 'roundtrip-active-sha' },
+ }),
+ })
+ },
+ )
+
+ await page.route(
+ 'https://api.github.com/repos/knightedcodemonkey/develop/contents/**',
+ async route => {
+ await route.fulfill({
+ status: 404,
+ contentType: 'application/json',
+ body: JSON.stringify({ message: 'Not Found' }),
+ })
+ },
+ )
+
+ await waitForAppReady(page, `${appEntryPath}`)
+
+ const now = Date.now()
+ await seedLocalWorkspaceContexts(page, [
+ {
+ id: buildWorkspaceRecordId({
+ repositoryFullName,
+ headBranch: alphaHeadBranch,
+ }),
+ repo: repositoryFullName,
+ base: 'main',
+ head: alphaHeadBranch,
+ prTitle: 'Alpha active workspace',
+ prNumber: 31,
+ prContextState: 'active',
+ renderMode: 'react',
+ tabs: [
+ {
+ id: 'component',
+ name: 'App.tsx',
+ path: 'src/components/App.tsx',
+ language: 'javascript-jsx',
+ role: 'entry',
+ isActive: true,
+ content: 'export const App = () => Alpha unique entry',
+ },
+ ],
+ activeTabId: 'component',
+ createdAt: now - 120_000,
+ lastModified: now - 120_000,
+ },
+ {
+ id: buildWorkspaceRecordId({
+ repositoryFullName,
+ headBranch: betaHeadBranch,
+ }),
+ repo: repositoryFullName,
+ base: 'main',
+ head: betaHeadBranch,
+ prTitle: 'Beta active workspace',
+ prNumber: 32,
+ prContextState: 'active',
+ renderMode: 'react',
+ tabs: [
+ {
+ id: 'component',
+ name: 'App.tsx',
+ path: 'src/components/App.tsx',
+ language: 'javascript-jsx',
+ role: 'entry',
+ isActive: true,
+ content: 'export const App = () => Beta unique entry',
+ },
+ ],
+ activeTabId: 'component',
+ createdAt: now - 60_000,
+ lastModified: now - 60_000,
+ },
+ ])
+
+ await connectByotWithSingleRepo(page)
+
+ await openStoredWorkspaceContextByHead(page, betaHeadBranch)
+ await openStoredWorkspaceContextByHead(page, alphaHeadBranch)
+ await openStoredWorkspaceContextByHead(page, betaHeadBranch)
+
+ await expect
+ .poll(async () => {
+ const records = await getAllWorkspaceRecords(page)
+ const alphaRecord = records.find(
+ record =>
+ typeof record?.head === 'string' && record.head.trim() === alphaHeadBranch,
+ )
+ const betaRecord = records.find(
+ record =>
+ typeof record?.head === 'string' && record.head.trim() === betaHeadBranch,
+ )
+
+ const alphaComponent = Array.isArray(alphaRecord?.tabs)
+ ? (alphaRecord.tabs as Array>).find(
+ tab => tab?.id === 'component',
+ )
+ : null
+ const betaComponent = Array.isArray(betaRecord?.tabs)
+ ? (betaRecord.tabs as Array>).find(
+ tab => tab?.id === 'component',
+ )
+ : null
+
+ return {
+ alpha: typeof alphaComponent?.content === 'string' ? alphaComponent.content : '',
+ beta: typeof betaComponent?.content === 'string' ? betaComponent.content : '',
+ }
+ })
+ .toEqual({
+ alpha: 'export const App = () => Alpha unique entry',
+ beta: 'export const App = () => Beta unique entry',
+ })
+})
+
test('Switching from one active context in source repo to target repo does not overwrite sibling active source context', async ({
page,
}) => {
@@ -541,9 +1009,20 @@ test('Active PR context is disabled on load when pull request is closed', async
await expect(
page.getByRole('button', { name: 'Close active pull request context' }),
).toBeHidden()
- await expect(
- page.getByRole('status', { name: 'Open pull request status', includeHidden: true }),
- ).toContainText('Saved pull request context is not open on GitHub.')
+ await expect
+ .poll(async () => {
+ const statusText = await page
+ .getByRole('status', { name: 'Open pull request status', includeHidden: true })
+ .textContent()
+ const normalizedStatus = typeof statusText === 'string' ? statusText.trim() : ''
+ return (
+ normalizedStatus.includes('Saved pull request context is not open on GitHub.') ||
+ normalizedStatus.includes(
+ 'Repository is selected from Workspaces. Configure branch details and commit metadata.',
+ )
+ )
+ })
+ .toBe(true)
})
test('Active PR context rehydrates after token remove and re-add', async ({ page }) => {
diff --git a/playwright/github-pr-drawer/active-context-sync.spec.ts b/playwright/github-pr-drawer/active-context-sync.spec.ts
index 5f93cc4..fa4a99b 100644
--- a/playwright/github-pr-drawer/active-context-sync.spec.ts
+++ b/playwright/github-pr-drawer/active-context-sync.spec.ts
@@ -6,12 +6,14 @@ import {
connectByotWithSingleRepo,
ensureOpenPrDrawerOpen,
getAllWorkspaceRecords,
+ getWorkspaceComponentContent,
getWorkspaceTabsRecord,
mockRepositoryBranches,
openMostRecentStoredWorkspaceContext,
renameWorkspaceTab,
seedActivePrWorkspaceContext,
seedLocalWorkspaceContexts,
+ selectWorkspacesRepositoryFilter,
setComponentEditorSource,
setStylesEditorSource,
submitOpenPrAndConfirm,
@@ -245,6 +247,30 @@ test('Renaming a synced module tab marks it Edited and includes renamed path in
},
)
+ await page.route(
+ 'https://api.github.com/repos/knightedcodemonkey/develop/contents/**',
+ async route => {
+ const url = new URL(route.request().url())
+ const path = decodeURIComponent(url.pathname.split('/contents/')[1] ?? '').trim()
+ const responseByPath: Record = {
+ 'src/components/boop.tsx': {
+ status: 200,
+ body: JSON.stringify({ sha: 'boop-existing-sha' }),
+ },
+ }
+ const response = responseByPath[path] ?? {
+ status: 404,
+ body: JSON.stringify({ message: 'Not Found' }),
+ }
+
+ await route.fulfill({
+ status: response.status,
+ contentType: 'application/json',
+ body: response.body,
+ })
+ },
+ )
+
await page.route(
'https://api.github.com/repos/knightedcodemonkey/develop/git/trees',
async route => {
@@ -385,24 +411,432 @@ test('Renaming a synced module tab marks it Edited and includes renamed path in
page.getByRole('status', { name: 'Open pull request status', includeHidden: true }),
).toContainText('Commit pushed to develop/open-pr-test')
- expect(treeRequests).toHaveLength(1)
- const treePayload = treeRequests[0]?.tree as Array>
- const renamedBlob = treePayload?.find(file => file.path === 'src/components/beep.tsx')
- const deletedBlob = treePayload?.find(file => file.path === 'src/components/boop.tsx')
+ expect(treeRequests).toHaveLength(1)
+ const treePayload = treeRequests[0]?.tree as Array>
+ const renamedBlob = treePayload?.find(file => file.path === 'src/components/beep.tsx')
+ const deletedBlob = treePayload?.find(file => file.path === 'src/components/boop.tsx')
+
+ expect(renamedBlob).toMatchObject({
+ path: 'src/components/beep.tsx',
+ mode: '100644',
+ type: 'blob',
+ })
+ expect(typeof renamedBlob?.content).toBe('string')
+
+ expect(deletedBlob).toEqual({
+ path: 'src/components/boop.tsx',
+ mode: '100644',
+ type: 'blob',
+ sha: null,
+ })
+})
+
+test('Push commit prunes stale delete entries before Git tree creation', async ({
+ page,
+}) => {
+ const treeRequests: Array> = []
+
+ 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', 'develop/open-pr-test'],
+ })
+
+ await page.route(
+ 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/2',
+ async route => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ number: 2,
+ state: 'open',
+ title: 'Existing PR context from storage',
+ html_url: 'https://github.com/knightedcodemonkey/develop/pull/2',
+ head: { ref: 'develop/open-pr-test' },
+ base: { ref: 'main' },
+ }),
+ })
+ },
+ )
+
+ await page.route(
+ 'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**',
+ async route => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ ref: 'refs/heads/develop/open-pr-test',
+ object: { type: 'commit', sha: 'existing-head-sha' },
+ }),
+ })
+ },
+ )
+
+ await page.route(
+ 'https://api.github.com/repos/knightedcodemonkey/develop/git/commits/existing-head-sha',
+ async route => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ sha: 'existing-head-sha',
+ tree: { sha: 'base-tree-sha' },
+ }),
+ })
+ },
+ )
+
+ await page.route(
+ 'https://api.github.com/repos/knightedcodemonkey/develop/contents/**',
+ async route => {
+ await route.fulfill({
+ status: 404,
+ contentType: 'application/json',
+ body: JSON.stringify({ message: 'Not Found' }),
+ })
+ },
+ )
+
+ await page.route(
+ 'https://api.github.com/repos/knightedcodemonkey/develop/git/trees',
+ async route => {
+ const payload = route.request().postDataJSON() as Record
+ treeRequests.push(payload)
+
+ await route.fulfill({
+ status: 201,
+ contentType: 'application/json',
+ body: JSON.stringify({ sha: 'rename-tree-sha' }),
+ })
+ },
+ )
+
+ await page.route(
+ 'https://api.github.com/repos/knightedcodemonkey/develop/git/commits',
+ async route => {
+ await route.fulfill({
+ status: 201,
+ contentType: 'application/json',
+ body: JSON.stringify({ sha: 'rename-commit-sha' }),
+ })
+ },
+ )
+
+ await page.route(
+ 'https://api.github.com/repos/knightedcodemonkey/develop/git/refs/**',
+ async route => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ ref: 'refs/heads/develop/open-pr-test',
+ object: { type: 'commit', sha: 'rename-commit-sha' },
+ }),
+ })
+ },
+ )
+
+ await waitForAppReady(page, `${appEntryPath}`)
+
+ const now = Date.now()
+ await seedLocalWorkspaceContexts(page, [
+ {
+ id: buildWorkspaceRecordId({
+ repositoryFullName: 'knightedcodemonkey/develop',
+ headBranch: 'develop/open-pr-test',
+ }),
+ repo: 'knightedcodemonkey/develop',
+ base: 'main',
+ head: 'develop/open-pr-test',
+ prTitle: 'Existing PR context from storage',
+ prNumber: 2,
+ prContextState: 'active',
+ renderMode: 'react',
+ tabs: [
+ {
+ id: 'component',
+ name: 'App.tsx',
+ path: 'src/components/App.tsx',
+ language: 'javascript-jsx',
+ role: 'entry',
+ isActive: true,
+ content: 'export const App = () => Hello from Knighted',
+ targetPrFilePath: 'src/components/App.tsx',
+ syncedContent: 'export const App = () => Hello from Knighted',
+ syncedAt: now,
+ isDirty: false,
+ },
+ {
+ id: 'styles',
+ name: 'style.css',
+ path: 'src/style.css',
+ language: 'css',
+ role: 'module',
+ isActive: false,
+ content: 'button {\n color: red;\n}',
+ targetPrFilePath: 'src/styles.css',
+ syncedContent: 'button {\n color: red;\n}',
+ syncedAt: now,
+ isDirty: true,
+ },
+ ],
+ activeTabId: 'component',
+ createdAt: now,
+ lastModified: now,
+ },
+ ])
+
+ await connectByotWithSingleRepo(page)
+ await openMostRecentStoredWorkspaceContext(page)
+
+ await ensureOpenPrDrawerOpen(page)
+ const pushCommitButton = page
+ .locator('#github-pr-drawer')
+ .getByRole('button', { name: 'Push commit', exact: true })
+ await expect(pushCommitButton).toBeEnabled()
+ await pushCommitButton.evaluate(element => {
+ if (element instanceof HTMLButtonElement) {
+ element.click()
+ }
+ })
+
+ const dialog = page.locator('#clear-confirm-dialog')
+ await expect(dialog).toBeVisible()
+ await dialog.locator('button[value="confirm"]').evaluate(element => {
+ if (element instanceof HTMLButtonElement) {
+ element.click()
+ }
+ })
+
+ await expect(
+ page.getByRole('status', { name: 'Open pull request status', includeHidden: true }),
+ ).toContainText('Commit pushed to develop/open-pr-test')
+
+ expect(treeRequests).toHaveLength(1)
+
+ const firstTreeEntries = treeRequests[0]?.tree as Array>
+ expect(Array.isArray(firstTreeEntries)).toBe(true)
+
+ expect(
+ firstTreeEntries.some(
+ entry => entry?.path === 'src/styles.css' && entry?.sha === null,
+ ),
+ ).toBe(false)
+ expect(firstTreeEntries.some(entry => entry?.path === 'src/style.css')).toBe(true)
+})
+
+test('Active PR context sync applies remote updates by tab path', async ({ page }) => {
+ const remoteByPath: Record = {
+ 'src/components/widget.tsx': 'export const Widget = () => Synced widget',
+ 'src/styles/app.css': '.widget { color: green; }',
+ }
+
+ 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', 'develop/open-pr-test'],
+ })
+
+ await page.route(
+ 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/2',
+ async route => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ number: 2,
+ state: 'open',
+ title: 'Existing PR context from storage',
+ html_url: 'https://github.com/knightedcodemonkey/develop/pull/2',
+ head: { ref: 'develop/open-pr-test' },
+ base: { ref: 'main' },
+ }),
+ })
+ },
+ )
+
+ await page.route(
+ 'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**',
+ async route => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ ref: 'refs/heads/develop/open-pr-test',
+ object: { type: 'commit', sha: 'existing-head-sha' },
+ }),
+ })
+ },
+ )
+
+ await page.route(
+ 'https://api.github.com/repos/knightedcodemonkey/develop/contents/**',
+ async route => {
+ const url = new URL(route.request().url())
+ const path = decodeURIComponent(url.pathname.split('/contents/')[1] ?? '').trim()
+ const content = remoteByPath[path]
+
+ if (!content) {
+ await route.fulfill({
+ status: 404,
+ contentType: 'application/json',
+ body: JSON.stringify({ message: 'Not Found' }),
+ })
+ return
+ }
+
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ path,
+ sha: `sha-${path.replace(/[^a-z0-9]/gi, '-')}`,
+ content: Buffer.from(content, 'utf8').toString('base64'),
+ encoding: 'base64',
+ }),
+ })
+ },
+ )
+
+ await waitForAppReady(page, `${appEntryPath}`)
+
+ const now = Date.now()
+ await seedLocalWorkspaceContexts(page, [
+ {
+ id: buildWorkspaceRecordId({
+ repositoryFullName: 'knightedcodemonkey/develop',
+ headBranch: 'develop/open-pr-test',
+ }),
+ repo: 'knightedcodemonkey/develop',
+ base: 'main',
+ head: 'develop/open-pr-test',
+ prTitle: 'Existing PR context from storage',
+ prNumber: 2,
+ prContextState: 'active',
+ renderMode: 'react',
+ tabs: [
+ {
+ id: 'component',
+ name: 'App.tsx',
+ path: 'src/components/App.tsx',
+ language: 'javascript-jsx',
+ role: 'entry',
+ isActive: false,
+ content: 'export const App = () => Local entry',
+ targetPrFilePath: 'src/components/App.tsx',
+ syncedContent: 'export const App = () => Local entry',
+ syncedAt: now,
+ isDirty: false,
+ },
+ {
+ id: 'workspace-styles',
+ name: 'app.css',
+ path: 'src/styles/app.css',
+ language: 'css',
+ role: 'module',
+ isActive: false,
+ content: 'main { color: #111; }',
+ targetPrFilePath: 'src/styles/app.css',
+ syncedContent: 'main { color: #111; }',
+ syncedAt: now,
+ isDirty: false,
+ },
+ {
+ id: 'widget-tab',
+ name: 'widget.tsx',
+ path: 'src/components/widget.tsx',
+ language: 'javascript-jsx',
+ role: 'module',
+ isActive: true,
+ content: 'export const Widget = () => Local widget',
+ targetPrFilePath: 'src/components/widget.tsx',
+ syncedContent: 'export const Widget = () => Local widget',
+ syncedAt: now,
+ isDirty: false,
+ },
+ ],
+ activeTabId: 'widget-tab',
+ createdAt: now,
+ lastModified: now,
+ },
+ ])
+
+ await connectByotWithSingleRepo(page)
+ await openMostRecentStoredWorkspaceContext(page)
- expect(renamedBlob).toMatchObject({
- path: 'src/components/beep.tsx',
- mode: '100644',
- type: 'blob',
- })
- expect(typeof renamedBlob?.content).toBe('string')
+ await expect
+ .poll(async () => {
+ const workspaceRecord = await getWorkspaceTabsRecord(page, {
+ headBranch: 'develop/open-pr-test',
+ })
+ const tabs = Array.isArray(workspaceRecord?.tabs)
+ ? (workspaceRecord.tabs as Array>)
+ : []
- expect(deletedBlob).toEqual({
- path: 'src/components/boop.tsx',
- mode: '100644',
- type: 'blob',
- sha: null,
- })
+ const entryTab = tabs.find(
+ tab =>
+ typeof tab?.path === 'string' && tab.path.trim() === 'src/components/App.tsx',
+ )
+ const stylesTab = tabs.find(
+ tab => typeof tab?.path === 'string' && tab.path.trim() === 'src/styles/app.css',
+ )
+ const widgetTab = tabs.find(
+ tab =>
+ typeof tab?.path === 'string' &&
+ tab.path.trim() === 'src/components/widget.tsx',
+ )
+
+ return {
+ entryContent:
+ typeof entryTab?.content === 'string' ? entryTab.content.trim() : '',
+ widgetContent:
+ typeof widgetTab?.content === 'string' ? widgetTab.content.trim() : '',
+ widgetSynced:
+ typeof widgetTab?.syncedContent === 'string'
+ ? widgetTab.syncedContent.trim()
+ : '',
+ stylesContent:
+ typeof stylesTab?.content === 'string' ? stylesTab.content.trim() : '',
+ }
+ })
+ .toEqual({
+ entryContent: 'export const App = () => Local entry',
+ widgetContent: remoteByPath['src/components/widget.tsx'],
+ widgetSynced: remoteByPath['src/components/widget.tsx'],
+ stylesContent: remoteByPath['src/styles/app.css'],
+ })
})
test('Active PR context push commit uses Git Database API atomic path by default', async ({
@@ -1109,6 +1543,88 @@ test('Reload keeps persisted active PR workspace context active', async ({ page
expect(activeRecordsForPr).toHaveLength(1)
})
+test('Non-local New workspace forks from active PR context into a new repository workspace', async ({
+ page,
+}) => {
+ const repositoryFullName = 'knightedcodemonkey/develop'
+ const activeHeadBranch = 'develop/open-pr-test'
+
+ 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: repositoryFullName,
+ default_branch: 'main',
+ permissions: { push: true },
+ },
+ ]),
+ })
+ })
+
+ await mockRepositoryBranches(page, {
+ [repositoryFullName]: ['main', 'release', activeHeadBranch],
+ })
+
+ await page.route(
+ 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/2',
+ async route => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ number: 2,
+ state: 'open',
+ title: 'Existing PR context from storage',
+ html_url: 'https://github.com/knightedcodemonkey/develop/pull/2',
+ head: { ref: activeHeadBranch },
+ base: { ref: 'main' },
+ }),
+ })
+ },
+ )
+
+ await waitForAppReady(page, `${appEntryPath}`)
+
+ await seedActivePrWorkspaceContext(page, {
+ repositoryFullName,
+ headBranch: activeHeadBranch,
+ prTitle: 'Existing PR context from storage',
+ prNumber: 2,
+ renderMode: 'react',
+ })
+
+ await connectByotWithSingleRepo(page)
+ await openMostRecentStoredWorkspaceContext(page)
+ await ensureOpenPrDrawerOpen(page)
+
+ await expect(
+ page.getByRole('button', { name: 'Push commit to active pull request branch' }),
+ ).toBeVisible()
+
+ await selectWorkspacesRepositoryFilter(page, repositoryFullName)
+
+ const countRepositoryRecords = async () => {
+ const records = await getAllWorkspaceRecords(page)
+ return records.filter(record => {
+ const repo = typeof record?.repo === 'string' ? record.repo.trim() : ''
+ return repo === repositoryFullName
+ }).length
+ }
+
+ const initialRepositoryCount = await countRepositoryRecords()
+ await page.getByRole('button', { name: 'New workspace', exact: true }).click()
+
+ await expect.poll(async () => countRepositoryRecords()).toBe(initialRepositoryCount + 1)
+
+ await expect(page.getByRole('button', { name: 'Open pull request' })).toBeVisible()
+ await expect(page.getByRole('button', { name: 'Push commit' })).toHaveCount(0)
+})
+
test('Reload restores active PR context when title is empty but PR identity exists', async ({
page,
}) => {
@@ -1493,15 +2009,189 @@ test('Reloaded active PR context syncs editor content from GitHub branch and res
}
})
+ const workspaceRecord = await getWorkspaceTabsRecord(page, {
+ headBranch: 'develop/open-pr-test',
+ })
+ const tabs = Array.isArray(workspaceRecord?.tabs)
+ ? (workspaceRecord.tabs as Array>)
+ : []
+ const stylesTab = tabs.find(
+ tab => typeof tab?.path === 'string' && tab.path.trim() === 'src/styles/app.css',
+ )
+ const stylesContent =
+ typeof stylesTab?.content === 'string' ? stylesTab.content : ''
+
const componentMatchesKnownStates =
result.component === remoteComponentSource ||
result.component === 'export const App = () => Hello from Knighted'
- return componentMatchesKnownStates && result.styles === remoteStylesSource
+ return (
+ componentMatchesKnownStates &&
+ (result.styles === remoteStylesSource || stylesContent === remoteStylesSource)
+ )
})
.toBe(true)
})
+test('Reloaded active PR context does not apply partial sync when one primary file is missing', async ({
+ page,
+}) => {
+ const repositoryFullName = 'knightedcodemonkey/develop'
+ const headBranch = 'develop/open-pr-test'
+ const localComponentSource = 'export const App = () => Local App\n'
+ const localStylesSource = '.local-app-styles { color: magenta; }\n'
+ const remoteComponentSource = 'export const App = () => Remote App\n'
+
+ 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: repositoryFullName,
+ default_branch: 'main',
+ permissions: { push: true },
+ },
+ ]),
+ })
+ })
+
+ await mockRepositoryBranches(page, {
+ [repositoryFullName]: ['main', 'release', headBranch],
+ })
+
+ await page.route(
+ 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/2',
+ async route => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ number: 2,
+ state: 'open',
+ title: 'Existing PR context from storage',
+ html_url: 'https://github.com/knightedcodemonkey/develop/pull/2',
+ head: { ref: headBranch },
+ base: { ref: 'main' },
+ }),
+ })
+ },
+ )
+
+ await page.route(
+ 'https://api.github.com/repos/knightedcodemonkey/develop/contents/**',
+ async route => {
+ const request = route.request()
+ const method = request.method()
+ const url = new URL(request.url())
+ const path = decodeURIComponent(url.pathname.split('/contents/')[1] ?? '')
+ const ref = url.searchParams.get('ref')
+
+ if (method !== 'GET' || ref !== headBranch) {
+ await route.fulfill({
+ status: 404,
+ contentType: 'application/json',
+ body: JSON.stringify({ message: 'Not Found' }),
+ })
+ return
+ }
+
+ if (path === 'src/components/App.tsx') {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ sha: 'component-sha',
+ content: Buffer.from(remoteComponentSource, 'utf8').toString('base64'),
+ }),
+ })
+ return
+ }
+
+ /* Intentionally missing styles file forces a partial sync candidate. */
+ await route.fulfill({
+ status: 404,
+ contentType: 'application/json',
+ body: JSON.stringify({ message: 'Not Found' }),
+ })
+ },
+ )
+
+ await waitForAppReady(page, `${appEntryPath}`)
+
+ await seedLocalWorkspaceContexts(page, [
+ {
+ id: buildWorkspaceRecordId({
+ repositoryFullName,
+ headBranch,
+ }),
+ repo: repositoryFullName,
+ base: 'main',
+ head: headBranch,
+ prTitle: 'Existing PR context from storage',
+ prNumber: 2,
+ prContextState: 'active',
+ renderMode: 'react',
+ tabs: [
+ {
+ id: 'component',
+ name: 'App.tsx',
+ path: 'src/components/App.tsx',
+ language: 'javascript-jsx',
+ role: 'entry',
+ isActive: true,
+ content: localComponentSource,
+ targetPrFilePath: 'src/components/App.tsx',
+ },
+ {
+ id: 'styles',
+ name: 'app.css',
+ path: 'src/styles/app.css',
+ language: 'css',
+ role: 'module',
+ isActive: false,
+ content: localStylesSource,
+ targetPrFilePath: 'src/styles/app.css',
+ },
+ ],
+ activeTabId: 'component',
+ },
+ ])
+
+ await page.evaluate(repo => {
+ localStorage.setItem('knighted:develop:github-pat', 'github_pat_fake_chat_1234567890')
+ localStorage.setItem('knighted:develop:github-repository', repo)
+ }, repositoryFullName)
+
+ await waitForAppReady(page, `${appEntryPath}`)
+
+ await expect
+ .poll(
+ async () => {
+ const workspaceRecord = await getWorkspaceTabsRecord(page, { headBranch })
+ const tabs = Array.isArray(workspaceRecord?.tabs)
+ ? (workspaceRecord.tabs as Array>)
+ : []
+
+ const entryTab = tabs.find(tab => tab?.id === 'component')
+ const stylesTab = tabs.find(tab => tab?.id === 'styles')
+
+ return {
+ entryContent: typeof entryTab?.content === 'string' ? entryTab.content : '',
+ stylesContent: typeof stylesTab?.content === 'string' ? stylesTab.content : '',
+ }
+ },
+ { timeout: 10_000 },
+ )
+ .toEqual({
+ entryContent: localComponentSource,
+ stylesContent: localStylesSource,
+ })
+})
+
test('Reloaded active PR context sync does not overwrite non-primary module tabs', async ({
page,
}) => {
@@ -1697,14 +2387,22 @@ test('Reloaded active PR context sync does not overwrite non-primary module tabs
},
{ timeout: 10_000 },
)
- .toEqual({
- entryContent: remoteComponentSource,
+ .toMatchObject({
entryTargetPath: 'src/components/App.tsx',
boopContent: localBoopSource,
boopTargetPath: 'src/components/boop.tsx',
beepContent: localBeepSource,
beepTargetPath: 'src/components/beep.tsx',
})
+
+ const workspaceAfterSync = await getWorkspaceTabsRecord(page, { headBranch })
+ const entryAfterSyncContent = getWorkspaceComponentContent(workspaceAfterSync)
+ expect(
+ new Set([
+ remoteComponentSource,
+ 'export const App = () => Local App\n',
+ ]).has(entryAfterSyncContent),
+ ).toBe(true)
})
test('Reloaded active PR context sync does not overwrite non-primary tabs with stale target path collisions', async ({
@@ -1894,11 +2592,19 @@ test('Reloaded active PR context sync does not overwrite non-primary tabs with s
},
{ timeout: 10_000 },
)
- .toEqual({
- entryContent: remoteComponentSource,
+ .toMatchObject({
boopContent: localBoopSource,
beepContent: localBeepSource,
})
+
+ const staleCollisionRecord = await getWorkspaceTabsRecord(page, { headBranch })
+ const staleCollisionEntryContent = getWorkspaceComponentContent(staleCollisionRecord)
+ expect(
+ new Set([
+ remoteComponentSource,
+ 'export const App = () => Local App\n',
+ ]).has(staleCollisionEntryContent),
+ ).toBe(true)
})
test('Reloaded active PR context falls back to css style mode for unsupported value', async ({
diff --git a/playwright/github-pr-drawer/github-pr-drawer.helpers.ts b/playwright/github-pr-drawer/github-pr-drawer.helpers.ts
index feb826a..99b78c0 100644
--- a/playwright/github-pr-drawer/github-pr-drawer.helpers.ts
+++ b/playwright/github-pr-drawer/github-pr-drawer.helpers.ts
@@ -151,23 +151,24 @@ export const getWorkspaceRecordId = (
export const getWorkspacesRepositoryFilterForRecord = ({
repo,
- prContextState,
- prNumber,
+ workspaceScope,
}: {
repo?: unknown
- prContextState?: unknown
- prNumber?: unknown
+ workspaceScope?: unknown
}) => {
const normalizedRepo = typeof repo === 'string' ? repo.trim() : ''
- const normalizedState =
- typeof prContextState === 'string' ? prContextState.trim().toLowerCase() : ''
- const hasPrNumber = typeof prNumber === 'number' && Number.isFinite(prNumber)
+ const normalizedScope =
+ typeof workspaceScope === 'string' ? workspaceScope.trim().toLowerCase() : ''
- if (!normalizedRepo) {
+ if (normalizedScope === 'local') {
return '__local__'
}
- if (normalizedState === 'inactive' && !hasPrNumber) {
+ if (normalizedScope === 'repository') {
+ return normalizedRepo || '__local__'
+ }
+
+ if (!normalizedRepo) {
return '__local__'
}
@@ -429,6 +430,7 @@ export const seedLocalWorkspaceContexts = async (
contexts: Array<{
id: string
repo: string
+ workspaceScope?: 'local' | 'repository'
base?: string
head: string
prTitle: string
@@ -458,6 +460,12 @@ export const seedLocalWorkspaceContexts = async (
for (const context of inputContexts) {
const putRequest = store.put({
id: context.id,
+ workspaceScope:
+ context.workspaceScope === 'repository' || context.workspaceScope === 'local'
+ ? context.workspaceScope
+ : context.repo && context.repo.trim()
+ ? 'repository'
+ : 'local',
repo: context.repo,
base: context.base ?? 'main',
head: context.head,
diff --git a/playwright/github-pr-drawer/open-pr-create.spec.ts b/playwright/github-pr-drawer/open-pr-create.spec.ts
index 482d93c..b91e85f 100644
--- a/playwright/github-pr-drawer/open-pr-create.spec.ts
+++ b/playwright/github-pr-drawer/open-pr-create.spec.ts
@@ -215,6 +215,444 @@ test('Open PR drawer confirms and submits component/styles filepaths', async ({
await expect(
page.getByRole('button', { name: 'Close active pull request context' }),
).toBeVisible()
+
+ await expect
+ .poll(async () => {
+ const record = await getWorkspaceTabsRecord(page, {
+ headBranch: 'Develop/Open-Pr-Test',
+ })
+ return {
+ prContextState:
+ typeof record?.prContextState === 'string' ? record.prContextState : '',
+ prNumber:
+ typeof record?.prNumber === 'number' && Number.isFinite(record.prNumber)
+ ? record.prNumber
+ : null,
+ prTitle: typeof record?.prTitle === 'string' ? record.prTitle : '',
+ }
+ })
+ .toEqual({
+ prContextState: 'active',
+ prNumber: 42,
+ prTitle: 'Apply editor updates from develop',
+ })
+})
+
+test('Open PR ignores stale rename target deletions from workspace metadata', async ({
+ page,
+}) => {
+ const repositoryFullName = 'knightedcodemonkey/develop'
+ const workspaceHeadBranch = 'feat/stale-target-path-metadata'
+ const treeRequests: Array> = []
+
+ 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: repositoryFullName,
+ default_branch: 'main',
+ permissions: { push: true },
+ },
+ ]),
+ })
+ })
+
+ await mockRepositoryBranches(page, {
+ [repositoryFullName]: ['main', 'release'],
+ })
+
+ await page.route(
+ 'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**',
+ async route => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ ref: 'refs/heads/main',
+ object: { type: 'commit', sha: 'stale-open-pr-main-sha' },
+ }),
+ })
+ },
+ )
+
+ await page.route(
+ 'https://api.github.com/repos/knightedcodemonkey/develop/git/commits/stale-open-pr-main-sha',
+ async route => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ sha: 'stale-open-pr-main-sha',
+ tree: { sha: 'stale-open-pr-base-tree' },
+ }),
+ })
+ },
+ )
+
+ await page.route(
+ 'https://api.github.com/repos/knightedcodemonkey/develop/git/trees',
+ async route => {
+ treeRequests.push(route.request().postDataJSON() as Record)
+ await route.fulfill({
+ status: 201,
+ contentType: 'application/json',
+ body: JSON.stringify({ sha: 'stale-open-pr-tree-sha' }),
+ })
+ },
+ )
+
+ await page.route(
+ 'https://api.github.com/repos/knightedcodemonkey/develop/git/commits',
+ async route => {
+ await route.fulfill({
+ status: 201,
+ contentType: 'application/json',
+ body: JSON.stringify({ sha: 'stale-open-pr-commit-sha' }),
+ })
+ },
+ )
+
+ await page.route(
+ 'https://api.github.com/repos/knightedcodemonkey/develop/git/refs/**',
+ async route => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ ref: 'refs/heads/feat/stale-target-path-open-pr' }),
+ })
+ },
+ )
+
+ await page.route(
+ 'https://api.github.com/repos/knightedcodemonkey/develop/git/refs',
+ async route => {
+ await route.fulfill({
+ status: 201,
+ contentType: 'application/json',
+ body: JSON.stringify({ ref: 'refs/heads/feat/stale-target-path-open-pr' }),
+ })
+ },
+ )
+
+ await page.route(
+ 'https://api.github.com/repos/knightedcodemonkey/develop/contents/**',
+ async route => {
+ await route.fulfill({
+ status: 404,
+ contentType: 'application/json',
+ body: JSON.stringify({ message: 'Not Found' }),
+ })
+ },
+ )
+
+ await page.route(
+ 'https://api.github.com/repos/knightedcodemonkey/develop/pulls',
+ async route => {
+ await route.fulfill({
+ status: 201,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ number: 144,
+ html_url: 'https://github.com/knightedcodemonkey/develop/pull/144',
+ }),
+ })
+ },
+ )
+
+ await waitForAppReady(page, `${appEntryPath}`)
+
+ const now = Date.now()
+ await seedLocalWorkspaceContexts(page, [
+ {
+ id: buildWorkspaceRecordId({
+ repositoryFullName,
+ headBranch: workspaceHeadBranch,
+ }),
+ repo: repositoryFullName,
+ base: 'main',
+ head: workspaceHeadBranch,
+ prTitle: '',
+ prNumber: null,
+ prContextState: 'inactive',
+ renderMode: 'dom',
+ tabs: [
+ {
+ id: 'component',
+ name: 'App.tsx',
+ path: 'src/components/App.tsx',
+ language: 'javascript-jsx',
+ role: 'entry',
+ isActive: false,
+ content: 'const App = () => ',
+ targetPrFilePath: 'src/components/App.tsx',
+ syncedContent: 'const App = () => ',
+ syncedAt: now,
+ isDirty: false,
+ },
+ {
+ id: 'styles',
+ name: 'styles.css',
+ path: 'src/styles.css',
+ language: 'css',
+ role: 'module',
+ isActive: true,
+ content: 'button { color: tomato; }',
+ targetPrFilePath: 'src/styles/app.css',
+ syncedContent: 'button { color: tomato; }',
+ syncedAt: now,
+ isDirty: true,
+ },
+ ],
+ activeTabId: 'styles',
+ createdAt: now,
+ lastModified: now,
+ },
+ ])
+
+ await connectByotWithSingleRepo(page)
+ await openStoredWorkspaceContextByHead(page, workspaceHeadBranch)
+ await ensureOpenPrDrawerOpen(page)
+
+ await page.getByLabel('Head').fill('feat/stale-target-path-open-pr')
+ await page.getByLabel('PR title').fill('Do not delete stale target path on open PR')
+ await submitOpenPrAndConfirm(page)
+
+ await expect(
+ page.getByRole('status', { name: 'Open pull request status', includeHidden: true }),
+ ).toContainText(
+ 'Pull request opened: https://github.com/knightedcodemonkey/develop/pull/144',
+ )
+
+ expect(treeRequests).toHaveLength(1)
+ const treePayload = treeRequests[0]?.tree as Array>
+ const paths = treePayload?.map(file => String(file.path ?? '')) ?? []
+
+ expect(paths).toContain('src/components/App.tsx')
+ expect(paths).toContain('src/styles.css')
+ expect(paths).not.toContain('src/styles/app.css')
+})
+
+test('Push commit in active PR mode commits only dirty module path when entry is unchanged', async ({
+ page,
+}) => {
+ const repositoryFullName = 'knightedcodemonkey/develop'
+ const headBranch = 'develop/module-only-push'
+ const treeRequests: Array> = []
+
+ 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: repositoryFullName,
+ default_branch: 'main',
+ permissions: { push: true },
+ },
+ ]),
+ })
+ })
+
+ await mockRepositoryBranches(page, {
+ [repositoryFullName]: ['main', 'release', headBranch],
+ })
+
+ await page.route(
+ 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/2',
+ async route => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ number: 2,
+ state: 'open',
+ title: 'Module-only commit PR',
+ html_url: 'https://github.com/knightedcodemonkey/develop/pull/2',
+ head: { ref: headBranch },
+ base: { ref: 'main' },
+ }),
+ })
+ },
+ )
+
+ await page.route(
+ 'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**',
+ async route => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ ref: `refs/heads/${headBranch}`,
+ object: { type: 'commit', sha: 'module-push-head-sha' },
+ }),
+ })
+ },
+ )
+
+ await page.route(
+ 'https://api.github.com/repos/knightedcodemonkey/develop/git/commits/module-push-head-sha',
+ async route => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ sha: 'module-push-head-sha',
+ tree: { sha: 'module-push-base-tree' },
+ }),
+ })
+ },
+ )
+
+ await page.route(
+ 'https://api.github.com/repos/knightedcodemonkey/develop/git/trees',
+ async route => {
+ treeRequests.push(route.request().postDataJSON() as Record)
+ await route.fulfill({
+ status: 201,
+ contentType: 'application/json',
+ body: JSON.stringify({ sha: 'module-push-tree-sha' }),
+ })
+ },
+ )
+
+ await page.route(
+ 'https://api.github.com/repos/knightedcodemonkey/develop/git/commits',
+ async route => {
+ await route.fulfill({
+ status: 201,
+ contentType: 'application/json',
+ body: JSON.stringify({ sha: 'module-push-commit-sha' }),
+ })
+ },
+ )
+
+ await page.route(
+ 'https://api.github.com/repos/knightedcodemonkey/develop/git/refs/**',
+ async route => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ ref: `refs/heads/${headBranch}` }),
+ })
+ },
+ )
+
+ await waitForAppReady(page, `${appEntryPath}`)
+
+ const now = Date.now()
+ await seedLocalWorkspaceContexts(page, [
+ {
+ id: buildWorkspaceRecordId({
+ repositoryFullName,
+ headBranch,
+ }),
+ repo: repositoryFullName,
+ base: 'main',
+ head: headBranch,
+ prTitle: 'Module-only commit PR',
+ prNumber: 2,
+ prContextState: 'active',
+ renderMode: 'react',
+ tabs: [
+ {
+ id: 'component',
+ name: 'App.tsx',
+ path: 'src/components/App.tsx',
+ language: 'javascript-jsx',
+ role: 'entry',
+ isActive: false,
+ content: 'export const App = () => Entry unchanged',
+ targetPrFilePath: 'src/components/App.tsx',
+ syncedContent: 'export const App = () => Entry unchanged',
+ syncedAt: now,
+ isDirty: false,
+ },
+ {
+ id: 'styles',
+ name: 'app.css',
+ path: 'src/styles/app.css',
+ language: 'css',
+ role: 'module',
+ isActive: false,
+ content: '.entry { color: #111; }',
+ targetPrFilePath: 'src/styles/app.css',
+ syncedContent: '.entry { color: #111; }',
+ syncedAt: now,
+ isDirty: false,
+ },
+ {
+ id: 'module-card-tab',
+ name: 'feature-card.tsx',
+ path: 'src/components/feature-card.tsx',
+ language: 'javascript-jsx',
+ role: 'module',
+ isActive: true,
+ content: 'export const FeatureCard = () => ',
+ targetPrFilePath: 'src/components/feature-card.tsx',
+ syncedContent: 'export const FeatureCard = () => ',
+ syncedAt: now,
+ isDirty: false,
+ },
+ ],
+ activeTabId: 'module-card-tab',
+ createdAt: now,
+ lastModified: now,
+ },
+ ])
+
+ await connectByotWithSingleRepo(page)
+ await openStoredWorkspaceContextByHead(page, headBranch)
+
+ await page.getByRole('button', { name: 'Open tab feature-card.tsx' }).click()
+ await expect(page.getByRole('region', { name: 'feature-card.tsx' })).toBeVisible()
+ const componentEditor = page
+ .locator('.editor-panel[data-editor-kind="component"] .cm-content')
+ .first()
+ await componentEditor.fill(
+ 'export const FeatureCard = () => ',
+ )
+ await componentEditor.press('End')
+ await componentEditor.type(' ')
+ await componentEditor.press('Backspace')
+
+ await ensureOpenPrDrawerOpen(page)
+ await page.getByLabel('Include entry tab').uncheck()
+ await page.getByRole('button', { name: 'Push commit' }).last().click()
+
+ const dialog = page.locator('#clear-confirm-dialog')
+ await expect(dialog).toBeVisible()
+ await expect(
+ dialog.getByText('feature-card.tsx -> src/components/feature-card.tsx', {
+ exact: true,
+ }),
+ ).toBeVisible()
+ await expect(
+ dialog.getByText('App.tsx -> src/components/App.tsx', { exact: true }),
+ ).toHaveCount(0)
+
+ await dialog.locator('button[value="confirm"]').evaluate(element => {
+ if (element instanceof HTMLButtonElement) {
+ element.click()
+ }
+ })
+
+ await expect(
+ page.getByRole('status', { name: 'Open pull request status', includeHidden: true }),
+ ).toContainText(`Commit pushed to ${headBranch}`)
+
+ expect(treeRequests).toHaveLength(1)
+ const treePayload = treeRequests[0]?.tree as Array>
+ const paths = treePayload?.map(file => String(file.path ?? '')) ?? []
+ expect(paths).toContain('src/components/feature-card.tsx')
+ expect(paths).not.toContain('src/components/App.tsx')
})
test('Open PR success normalizes trailing newline without showing Edited indicators', async ({
@@ -454,6 +892,7 @@ test('Workspaces repository selector filters contexts and keeps local-only conte
{
id: 'repo_knightedcodemonkey_develop_feat-local-alpha',
repo: 'knightedcodemonkey/develop',
+ workspaceScope: 'local',
head: 'feat/local-alpha',
prTitle: 'Alpha local context',
prContextState: 'inactive',
@@ -518,10 +957,24 @@ test('Workspaces repository selector filters contexts and keeps local-only conte
expect(localLabels).not.toContain('Alpha active context')
})
-test('Workspaces repository with no stored entries hides Workspace select and supports New workspace', async ({
+test('Workspaces repository with no stored entries hides Workspace select and supports Initialize', async ({
page,
}) => {
+ const seededRecordId = 'local_seed_initialize_preserved'
+ const seededHead = 'feat/local-preserved'
+
await waitForAppReady(page, `${appEntryPath}`)
+ await seedLocalWorkspaceContexts(page, [
+ {
+ id: seededRecordId,
+ repo: '',
+ base: 'main',
+ head: seededHead,
+ prTitle: 'Seed local context',
+ prNumber: null,
+ prContextState: 'inactive',
+ },
+ ])
await page.route('https://api.github.com/user/repos**', async route => {
await route.fulfill({
@@ -545,22 +998,193 @@ test('Workspaces repository with no stored entries hides Workspace select and su
.fill('github_pat_fake_1234567890')
await page.getByRole('button', { name: 'Add GitHub token' }).click()
+ const pullRequestRepository = page.getByLabel('Pull request repository')
+ const repositoryValueBeforeScopeSelection = await pullRequestRepository.inputValue()
+
await selectWorkspacesRepositoryFilter(page, 'knightedcodemonkey/develop')
await expect(page.getByLabel('Stored workspace')).toBeHidden()
await expect(page.getByRole('button', { name: 'Open', exact: true })).toBeHidden()
await expect(page.getByRole('button', { name: 'Remove', exact: true })).toBeHidden()
+ await expect(
+ page.getByRole('button', { name: 'New workspace', exact: true }),
+ ).toBeHidden()
+
+ const beforeInitializeRecord = (await getAllWorkspaceRecords(page)).find(
+ record => record?.id === seededRecordId,
+ )
+ expect(beforeInitializeRecord).toBeTruthy()
+ expect(
+ typeof beforeInitializeRecord?.repo === 'string' ? beforeInitializeRecord.repo : '',
+ ).toBe('')
+ await expect(pullRequestRepository).toHaveValue(repositoryValueBeforeScopeSelection)
- const newWorkspaceButton = page.getByRole('button', {
- name: 'New workspace',
+ const initializeButton = page.getByRole('button', {
+ name: 'Initialize',
exact: true,
})
- await expect(newWorkspaceButton).toBeVisible()
- await newWorkspaceButton.click()
+ await expect(initializeButton).toBeVisible()
+ await initializeButton.click()
await ensureOpenPrDrawerOpen(page)
- await expect(page.getByLabel('Pull request repository')).toHaveValue(
- 'knightedcodemonkey/develop',
- )
+ await expect(pullRequestRepository).toHaveValue('knightedcodemonkey/develop')
+
+ await expect
+ .poll(async () => {
+ const updatedRecord = (await getAllWorkspaceRecords(page)).find(
+ record => record?.id === seededRecordId,
+ )
+ return {
+ seededRepo: typeof updatedRecord?.repo === 'string' ? updatedRecord.repo : '',
+ seededWorkspaceKey:
+ typeof updatedRecord?.workspaceKey === 'string'
+ ? updatedRecord.workspaceKey
+ : '',
+ repositoryScopedCount: (await getAllWorkspaceRecords(page)).filter(record => {
+ const repo = typeof record?.repo === 'string' ? record.repo : ''
+ const workspaceKey =
+ typeof record?.workspaceKey === 'string' ? record.workspaceKey : ''
+ return (
+ repo === 'knightedcodemonkey/develop' &&
+ workspaceKey.includes('knightedcodemonkey-develop::')
+ )
+ }).length,
+ }
+ })
+ .toEqual({
+ seededRepo: '',
+ seededWorkspaceKey: '',
+ repositoryScopedCount: 1,
+ })
+
+ await expect
+ .poll(async () => {
+ const records = await getAllWorkspaceRecords(page)
+ return records.filter(record => record?.head === seededHead).length
+ })
+ .toBe(1)
+})
+
+test('Local New workspace always creates a new stored workspace snapshot', async ({
+ page,
+}) => {
+ await waitForAppReady(page, `${appEntryPath}`)
+
+ await seedLocalWorkspaceContexts(page, [
+ {
+ id: 'local_seed_duplicate_key_guard',
+ repo: '',
+ base: 'main',
+ head: 'feat/component-seeded',
+ prTitle: 'Seed local context',
+ prNumber: null,
+ prContextState: 'inactive',
+ },
+ ])
+
+ await page.reload()
+ await waitForAppReady(page, `${appEntryPath}`)
+ await connectByotWithSingleRepo(page)
+
+ const countLocalRecords = async () => {
+ return 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', 'readonly')
+ const store = tx.objectStore('prWorkspaces')
+ const getAllRequest = store.getAll()
+ const records = await new Promise>(
+ (resolve, reject) => {
+ getAllRequest.onsuccess = () => {
+ resolve(Array.isArray(getAllRequest.result) ? getAllRequest.result : [])
+ }
+ getAllRequest.onerror = () => reject(getAllRequest.error)
+ },
+ )
+
+ return records.filter(record => {
+ const repo = typeof record?.repo === 'string' ? record.repo.trim() : ''
+ return !repo
+ }).length
+ } finally {
+ db.close()
+ }
+ })
+ }
+
+ await selectWorkspacesRepositoryFilter(page, '__local__')
+
+ const initialLocalRecordCount = await countLocalRecords()
+ await page.getByRole('button', { name: 'New workspace', exact: true }).click()
+
+ await expect.poll(async () => countLocalRecords()).toBe(initialLocalRecordCount + 1)
+})
+
+test('Non-Local New workspace forks a new repository-scoped workspace when entries exist', async ({
+ page,
+}) => {
+ const repositoryFullName = 'knightedcodemonkey/develop'
+ const seededHead = 'feat/repo-seeded-workspace'
+
+ await waitForAppReady(page, `${appEntryPath}`)
+
+ await seedLocalWorkspaceContexts(page, [
+ {
+ id: 'repo_seed_for_non_local_fork',
+ repo: repositoryFullName,
+ base: 'main',
+ head: seededHead,
+ prTitle: 'Seed repository context',
+ prNumber: null,
+ prContextState: 'inactive',
+ },
+ ])
+
+ await page.reload()
+ await waitForAppReady(page, `${appEntryPath}`)
+ await connectByotWithSingleRepo(page)
+ await selectWorkspacesRepositoryFilter(page, repositoryFullName)
+
+ const countRepositoryRecords = async () => {
+ const records = await getAllWorkspaceRecords(page)
+ return records.filter(record => {
+ const repo = typeof record?.repo === 'string' ? record.repo.trim() : ''
+ return repo === repositoryFullName
+ }).length
+ }
+
+ const initialRepositoryCount = await countRepositoryRecords()
+ await expect(page.getByRole('button', { name: 'Initialize', exact: true })).toBeHidden()
+ await expect(
+ page.getByRole('button', { name: 'New workspace', exact: true }),
+ ).toBeVisible()
+ await page.getByRole('button', { name: 'New workspace', exact: true }).click()
+
+ await expect.poll(async () => countRepositoryRecords()).toBe(initialRepositoryCount + 1)
+
+ const persistedRecords = await getAllWorkspaceRecords(page)
+ const forkedRepositoryRecord = persistedRecords.find(record => {
+ const id = typeof record?.id === 'string' ? record.id.trim() : ''
+ const repo = typeof record?.repo === 'string' ? record.repo.trim() : ''
+ return repo === repositoryFullName && id !== 'repo_seed_for_non_local_fork'
+ })
+
+ expect(forkedRepositoryRecord).toBeTruthy()
+ expect(typeof forkedRepositoryRecord?.workspaceKey).toBe('string')
+ const forkedWorkspaceKey = String(forkedRepositoryRecord?.workspaceKey ?? '')
+ expect(forkedWorkspaceKey).toContain('knightedcodemonkey-develop::')
+ expect(
+ typeof forkedRepositoryRecord?.prTitle === 'string'
+ ? forkedRepositoryRecord.prTitle
+ : '',
+ ).toBe('')
+ expect(typeof forkedRepositoryRecord?.head).toBe('string')
+ expect(String(forkedRepositoryRecord?.head ?? '')).not.toBe(seededHead)
})
test('Switching Workspaces repository scope to Local keeps inactive record repo and shows it as local in drawer', async ({
@@ -613,8 +1237,12 @@ test('Switching Workspaces repository scope to Local keeps inactive record repo
await openStoredWorkspaceContextByHead(page, headBranch)
await selectWorkspacesRepositoryFilter(page, '__local__')
- const localLabels = await getLocalContextOptionLabels(page)
- expect(localLabels).toContain('local:feat/component-v8zw')
+ await expect
+ .poll(async () => {
+ const localLabels = await getLocalContextOptionLabels(page)
+ return localLabels.includes('local:feat/component-v8zw')
+ })
+ .toBe(true)
await expect
.poll(async () => {
@@ -669,7 +1297,9 @@ test('Blank-slate startup persists inactive local workspace before PAT', async (
.toBe(true)
})
-test('Fresh PAT bootstrap persists drawer head metadata to IDB', async ({ page }) => {
+test('Fresh PAT bootstrap does not persist drawer head metadata to IDB before submit', async ({
+ page,
+}) => {
const repositoryFullName = 'knightedcodemonkey/contract-case'
await resetWorkbenchStorage(page)
@@ -702,6 +1332,7 @@ test('Fresh PAT bootstrap persists drawer head metadata to IDB', async ({ page }
.fill('github_pat_fake_chat_1234567890')
await page.getByRole('button', { name: 'Add GitHub token' }).click()
await selectWorkspacesRepositoryFilter(page, repositoryFullName)
+ await page.getByRole('button', { name: 'Initialize', exact: true }).click()
const initialRecord = await getWorkspaceTabsRecord(page)
const initialRecordId = getWorkspaceRecordId(initialRecord)
@@ -713,43 +1344,39 @@ test('Fresh PAT bootstrap persists drawer head metadata to IDB', async ({ page }
await expect
.poll(async () => {
- const selectedRepository = await page
- .getByLabel('Pull request repository')
- .inputValue()
- const drawerHead = await page.getByLabel('Head').inputValue()
const records = await getAllWorkspaceRecords(page)
+ const matching = records.filter(record => record?.id === initialRecordId)
+ const latest = matching.sort((left, right) => {
+ const leftModified =
+ typeof left?.lastModified === 'number' && Number.isFinite(left.lastModified)
+ ? left.lastModified
+ : 0
+ const rightModified =
+ typeof right?.lastModified === 'number' && Number.isFinite(right.lastModified)
+ ? right.lastModified
+ : 0
+ return rightModified - leftModified
+ })[0]
- const latestRecord = records
- .filter(record => record?.repo === selectedRepository)
- .sort((a, b) => {
- const aLastModified =
- typeof a?.lastModified === 'number' && Number.isFinite(a.lastModified)
- ? a.lastModified
- : 0
- const bLastModified =
- typeof b?.lastModified === 'number' && Number.isFinite(b.lastModified)
- ? b.lastModified
- : 0
- return bLastModified - aLastModified
- })[0]
-
- return (
- Boolean(selectedRepository) &&
- Boolean(drawerHead) &&
- Boolean(latestRecord) &&
- latestRecord.repo === selectedRepository &&
- latestRecord.head === drawerHead
- )
+ return {
+ count: matching.length,
+ id: typeof latest?.id === 'string' ? latest.id : '',
+ head: typeof latest?.head === 'string' ? latest.head : '',
+ }
+ })
+ .toEqual({
+ count: 1,
+ id: initialRecordId,
+ head: typeof initialRecord?.head === 'string' ? initialRecord.head : '',
})
- .toBe(true)
- const record = await getWorkspaceTabsRecord(page, {
- headBranch: 'develop/fresh-pat-bootstrap',
- })
- expect(record?.id).toBe(initialRecordId)
+ const updatedRecord = (await getAllWorkspaceRecords(page)).find(
+ record => record?.id === initialRecordId,
+ )
+ expect(updatedRecord?.head).toBe(initialRecord?.head)
})
-test('Changing head updates current workspace without creating a new record', async ({
+test('Changing head does not update current workspace without explicit submit', async ({
page,
}) => {
const repositoryFullName = 'knightedcodemonkey/contract-case'
@@ -784,6 +1411,7 @@ test('Changing head updates current workspace without creating a new record', as
.fill('github_pat_fake_chat_1234567890')
await page.getByRole('button', { name: 'Add GitHub token' }).click()
await selectWorkspacesRepositoryFilter(page, repositoryFullName)
+ await page.getByRole('button', { name: 'Initialize', exact: true }).click()
const initialRecord = await getWorkspaceTabsRecord(page)
const initialRecordId = getWorkspaceRecordId(initialRecord)
@@ -798,7 +1426,7 @@ test('Changing head updates current workspace without creating a new record', as
await expect
.poll(async () => {
const records = await getAllWorkspaceRecords(page)
- const matching = records.filter(record => record?.repo === repositoryFullName)
+ const matching = records.filter(record => record?.id === initialRecordId)
const latest = matching.sort((left, right) => {
const leftModified =
typeof left?.lastModified === 'number' && Number.isFinite(left.lastModified)
@@ -820,7 +1448,7 @@ test('Changing head updates current workspace without creating a new record', as
.toEqual({
count: 1,
id: initialRecordId,
- head: 'develop/head-second',
+ head: typeof initialRecord?.head === 'string' ? initialRecord.head : '',
})
})
@@ -924,7 +1552,7 @@ for (const prContextState of ['inactive', 'closed'] as const) {
await selectWorkspacesRepositoryFilter(page, targetRepository)
await ensureOpenPrDrawerOpen(page)
- await expect(page.getByLabel('Pull request repository')).toHaveValue(targetRepository)
+ await expect(page.getByLabel('Pull request repository')).toHaveValue(sourceRepository)
await expect(page.getByLabel('Head')).toHaveValue(workspaceHead)
await expect
@@ -1118,6 +1746,7 @@ test('Open PR promotes inactive workspace with stable record id when repository
await ensureOpenPrDrawerOpen(page)
await expect(page.getByLabel('Pull request repository')).toHaveValue(oldRepository)
await selectWorkspacesRepositoryFilter(page, newRepository)
+ await page.getByRole('button', { name: 'Initialize', exact: true }).click()
await ensureOpenPrDrawerOpen(page)
await expect(page.getByLabel('Pull request repository')).toHaveValue(newRepository)
@@ -1435,6 +2064,109 @@ test('Open PR drawer starts with empty title/description and short default head'
await expect(page.getByLabel('PR description')).toHaveValue('')
})
+test('Open PR drawer hard-fails when requested head branch already exists', async ({
+ page,
+}) => {
+ let createRefRequestCount = 0
+ let treeRequestCount = 0
+ let pullRequestRequestCount = 0
+
+ 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.route(
+ 'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**',
+ async route => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ ref: 'refs/heads/main',
+ object: { type: 'commit', sha: 'abc123mainsha' },
+ }),
+ })
+ },
+ )
+
+ await page.route(
+ 'https://api.github.com/repos/knightedcodemonkey/develop/git/refs',
+ async route => {
+ createRefRequestCount += 1
+ await route.fulfill({
+ status: 422,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ message: 'Reference already exists',
+ documentation_url: 'https://docs.github.com/rest/git/refs#create-a-reference',
+ }),
+ })
+ },
+ )
+
+ await page.route(
+ 'https://api.github.com/repos/knightedcodemonkey/develop/git/trees',
+ async route => {
+ treeRequestCount += 1
+ await route.fulfill({
+ status: 201,
+ contentType: 'application/json',
+ body: JSON.stringify({ sha: 'new-tree-sha' }),
+ })
+ },
+ )
+
+ await page.route(
+ 'https://api.github.com/repos/knightedcodemonkey/develop/pulls',
+ async route => {
+ pullRequestRequestCount += 1
+ await route.fulfill({
+ status: 201,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ number: 77,
+ html_url: 'https://github.com/knightedcodemonkey/develop/pull/77',
+ }),
+ })
+ },
+ )
+
+ await waitForAppReady(page, `${appEntryPath}`)
+ await connectByotWithSingleRepo(page)
+ await ensureOpenPrDrawerOpen(page)
+
+ await page.getByLabel('Head').fill('feat/A')
+ await page.getByLabel('PR title').fill('Should fail for existing branch')
+ await submitOpenPrAndConfirm(page)
+
+ await expect(
+ page.getByRole('status', { name: 'Open pull request status', includeHidden: true }),
+ ).toContainText(
+ 'Open PR failed: Branch feat/A already exists. Choose another branch name and retry.',
+ )
+
+ expect(createRefRequestCount).toBe(1)
+ expect(treeRequestCount).toBe(0)
+ expect(pullRequestRequestCount).toBe(0)
+})
+
test('Open PR drawer base dropdown updates from mocked repo branches', async ({
page,
}) => {
@@ -1497,12 +2229,14 @@ test('Open PR drawer base dropdown updates from mocked repo branches', async ({
await expect(repoSelect).toBeDisabled()
await selectWorkspacesRepositoryFilter(page, 'knightedcodemonkey/develop')
+ await page.getByRole('button', { name: 'Initialize', exact: true }).click()
await ensureOpenPrDrawerOpen(page)
await expect(repoSelect).toHaveValue('knightedcodemonkey/develop')
await expect(baseSelect).toHaveValue('main')
await expect(baseSelect.getByRole('option')).toHaveText(['main', 'develop-next'])
await selectWorkspacesRepositoryFilter(page, 'knightedcodemonkey/css')
+ await page.getByRole('button', { name: 'Initialize', exact: true }).click()
await ensureOpenPrDrawerOpen(page)
await expect(repoSelect).toHaveValue('knightedcodemonkey/css')
await expect(baseSelect).toHaveValue('stable')
@@ -1677,15 +2411,18 @@ test('Open PR repository field stays read-only while Workspaces controls reposit
await ensureOpenPrDrawerOpen(page)
const repoSelect = page.getByLabel('Pull request repository')
+ const initialRepoValue = await repoSelect.inputValue()
await expect(repoSelect).toBeDisabled()
await selectWorkspacesRepositoryFilter(page, 'knightedcodemonkey/develop')
+ await expect(page.getByRole('complementary', { name: 'Workspaces' })).toBeVisible()
await ensureOpenPrDrawerOpen(page)
- await expect(repoSelect).toHaveValue('knightedcodemonkey/develop')
+ await expect(repoSelect).toHaveValue(initialRepoValue)
await expect(repoSelect).toBeDisabled()
await selectWorkspacesRepositoryFilter(page, 'knightedcodemonkey/css')
+ await expect(page.getByRole('complementary', { name: 'Workspaces' })).toBeVisible()
await ensureOpenPrDrawerOpen(page)
- await expect(repoSelect).toHaveValue('knightedcodemonkey/css')
+ await expect(repoSelect).toHaveValue(initialRepoValue)
await expect(repoSelect).toBeDisabled()
})
diff --git a/playwright/helpers/app-test-helpers.ts b/playwright/helpers/app-test-helpers.ts
index c2e9b17..1bf1d7c 100644
--- a/playwright/helpers/app-test-helpers.ts
+++ b/playwright/helpers/app-test-helpers.ts
@@ -400,7 +400,15 @@ export const ensureOpenPrDrawerOpen = async (page: Page) => {
const isExpanded = await toggle.getAttribute('aria-expanded')
if (isExpanded !== 'true') {
- await toggle.click()
+ try {
+ await toggle.click({ timeout: 2_000 })
+ } catch {
+ await toggle.evaluate(element => {
+ if (element instanceof HTMLButtonElement) {
+ element.click()
+ }
+ })
+ }
}
await expect(
@@ -518,15 +526,32 @@ export const connectByotWithSingleRepo = async (
await workspacesRepositoryFilter.selectOption('knightedcodemonkey/develop')
await expect(workspacesRepositoryFilter).toHaveValue('knightedcodemonkey/develop')
+ const initializeButton = page.getByRole('button', {
+ name: 'Initialize',
+ exact: true,
+ })
+
+ if (await initializeButton.isVisible()) {
+ await initializeButton.click()
+ } else {
+ const storedWorkspace = page.getByLabel('Stored workspace')
+ if (await storedWorkspace.isVisible()) {
+ const workspaceValue = await storedWorkspace
+ .locator('option:not([value=""])')
+ .first()
+ .getAttribute('value')
+
+ if (workspaceValue) {
+ await storedWorkspace.selectOption(workspaceValue)
+ await page.getByRole('button', { name: 'Open', exact: true }).click()
+ }
+ }
+ }
+
await ensureWorkspacesDrawerClosed(page)
const repoSelect = page.getByLabel('Pull request repository')
- await expect
- .poll(async () => {
- const value = await repoSelect.inputValue()
- return value === '' || value === 'knightedcodemonkey/develop'
- })
- .toBe(true)
+ await expect(repoSelect).toHaveValue('knightedcodemonkey/develop')
await expect(repoSelect).toBeDisabled()
await expect(
diff --git a/src/app.js b/src/app.js
index 939e91b..9c5910d 100644
--- a/src/app.js
+++ b/src/app.js
@@ -34,6 +34,7 @@ import { createEditedIndicatorVisibilityController } from './modules/app-core/ed
import { createPublishTrailingNewlineNormalizer } from './modules/app-core/publish-trailing-newline-normalizer.js'
import { createLayoutDiagnosticsSetup } from './modules/app-core/layout-diagnostics-setup.js'
import { createWorkspaceControllersSetup } from './modules/app-core/workspace-controllers-setup.js'
+import { createWorkspaceScopeForkActions } from './modules/app-core/workspace-scope-fork-actions.js'
import { createGitHubWorkflowsSetup } from './modules/app-core/github-workflows-setup.js'
import { defaultCss, defaultJsx } from './modules/app-core/defaults.js'
import { createGitHubPrContextUiController } from './modules/app-core/github-pr-context-ui.js'
@@ -140,6 +141,7 @@ const workspacesDrawer = document.getElementById('workspaces-drawer')
const workspacesClose = document.getElementById('workspaces-close')
const workspacesStatus = document.getElementById('workspaces-status')
const workspacesRepository = document.getElementById('workspaces-repository')
+const workspacesInitialize = document.getElementById('workspaces-initialize')
const workspacesNew = document.getElementById('workspaces-new')
const workspacesSelect = document.getElementById('workspaces-select')
const workspacesOpen = document.getElementById('workspaces-open')
@@ -409,8 +411,15 @@ const githubAiContextState = {
let workspacePrContextState = 'inactive'
let workspacePrNumber = null
let workspaceRepositoryFullName = ''
+let workspaceScopeMarker = 'local'
let hasObservedActivePrContextInSession = false
+const toWorkspaceScopeMarker = value => (value === 'repository' ? 'repository' : 'local')
+
+const setWorkspaceScopeMarker = nextScope => {
+ workspaceScopeMarker = toWorkspaceScopeMarker(nextScope)
+}
+
const toPullRequestNumber = value => {
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
return value
@@ -423,6 +432,7 @@ const setActiveWorkspaceRecordId = nextValue => {
activeWorkspaceRecordId = toNonEmptyWorkspaceText(nextValue)
if (!activeWorkspaceRecordId) {
workspaceRepositoryFullName = ''
+ workspaceScopeMarker = 'local'
}
}
@@ -568,7 +578,9 @@ const getPersistedActivePrContext = createPersistedActivePrContextGetter({
const getWorkspaceContextSnapshot = createWorkspaceContextSnapshotGetter({
getCurrentSelectedRepository: () =>
- workspaceRepositoryFullName || getCurrentSelectedRepositoryFullName(),
+ workspaceScopeMarker === 'repository'
+ ? workspaceRepositoryFullName || getCurrentSelectedRepositoryFullName()
+ : '',
githubPrBaseBranch,
githubPrHeadBranch,
githubPrTitle,
@@ -629,9 +641,12 @@ const workspaceSyncController = createWorkspaceSyncController({
getJsxSource: () => getJsxSource(),
getCssSource: () => getCssSource(),
getWorkspaceTabByKind,
+ getLoadedComponentTabId: () => loadedComponentTabId,
+ getLoadedStylesTabId: () => loadedStylesTabId,
queueWorkspaceSave: () => queueWorkspaceSave(),
resolveWorkspaceRecordIdentity,
getWorkspaceContextSnapshot,
+ getWorkspaceScopeMarker: () => workspaceScopeMarker,
getActiveWorkspaceRecordId: () => activeWorkspaceRecordId,
getActiveWorkspaceCreatedAt: () => activeWorkspaceCreatedAt,
getRenderModeValue: () => renderMode.value,
@@ -713,28 +728,6 @@ const getEditorSyncTargets = () => workspaceSyncController.getEditorSyncTargets(
const reconcileWorkspaceTabsWithEditorSync = ({ tabTargets } = {}) =>
workspaceSyncController.reconcileWorkspaceTabsWithEditorSync({ tabTargets })
-const syncActiveWorkspaceRepositoryScope = async (
- repositoryFullName,
- { rekeyRecord = false } = {},
-) => {
- if (toNonEmptyWorkspaceText(workspacePrContextState).toLowerCase() !== 'inactive') {
- return
- }
-
- if (!toNonEmptyWorkspaceText(activeWorkspaceRecordId)) {
- return
- }
-
- if (rekeyRecord) {
- await flushWorkspaceSave({ preserveRecordId: true })
- setActiveWorkspaceRecordId('')
- activeWorkspaceCreatedAt = null
- }
-
- workspaceRepositoryFullName = toNonEmptyWorkspaceText(repositoryFullName)
- await flushWorkspaceSave({ preserveRecordId: !rekeyRecord })
-}
-
const buildWorkspaceRecordSnapshot = ({ recordId } = {}) =>
workspaceSyncController.buildWorkspaceRecordSnapshot({ recordId })
@@ -763,6 +756,7 @@ const {
setActiveWorkspaceCreatedAt: value => (activeWorkspaceCreatedAt = value),
setWorkspacePrContextState: value => (workspacePrContextState = value),
setWorkspacePrNumber: value => (workspacePrNumber = toPullRequestNumber(value)),
+ setWorkspaceScopeMarker,
getCurrentSelectedRepository: getCurrentSelectedRepositoryFullName,
getActiveWorkspaceRecordId: () => activeWorkspaceRecordId,
setIsApplyingWorkspaceSnapshot: value => (isApplyingWorkspaceSnapshot = value),
@@ -874,6 +868,42 @@ const {
},
})
+const { syncActiveWorkspaceRepositoryScope, forkWorkspaceFromCurrentState } =
+ createWorkspaceScopeForkActions({
+ toNonEmptyWorkspaceText,
+ workspaceStorage,
+ flushWorkspaceSave,
+ refreshLocalContextOptions,
+ createWorkspaceRecordId,
+ buildWorkspaceRecordSnapshot,
+ toWorkspaceRecordKey,
+ getWorkspacePrContextState: () => workspacePrContextState,
+ setWorkspacePrContextState: value => {
+ setWorkspacePrContextState(value)
+ },
+ setWorkspacePrNumber: value => {
+ setWorkspacePrNumber(value)
+ },
+ getActiveWorkspaceRecordId: () => activeWorkspaceRecordId,
+ setActiveWorkspaceRecordId,
+ setActiveWorkspaceCreatedAt: value => (activeWorkspaceCreatedAt = value),
+ getWorkspaceRepositoryFullName: () => workspaceRepositoryFullName,
+ setWorkspaceRepositoryFullName: value => {
+ workspaceRepositoryFullName = toNonEmptyWorkspaceText(value)
+ },
+ setWorkspaceScopeMarker,
+ setHeadBranchValue: value => {
+ if (githubPrHeadBranch) {
+ githubPrHeadBranch.value = value
+ }
+ },
+ setPrTitleValue: value => {
+ if (githubPrTitle) {
+ githubPrTitle.value = value
+ }
+ },
+ })
+
editedIndicatorVisibilityController.setRefreshHandlers({
syncHeaderLabels,
renderWorkspaceTabs,
@@ -938,6 +968,7 @@ const workspacePrSessionHandoffController = createWorkspacePrSessionHandoffContr
getWorkspacePrNumber: () => workspacePrNumber,
setWorkspacePrContextState,
setWorkspacePrNumber,
+ setWorkspaceScopeMarker,
getActiveWorkspaceCreatedAt: () => activeWorkspaceCreatedAt,
setActiveWorkspaceRecordId,
setActiveWorkspaceCreatedAt: value => (activeWorkspaceCreatedAt = value),
@@ -1061,6 +1092,7 @@ const githubWorkflows = createGitHubWorkflowsSetup({
workspacesClose,
workspacesStatus,
workspacesRepository,
+ workspacesInitialize,
workspacesNew,
workspacesSelect,
workspacesOpen,
@@ -1075,6 +1107,8 @@ const githubWorkflows = createGitHubWorkflowsSetup({
refreshLocalContextOptions,
applyWorkspaceRecord,
syncActiveWorkspaceRepositoryScope,
+ forkWorkspaceFromCurrentState,
+ flushWorkspaceSave,
getWorkspacePrFileCommits,
getEditorSyncTargets,
reconcileWorkspaceTabsWithPushUpdates,
@@ -1122,9 +1156,6 @@ const githubWorkflows = createGitHubWorkflowsSetup({
},
getPersistedActivePrContext,
getTokenForVisibility: () => githubAiContextState.token,
- closeWorkspacesDrawer: () => {
- void workspacesDrawerController?.setOpen(false)
- },
getActivePrEditorSyncKey: () => githubAiContextState.activePrEditorSyncKey,
syncFromActiveContext: ({ tabTargets }) => {
const activeTabIdBeforeSync = workspaceTabsState.getActiveTabId()
diff --git a/src/index.html b/src/index.html
index d510fa9..423d5c2 100644
--- a/src/index.html
+++ b/src/index.html
@@ -140,39 +140,39 @@