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>
|
||||
<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>
|
||||
<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 { 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.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
<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.archived === true
|
||||
? 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>`}
|
||||
${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="Archive session" @click=${() => { this.openMenuSessionId = undefined; this.onArchive?.(session); }}>Archive</button>`}
|
||||
</div>
|
||||
` : null}
|
||||
</div>
|
||||
@@ -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];
|
||||
|
||||
@@ -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 { 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<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) {
|
||||
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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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