From aab9ffb8e2a13a1f3a732afcc86adcc533f6c7fc Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Fri, 15 May 2026 09:39:17 +0200 Subject: [PATCH] feat: preserve browser-cached new sessions --- .changeset/browser-cached-new-sessions.md | 5 + src/client/src/cachedNewSessions.test.ts | 74 ++++++++++ src/client/src/cachedNewSessions.ts | 129 +++++++++++++++++ src/client/src/components/PiWebApp.ts | 2 +- src/client/src/components/PromptEditor.ts | 31 +--- src/client/src/components/SessionList.ts | 11 +- .../src/controllers/sessionController.test.ts | 134 ++++++++++++++++++ .../src/controllers/sessionController.ts | 64 ++++++++- .../src/controllers/workspaceController.ts | 3 +- src/client/src/promptDraftStorage.ts | 45 ++++++ 10 files changed, 460 insertions(+), 38 deletions(-) create mode 100644 .changeset/browser-cached-new-sessions.md create mode 100644 src/client/src/cachedNewSessions.test.ts create mode 100644 src/client/src/cachedNewSessions.ts create mode 100644 src/client/src/controllers/sessionController.test.ts create mode 100644 src/client/src/promptDraftStorage.ts diff --git a/.changeset/browser-cached-new-sessions.md b/.changeset/browser-cached-new-sessions.md new file mode 100644 index 0000000..a70d895 --- /dev/null +++ b/.changeset/browser-cached-new-sessions.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Preserve newly started empty sessions and their prompt drafts across browser reloads until the user deletes them. diff --git a/src/client/src/cachedNewSessions.test.ts b/src/client/src/cachedNewSessions.test.ts new file mode 100644 index 0000000..ed2d5ac --- /dev/null +++ b/src/client/src/cachedNewSessions.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import type { SessionInfo } from "./api"; +import { forgetCachedNewSession, isCachedNewSessionInfo, loadCachedNewSessions, mergeCachedNewSessions, rememberCachedNewSession } from "./cachedNewSessions"; + +class MemoryStorage implements Storage { + private readonly values = new Map(); + + get length(): number { + return this.values.size; + } + + clear(): void { + this.values.clear(); + } + + getItem(key: string): string | null { + return this.values.get(key) ?? null; + } + + key(index: number): string | null { + return Array.from(this.values.keys())[index] ?? null; + } + + removeItem(key: string): void { + this.values.delete(key); + } + + setItem(key: string, value: string): void { + this.values.set(key, value); + } +} + +const baseSession: SessionInfo = { + id: "session-1", + path: "/tmp/session-1.jsonl", + cwd: "/repo", + created: "2026-05-15T00:00:00.000Z", + modified: "2026-05-15T00:00:00.000Z", + messageCount: 0, + firstMessage: "", +}; + +describe("cached new sessions", () => { + it("stores and reloads new sessions with a browser-cache marker", () => { + const storage = new MemoryStorage(); + + rememberCachedNewSession(baseSession, storage); + + const cached = loadCachedNewSessions(storage); + expect(cached).toHaveLength(1); + expect(cached[0]?.id).toBe("session-1"); + expect(isCachedNewSessionInfo(cached[0])).toBe(true); + }); + + it("merges cached sessions for the selected cwd without duplicating server sessions", () => { + const storage = new MemoryStorage(); + rememberCachedNewSession(baseSession, storage); + rememberCachedNewSession({ ...baseSession, id: "other", cwd: "/other" }, storage); + + expect(mergeCachedNewSessions("/repo", [], storage).map((session) => session.id)).toEqual(["session-1"]); + expect(mergeCachedNewSessions("/repo", [baseSession], storage).map((session) => session.id)).toEqual(["session-1"]); + expect(isCachedNewSessionInfo(mergeCachedNewSessions("/repo", [baseSession], storage)[0])).toBe(false); + expect(loadCachedNewSessions(storage).map((session) => session.id)).toEqual(["other"]); + }); + + it("forgets cached sessions", () => { + const storage = new MemoryStorage(); + rememberCachedNewSession(baseSession, storage); + + forgetCachedNewSession("session-1", storage); + + expect(loadCachedNewSessions(storage)).toEqual([]); + }); +}); diff --git a/src/client/src/cachedNewSessions.ts b/src/client/src/cachedNewSessions.ts new file mode 100644 index 0000000..1386338 --- /dev/null +++ b/src/client/src/cachedNewSessions.ts @@ -0,0 +1,129 @@ +import type { SessionInfo } from "./api"; + +const storageKey = "pi-web:cached-new-sessions:v1"; +const markerProperty = "browserCachedNew"; + +export type CachedNewSessionInfo = SessionInfo & { browserCachedNew: true }; + +function browserStorage(): Storage | undefined { + try { + return typeof localStorage === "undefined" ? undefined : localStorage; + } catch { + return undefined; + } +} + +export function rememberCachedNewSession(session: SessionInfo, storage = browserStorage()): void { + if (session.messageCount !== 0 || session.archived === true) return; + const sessions = loadCachedNewSessions(storage).filter((candidate) => candidate.id !== session.id); + saveCachedNewSessions([markCachedNewSessionInfo(session), ...sessions], storage); +} + +export function markCachedNewSessionInfo(session: SessionInfo): CachedNewSessionInfo { + return { ...session, browserCachedNew: true }; +} + +export function forgetCachedNewSession(sessionId: string, storage = browserStorage()): void { + const sessions = loadCachedNewSessions(storage).filter((session) => session.id !== sessionId); + saveCachedNewSessions(sessions, storage); +} + +export function mergeCachedNewSessions(cwd: string, sessions: SessionInfo[], storage = browserStorage()): SessionInfo[] { + const sessionIds = new Set(sessions.map((session) => session.id)); + const cachedSessions = loadCachedNewSessions(storage); + const retainedCachedSessions = cachedSessions.filter((session) => !sessionIds.has(session.id)); + if (retainedCachedSessions.length !== cachedSessions.length) saveCachedNewSessions(retainedCachedSessions, storage); + const cached = retainedCachedSessions.filter((session) => session.cwd === cwd); + return [...cached, ...sessions]; +} + +export function isCachedNewSessionInfo(session: SessionInfo | undefined): session is CachedNewSessionInfo { + if (session === undefined) return false; + return hasCachedNewMarker(session) && session.browserCachedNew === true; +} + +export function stripCachedNewSessionMarker(session: SessionInfo): SessionInfo { + return { + id: session.id, + path: session.path, + cwd: session.cwd, + ...(session.name === undefined ? {} : { name: session.name }), + created: session.created, + modified: session.modified, + messageCount: session.messageCount, + firstMessage: session.firstMessage, + ...(session.parentSessionPath === undefined ? {} : { parentSessionPath: session.parentSessionPath }), + ...(session.archived === true ? { archived: true } : {}), + ...(session.archivedAt === undefined ? {} : { archivedAt: session.archivedAt }), + }; +} + +export function loadCachedNewSessions(storage = browserStorage()): CachedNewSessionInfo[] { + try { + const raw = storage?.getItem(storageKey); + if (raw === undefined || raw === null || raw === "") return []; + const value: unknown = JSON.parse(raw); + if (!Array.isArray(value)) return []; + return value.flatMap((candidate) => parseCachedSession(candidate)); + } catch { + return []; + } +} + +function saveCachedNewSessions(sessions: CachedNewSessionInfo[], storage = browserStorage()): void { + try { + if (sessions.length === 0) storage?.removeItem(storageKey); + else storage?.setItem(storageKey, JSON.stringify(sessions)); + } catch { + // Ignore localStorage quota/privacy errors. + } +} + +function parseCachedSession(value: unknown): CachedNewSessionInfo[] { + if (!isRecord(value)) return []; + const id = stringField(value, "id"); + const path = stringField(value, "path"); + const cwd = stringField(value, "cwd"); + const created = stringField(value, "created"); + const modified = stringField(value, "modified"); + const firstMessage = stringField(value, "firstMessage"); + const messageCount = numberField(value, "messageCount"); + if (id === undefined || path === undefined || cwd === undefined || created === undefined || modified === undefined || firstMessage === undefined || messageCount !== 0) return []; + const name = optionalStringField(value, "name"); + const parentSessionPath = optionalStringField(value, "parentSessionPath"); + return [{ + id, + path, + cwd, + ...(name === undefined ? {} : { name }), + created, + modified, + messageCount, + firstMessage, + ...(parentSessionPath === undefined ? {} : { parentSessionPath }), + browserCachedNew: true, + }]; +} + +function hasCachedNewMarker(session: SessionInfo): session is SessionInfo & { browserCachedNew: unknown } { + return markerProperty in session; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function stringField(record: Record, key: string): string | undefined { + const value = record[key]; + return typeof value === "string" ? value : undefined; +} + +function optionalStringField(record: Record, key: string): string | undefined { + const value = record[key]; + return value === undefined || typeof value !== "string" ? undefined : value; +} + +function numberField(record: Record, key: string): number | undefined { + const value = record[key]; + return typeof value === "number" ? value : undefined; +} diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 2bd09b1..73858dc 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -268,7 +268,7 @@ export class PiWebApp extends LitElement { this.withChatScrollTransition(() => this.workspaces.selectProject(project))} .onClose=${(project: Project) => this.projects.closeProject(project.id)}> this.plugins.getWorkspaceLabelItems(this.state, workspace)} .onSelect=${(workspace: Workspace) => this.withChatScrollTransition(() => this.workspaces.selectWorkspace(workspace))}> - openChatAfter(() => this.sessions.startSession())} .onSelect=${(session: SessionInfo) => openChatAfter(() => this.sessions.selectSession(session))} .onArchive=${(session: SessionInfo) => this.sessions.archiveSession(session)} .onRestore=${(session: SessionInfo) => openChatAfter(() => this.sessions.restoreSession(session))} .onDetachParent=${(session: SessionInfo) => this.sessions.detachParent(session)}> + openChatAfter(() => this.sessions.startSession())} .onSelect=${(session: SessionInfo) => openChatAfter(() => this.sessions.selectSession(session))} .onArchive=${(session: SessionInfo) => this.sessions.archiveSession(session)} .onRestore=${(session: SessionInfo) => openChatAfter(() => this.sessions.restoreSession(session))} .onDelete=${(session: SessionInfo) => this.sessions.deleteCachedNewSession(session)} .onDetachParent=${(session: SessionInfo) => this.sessions.detachParent(session)}> `; } diff --git a/src/client/src/components/PromptEditor.ts b/src/client/src/components/PromptEditor.ts index 2bbbc2b..4eff972 100644 --- a/src/client/src/components/PromptEditor.ts +++ b/src/client/src/components/PromptEditor.ts @@ -7,6 +7,7 @@ import { LitElement, html, type PropertyValues } from "lit"; import { customElement, property, query, state } from "lit/decorators.js"; import { api, type FileSuggestion, type SessionStatus, type SlashCommand } from "../api"; import { inputModeForDraft } from "../inputModes"; +import { clearDraft, loadDraft, saveDraft } from "../promptDraftStorage"; import { promptEditorStyles, type CompletionItem } from "./shared"; import "./AutocompleteMenu"; @@ -298,33 +299,3 @@ function emptyFileSuggestions(): FileSuggestion[] { return []; } -const draftStoragePrefix = "pi-web:prompt-draft:"; - -function draftStorageKey(sessionId: string): string { - return `${draftStoragePrefix}${sessionId}`; -} - -function loadDraft(sessionId: string): string { - try { - return localStorage.getItem(draftStorageKey(sessionId)) ?? ""; - } catch { - return ""; - } -} - -function saveDraft(sessionId: string, draft: string): void { - try { - if (draft) localStorage.setItem(draftStorageKey(sessionId), draft); - else localStorage.removeItem(draftStorageKey(sessionId)); - } catch { - // Ignore localStorage quota/privacy errors. - } -} - -function clearDraft(sessionId: string): void { - try { - localStorage.removeItem(draftStorageKey(sessionId)); - } catch { - // Ignore localStorage quota/privacy errors. - } -} diff --git a/src/client/src/components/SessionList.ts b/src/client/src/components/SessionList.ts index e8f9a27..5c5f1ae 100644 --- a/src/client/src/components/SessionList.ts +++ b/src/client/src/components/SessionList.ts @@ -1,6 +1,7 @@ import { LitElement, html, type PropertyValues } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import type { SessionActivity, SessionInfo, SessionStatus } from "../api"; +import { isCachedNewSessionInfo } from "../cachedNewSessions"; import { activateSelectableRow, activateSelectableRowFromKeyboard } from "./selectableRow"; import { listStyles } from "./shared"; @@ -33,6 +34,7 @@ export class SessionList extends LitElement { }; @property({ attribute: false }) onArchive?: (session: SessionInfo) => void; @property({ attribute: false }) onRestore?: (session: SessionInfo) => void; + @property({ attribute: false }) onDelete?: (session: SessionInfo) => void; @property({ attribute: false }) onDetachParent?: (session: SessionInfo) => void; override connectedCallback(): void { @@ -92,9 +94,11 @@ export class SessionList extends LitElement { ${this.openMenuSessionId === session.id ? html`
${session.parentSessionPath !== undefined ? html`` : null} - ${session.archived === true - ? html`` - : html``} + ${isCachedNewSessionInfo(session) + ? html`` + : session.archived === true + ? html`` + : html``}
` : null} @@ -124,6 +128,7 @@ export class SessionList extends LitElement { } private renderStatus(session: SessionInfo) { + if (isCachedNewSessionInfo(session)) return "new · "; if (session.archived === true) return "read-only · "; const status = this.statuses[session.id]; const activity = this.activities[session.id]; diff --git a/src/client/src/controllers/sessionController.test.ts b/src/client/src/controllers/sessionController.test.ts new file mode 100644 index 0000000..f8d8947 --- /dev/null +++ b/src/client/src/controllers/sessionController.test.ts @@ -0,0 +1,134 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { api as defaultApi, type MessagePage, type SessionInfo, type SessionStatus, type Workspace } from "../api"; +import { loadCachedNewSessions, markCachedNewSessionInfo, rememberCachedNewSession } from "../cachedNewSessions"; +import { initialAppState, type AppState } from "../appState"; +import { loadDraft, saveDraft } from "../promptDraftStorage"; +import { SessionController, type SessionEventSocket } from "./sessionController"; + +class MemoryStorage implements Storage { + private readonly values = new Map(); + + get length(): number { + return this.values.size; + } + + clear(): void { + this.values.clear(); + } + + getItem(key: string): string | null { + return this.values.get(key) ?? null; + } + + key(index: number): string | null { + return Array.from(this.values.keys())[index] ?? null; + } + + removeItem(key: string): void { + this.values.delete(key); + } + + setItem(key: string, value: string): void { + this.values.set(key, value); + } +} + +class FakeSocket implements SessionEventSocket { + readonly connectedSessionIds: string[] = []; + + connect(sessionId: string): void { + this.connectedSessionIds.push(sessionId); + } + + setHandler(): void { + // Test socket does not emit events. + } + + close(): void { + // No-op. + } +} + +const workspace: Workspace = { + id: "workspace-1", + projectId: "project-1", + path: "/repo", + label: "repo", + isMain: true, + isGitRepo: true, + isGitWorktree: false, +}; + +const oldSession: SessionInfo = { + id: "old-session", + path: "/tmp/old-session.jsonl", + cwd: "/repo", + created: "2026-05-15T00:00:00.000Z", + modified: "2026-05-15T00:00:00.000Z", + messageCount: 0, + firstMessage: "", +}; + +const replacementSession: SessionInfo = { + ...oldSession, + id: "new-session", + path: "/tmp/new-session.jsonl", +}; + +const emptyPage: MessagePage = { messages: [], start: 0, total: 0 }; + +function status(sessionId: string): SessionStatus { + return { + sessionId, + isStreaming: false, + isCompacting: false, + isBashRunning: false, + pendingMessageCount: 0, + queuedMessages: [], + tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + cost: 0, + }; +} + +describe("SessionController", () => { + afterEach(() => { + Object.defineProperty(globalThis, "localStorage", { value: undefined, configurable: true }); + }); + + it("recreates missing browser-cached new sessions and moves their draft", async () => { + const storage = new MemoryStorage(); + Object.defineProperty(globalThis, "localStorage", { value: storage, configurable: true }); + rememberCachedNewSession(oldSession); + saveDraft(oldSession.id, "draft text"); + + let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [markCachedNewSessionInfo(oldSession)] }; + const urlUpdates: ({ replace?: boolean | undefined } | undefined)[] = []; + const socket = new FakeSocket(); + const api: typeof defaultApi = { + ...defaultApi, + startSession: () => Promise.resolve(replacementSession), + messages: (sessionId) => { + if (sessionId === oldSession.id) return Promise.reject(new Error("Session not found")); + return Promise.resolve(emptyPage); + }, + status: (sessionId) => Promise.resolve(status(sessionId)), + }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + (options) => { urlUpdates.push(options); }, + undefined, + { api, socket }, + ); + + await controller.selectSession(markCachedNewSessionInfo(oldSession), { updateUrl: false }); + + expect(state.selectedSession?.id).toBe(replacementSession.id); + expect(state.sessions.map((session) => session.id)).toEqual([replacementSession.id]); + expect(socket.connectedSessionIds).toEqual([oldSession.id, replacementSession.id]); + expect(loadDraft(oldSession.id)).toBe(""); + expect(loadDraft(replacementSession.id)).toBe("draft text"); + expect(loadCachedNewSessions().map((session) => session.id)).toEqual([replacementSession.id]); + expect(urlUpdates).toEqual([{ replace: true }]); + }); +}); diff --git a/src/client/src/controllers/sessionController.ts b/src/client/src/controllers/sessionController.ts index 208fdca..c166a0d 100644 --- a/src/client/src/controllers/sessionController.ts +++ b/src/client/src/controllers/sessionController.ts @@ -1,5 +1,7 @@ import { api as defaultApi, type CommandResult, type SessionActivity, type SessionInfo, type SessionStatus, type ThinkingLevel } from "../api"; +import { forgetCachedNewSession, isCachedNewSessionInfo, markCachedNewSessionInfo, rememberCachedNewSession, stripCachedNewSessionMarker } from "../cachedNewSessions"; import { textMessage } from "../chatMessages"; +import { clearDraft, moveDraft } from "../promptDraftStorage"; import { ChatTranscriptStore } from "../chatTranscriptStore"; import { isShellInput } from "../inputModes"; import { SessionSocket, type GlobalSessionEvent, type SessionUiEvent } from "../sessionSocket"; @@ -64,8 +66,10 @@ export class SessionController { if (!workspace) return; try { const session = await this.api.startSession(workspace.path); - this.setState({ sessions: [session, ...this.getState().sessions] }); - await this.selectSession(session); + rememberCachedNewSession(session); + const cachedSession = markCachedNewSessionInfo(session); + this.setState({ sessions: [cachedSession, ...this.getState().sessions] }); + await this.selectSession(cachedSession); } catch (error) { this.setState({ error: String(error) }); } @@ -116,7 +120,12 @@ export class SessionController { this.socket.setHandler((event) => { this.applyEvent(event); }); if (options?.updateUrl !== false) this.updateUrl(); } catch (error) { - if (seq === this.selectionSeq) this.setState({ error: String(error) }); + if (seq !== this.selectionSeq || this.getState().selectedSession?.id !== session.id) return; + if (isCachedNewSessionInfo(session) && isSessionNotFoundError(error)) { + await this.recreateCachedNewSession(session, options); + return; + } + this.setState({ error: String(error) }); } } @@ -145,6 +154,7 @@ export class SessionController { if (!session || session.archived === true) return; try { await this.api.prompt(session.id, text, streamingBehavior); + this.markCachedNewSessionPersisted(session); } catch (error) { this.setState({ error: String(error) }); } @@ -156,6 +166,7 @@ export class SessionController { this.setState({ messages: [...this.getState().messages, textMessage("user", text)] }); try { await this.api.shell(session.id, text); + this.markCachedNewSessionPersisted(session); } catch (error) { this.setState({ messages: [...this.getState().messages, textMessage("system", String(error))], error: String(error) }); } @@ -167,6 +178,7 @@ export class SessionController { this.setState({ messages: [...this.getState().messages, textMessage("user", text)] }); try { this.applyCommandResult(await this.api.runCommand(session.id, text)); + this.markCachedNewSessionPersisted(session); } catch (error) { this.setState({ messages: [...this.getState().messages, textMessage("system", String(error))], error: String(error) }); } @@ -189,6 +201,10 @@ export class SessionController { async archiveSession(session = this.getState().selectedSession) { if (!session) return; + if (isCachedNewSessionInfo(session)) { + await this.deleteCachedNewSession(session); + return; + } try { await this.api.archive(session.id); const state = this.getState(); @@ -206,6 +222,24 @@ export class SessionController { } } + async deleteCachedNewSession(session = this.getState().selectedSession) { + if (!isCachedNewSessionInfo(session)) return; + void this.api.stop(session.id).catch(() => { + // Best-effort cleanup for browser-cached sessions that may not exist server-side anymore. + }); + forgetCachedNewSession(session.id); + clearDraft(session.id); + const sessions = this.getState().sessions.filter((candidate) => candidate.id !== session.id); + this.setState({ sessions }); + if (this.getState().selectedSession?.id !== session.id) return; + const next = sessions.find((candidate) => candidate.archived !== true) ?? sessions[0]; + if (next !== undefined) await this.selectSession(next); + else { + this.clearActiveSession(); + this.updateUrl(); + } + } + async restoreSession(session = this.getState().selectedSession) { if (!session) return; try { @@ -332,6 +366,26 @@ export class SessionController { }); } + private async recreateCachedNewSession(session: SessionInfo, options?: { updateUrl?: boolean | undefined }): Promise { + try { + const replacement = await this.api.startSession(session.cwd); + rememberCachedNewSession(replacement); + moveDraft(session.id, replacement.id); + forgetCachedNewSession(session.id); + const cachedReplacement = markCachedNewSessionInfo(replacement); + this.setState({ sessions: [cachedReplacement, ...this.getState().sessions.filter((candidate) => candidate.id !== session.id)], error: "" }); + await this.selectSession(cachedReplacement, { updateUrl: false }); + this.updateUrl(options?.updateUrl === false ? { replace: true } : undefined); + } catch (error) { + this.setState({ error: String(error) }); + } + } + + private markCachedNewSessionPersisted(session: SessionInfo): void { + if (!isCachedNewSessionInfo(session)) return; + this.replaceSession(stripCachedNewSessionMarker(session)); + } + private applyCommandResult(result: CommandResult) { if (result.type === "select") { this.setState({ commandDialog: result }); @@ -456,3 +510,7 @@ function isHighFrequencyTranscriptEvent(event: SessionUiEvent): boolean { return event.type === "assistant.delta" || event.type === "assistant.thinking.delta" || event.type === "shell.chunk"; } +function isSessionNotFoundError(error: unknown): boolean { + return error instanceof Error && error.message.toLowerCase().includes("session not found"); +} + diff --git a/src/client/src/controllers/workspaceController.ts b/src/client/src/controllers/workspaceController.ts index c174ec7..8e8405b 100644 --- a/src/client/src/controllers/workspaceController.ts +++ b/src/client/src/controllers/workspaceController.ts @@ -1,4 +1,5 @@ import { api, type Project, type Workspace } from "../api"; +import { mergeCachedNewSessions } from "../cachedNewSessions"; import type { GetState, RouteTarget, SetState, UpdateUrl } from "./types"; import type { SessionController } from "./sessionController"; import { InMemoryWorkspaceSelectionMemory, selectPreferredWorkspace, type WorkspaceSelectionMemory } from "./workspaceSelection"; @@ -41,7 +42,7 @@ export class WorkspaceController { this.sessions.clearActiveSession(); this.setState({ selectedWorkspace: workspace, sessions: [], fileTree: [], expandedDirs: {}, selectedFilePath: undefined, selectedFileContent: undefined, fileTreeStale: false, gitStatus: undefined, selectedDiffPath: undefined, selectedDiff: undefined, selectedStagedDiff: undefined, gitStale: false, error: "" }); try { - const sessions = await api.sessions(workspace.path); + const sessions = mergeCachedNewSessions(workspace.path, await api.sessions(workspace.path)); this.setState({ sessions }); const session = this.sessions.preferredSession(workspace.path, sessions, target?.sessionId); if (session) await this.sessions.selectSession(session, { updateUrl: target?.updateUrl }); diff --git a/src/client/src/promptDraftStorage.ts b/src/client/src/promptDraftStorage.ts new file mode 100644 index 0000000..69bd4b5 --- /dev/null +++ b/src/client/src/promptDraftStorage.ts @@ -0,0 +1,45 @@ +const draftStoragePrefix = "pi-web:prompt-draft:"; + +function draftStorageKey(sessionId: string): string { + return `${draftStoragePrefix}${sessionId}`; +} + +function browserStorage(): Storage | undefined { + try { + return typeof localStorage === "undefined" ? undefined : localStorage; + } catch { + return undefined; + } +} + +export function loadDraft(sessionId: string, storage = browserStorage()): string { + try { + return storage?.getItem(draftStorageKey(sessionId)) ?? ""; + } catch { + return ""; + } +} + +export function saveDraft(sessionId: string, draft: string, storage = browserStorage()): void { + try { + if (draft) storage?.setItem(draftStorageKey(sessionId), draft); + else storage?.removeItem(draftStorageKey(sessionId)); + } catch { + // Ignore localStorage quota/privacy errors. + } +} + +export function clearDraft(sessionId: string, storage = browserStorage()): void { + try { + storage?.removeItem(draftStorageKey(sessionId)); + } catch { + // Ignore localStorage quota/privacy errors. + } +} + +export function moveDraft(fromSessionId: string, toSessionId: string, storage = browserStorage()): void { + const draft = loadDraft(fromSessionId, storage); + if (draft === "") return; + saveDraft(toSessionId, draft, storage); + clearDraft(fromSessionId, storage); +}