Skip to content

Commit 2c08b15

Browse files
author
DavidQ
committed
Persist and restore session selections for diff and merge workflows - PR 11.240
1 parent 66dc4bc commit 2c08b15

3 files changed

Lines changed: 311 additions & 5 deletions

File tree

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# PR_11_240 — Persist Session Selection And Restore On Load
2+
3+
## Files Changed
4+
- `tools/workspace-v2/index.js`
5+
- `tests/runtime/V2SelectionPersistence.test.mjs`
6+
7+
## Implementation Summary
8+
- Added selection persistence key:
9+
- `v2-session-selection`
10+
- Persisted shape:
11+
- `{ "sessionA": "<contextId>", "sessionB": "<contextId>" }`
12+
- Added methods:
13+
- `readPersistedSessionSelection()`
14+
- `writePersistedSessionSelection(leftEntry, rightEntry)`
15+
- `clearPersistedSessionSelection()`
16+
- `findSessionEntryByContextId(entries, contextId)`
17+
- `resolvePersistedSelectionIds(entries)`
18+
- Restore behavior:
19+
- Restores only when both sessions are present in current inventory and distinct.
20+
- Rejects identical restored selections.
21+
- Rejects missing/invalid/deleted restored selections.
22+
- Falls back to `No session selected` when restore is invalid.
23+
- UI/state behavior:
24+
- Restore path runs through existing selector rendering and re-evaluates button states.
25+
- Reset behavior:
26+
- `Clear Session Storage` now also clears `v2-session-selection`.
27+
28+
## Scope Confirmation
29+
- No schema/sample/game changes.
30+
- No merge/diff algorithm changes.
31+
- No fallback/auto-generated sessions.
32+
- Existing selection validation rules preserved.
33+
34+
## Validation Commands
35+
- `node --check tools/workspace-v2/index.js`
36+
- `node --check tests/runtime/V2SelectionPersistence.test.mjs`
37+
- `node tests/runtime/V2SelectionPersistence.test.mjs`
38+
- `node tests/runtime/V2DiffMergeButtonState.test.mjs`
39+
40+
## Validation Results
41+
- `node --check tools/workspace-v2/index.js` → PASS
42+
- `node --check tests/runtime/V2SelectionPersistence.test.mjs` → PASS
43+
- `node tests/runtime/V2SelectionPersistence.test.mjs` → PASS
44+
- `node tests/runtime/V2DiffMergeButtonState.test.mjs` → PASS
45+
46+
Runtime artifacts:
47+
- `tmp/v2-selection-persistence-results.json`
48+
- `tmp/v2-diff-merge-button-state-results.json`
49+
50+
## Required Behavior Verification
51+
- selections persist after page refresh (persist/read/resolve path): PASS
52+
- valid selections restore correctly: PASS
53+
- deleted sessions do not restore: PASS
54+
- same-session restore is rejected: PASS
55+
- buttons reflect restored state correctly: PASS
56+
- reset clears persisted selection: PASS
57+
58+
## Full Smoke Decision
59+
- Full samples smoke not run.
60+
- Reason: scoped Workspace V2 selector persistence update with targeted executable validation.
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import assert from "node:assert/strict";
2+
import fs from "node:fs";
3+
import path from "node:path";
4+
import { execFileSync } from "node:child_process";
5+
import { fileURLToPath, pathToFileURL } from "node:url";
6+
7+
const __filename = fileURLToPath(import.meta.url);
8+
const __dirname = path.dirname(__filename);
9+
const repoRoot = path.resolve(__dirname, "..", "..");
10+
const workspaceJsPath = path.join(repoRoot, "tools", "workspace-v2", "index.js");
11+
const resultsPath = path.join(repoRoot, "tmp", "v2-selection-persistence-results.json");
12+
13+
function readText(filePath) {
14+
return fs.readFileSync(filePath, "utf8");
15+
}
16+
17+
function checkSyntax(jsPath) {
18+
try {
19+
execFileSync(process.execPath, ["--check", jsPath], {
20+
cwd: repoRoot,
21+
stdio: ["ignore", "pipe", "pipe"]
22+
});
23+
return { ok: true, error: "" };
24+
} catch (error) {
25+
return { ok: false, error: (error?.stderr || error?.stdout || error?.message || "").toString().trim() };
26+
}
27+
}
28+
29+
function safeParse(raw) {
30+
try {
31+
return { ok: true, value: JSON.parse(raw) };
32+
} catch {
33+
return { ok: false, value: null };
34+
}
35+
}
36+
37+
function isValidPayload(payload) {
38+
return Boolean(payload && typeof payload === "object" && !Array.isArray(payload));
39+
}
40+
41+
function readPersistedSelection(store) {
42+
const raw = store["v2-session-selection"];
43+
if (!raw) return { sessionA: "", sessionB: "" };
44+
const parsed = safeParse(raw);
45+
if (!parsed.ok || !parsed.value || typeof parsed.value !== "object" || Array.isArray(parsed.value)) {
46+
return { sessionA: "", sessionB: "" };
47+
}
48+
return {
49+
sessionA: typeof parsed.value.sessionA === "string" ? parsed.value.sessionA.trim() : "",
50+
sessionB: typeof parsed.value.sessionB === "string" ? parsed.value.sessionB.trim() : ""
51+
};
52+
}
53+
54+
function resolvePersistedSelectionIds(entries, store) {
55+
if (!Array.isArray(entries) || entries.length < 2) return { leftId: "", rightId: "" };
56+
const persisted = readPersistedSelection(store);
57+
if (!persisted.sessionA || !persisted.sessionB || persisted.sessionA === persisted.sessionB) {
58+
return { leftId: "", rightId: "" };
59+
}
60+
const left = entries.find((entry) => entry.contextId === persisted.sessionA && isValidPayload(entry.payload));
61+
const right = entries.find((entry) => entry.contextId === persisted.sessionB && isValidPayload(entry.payload));
62+
if (!left || !right || left.id === right.id) return { leftId: "", rightId: "" };
63+
return { leftId: left.id, rightId: right.id };
64+
}
65+
66+
function writePersistedSelection(store, leftEntry, rightEntry) {
67+
const sessionA = leftEntry && typeof leftEntry.contextId === "string" ? leftEntry.contextId : "";
68+
const sessionB = rightEntry && typeof rightEntry.contextId === "string" ? rightEntry.contextId : "";
69+
store["v2-session-selection"] = JSON.stringify({ sessionA, sessionB });
70+
}
71+
72+
function clearPersistedSelection(store) {
73+
delete store["v2-session-selection"];
74+
}
75+
76+
function canRunAction(leftId, rightId) {
77+
return Boolean(leftId && rightId && leftId !== rightId);
78+
}
79+
80+
export function run() {
81+
const failures = [];
82+
const jsExists = fs.existsSync(workspaceJsPath);
83+
const js = jsExists ? readText(workspaceJsPath) : "";
84+
const syntax = checkSyntax(workspaceJsPath);
85+
86+
const hasStorageKey = js.includes("this.sessionSelectionStorageKey = \"v2-session-selection\";");
87+
const hasRead = js.includes("readPersistedSessionSelection()");
88+
const hasWrite = js.includes("writePersistedSessionSelection(leftEntry, rightEntry)");
89+
const hasResolve = js.includes("resolvePersistedSelectionIds(entries)");
90+
const hasClear = js.includes("clearPersistedSessionSelection()");
91+
const hasResetHook = js.includes("this.clearPersistedSessionSelection();");
92+
93+
if (!jsExists) failures.push("Missing tools/workspace-v2/index.js.");
94+
if (!syntax.ok) failures.push("workspace-v2/index.js failed syntax check.");
95+
if (!hasStorageKey) failures.push("Missing v2-session-selection storage key.");
96+
if (!hasRead) failures.push("Missing readPersistedSessionSelection().");
97+
if (!hasWrite) failures.push("Missing writePersistedSessionSelection(...).");
98+
if (!hasResolve) failures.push("Missing resolvePersistedSelectionIds(...).");
99+
if (!hasClear) failures.push("Missing clearPersistedSessionSelection().");
100+
if (!hasResetHook) failures.push("Missing reset hook that clears persisted selection.");
101+
102+
const entries = [
103+
{ id: "history:ctx-a", contextId: "ctx-a", payload: { toolId: "asset-browser-v2", payloadJson: { a: 1 } } },
104+
{ id: "history:ctx-b", contextId: "ctx-b", payload: { toolId: "asset-browser-v2", payloadJson: { b: 2 } } }
105+
];
106+
const store = {};
107+
108+
writePersistedSelection(store, entries[0], entries[1]);
109+
const persisted = readPersistedSelection(store);
110+
if (persisted.sessionA !== "ctx-a" || persisted.sessionB !== "ctx-b") {
111+
failures.push("Selections were not persisted correctly.");
112+
}
113+
114+
const restored = resolvePersistedSelectionIds(entries, store);
115+
if (restored.leftId !== "history:ctx-a" || restored.rightId !== "history:ctx-b") {
116+
failures.push("Valid persisted selections were not restored.");
117+
}
118+
119+
const deletedEntries = [{ id: "history:ctx-a", contextId: "ctx-a", payload: { toolId: "asset-browser-v2", payloadJson: { a: 1 } } }];
120+
const deletedRestore = resolvePersistedSelectionIds(deletedEntries, store);
121+
if (deletedRestore.leftId || deletedRestore.rightId) {
122+
failures.push("Deleted/missing sessions should not restore.");
123+
}
124+
125+
store["v2-session-selection"] = JSON.stringify({ sessionA: "ctx-a", sessionB: "ctx-a" });
126+
const sameRestore = resolvePersistedSelectionIds(entries, store);
127+
if (sameRestore.leftId || sameRestore.rightId) {
128+
failures.push("Same-session restore should be rejected.");
129+
}
130+
131+
store["v2-session-selection"] = JSON.stringify({ sessionA: "ctx-a", sessionB: "ctx-b" });
132+
const restoredForState = resolvePersistedSelectionIds(entries, store);
133+
if (!canRunAction(restoredForState.leftId, restoredForState.rightId)) {
134+
failures.push("Buttons should reflect restored valid distinct state as enabled.");
135+
}
136+
137+
clearPersistedSelection(store);
138+
if (Object.prototype.hasOwnProperty.call(store, "v2-session-selection")) {
139+
failures.push("Reset should clear v2-session-selection.");
140+
}
141+
142+
fs.mkdirSync(path.dirname(resultsPath), { recursive: true });
143+
fs.writeFileSync(resultsPath, `${JSON.stringify({
144+
generatedAt: new Date().toISOString(),
145+
failures,
146+
checks: {
147+
jsExists,
148+
syntax,
149+
hasStorageKey,
150+
hasRead,
151+
hasWrite,
152+
hasResolve,
153+
hasClear,
154+
hasResetHook
155+
},
156+
scenarios: {
157+
persisted,
158+
restored,
159+
deletedRestore,
160+
sameRestore,
161+
restoredForState,
162+
selectionCleared: !Object.prototype.hasOwnProperty.call(store, "v2-session-selection")
163+
}
164+
}, null, 2)}\n`, "utf8");
165+
166+
console.log(`v2 selection persistence results: ${resultsPath}`);
167+
assert.equal(failures.length, 0, `V2 selection persistence failures: ${failures.join(" | ")}`);
168+
return { failures };
169+
}
170+
171+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
172+
try {
173+
const summary = run();
174+
console.log(JSON.stringify(summary, null, 2));
175+
} catch (error) {
176+
console.error(error);
177+
process.exitCode = 1;
178+
}
179+
}

