Skip to content

Commit 71bdbd0

Browse files
refactor: simplify PR close flow with deterministic workspace state updates (#106)
1 parent e1208e9 commit 71bdbd0

14 files changed

Lines changed: 260 additions & 131 deletions

.github/instructions/pr-review.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ You are reviewing changes for @knighted/develop. Be concise, technical, and spec
2424
## What to verify
2525

2626
- No generated artifacts are edited (dist/, coverage/, test-results/).
27+
- Duplicated logic paths are avoided when a shared helper/module already exists; prefer reusing the established implementation.
2728
- CDN import/fallback behavior is not bypassed with ad hoc URLs in feature modules.
2829
- Sensitive values (PAT/token) are not logged or exposed in UI/status output.
2930
- New UI behavior is covered in Playwright where appropriate.

docs/idb-workspace-state.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@ If a value is required to accurately restore PR/workspace behavior after reload,
4646

4747
`localStorage` should only mirror user preferences and lightweight bootstrap values.
4848

49+
PR metadata boundary:
50+
51+
- PR drawer form edits are draft-only input.
52+
- Persisted PR metadata in workspace records is updated on successful workflow outcomes
53+
(Open PR, Push Commit, Close/verified closed updates), not on each form edit.
54+
4955
## Post-Push Baseline Invariant
5056

5157
After a successful Push Commit action for an active PR workspace:

docs/pr-context-storage-matrix.md

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ When the app loads, workspace restore scope depends on whether a repository is s
4848
- If a repository is selected: use repository-scoped records only (`repo` match).
4949
- If no repository is selected: evaluate all stored workspace records.
5050

51+
If a repository is selected and no repository-scoped records are found, restore falls back
52+
to evaluating all stored workspace records.
53+
5154
Selection order:
5255

5356
1. Load candidate records using the scope above.
@@ -59,14 +62,33 @@ Selection order:
5962
3. If preferred-by-id or preferred-by-key exists and is `active`, select it.
6063
4. Otherwise select the first `active` record in candidates.
6164
5. Otherwise select preferred-by-id or preferred-by-key if present.
62-
6. Otherwise fall back to the first record returned by IDB ordering.
65+
6. Otherwise fall back to the first candidate by recency (`lastModified` descending).
6366

6467
Notes:
6568

6669
- No `active workspace` pointer is stored in `localStorage`.
6770
- Restore behavior is intentionally derived from IDB workspace records + in-memory runtime state.
6871
- This avoids cross-storage drift between `localStorage` and IndexedDB.
6972

73+
## PR Drawer Persistence Boundary
74+
75+
PR drawer field edits are treated as draft input.
76+
77+
- Editing PR base/head/title in the drawer does not persist workspace record metadata by itself.
78+
- Workspace PR metadata is committed by explicit successful workflow outcomes (for example:
79+
successful Open PR, successful Push Commit, or successful Close PR/verified closed updates).
80+
81+
This keeps workspace records aligned to verified workflow outcomes instead of intermediate
82+
form state.
83+
84+
## Active Record Model
85+
86+
Multiple `active` PR workspaces may exist for the same repository.
87+
88+
- The model does not enforce a single-active-record-per-repository invariant.
89+
- Restore selection still follows the algorithm above and picks one workspace to load into
90+
the current runtime.
91+
7092
## Why PR Context Lives In IDB Only
7193

7294
PR workflow state is part of workspace state.
@@ -101,14 +123,10 @@ indexedDB.open('knighted-develop-workspaces').onsuccess = event => {
101123
}
102124
```
103125

104-
## End-Of-Session Behavior
105-
106-
`Close` is the end-of-session action for PR-linked workspaces.
107-
108-
When close is confirmed:
126+
## Close Behavior
109127

110-
1. The current workspace is archived as historical (`closed`).
111-
2. The app immediately switches to a fresh local workspace (`inactive`) with a single empty entry tab.
112-
3. Status messaging guides the user to continue locally or reopen a stored workspace from Workspaces.
128+
`Close` archives PR-linked context in IDB and clears active PR context in runtime.
113129

114-
In the Workspaces drawer, inactive local-only workspace options are prefixed with `local:`.
130+
- Matching workspace records for that PR context are persisted as `closed`.
131+
- Close does not force an automatic fresh-local-workspace handoff.
132+
- Workspace switching remains an explicit Workspaces action.

playwright/github-pr-drawer/active-context-switch.spec.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@ test('Switching active workspace to closed preserves switched-from record integr
3737
page,
3838
targetState: 'closed',
3939
})
40+
41+
await expect(page.getByRole('button', { name: 'Open pull request' })).toBeVisible()
42+
await expect(
43+
page.getByRole('button', { name: 'Close active pull request context' }),
44+
).toBeHidden()
45+
4046
await expect(page.getByRole('status', { name: 'App status' })).toContainText('Rendered')
4147
})
4248

@@ -925,8 +931,7 @@ test('Active PR context updates controls and can be closed from AI controls', as
925931
).toBeVisible()
926932
await expect(
927933
page.getByRole('list', { name: 'Workspace editor tabs' }).getByRole('listitem'),
928-
).toHaveCount(1)
929-
await expect(page.locator('#preview-host iframe')).toHaveCount(0)
934+
).toHaveCount(2)
930935

931936
await expect
932937
.poll(async () => {
@@ -941,12 +946,15 @@ test('Active PR context updates controls and can be closed from AI controls', as
941946
return {
942947
prContextState: closedRecord?.prContextState,
943948
prNumber: closedRecord?.prNumber,
949+
prTitle: closedRecord?.prTitle,
944950
}
945951
})
946952
.toEqual({
947953
prContextState: 'closed',
948954
prNumber: 2,
955+
prTitle: 'Existing PR context from storage',
949956
})
957+
950958
expect(closePullRequestRequestCount).toBe(1)
951959
})
952960

playwright/github-pr-drawer/github-pr-drawer.helpers.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -926,10 +926,7 @@ export const runActiveWorkspaceSwitchIntegrityScenario = async ({
926926
JSON.stringify(snapshot.active) === JSON.stringify(originalSnapshot.active)
927927

928928
const target = snapshot.target
929-
const targetStateMatches =
930-
targetState === 'closed'
931-
? target?.prContextState === 'closed' || target?.prContextState === 'inactive'
932-
: target?.prContextState === expectedTargetPrContextState
929+
const targetStateMatches = target?.prContextState === expectedTargetPrContextState
933930

934931
const targetMatches =
935932
target?.repo === originalSnapshot.target.repo &&

src/app.js

Lines changed: 47 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -812,15 +812,15 @@ const {
812812
return
813813
}
814814

815+
prDrawerController.clearSelectedRepositoryActivePrContext({ resetForm: false })
816+
815817
const nextWorkspaceRepositoryFullName =
816818
typeof workspace.repo === 'string' ? workspace.repo.trim() : ''
817819
if (nextWorkspaceRepositoryFullName) {
818820
workspaceRepositoryFullName = nextWorkspaceRepositoryFullName
819821
byotControls.setSelectedRepository(nextWorkspaceRepositoryFullName)
820822
}
821823

822-
prDrawerController.clearSelectedRepositoryActivePrContext({ resetForm: false })
823-
824824
const state =
825825
typeof workspace.prContextState === 'string'
826826
? workspace.prContextState.trim().toLowerCase()
@@ -955,6 +955,7 @@ const workspacePrSessionHandoffController = createWorkspacePrSessionHandoffContr
955955
defaults: {
956956
defaultComponentTabName,
957957
defaultComponentTabPath,
958+
defaultComponentTabContent: defaultJsx,
958959
},
959960
state: {
960961
getWorkspacePrNumber: () => workspacePrNumber,
@@ -998,17 +999,6 @@ const workspacePrSessionHandoffController = createWorkspacePrSessionHandoffContr
998999
},
9991000
})
10001001

1001-
const archivePrSessionAndStartFreshLocal = ({ result, archivedState, statusMessage }) => {
1002-
hasObservedActivePrContextInSession = false
1003-
setWorkspacePrNumber(result?.pullRequestNumber)
1004-
byotControls.clearSelectedRepositoryPreference()
1005-
workspaceRepositoryFullName = ''
1006-
workspacePrSessionHandoffController.archivePrWorkspaceAndStartFreshLocal({
1007-
archivedState,
1008-
statusMessage,
1009-
})
1010-
}
1011-
10121002
const onPrContextStateChange = createPrContextStateChangeHandler({
10131003
toNonEmptyWorkspaceText,
10141004
toPullRequestNumber,
@@ -1120,16 +1110,25 @@ const githubWorkflows = createGitHubWorkflowsSetup({
11201110
if (nextPrNumber !== null) {
11211111
setWorkspacePrNumber(nextPrNumber)
11221112
}
1123-
persistWorkspacePrContextState('closed')
1113+
setWorkspacePrContextState('closed')
11241114

11251115
const persistClosedRecords = async () => {
1116+
const activeWorkspaceId = toNonEmptyWorkspaceText(activeWorkspaceRecordId)
1117+
const activeWorkspaceRecord = activeWorkspaceId
1118+
? await workspaceStorage.getWorkspaceById(activeWorkspaceId)
1119+
: null
1120+
const preservedPrTitle =
1121+
toNonEmptyWorkspaceText(activeWorkspaceRecord?.prTitle) ||
1122+
toNonEmptyWorkspaceText(githubPrTitle?.value)
1123+
11261124
await persistClosedPrContextRecords({
11271125
workspaceStorage,
11281126
selectedRepository: toNonEmptyWorkspaceText(
11291127
getCurrentSelectedRepositoryFullName(),
11301128
),
11311129
nextPrNumber,
11321130
normalizedHead: toNonEmptyWorkspaceText(githubPrHeadBranch?.value),
1131+
fallbackPrTitle: preservedPrTitle,
11331132
toNonEmptyWorkspaceText,
11341133
refreshLocalContextOptions,
11351134
})
@@ -1140,11 +1139,40 @@ const githubWorkflows = createGitHubWorkflowsSetup({
11401139
})
11411140
},
11421141
onPrContextClosed: result => {
1143-
archivePrSessionAndStartFreshLocal({
1144-
result,
1145-
archivedState: 'closed',
1146-
statusMessage:
1147-
'PR context closed. Open Workspaces to load a saved workspace or continue with this local workspace.',
1142+
hasObservedActivePrContextInSession = false
1143+
const nextPrNumber =
1144+
toPullRequestNumber(result?.pullRequestNumber) ??
1145+
parsePullRequestNumberFromUrl(result?.pullRequestUrl)
1146+
if (nextPrNumber !== null) {
1147+
setWorkspacePrNumber(nextPrNumber)
1148+
}
1149+
setWorkspacePrContextState('closed')
1150+
1151+
const persistClosedRecords = async () => {
1152+
const activeWorkspaceId = toNonEmptyWorkspaceText(activeWorkspaceRecordId)
1153+
const activeWorkspaceRecord = activeWorkspaceId
1154+
? await workspaceStorage.getWorkspaceById(activeWorkspaceId)
1155+
: null
1156+
const preservedPrTitle =
1157+
toNonEmptyWorkspaceText(result?.prTitle) ||
1158+
toNonEmptyWorkspaceText(activeWorkspaceRecord?.prTitle) ||
1159+
toNonEmptyWorkspaceText(githubPrTitle?.value)
1160+
1161+
await persistClosedPrContextRecords({
1162+
workspaceStorage,
1163+
selectedRepository: toNonEmptyWorkspaceText(
1164+
getCurrentSelectedRepositoryFullName(),
1165+
),
1166+
nextPrNumber,
1167+
normalizedHead: toNonEmptyWorkspaceText(githubPrHeadBranch?.value),
1168+
fallbackPrTitle: preservedPrTitle,
1169+
toNonEmptyWorkspaceText,
1170+
refreshLocalContextOptions,
1171+
})
1172+
}
1173+
1174+
void persistClosedRecords().catch(() => {
1175+
/* Save failures are already surfaced through saver onError. */
11481176
})
11491177
},
11501178
getPersistedActivePrContext,

src/modules/app-core/github-workflows.js

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { toWorkspaceRecordKey } from '../workspace/workspace-tab-helpers.js'
2+
13
const initializeGitHubWorkflows = ({
24
createGitHubPrEditorSyncController,
35
createGitHubChatDrawer,
@@ -228,13 +230,6 @@ const initializeGitHubWorkflows = ({
228230
setSelectedRepository: setCurrentSelectedRepository,
229231
getFileCommits: getWorkspacePrFileCommits,
230232
getEditorSyncTargets,
231-
persistWorkspaceMetadataOnSubmit: async () => {
232-
if (typeof flushWorkspaceSave !== 'function') {
233-
return
234-
}
235-
236-
await flushWorkspaceSave({ preserveRecordId: true })
237-
},
238233
getTopLevelDeclarations,
239234
getRenderMode,
240235
getStyleMode,
@@ -249,6 +244,7 @@ const initializeGitHubWorkflows = ({
249244
fileUpdates,
250245
repositoryFullName,
251246
pullRequestNumber,
247+
branch,
252248
}) => {
253249
if (typeof onPrContextStateChange === 'function') {
254250
onPrContextStateChange(githubAiContextState.activePrContext)
@@ -285,6 +281,28 @@ const initializeGitHubWorkflows = ({
285281
activeWorkspaceRecordId,
286282
)
287283
if (activeWorkspaceRecord && typeof activeWorkspaceRecord === 'object') {
284+
const nextHeadBranch =
285+
typeof githubAiContextState.activePrContext?.headBranch === 'string' &&
286+
githubAiContextState.activePrContext.headBranch.trim()
287+
? githubAiContextState.activePrContext.headBranch.trim()
288+
: typeof branch === 'string' && branch.trim()
289+
? branch.trim()
290+
: typeof activeWorkspaceRecord.head === 'string'
291+
? activeWorkspaceRecord.head
292+
: ''
293+
const nextBaseBranch =
294+
typeof githubAiContextState.activePrContext?.baseBranch === 'string' &&
295+
githubAiContextState.activePrContext.baseBranch.trim()
296+
? githubAiContextState.activePrContext.baseBranch.trim()
297+
: typeof activeWorkspaceRecord.base === 'string'
298+
? activeWorkspaceRecord.base
299+
: ''
300+
const nextRepositoryFullName =
301+
toSafeRepositoryFullName(repositoryFullName) ||
302+
toSafeRepositoryFullName(
303+
githubAiContextState.activePrContext?.repositoryFullName,
304+
) ||
305+
toSafeRepositoryFullName(activeWorkspaceRecord.repo)
288306
const nextPrTitle =
289307
typeof githubAiContextState.activePrContext?.prTitle === 'string' &&
290308
githubAiContextState.activePrContext.prTitle.trim()
@@ -303,6 +321,14 @@ const initializeGitHubWorkflows = ({
303321

304322
const savedWorkspaceRecord = await workspaceStorage.upsertWorkspace({
305323
...activeWorkspaceRecord,
324+
workspaceScope: nextRepositoryFullName ? 'repository' : 'local',
325+
workspaceKey: toWorkspaceRecordKey({
326+
repositoryFullName: nextRepositoryFullName,
327+
headBranch: nextHeadBranch,
328+
}),
329+
repo: nextRepositoryFullName,
330+
base: nextBaseBranch,
331+
head: nextHeadBranch,
306332
prContextState: 'active',
307333
prNumber: nextPrNumber,
308334
prTitle: nextPrTitle,
@@ -544,8 +570,10 @@ const initializeGitHubWorkflows = ({
544570
onPrContextStateChange(prDrawerController.getActivePrContext())
545571
}
546572

573+
let isClosingActivePullRequest = false
574+
547575
githubPrContextClose?.addEventListener('click', () => {
548-
if (!githubAiContextState.activePrContext) {
576+
if (!githubAiContextState.activePrContext || isClosingActivePullRequest) {
549577
return
550578
}
551579

@@ -559,6 +587,15 @@ const initializeGitHubWorkflows = ({
559587
copy: `${referenceLine}PR title: ${githubAiContextState.activePrContext.prTitle}\nHead branch: ${githubAiContextState.activePrContext.headBranch}\n\nThis will close the pull request on GitHub and clear the active pull request context for the selected repository.`,
560588
confirmButtonText: 'Close PR on GitHub',
561589
onConfirm: () => {
590+
if (isClosingActivePullRequest) {
591+
return
592+
}
593+
594+
isClosingActivePullRequest = true
595+
if (githubPrContextClose instanceof HTMLButtonElement) {
596+
githubPrContextClose.disabled = true
597+
}
598+
562599
void prDrawerController
563600
.closeActivePullRequestOnGitHub()
564601
.then(result => {
@@ -586,6 +623,12 @@ const initializeGitHubWorkflows = ({
586623
setStatus(`Close context failed: ${message}`, 'error')
587624
showAppToast(`Close context failed: ${message}`)
588625
})
626+
.finally(() => {
627+
isClosingActivePullRequest = false
628+
if (githubPrContextClose instanceof HTMLButtonElement) {
629+
githubPrContextClose.disabled = false
630+
}
631+
})
589632
},
590633
})
591634
})

0 commit comments

Comments
 (0)