Archived
feat: preserve browser-cached new sessions
This commit is contained in:
@@ -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.
|
||||||
@@ -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<string, string>();
|
||||||
|
|
||||||
|
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringField(record: Record<string, unknown>, key: string): string | undefined {
|
||||||
|
const value = record[key];
|
||||||
|
return typeof value === "string" ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalStringField(record: Record<string, unknown>, key: string): string | undefined {
|
||||||
|
const value = record[key];
|
||||||
|
return value === undefined || typeof value !== "string" ? undefined : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberField(record: Record<string, unknown>, key: string): number | undefined {
|
||||||
|
const value = record[key];
|
||||||
|
return typeof value === "number" ? value : undefined;
|
||||||
|
}
|
||||||
@@ -268,7 +268,7 @@ export class PiWebApp extends LitElement {
|
|||||||
</header>
|
</header>
|
||||||
<project-list .projects=${this.state.projects} .selected=${this.state.selectedProject} .onSelect=${(project: Project) => this.withChatScrollTransition(() => this.workspaces.selectProject(project))} .onClose=${(project: Project) => this.projects.closeProject(project.id)}></project-list>
|
<project-list .projects=${this.state.projects} .selected=${this.state.selectedProject} .onSelect=${(project: Project) => this.withChatScrollTransition(() => this.workspaces.selectProject(project))} .onClose=${(project: Project) => this.projects.closeProject(project.id)}></project-list>
|
||||||
<workspace-list .workspaces=${this.state.workspaces} .selected=${this.state.selectedWorkspace} .workspaceLabelItems=${(workspace: Workspace) => this.plugins.getWorkspaceLabelItems(this.state, workspace)} .onSelect=${(workspace: Workspace) => this.withChatScrollTransition(() => this.workspaces.selectWorkspace(workspace))}></workspace-list>
|
<workspace-list .workspaces=${this.state.workspaces} .selected=${this.state.selectedWorkspace} .workspaceLabelItems=${(workspace: Workspace) => this.plugins.getWorkspaceLabelItems(this.state, workspace)} .onSelect=${(workspace: Workspace) => this.withChatScrollTransition(() => this.workspaces.selectWorkspace(workspace))}></workspace-list>
|
||||||
<session-list .sessions=${this.state.sessions} .statuses=${this.state.sessionStatuses} .activities=${this.state.sessionActivities} .selected=${this.state.selectedSession} .canStart=${!!this.state.selectedWorkspace} .onStart=${() => 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)}></session-list>
|
<session-list .sessions=${this.state.sessions} .statuses=${this.state.sessionStatuses} .activities=${this.state.sessionActivities} .selected=${this.state.selectedSession} .canStart=${!!this.state.selectedWorkspace} .onStart=${() => 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)}></session-list>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { LitElement, html, type PropertyValues } from "lit";
|
|||||||
import { customElement, property, query, state } from "lit/decorators.js";
|
import { customElement, property, query, state } from "lit/decorators.js";
|
||||||
import { api, type FileSuggestion, type SessionStatus, type SlashCommand } from "../api";
|
import { api, type FileSuggestion, type SessionStatus, type SlashCommand } from "../api";
|
||||||
import { inputModeForDraft } from "../inputModes";
|
import { inputModeForDraft } from "../inputModes";
|
||||||
|
import { clearDraft, loadDraft, saveDraft } from "../promptDraftStorage";
|
||||||
import { promptEditorStyles, type CompletionItem } from "./shared";
|
import { promptEditorStyles, type CompletionItem } from "./shared";
|
||||||
import "./AutocompleteMenu";
|
import "./AutocompleteMenu";
|
||||||
|
|
||||||
@@ -298,33 +299,3 @@ function emptyFileSuggestions(): FileSuggestion[] {
|
|||||||
return [];
|
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.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { LitElement, html, type PropertyValues } from "lit";
|
import { LitElement, html, type PropertyValues } from "lit";
|
||||||
import { customElement, property, state } from "lit/decorators.js";
|
import { customElement, property, state } from "lit/decorators.js";
|
||||||
import type { SessionActivity, SessionInfo, SessionStatus } from "../api";
|
import type { SessionActivity, SessionInfo, SessionStatus } from "../api";
|
||||||
|
import { isCachedNewSessionInfo } from "../cachedNewSessions";
|
||||||
import { activateSelectableRow, activateSelectableRowFromKeyboard } from "./selectableRow";
|
import { activateSelectableRow, activateSelectableRowFromKeyboard } from "./selectableRow";
|
||||||
import { listStyles } from "./shared";
|
import { listStyles } from "./shared";
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@ export class SessionList extends LitElement {
|
|||||||
};
|
};
|
||||||
@property({ attribute: false }) onArchive?: (session: SessionInfo) => void;
|
@property({ attribute: false }) onArchive?: (session: SessionInfo) => void;
|
||||||
@property({ attribute: false }) onRestore?: (session: SessionInfo) => void;
|
@property({ attribute: false }) onRestore?: (session: SessionInfo) => void;
|
||||||
|
@property({ attribute: false }) onDelete?: (session: SessionInfo) => void;
|
||||||
@property({ attribute: false }) onDetachParent?: (session: SessionInfo) => void;
|
@property({ attribute: false }) onDetachParent?: (session: SessionInfo) => void;
|
||||||
|
|
||||||
override connectedCallback(): void {
|
override connectedCallback(): void {
|
||||||
@@ -92,7 +94,9 @@ export class SessionList extends LitElement {
|
|||||||
${this.openMenuSessionId === session.id ? html`
|
${this.openMenuSessionId === session.id ? html`
|
||||||
<div class="action-menu-panel" style=${this.menuStyle}>
|
<div class="action-menu-panel" style=${this.menuStyle}>
|
||||||
${session.parentSessionPath !== undefined ? html`<button title="Detach from parent" @click=${() => { this.openMenuSessionId = undefined; this.onDetachParent?.(session); }}>Detach from parent</button>` : null}
|
${session.parentSessionPath !== undefined ? html`<button title="Detach from parent" @click=${() => { this.openMenuSessionId = undefined; this.onDetachParent?.(session); }}>Detach from parent</button>` : null}
|
||||||
${session.archived === true
|
${isCachedNewSessionInfo(session)
|
||||||
|
? html`<button title="Delete browser-cached new session" @click=${() => { this.openMenuSessionId = undefined; this.onDelete?.(session); }}>Delete</button>`
|
||||||
|
: session.archived === true
|
||||||
? html`<button title="Restore session" @click=${() => { this.openMenuSessionId = undefined; this.onRestore?.(session); }}>Restore</button>`
|
? html`<button title="Restore session" @click=${() => { this.openMenuSessionId = undefined; this.onRestore?.(session); }}>Restore</button>`
|
||||||
: html`<button title="Archive session" @click=${() => { this.openMenuSessionId = undefined; this.onArchive?.(session); }}>Archive</button>`}
|
: html`<button title="Archive session" @click=${() => { this.openMenuSessionId = undefined; this.onArchive?.(session); }}>Archive</button>`}
|
||||||
</div>
|
</div>
|
||||||
@@ -124,6 +128,7 @@ export class SessionList extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private renderStatus(session: SessionInfo) {
|
private renderStatus(session: SessionInfo) {
|
||||||
|
if (isCachedNewSessionInfo(session)) return "new · ";
|
||||||
if (session.archived === true) return "read-only · ";
|
if (session.archived === true) return "read-only · ";
|
||||||
const status = this.statuses[session.id];
|
const status = this.statuses[session.id];
|
||||||
const activity = this.activities[session.id];
|
const activity = this.activities[session.id];
|
||||||
|
|||||||
@@ -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<string, string>();
|
||||||
|
|
||||||
|
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 }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { api as defaultApi, type CommandResult, type SessionActivity, type SessionInfo, type SessionStatus, type ThinkingLevel } from "../api";
|
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 { textMessage } from "../chatMessages";
|
||||||
|
import { clearDraft, moveDraft } from "../promptDraftStorage";
|
||||||
import { ChatTranscriptStore } from "../chatTranscriptStore";
|
import { ChatTranscriptStore } from "../chatTranscriptStore";
|
||||||
import { isShellInput } from "../inputModes";
|
import { isShellInput } from "../inputModes";
|
||||||
import { SessionSocket, type GlobalSessionEvent, type SessionUiEvent } from "../sessionSocket";
|
import { SessionSocket, type GlobalSessionEvent, type SessionUiEvent } from "../sessionSocket";
|
||||||
@@ -64,8 +66,10 @@ export class SessionController {
|
|||||||
if (!workspace) return;
|
if (!workspace) return;
|
||||||
try {
|
try {
|
||||||
const session = await this.api.startSession(workspace.path);
|
const session = await this.api.startSession(workspace.path);
|
||||||
this.setState({ sessions: [session, ...this.getState().sessions] });
|
rememberCachedNewSession(session);
|
||||||
await this.selectSession(session);
|
const cachedSession = markCachedNewSessionInfo(session);
|
||||||
|
this.setState({ sessions: [cachedSession, ...this.getState().sessions] });
|
||||||
|
await this.selectSession(cachedSession);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.setState({ error: String(error) });
|
this.setState({ error: String(error) });
|
||||||
}
|
}
|
||||||
@@ -116,7 +120,12 @@ export class SessionController {
|
|||||||
this.socket.setHandler((event) => { this.applyEvent(event); });
|
this.socket.setHandler((event) => { this.applyEvent(event); });
|
||||||
if (options?.updateUrl !== false) this.updateUrl();
|
if (options?.updateUrl !== false) this.updateUrl();
|
||||||
} catch (error) {
|
} 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;
|
if (!session || session.archived === true) return;
|
||||||
try {
|
try {
|
||||||
await this.api.prompt(session.id, text, streamingBehavior);
|
await this.api.prompt(session.id, text, streamingBehavior);
|
||||||
|
this.markCachedNewSessionPersisted(session);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.setState({ error: String(error) });
|
this.setState({ error: String(error) });
|
||||||
}
|
}
|
||||||
@@ -156,6 +166,7 @@ export class SessionController {
|
|||||||
this.setState({ messages: [...this.getState().messages, textMessage("user", text)] });
|
this.setState({ messages: [...this.getState().messages, textMessage("user", text)] });
|
||||||
try {
|
try {
|
||||||
await this.api.shell(session.id, text);
|
await this.api.shell(session.id, text);
|
||||||
|
this.markCachedNewSessionPersisted(session);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.setState({ messages: [...this.getState().messages, textMessage("system", String(error))], error: String(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)] });
|
this.setState({ messages: [...this.getState().messages, textMessage("user", text)] });
|
||||||
try {
|
try {
|
||||||
this.applyCommandResult(await this.api.runCommand(session.id, text));
|
this.applyCommandResult(await this.api.runCommand(session.id, text));
|
||||||
|
this.markCachedNewSessionPersisted(session);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.setState({ messages: [...this.getState().messages, textMessage("system", String(error))], error: String(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) {
|
async archiveSession(session = this.getState().selectedSession) {
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
|
if (isCachedNewSessionInfo(session)) {
|
||||||
|
await this.deleteCachedNewSession(session);
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await this.api.archive(session.id);
|
await this.api.archive(session.id);
|
||||||
const state = this.getState();
|
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) {
|
async restoreSession(session = this.getState().selectedSession) {
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
try {
|
try {
|
||||||
@@ -332,6 +366,26 @@ export class SessionController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async recreateCachedNewSession(session: SessionInfo, options?: { updateUrl?: boolean | undefined }): Promise<void> {
|
||||||
|
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) {
|
private applyCommandResult(result: CommandResult) {
|
||||||
if (result.type === "select") {
|
if (result.type === "select") {
|
||||||
this.setState({ commandDialog: result });
|
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";
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { api, type Project, type Workspace } from "../api";
|
import { api, type Project, type Workspace } from "../api";
|
||||||
|
import { mergeCachedNewSessions } from "../cachedNewSessions";
|
||||||
import type { GetState, RouteTarget, SetState, UpdateUrl } from "./types";
|
import type { GetState, RouteTarget, SetState, UpdateUrl } from "./types";
|
||||||
import type { SessionController } from "./sessionController";
|
import type { SessionController } from "./sessionController";
|
||||||
import { InMemoryWorkspaceSelectionMemory, selectPreferredWorkspace, type WorkspaceSelectionMemory } from "./workspaceSelection";
|
import { InMemoryWorkspaceSelectionMemory, selectPreferredWorkspace, type WorkspaceSelectionMemory } from "./workspaceSelection";
|
||||||
@@ -41,7 +42,7 @@ export class WorkspaceController {
|
|||||||
this.sessions.clearActiveSession();
|
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: "" });
|
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 {
|
try {
|
||||||
const sessions = await api.sessions(workspace.path);
|
const sessions = mergeCachedNewSessions(workspace.path, await api.sessions(workspace.path));
|
||||||
this.setState({ sessions });
|
this.setState({ sessions });
|
||||||
const session = this.sessions.preferredSession(workspace.path, sessions, target?.sessionId);
|
const session = this.sessions.preferredSession(workspace.path, sessions, target?.sessionId);
|
||||||
if (session) await this.sessions.selectSession(session, { updateUrl: target?.updateUrl });
|
if (session) await this.sessions.selectSession(session, { updateUrl: target?.updateUrl });
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user