tools/workspace-v2/index.js

Lines changed: 72 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ class WorkspaceV2SessionProducer {
66
this.historyStorageKey = "v2-session-history";
77
this.errorLogsStorageKey = "v2-error-logs";
88
this.mergeAuditStorageKey = "v2-merge-audit-log";
9+
this.sessionSelectionStorageKey = "v2-session-selection";
910
this.historyMaxEntries = 10;
1011
this.urlLengthLimit = 2000;
1112
this.sessionPayloadBytesLimit = 1024 * 1024;
@@ -530,6 +531,53 @@ class WorkspaceV2SessionProducer {
530531
return inventory;
531532
}
532533

534+
readPersistedSessionSelection() {
535+
const raw = localStorage.getItem(this.sessionSelectionStorageKey);
536+
if (!raw) {
537+
return { sessionA: "", sessionB: "" };
538+
}
539+
const parsed = this.safeParseJson(raw);
540+
if (!parsed.ok || !parsed.value || typeof parsed.value !== "object" || Array.isArray(parsed.value)) {
541+
return { sessionA: "", sessionB: "" };
542+
}
543+
const sessionA = typeof parsed.value.sessionA === "string" ? parsed.value.sessionA.trim() : "";
544+
const sessionB = typeof parsed.value.sessionB === "string" ? parsed.value.sessionB.trim() : "";
545+
return { sessionA, sessionB };
546+
}
547+
548+
writePersistedSessionSelection(leftEntry, rightEntry) {
549+
const sessionA = leftEntry && typeof leftEntry.contextId === "string" ? leftEntry.contextId : "";
550+
const sessionB = rightEntry && typeof rightEntry.contextId === "string" ? rightEntry.contextId : "";
551+
localStorage.setItem(this.sessionSelectionStorageKey, JSON.stringify({ sessionA, sessionB }));
552+
}
553+
554+
clearPersistedSessionSelection() {
555+
localStorage.removeItem(this.sessionSelectionStorageKey);
556+
}
557+
558+
findSessionEntryByContextId(entries, contextId) {
559+
if (!Array.isArray(entries) || typeof contextId !== "string" || !contextId.trim()) {
560+
return null;
561+
}
562+
return entries.find((entry) => entry.contextId === contextId.trim()) || null;
563+
}
564+
565+
resolvePersistedSelectionIds(entries) {
566+
if (!Array.isArray(entries) || entries.length < 2) {
567+
return { leftId: "", rightId: "" };
568+
}
569+
const persisted = this.readPersistedSessionSelection();
570+
if (!persisted.sessionA || !persisted.sessionB || persisted.sessionA === persisted.sessionB) {
571+
return { leftId: "", rightId: "" };
572+
}
573+
const leftEntry = this.findSessionEntryByContextId(entries, persisted.sessionA);
574+
const rightEntry = this.findSessionEntryByContextId(entries, persisted.sessionB);
575+
if (!leftEntry || !rightEntry || leftEntry.id === rightEntry.id) {
576+
return { leftId: "", rightId: "" };
577+
}
578+
return { leftId: leftEntry.id, rightId: rightEntry.id };
579+
}
580+
533581
findSessionEntryById(entries, selectedId) {
534582
if (!Array.isArray(entries) || typeof selectedId !== "string" || !selectedId.trim()) {
535583
return null;
@@ -568,6 +616,7 @@ class WorkspaceV2SessionProducer {
568616
updateDiffSelectionFeedbackAndState() {
569617
const left = this.findSessionEntryById(this.diffCandidates, this.diffLeftSelect.value);
570618
const right = this.findSessionEntryById(this.diffCandidates, this.diffRightSelect.value);
619+
this.writePersistedSessionSelection(left, right);
571620
this.diffLeftSelectedLabelNode.textContent = this.formatSelectionLabel(left);
572621
this.diffRightSelectedLabelNode.textContent = this.formatSelectionLabel(right);
573622
const canRunDiff = Boolean(left && right && left.id !== right.id);
@@ -584,6 +633,7 @@ class WorkspaceV2SessionProducer {
584633
updateMergeSelectionFeedbackAndState() {
585634
const left = this.findSessionEntryById(this.mergeCandidates, this.mergeLeftSelect.value);
586635
const right = this.findSessionEntryById(this.mergeCandidates, this.mergeRightSelect.value);
636+
this.writePersistedSessionSelection(left, right);
587637
this.mergeLeftSelectedLabelNode.textContent = this.formatSelectionLabel(left);
588638
this.mergeRightSelectedLabelNode.textContent = this.formatSelectionLabel(right);
589639
const canPreviewMerge = Boolean(left && right && left.id !== right.id);
@@ -608,6 +658,7 @@ class WorkspaceV2SessionProducer {
608658
this.diffCandidates = this.resolveWorkspaceSessionInventory();
609659
const currentLeft = this.diffLeftSelect.value;
610660
const currentRight = this.diffRightSelect.value;
661+
const persistedSelections = this.resolvePersistedSelectionIds(this.diffCandidates);
611662
this.diffLeftSelect.replaceChildren();
612663
this.diffRightSelect.replaceChildren();
613664

@@ -633,8 +684,12 @@ class WorkspaceV2SessionProducer {
633684
this.diffRightSelect.appendChild(rightOption);
634685
});
635686

636-
this.diffLeftSelect.value = this.diffCandidates.some((entry) => entry.id === currentLeft) ? currentLeft : "";
637-
this.diffRightSelect.value = this.diffCandidates.some((entry) => entry.id === currentRight) ? currentRight : "";
687+
this.diffLeftSelect.value = this.diffCandidates.some((entry) => entry.id === currentLeft)
688+
? currentLeft
689+
: persistedSelections.leftId;
690+
this.diffRightSelect.value = this.diffCandidates.some((entry) => entry.id === currentRight)
691+
? currentRight
692+
: persistedSelections.rightId;
638693

639694
this.diffEmptyState.hidden = this.diffCandidates.length >= 2;
640695
this.diffEmptyState.textContent = "Create or reopen at least two Workspace V2 sessions before comparing.";
@@ -656,6 +711,7 @@ class WorkspaceV2SessionProducer {
656711
this.applyMergeButton.disabled = true;
657712
const currentLeft = this.mergeLeftSelect.value;
658713
const currentRight = this.mergeRightSelect.value;
714+
const persistedSelections = this.resolvePersistedSelectionIds(this.mergeCandidates);
659715
this.mergeLeftSelect.replaceChildren();
660716
this.mergeRightSelect.replaceChildren();
661717

@@ -681,8 +737,12 @@ class WorkspaceV2SessionProducer {
681737
this.mergeRightSelect.appendChild(rightOption);
682738
});
683739

684-
this.mergeLeftSelect.value = this.mergeCandidates.some((entry) => entry.id === currentLeft) ? currentLeft : "";
685-
this.mergeRightSelect.value = this.mergeCandidates.some((entry) => entry.id === currentRight) ? currentRight : "";
740+
this.mergeLeftSelect.value = this.mergeCandidates.some((entry) => entry.id === currentLeft)
741+
? currentLeft
742+
: persistedSelections.leftId;
743+
this.mergeRightSelect.value = this.mergeCandidates.some((entry) => entry.id === currentRight)
744+
? currentRight
745+
: persistedSelections.rightId;
686746

687747
this.mergeEmptyState.hidden = this.mergeCandidates.length >= 2;
688748
this.mergeEmptyState.textContent = "Create or reopen at least two Workspace V2 sessions before previewing a merge.";
@@ -1202,10 +1262,17 @@ class WorkspaceV2SessionProducer {
12021262

12031263
clearSessionStorage(emitStatus = true) {
12041264
sessionStorage.clear();
1265+
this.clearPersistedSessionSelection();
1266+
this.diffLeftSelect.value = "";
1267+
this.diffRightSelect.value = "";
1268+
this.mergeLeftSelect.value = "";
1269+
this.mergeRightSelect.value = "";
1270+
this.updateDiffSelectionFeedbackAndState();
1271+
this.updateMergeSelectionFeedbackAndState();
12051272
this.currentHostContextId = "";
12061273
this.renderDiagnosticsPanel();
12071274
if (emitStatus) {
1208-
this.statusNode.textContent = "Session storage cleared.";
1275+
this.statusNode.textContent = "Session storage cleared and session selections reset.";
12091276
}
12101277
}
12111278

0 commit comments

Comments
 (0)