Skip to content

Commit ce43c92

Browse files
feat: improved workspace ux and state identity separation. (#103)
1 parent 92310ee commit ce43c92

39 files changed

Lines changed: 4330 additions & 1089 deletions

docs/idb-workspace-state.md

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,14 @@ Each workspace record may include:
1717
- `id`
1818
- `createdAt`
1919
- `lastModified`
20+
- `workspaceScope` (`local` | `repository`)
2021
- Repository and PR context:
2122
- `repo`
2223
- `base`
2324
- `head`
2425
- `prTitle`
2526
- `prNumber`
26-
- `prContextState` (`inactive` | `active` | `disconnected` | `closed`)
27+
- `prContextState` (`inactive` | `active` | `closed`)
2728
- Runtime/editor state:
2829
- `renderMode`
2930
- `activeTabId`
@@ -37,10 +38,34 @@ IDB supports that by storing:
3738

3839
- Full workspace snapshots
3940
- Repo-scoped context records
40-
- Historical transitions such as disconnected or closed PR context
41+
- Historical transitions such as closed PR context
4142

4243
## Design Rule
4344

4445
If a value is required to accurately restore PR/workspace behavior after reload, it must live in IDB records.
4546

4647
`localStorage` should only mirror user preferences and lightweight bootstrap values.
48+
49+
## Post-Push Baseline Invariant
50+
51+
After a successful Push Commit action for an active PR workspace:
52+
53+
- The active workspace record must persist immediately in IDB.
54+
- Any committed tab path returned by push updates must persist with:
55+
- `isDirty = false`
56+
- `syncedContent = content`
57+
- `syncedAt` updated to the push/reconcile time
58+
- `lastSyncedRemoteSha` set when a commit SHA is available
59+
- The same clean baseline must survive a full page reload.
60+
61+
Dirty-state note:
62+
63+
- When `syncedContent` is present for a tab, canonical dirty state is derived from
64+
`content !== syncedContent`.
65+
- This prevents stale UI-only dirty flags from overriding persisted sync baseline truth.
66+
67+
## Behavioral Spec
68+
69+
For action-level drawer semantics and state machine behavior, see:
70+
71+
- `docs/workspaces-behavior-algorithm.md`
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# Issue #99 + Workspaces Drawer UX Plan
2+
3+
## Goal
4+
5+
Simplify PR/workspace lifecycle and the Workspaces drawer UX by:
6+
7+
- removing disconnected state and Disconnect action paths
8+
- using workspace terminology in the drawer (not context)
9+
- separating new workspace initialization from workspace selection
10+
- preserving strict explicit intent semantics (no implicit apply/mutation from select changes)
11+
12+
## Decisions
13+
14+
- New workspace is an explicit direct action via a `New workspace` button.
15+
- `New workspace` must work for both repository scopes and `Local`.
16+
- `Open` remains the explicit action for applying an existing stored workspace.
17+
- If the selected repository has no stored workspaces, hide the workspace select and show the new-workspace path.
18+
19+
## Implementation Steps
20+
21+
1. Remove disconnected model paths (Issue #99)
22+
23+
- Remove Disconnect control from UI.
24+
- Remove disconnected event wiring and runtime callbacks.
25+
- Remove disconnected public action paths.
26+
- Normalize legacy `disconnected` records to `inactive` during restore/normalization.
27+
28+
2. Redesign drawer flow
29+
30+
- Replace starter option-in-select behavior with a dedicated `New workspace` button adjacent to repository select.
31+
- Keep workspace select for stored workspaces only.
32+
- Hide workspace select when no stored workspaces exist for the selected scope.
33+
- Keep strict explicit selection semantics (no auto-apply from select/filter changes).
34+
35+
3. Update copy and accessibility
36+
37+
- Replace "Stored contexts" and related "context" wording with "Workspace" wording.
38+
- Update status and aria labels consistently.
39+
40+
4. Remove obsolete code paths
41+
42+
- Remove starter prefix constants and parsing.
43+
- Remove disconnected-only logic and stale styling/branches.
44+
45+
5. Update tests
46+
47+
- Remove/replace disconnected-focused scenarios.
48+
- Update helpers/selectors to new workspace labels and `New workspace` action.
49+
- Add/adjust scenarios for empty repository scope (select hidden) and explicit Open behavior for existing workspaces.
50+
51+
6. Update docs
52+
53+
- Update storage/state docs to remove disconnected semantics.
54+
- Update drawer UX docs to reflect repository row + new workspace action flow.
55+
56+
## Verification
57+
58+
1. `npm run lint`
59+
2. Targeted Playwright (Chromium first):
60+
61+
- `playwright/github-pr-drawer/active-context-switch.spec.ts`
62+
- `playwright/github-pr-drawer/open-pr-create.spec.ts`
63+
64+
3. Broader Playwright run for workspace/PR drawer flows.
65+
4. Manual verification in dev server for:
66+
67+
- repository + new workspace row
68+
- local new workspace creation
69+
- hidden workspace select when no stored entries
70+
- explicit Open required for existing entries
71+
- no Disconnect control

docs/localstorage-state.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ Do not store pull request context in `localStorage`.
1919
Examples that must stay out of `localStorage`:
2020

2121
- Selected repository preference (`owner/repo`)
22-
- PR context state (`active`, `disconnected`, `closed`, `inactive`)
22+
- PR context state (`active`, `closed`, `inactive`)
2323
- PR number and URL
2424
- PR base/head/title/body
2525
- PR drawer repository-scoped workflow state
@@ -32,3 +32,7 @@ Examples that must stay out of `localStorage`:
3232
If data is needed to restore workspace or pull request workflow state, it belongs in IndexedDB workspace records.
3333

3434
Repository selection is derived from in-memory BYOT controls and IndexedDB-backed workspace records, not from a dedicated localStorage key.
35+
36+
For the Workspaces drawer action/state algorithm, see:
37+
38+
- `docs/workspaces-behavior-algorithm.md`

docs/pr-context-storage-matrix.md

Lines changed: 15 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ See the full storage ownership docs for non-PR keys:
2020
- Database: `knighted-develop-workspaces`
2121
- Object store: `prWorkspaces`
2222
- Relevant fields in each workspace record:
23-
- `prContextState`: `inactive` | `active` | `disconnected` | `closed`
23+
- `prContextState`: `inactive` | `active` | `closed`
2424
- `prNumber`: `number | null`
2525
- `prTitle`, `base`, `head`
2626
- `repo`
@@ -34,12 +34,12 @@ See the full storage ownership docs for non-PR keys:
3434

3535
Use this matrix as the source of truth when debugging UI/storage mismatch.
3636

37-
| Scenario | IDB `prContextState` | IDB `prNumber` | localStorage PR fields | Notes |
38-
| --------------------------------------------- | -------------------- | --------------------------------- | ---------------------- | -------------------------------------------------------------------------------------------------------------- |
39-
| A. Local workspace only, no PR context | `inactive` | `null` | none | No connected PR context. |
40-
| B. Workspace is for an active, open PR | `active` | PR number | none | Push mode in PR controls. |
41-
| 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. |
42-
| D. Workspace is for a PR closed on GitHub | `closed` | closed PR number | none | Historical context retained for debugging/reference. |
37+
| Scenario | IDB `prContextState` | IDB `prNumber` | localStorage PR fields | Notes |
38+
| ------------------------------------------ | -------------------- | ---------------- | ---------------------- | --------------------------------------------------------------------------------------------------------------- |
39+
| A. Local workspace only, no PR context | `inactive` | `null` | none | No connected PR context. |
40+
| B. Workspace is for an active, open PR | `active` | PR number | none | Push mode in PR controls. |
41+
| C. Workspace is for a PR closed on GitHub | `closed` | closed PR number | none | Historical context retained for debugging/reference. |
42+
| D. Active PR immediately after push commit | `active` | PR number | none | Committed tabs persist clean baseline (`isDirty=false`, `syncedContent=content`) and remain clean after reload. |
4343

4444
## Current Workspace Selection On Load
4545

@@ -81,10 +81,12 @@ When the UI does not match expected PR state:
8181
- `prContextState`
8282
- `prNumber`
8383
- `repo`, `head`, `prTitle`
84+
85+
- committed tab fields: `isDirty`, `syncedContent`, `content`, `syncedAt`, `lastSyncedRemoteSha`
86+
8487
2. Compare against the matrix above.
85-
3. If scenario C is expected, open that workspace from Workspaces to restore runtime PR context.
86-
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`.
87-
5. If the PR is no longer open, expect Open PR mode to remain and status messaging to explain verification results.
88+
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`.
89+
4. If the PR is no longer open, expect Open PR mode to remain and status messaging to explain verification results.
8890

8991
## Console Snippets
9092

@@ -99,19 +101,13 @@ indexedDB.open('knighted-develop-workspaces').onsuccess = event => {
99101
}
100102
```
101103

102-
## Reconnect Behavior
103-
104-
Reconnect behavior from the Workspaces drawer is implemented.
105-
106-
Opening a `disconnected` workspace record restores active PR runtime context for that repository and reinitializes editor state from the selected workspace record.
107-
108104
## End-Of-Session Behavior
109105

110-
`Disconnect` and `Close` are treated as end-of-session actions for PR-linked workspaces.
106+
`Close` is the end-of-session action for PR-linked workspaces.
111107

112-
When either action is confirmed:
108+
When close is confirmed:
113109

114-
1. The current workspace is archived as historical (`disconnected` or `closed`).
110+
1. The current workspace is archived as historical (`closed`).
115111
2. The app immediately switches to a fresh local workspace (`inactive`) with a single empty entry tab.
116112
3. Status messaging guides the user to continue locally or reopen a stored workspace from Workspaces.
117113

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
# Workspaces Drawer Behavior Algorithm
2+
3+
This document locks in the intended behavior for Workspaces drawer actions.
4+
5+
## Goals
6+
7+
- Keep action semantics explicit and predictable.
8+
- Keep button visibility state-driven and mutually exclusive.
9+
- Preserve workspace restore behavior by persisting state in IndexedDB.
10+
11+
## Core Terms
12+
13+
- `Local` scope: Workspaces whose `workspaceScope` is `local`.
14+
- `Repository` scope: Workspaces whose `workspaceScope` is `repository` and whose `repo` matches the selected repository filter.
15+
- `workspaceKey`: Derived identity key from repository + head branch. Used for matching/preference logic, not for UI policy by itself.
16+
17+
## Required Invariants
18+
19+
1. `Initialize` and `New workspace` must never be visible at the same time.
20+
2. `Local` scope never shows `Initialize`.
21+
3. `Initialize` for non-Local empty scope updates the active workspace in place (no fork).
22+
4. `New workspace` always forks from current editor/runtime state into a new record id.
23+
5. Fork creation must generate a fresh head branch suffix so `workspaceKey` and visible labels are distinct.
24+
6. Any workspace created via `New workspace` must persist with `prContextState = "inactive"`.
25+
7. For `New workspace`, `workspaceScope` is target-dependent:
26+
- `local` when repository filter is `__local__`
27+
- `repository` when repository filter is a non-local `owner/repo`
28+
29+
## UI State Machine
30+
31+
State is derived from:
32+
33+
- Selected repository filter (`__local__` vs non-local `owner/repo`)
34+
- Presence of stored workspaces in the selected scope
35+
36+
States:
37+
38+
1. `local-empty`
39+
- Show: `New workspace`
40+
- Hide: `Initialize`, workspace select, `Open`, `Remove`
41+
2. `local-with-workspaces`
42+
- Show: `New workspace`, workspace select, `Open`, `Remove`
43+
- Hide: `Initialize`
44+
3. `repository-empty`
45+
- Show: `Initialize`
46+
- Hide: `New workspace`, workspace select, `Open`, `Remove`
47+
4. `repository-with-workspaces`
48+
- Show: `New workspace`, workspace select, `Open`, `Remove`
49+
- Hide: `Initialize`
50+
51+
## Action Semantics
52+
53+
### A) Local + New workspace
54+
55+
- Action: fork current workspace into a new record.
56+
- Persisted updates:
57+
- `workspaceScope = "local"`
58+
- `prContextState = "inactive"`
59+
- `repo = ""`
60+
- fresh `id`
61+
- fresh local `head` (suffix-appended)
62+
- `workspaceKey = local::<fresh-head>`
63+
64+
### B) Non-Local + Initialize (no stored workspaces in selected repository)
65+
66+
- Action: update active workspace in place to selected repository scope.
67+
- Persisted updates on current record:
68+
- `workspaceScope = "repository"`
69+
- `repo = <selected owner/repo>`
70+
- `workspaceKey = <selected owner/repo>::<current head>`
71+
- Must preserve current record id.
72+
73+
### C) Non-Local + New workspace (stored workspaces exist)
74+
75+
- Action: fork current workspace into a new repository-scoped record.
76+
- Persisted updates:
77+
- `workspaceScope = "repository"`
78+
- `prContextState = "inactive"`
79+
- `repo = <selected owner/repo>`
80+
- fresh `id`
81+
- fresh repository `head` (suffix-appended from current head)
82+
- `workspaceKey = <selected owner/repo>::<head>`
83+
84+
## Storage Notes
85+
86+
- Canonical workflow state lives in IndexedDB (`prWorkspaces` records).
87+
- `localStorage` must not own repository/workspace workflow state.
88+
89+
## Regression Coverage Expectations
90+
91+
At minimum, tests should verify:
92+
93+
1. Local `New workspace` creates a new record and distinct local label/key.
94+
2. Non-local empty scope shows only `Initialize` and updates active record in place.
95+
3. Non-local scope with records shows only `New workspace` and forks new record.
96+
4. `Initialize` and `New workspace` never coexist.

playwright/github-byot-ai.spec.ts

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -768,22 +768,16 @@ test('BYOT remembers selected repository across reloads', async ({ page }) => {
768768

769769
await page.getByRole('button', { name: 'Workspaces' }).click()
770770
const workspaceRepositoryFilter = page.getByLabel('Workspace repository filter')
771-
const storedContextsSelect = page.getByLabel('Stored local editor contexts')
772-
const openStoredContextButton = page.getByRole('button', {
773-
name: 'Open',
771+
const initializeButton = page.getByRole('button', {
772+
name: 'Initialize',
774773
exact: true,
775774
})
776775
await expect(workspaceRepositoryFilter).toBeVisible()
777776
await workspaceRepositoryFilter.selectOption('knightedcodemonkey/develop')
778777
await expect(workspaceRepositoryFilter).toHaveValue('knightedcodemonkey/develop')
779778

780-
await expect(storedContextsSelect).toBeVisible()
781-
await storedContextsSelect.selectOption({
782-
label: 'Start new context for knightedcodemonkey/develop',
783-
})
784-
await expect(openStoredContextButton).toBeEnabled()
785-
await openStoredContextButton.click()
786-
await page.getByRole('button', { name: 'Close workspaces drawer' }).click()
779+
await expect(initializeButton).toBeVisible()
780+
await initializeButton.click()
787781

788782
await ensureOpenPrDrawerOpen(page)
789783
await expect(repoSelect).toHaveValue('knightedcodemonkey/develop')

0 commit comments

Comments
 (0)