fix: persist navigation memory per browser tab

This commit is contained in:
Federico Jaramillo Martinez
2026-06-04 15:57:29 +02:00
parent f1c8f1f014
commit 30fb9602d6
11 changed files with 382 additions and 8 deletions
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Preserve machine, workspace, session, and terminal navigation memory across reloads within each browser tab.
+8 -4
View File
@@ -12,8 +12,10 @@ import { MachineController } from "../controllers/machineController";
import { ProjectController } from "../controllers/projectController";
import { SessionController } from "../controllers/sessionController";
import { WorkspaceController, canDeleteWorkspace } from "../controllers/workspaceController";
import { emptyMachineNavigationSnapshot, InMemoryMachineNavigationMemory, machineNavigationSnapshotFromState, routeFromMachineNavigationSnapshot, type MachineNavigationSnapshot, type WorkspaceRouteSurface } from "../controllers/machineNavigationMemory";
import { InMemoryTerminalSelectionMemory } from "../controllers/terminalSelection";
import { emptyMachineNavigationSnapshot, machineNavigationSnapshotFromState, routeFromMachineNavigationSnapshot, SessionStorageMachineNavigationMemory, type MachineNavigationSnapshot, type WorkspaceRouteSurface } from "../controllers/machineNavigationMemory";
import { SessionStorageSessionSelectionMemory } from "../controllers/sessionSelection";
import { SessionStorageTerminalSelectionMemory } from "../controllers/terminalSelection";
import { SessionStorageWorkspaceSelectionMemory } from "../controllers/workspaceSelection";
import { KeyboardShortcutDispatcher } from "../keyboardShortcuts";
import { selectedMachineId } from "../controllers/types";
import { RealtimeSocket } from "../sessionSocket";
@@ -79,6 +81,7 @@ export class PiWebApp extends LitElement {
() => this.state,
(patch) => { this.setState(patch); },
() => { this.updateUrl(); },
new SessionStorageSessionSelectionMemory(),
);
private readonly activity = new ActivityController(
() => this.state,
@@ -94,6 +97,7 @@ export class PiWebApp extends LitElement {
(patch) => { this.setState(patch); },
() => { this.updateUrl(); },
this.sessions,
new SessionStorageWorkspaceSelectionMemory(),
);
private readonly projects = new ProjectController(
() => this.state,
@@ -120,8 +124,8 @@ export class PiWebApp extends LitElement {
private readonly realtime = new RealtimeSocket();
private readonly machineActivitySockets = new Map<string, RealtimeSocket>();
private readonly activeTerminalIds = new Set<string>();
private readonly machineNavigation = new InMemoryMachineNavigationMemory();
private readonly terminalSelection = new InMemoryTerminalSelectionMemory();
private readonly machineNavigation = new SessionStorageMachineNavigationMemory();
private readonly terminalSelection = new SessionStorageTerminalSelectionMemory();
private readonly appShell = new AppShellController(this);
private readonly panelCollapse = new PanelCollapseController(this);
private readonly mobileNavigation = new MobileNavigationController(
@@ -1,7 +1,8 @@
import { describe, expect, it } from "vitest";
import { initialAppState, type AppState } from "../appState";
import type { Machine, Project, SessionInfo, Workspace } from "../api";
import { emptyMachineNavigationSnapshot, InMemoryMachineNavigationMemory, machineNavigationSnapshotFromState, routeFromMachineNavigationSnapshot } from "./machineNavigationMemory";
import type { KeyValueStorage } from "./sessionStorageMemory";
import { emptyMachineNavigationSnapshot, InMemoryMachineNavigationMemory, machineNavigationSnapshotFromState, routeFromMachineNavigationSnapshot, SessionStorageMachineNavigationMemory } from "./machineNavigationMemory";
describe("InMemoryMachineNavigationMemory", () => {
it("remembers independent navigation snapshots per machine", () => {
@@ -30,6 +31,38 @@ describe("InMemoryMachineNavigationMemory", () => {
});
});
describe("SessionStorageMachineNavigationMemory", () => {
it("persists independent navigation snapshots in per-tab storage", () => {
const storage = memoryStorage();
const memory = new SessionStorageMachineNavigationMemory(storage);
memory.remember({ machineId: "local", projectId: "local-project", surface: { selectedFilePath: "README.md" } });
memory.remember({ machineId: "remote", projectId: "remote-project", workspaceId: "remote-workspace", sessionId: "remote-session", surface: {} });
const restored = new SessionStorageMachineNavigationMemory(storage);
expect(restored.latest("local")?.projectId).toBe("local-project");
expect(restored.latest("remote")?.workspaceId).toBe("remote-workspace");
restored.forget("local");
expect(new SessionStorageMachineNavigationMemory(storage).latest("local")).toBeUndefined();
expect(new SessionStorageMachineNavigationMemory(storage).latest("remote")?.projectId).toBe("remote-project");
});
it("ignores malformed stored snapshots", () => {
const storage = memoryStorage({
"pi-web:machine-navigation:v1": JSON.stringify({ version: 1, entries: [["local", { machineId: "local", tool: "bad", surface: { selectedFilePath: "README.md" } }], ["remote", { projectId: "missing-machine", surface: {} }]] }),
});
const memory = new SessionStorageMachineNavigationMemory(storage);
expect(memory.latest("local")?.tool).toBeUndefined();
expect(memory.latest("local")?.surface.selectedFilePath).toBe("README.md");
expect(memory.latest("remote")).toBeUndefined();
});
});
describe("machineNavigationSnapshotFromState", () => {
it("captures the selected machine location and workspace surface", () => {
const state: AppState = {
@@ -116,3 +149,12 @@ function workspace(id: string, projectId: string): Workspace {
function session(id: string): SessionInfo {
return { id, path: `/tmp/project/.pi/sessions/${id}`, cwd: "/tmp/project", created: "now", modified: "now", messageCount: 0, firstMessage: "" };
}
function memoryStorage(seed: Record<string, string> = {}): KeyValueStorage {
const values = new Map(Object.entries(seed));
return {
getItem: (key) => values.get(key) ?? null,
setItem: (key, value) => { values.set(key, value); },
removeItem: (key) => { values.delete(key); },
};
}
@@ -1,6 +1,7 @@
import type { AppState } from "../appState";
import { LOCAL_MACHINE_ID } from "../machineKeys";
import type { AppRoute } from "../route";
import { browserSessionStorage, PersistentValueMap, type KeyValueStorage } from "./sessionStorageMemory";
export interface WorkspaceRouteSurface {
selectedFilePath?: string | undefined;
@@ -41,6 +42,29 @@ export class InMemoryMachineNavigationMemory implements MachineNavigationMemory
}
}
const machineNavigationStorageKey = "pi-web:machine-navigation:v1";
export class SessionStorageMachineNavigationMemory implements MachineNavigationMemory {
private readonly snapshotsByMachine: PersistentValueMap<MachineNavigationSnapshot>;
constructor(storage: KeyValueStorage | undefined = browserSessionStorage()) {
this.snapshotsByMachine = new PersistentValueMap(machineNavigationStorageKey, parseMachineNavigationSnapshot, storage);
}
latest(machineId: string): MachineNavigationSnapshot | undefined {
const snapshot = this.snapshotsByMachine.get(machineId);
return snapshot?.machineId === machineId ? cloneSnapshot(snapshot) : undefined;
}
remember(snapshot: MachineNavigationSnapshot): void {
this.snapshotsByMachine.set(snapshot.machineId, cloneSnapshot(snapshot));
}
forget(machineId: string): void {
this.snapshotsByMachine.delete(machineId);
}
}
export function emptyMachineNavigationSnapshot(machineId: string): MachineNavigationSnapshot {
return { machineId, surface: {} };
}
@@ -79,3 +103,53 @@ function cloneSnapshot(snapshot: MachineNavigationSnapshot): MachineNavigationSn
surface: { ...snapshot.surface },
};
}
function parseMachineNavigationSnapshot(value: unknown): MachineNavigationSnapshot | undefined {
if (!isRecord(value)) return undefined;
const machineId = optionalStringField(value, "machineId");
if (machineId === undefined) return undefined;
const tool = parseQualifiedId(optionalStringField(value, "tool"));
const view = parseMainView(optionalStringField(value, "view"));
return {
machineId,
projectId: optionalStringField(value, "projectId"),
workspaceId: optionalStringField(value, "workspaceId"),
sessionId: optionalStringField(value, "sessionId"),
...(tool === undefined ? {} : { tool }),
...(view === undefined ? {} : { view }),
surface: parseWorkspaceRouteSurface(value["surface"]),
};
}
function parseWorkspaceRouteSurface(value: unknown): WorkspaceRouteSurface {
if (!isRecord(value)) return {};
return {
selectedFilePath: optionalStringField(value, "selectedFilePath"),
selectedDiffPath: optionalStringField(value, "selectedDiffPath"),
selectedTerminalId: optionalStringField(value, "selectedTerminalId"),
};
}
function parseMainView(value: string | undefined): AppState["mainView"] | undefined {
if (value === "navigation" || value === "chat") return value;
return parseQualifiedId(value);
}
type QualifiedRouteId = NonNullable<AppRoute["tool"]>;
function parseQualifiedId(value: string | undefined): AppRoute["tool"] | undefined {
return isQualifiedId(value) ? value : undefined;
}
function isQualifiedId(value: string | undefined): value is QualifiedRouteId {
return value !== undefined && /^[a-z][a-z0-9.-]*:[a-z][a-z0-9.-]*$/u.test(value);
}
function optionalStringField(record: Record<string, unknown>, key: string): string | undefined {
const value = record[key];
return typeof value === "string" && value !== "" ? value : undefined;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import type { SessionInfo } from "../api";
import { InMemorySessionSelectionMemory, markSessionArchived, markSessionsArchived, selectPreferredSession, selectionAfterArchivingSession, selectionAfterArchivingSessions, shouldDeselectAfterArchivedCollapse } from "./sessionSelection";
import type { KeyValueStorage } from "./sessionStorageMemory";
import { InMemorySessionSelectionMemory, markSessionArchived, markSessionsArchived, selectPreferredSession, selectionAfterArchivingSession, selectionAfterArchivingSessions, SessionStorageSessionSelectionMemory, shouldDeselectAfterArchivedCollapse } from "./sessionSelection";
describe("selectPreferredSession", () => {
it("prefers an explicit target session by id", () => {
@@ -69,6 +70,26 @@ describe("InMemorySessionSelectionMemory", () => {
});
});
describe("SessionStorageSessionSelectionMemory", () => {
it("persists the latest selected session per workspace cwd", () => {
const storage = memoryStorage();
const memory = new SessionStorageSessionSelectionMemory(storage);
memory.rememberSession({ ...testSession("s1"), cwd: "local:/tmp/one" });
memory.rememberSession({ ...testSession("s2"), cwd: "remote:/tmp/one" });
const restored = new SessionStorageSessionSelectionMemory(storage);
expect(restored.latestSessionId("local:/tmp/one")).toBe("s1");
expect(restored.latestSessionId("remote:/tmp/one")).toBe("s2");
restored.forgetWorkspace("local:/tmp/one");
expect(new SessionStorageSessionSelectionMemory(storage).latestSessionId("local:/tmp/one")).toBeUndefined();
expect(new SessionStorageSessionSelectionMemory(storage).latestSessionId("remote:/tmp/one")).toBe("s2");
});
});
describe("markSessionArchived", () => {
it("marks the matching session archived without mutating the original", () => {
const sessions = [testSession("s1"), testSession("s2")];
@@ -129,3 +150,12 @@ describe("selectionAfterArchivingSession", () => {
function testSession(id: string): SessionInfo {
return { id, path: `/tmp/project/.pi/sessions/${id}`, cwd: "/tmp/project", created: "now", modified: "now", messageCount: 0, firstMessage: "" };
}
function memoryStorage(seed: Record<string, string> = {}): KeyValueStorage {
const values = new Map(Object.entries(seed));
return {
getItem: (key) => values.get(key) ?? null,
setItem: (key, value) => { values.set(key, value); },
removeItem: (key) => { values.delete(key); },
};
}
@@ -1,4 +1,5 @@
import type { SessionInfo } from "../api";
import { browserSessionStorage, parseStoredString, PersistentValueMap, type KeyValueStorage } from "./sessionStorageMemory";
export interface SessionSelectionMemory {
latestSessionId(cwd: string): string | undefined;
@@ -22,6 +23,28 @@ export class InMemorySessionSelectionMemory implements SessionSelectionMemory {
}
}
const sessionSelectionStorageKey = "pi-web:session-selection:v1";
export class SessionStorageSessionSelectionMemory implements SessionSelectionMemory {
private readonly sessionIdsByCwd: PersistentValueMap<string>;
constructor(storage: KeyValueStorage | undefined = browserSessionStorage()) {
this.sessionIdsByCwd = new PersistentValueMap(sessionSelectionStorageKey, parseStoredString, storage);
}
latestSessionId(cwd: string): string | undefined {
return this.sessionIdsByCwd.get(cwd);
}
rememberSession(session: SessionInfo): void {
this.sessionIdsByCwd.set(session.cwd, session.id);
}
forgetWorkspace(cwd: string): void {
this.sessionIdsByCwd.delete(cwd);
}
}
export function selectPreferredSession(sessions: SessionInfo[], options?: { targetSessionId?: string | undefined; latestSessionId?: string | undefined }): SessionInfo | undefined {
const targetSessionId = options?.targetSessionId;
if (targetSessionId !== undefined && targetSessionId !== "") return sessionByIdOrPrefix(sessions, targetSessionId);
@@ -0,0 +1,90 @@
export interface KeyValueStorage {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}
interface StoredMapEnvelope {
version: 1;
entries: readonly (readonly [string, unknown])[];
}
export type StorageValueParser<T> = (value: unknown) => T | undefined;
export function browserSessionStorage(): KeyValueStorage | undefined {
try {
return typeof sessionStorage === "undefined" ? undefined : sessionStorage;
} catch {
return undefined;
}
}
export class PersistentValueMap<T> {
private readonly values = new Map<string, T>();
constructor(private readonly storageKey: string, private readonly parseValue: StorageValueParser<T>, private readonly storage = browserSessionStorage()) {
for (const [key, value] of loadEntries(storageKey, parseValue, storage)) this.values.set(key, value);
}
get(key: string): T | undefined {
return this.values.get(key);
}
set(key: string, value: T): void {
this.values.set(key, value);
this.save();
}
delete(key: string): void {
this.values.delete(key);
this.save();
}
entries(): [string, T][] {
return [...this.values.entries()];
}
private save(): void {
try {
if (this.values.size === 0) {
this.storage?.removeItem(this.storageKey);
return;
}
const envelope: StoredMapEnvelope = { version: 1, entries: [...this.values.entries()] };
this.storage?.setItem(this.storageKey, JSON.stringify(envelope));
} catch {
// Keep the in-memory copy even if sessionStorage is unavailable or full.
}
}
}
export function parseStoredString(value: unknown): string | undefined {
return typeof value === "string" && value !== "" ? value : undefined;
}
function loadEntries<T>(storageKey: string, parseValue: StorageValueParser<T>, storage: KeyValueStorage | undefined): [string, T][] {
try {
const raw = storage?.getItem(storageKey);
if (raw === undefined || raw === null || raw === "") return [];
const value: unknown = JSON.parse(raw);
if (!isStoredMapEnvelope(value)) return [];
const entries: [string, T][] = [];
for (const entry of value.entries) {
const key = entry[0];
const parsed = parseValue(entry[1]);
if (parsed !== undefined) entries.push([key, parsed]);
}
return entries;
} catch {
return [];
}
}
function isStoredMapEnvelope(value: unknown): value is StoredMapEnvelope {
if (!isRecord(value) || value["version"] !== 1 || !Array.isArray(value["entries"])) return false;
return value["entries"].every((entry) => Array.isArray(entry) && entry.length === 2 && typeof entry[0] === "string" && entry[0] !== "");
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import type { TerminalInfo } from "../api";
import { InMemoryTerminalSelectionMemory, selectFallbackTerminal, selectPreferredTerminal } from "./terminalSelection";
import type { KeyValueStorage } from "./sessionStorageMemory";
import { InMemoryTerminalSelectionMemory, selectFallbackTerminal, selectPreferredTerminal, SessionStorageTerminalSelectionMemory } from "./terminalSelection";
function terminal(id: string, exited = false): TerminalInfo {
return { id, cwd: "/repo", name: id, createdAt: "now", exited };
@@ -34,4 +35,27 @@ describe("terminal selection", () => {
expect(memory.latestTerminalId("/repo")).toBeUndefined();
expect(memory.latestTerminalId("/other")).toBe("t2");
});
it("persists terminal ids per workspace cwd", () => {
const storage = memoryStorage();
const memory = new SessionStorageTerminalSelectionMemory(storage);
memory.rememberTerminal("local:/repo", "t1");
memory.rememberTerminal("remote:/repo", "t2");
const restored = new SessionStorageTerminalSelectionMemory(storage);
expect(restored.latestTerminalId("local:/repo")).toBe("t1");
restored.forgetTerminal("t1");
expect(new SessionStorageTerminalSelectionMemory(storage).latestTerminalId("local:/repo")).toBeUndefined();
expect(new SessionStorageTerminalSelectionMemory(storage).latestTerminalId("remote:/repo")).toBe("t2");
});
});
function memoryStorage(seed: Record<string, string> = {}): KeyValueStorage {
const values = new Map(Object.entries(seed));
return {
getItem: (key) => values.get(key) ?? null,
setItem: (key, value) => { values.set(key, value); },
removeItem: (key) => { values.delete(key); },
};
}
@@ -1,4 +1,5 @@
import type { TerminalInfo } from "../api";
import { browserSessionStorage, parseStoredString, PersistentValueMap, type KeyValueStorage } from "./sessionStorageMemory";
export interface TerminalSelectionMemory {
latestTerminalId(cwd: string): string | undefined;
@@ -29,6 +30,34 @@ export class InMemoryTerminalSelectionMemory implements TerminalSelectionMemory
}
}
const terminalSelectionStorageKey = "pi-web:terminal-selection:v1";
export class SessionStorageTerminalSelectionMemory implements TerminalSelectionMemory {
private readonly terminalIdsByCwd: PersistentValueMap<string>;
constructor(storage: KeyValueStorage | undefined = browserSessionStorage()) {
this.terminalIdsByCwd = new PersistentValueMap(terminalSelectionStorageKey, parseStoredString, storage);
}
latestTerminalId(cwd: string): string | undefined {
return this.terminalIdsByCwd.get(cwd);
}
rememberTerminal(cwd: string, terminalId: string): void {
this.terminalIdsByCwd.set(cwd, terminalId);
}
forgetWorkspace(cwd: string): void {
this.terminalIdsByCwd.delete(cwd);
}
forgetTerminal(terminalId: string): void {
for (const [cwd, rememberedTerminalId] of this.terminalIdsByCwd.entries()) {
if (rememberedTerminalId === terminalId) this.terminalIdsByCwd.delete(cwd);
}
}
}
export function selectPreferredTerminal(terminals: TerminalInfo[], options?: { targetTerminalId?: string | undefined; latestTerminalId?: string | undefined }): TerminalInfo | undefined {
const targetTerminalId = options?.targetTerminalId;
if (targetTerminalId !== undefined && targetTerminalId !== "") return terminals.find((terminal) => terminal.id === targetTerminalId);
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import type { Workspace } from "../api";
import { InMemoryWorkspaceSelectionMemory, selectPreferredWorkspace } from "./workspaceSelection";
import type { KeyValueStorage } from "./sessionStorageMemory";
import { InMemoryWorkspaceSelectionMemory, selectPreferredWorkspace, SessionStorageWorkspaceSelectionMemory } from "./workspaceSelection";
describe("selectPreferredWorkspace", () => {
it("prefers an explicit target workspace", () => {
@@ -45,6 +46,35 @@ describe("InMemoryWorkspaceSelectionMemory", () => {
});
});
describe("SessionStorageWorkspaceSelectionMemory", () => {
it("persists the latest selected workspace per project", () => {
const storage = memoryStorage();
const memory = new SessionStorageWorkspaceSelectionMemory(storage);
memory.rememberWorkspace({ ...testWorkspace("feature"), projectId: "local:p1" });
memory.rememberWorkspace({ ...testWorkspace("other"), projectId: "remote:p1" });
const restored = new SessionStorageWorkspaceSelectionMemory(storage);
expect(restored.latestWorkspaceId("local:p1")).toBe("feature");
expect(restored.latestWorkspaceId("remote:p1")).toBe("other");
restored.forgetProject("local:p1");
expect(new SessionStorageWorkspaceSelectionMemory(storage).latestWorkspaceId("local:p1")).toBeUndefined();
expect(new SessionStorageWorkspaceSelectionMemory(storage).latestWorkspaceId("remote:p1")).toBe("other");
});
});
function testWorkspace(id: string): Workspace {
return { id, projectId: "project", path: `/tmp/project/${id}`, label: id, isMain: id === "main", isGitRepo: true, isGitWorktree: id !== "main" };
}
function memoryStorage(seed: Record<string, string> = {}): KeyValueStorage {
const values = new Map(Object.entries(seed));
return {
getItem: (key) => values.get(key) ?? null,
setItem: (key, value) => { values.set(key, value); },
removeItem: (key) => { values.delete(key); },
};
}
@@ -1,4 +1,5 @@
import type { Workspace } from "../api";
import { browserSessionStorage, parseStoredString, PersistentValueMap, type KeyValueStorage } from "./sessionStorageMemory";
export interface WorkspaceSelectionMemory {
latestWorkspaceId(projectId: string): string | undefined;
@@ -22,6 +23,28 @@ export class InMemoryWorkspaceSelectionMemory implements WorkspaceSelectionMemor
}
}
const workspaceSelectionStorageKey = "pi-web:workspace-selection:v1";
export class SessionStorageWorkspaceSelectionMemory implements WorkspaceSelectionMemory {
private readonly workspaceIdsByProject: PersistentValueMap<string>;
constructor(storage: KeyValueStorage | undefined = browserSessionStorage()) {
this.workspaceIdsByProject = new PersistentValueMap(workspaceSelectionStorageKey, parseStoredString, storage);
}
latestWorkspaceId(projectId: string): string | undefined {
return this.workspaceIdsByProject.get(projectId);
}
rememberWorkspace(workspace: Workspace): void {
this.workspaceIdsByProject.set(workspace.projectId, workspace.id);
}
forgetProject(projectId: string): void {
this.workspaceIdsByProject.delete(projectId);
}
}
export function selectPreferredWorkspace(workspaces: Workspace[], options?: { targetWorkspaceId?: string | undefined; latestWorkspaceId?: string | undefined }): Workspace | undefined {
const targetWorkspaceId = options?.targetWorkspaceId;
if (targetWorkspaceId !== undefined && targetWorkspaceId !== "") return workspaces.find((workspace) => workspace.id === targetWorkspaceId);