fix: handle session start persistence

This commit is contained in:
Federico Jaramillo Martinez
2026-07-02 18:46:44 +02:00
parent d2e10cd89a
commit 2665d1e4bc
18 changed files with 1154 additions and 196 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Create editable chats immediately when starting sessions, queue sends until the backend session is ready, and use server-backed persistence signals for session archive/delete/reload actions.
+28 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMessagePage, parsePiPackageMutationResponse, parsePiPackagesResponse, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionStatus, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers";
import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMessagePage, parsePiPackageMutationResponse, parsePiPackagesResponse, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionInfo, parseSessionStatus, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers";
describe("API parsers", () => {
it("parses PI WEB config responses", () => {
@@ -112,9 +112,35 @@ describe("API parsers", () => {
expect(() => parseSessionBulkDeleteArchivedResponse({ deleted: true, deletedSessionIds: [1], failures: [], generatedAt: "now" })).toThrow("Expected string array field: deletedSessionIds");
});
it("parses session info including optional persistence signals", () => {
expect(parseSessionInfo({
id: "s1",
path: "/sessions/s1.jsonl",
cwd: "/repo",
persisted: false,
name: "Draft session",
created: "2026-01-01T00:00:00.000Z",
modified: "2026-01-01T00:01:00.000Z",
messageCount: 0,
firstMessage: "",
})).toEqual({
id: "s1",
path: "/sessions/s1.jsonl",
cwd: "/repo",
persisted: false,
name: "Draft session",
created: "2026-01-01T00:00:00.000Z",
modified: "2026-01-01T00:01:00.000Z",
messageCount: 0,
firstMessage: "",
});
expect(() => parseSessionInfo({ id: "s1", path: "", cwd: "/repo", persisted: "yes", created: "now", modified: "now", messageCount: 0, firstMessage: "" })).toThrow("Expected optional boolean field: persisted");
});
it("validates session status including optional model and nullable context usage", () => {
expect(parseSessionStatus({
sessionId: "s1",
persisted: true,
isStreaming: false,
isCompacting: true,
isBashRunning: false,
@@ -128,6 +154,7 @@ describe("API parsers", () => {
thinkingLevel: "medium",
})).toEqual({
sessionId: "s1",
persisted: true,
isStreaming: false,
isCompacting: true,
isBashRunning: false,
+3
View File
@@ -157,12 +157,14 @@ function optionalWorkspaceEffectiveConfig(value: unknown): Workspace["effectiveC
export function parseSessionInfo(value: unknown): SessionInfo {
const record = requireRecord(value);
const name = optionalString(record, "name");
const persisted = parseOptionalBoolean(record["persisted"], "persisted");
const parentSessionPath = optionalString(record, "parentSessionPath");
const archivedAt = optionalString(record, "archivedAt");
return {
id: requireString(record, "id"),
path: requireString(record, "path"),
cwd: requireString(record, "cwd"),
...(persisted === undefined ? {} : { persisted }),
...(name === undefined ? {} : { name }),
created: requireString(record, "created"),
modified: requireString(record, "modified"),
@@ -178,6 +180,7 @@ export function parseSessionStatus(value: unknown): SessionStatus {
const record = requireRecord(value);
return {
sessionId: requireString(record, "sessionId"),
...optionalField("persisted", parseOptionalBoolean(record["persisted"], "persisted")),
isStreaming: requireBoolean(record, "isStreaming"),
isCompacting: requireBoolean(record, "isCompacting"),
isBashRunning: requireBoolean(record, "isBashRunning"),
+6 -1
View File
@@ -1,4 +1,4 @@
import type { AuthProviderOption, CommandOption, CommandResult, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Machine, MachineHealth, MachineRuntime, OAuthFlowState, PiWebStatusResponse, Project, SessionActivity, SessionInfo, SessionStatus, TerminalCommandRun, Workspace, WorkspaceActivity } from "./api";
import type { AuthProviderOption, CommandOption, CommandResult, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Machine, MachineHealth, MachineRuntime, OAuthFlowState, PiWebStatusResponse, Project, QueuedSessionMessage, SessionActivity, SessionInfo, SessionStatus, TerminalCommandRun, Workspace, WorkspaceActivity } from "./api";
import type { ChatLine } from "./components/shared";
import type { QualifiedContributionId } from "./plugins/ids";
import type { WorkspaceUploadBatchState } from "./workspaceUploadState";
@@ -20,6 +20,8 @@ export interface AppState {
isReceivingPartialStream: boolean;
/** Sessions with a prompt upload in flight, keyed by sessionId (client-owned). */
sendingPrompts: Record<string, true>;
/** Client-side queued sends waiting for a just-created backend session, keyed by sessionId. */
clientQueuedSessionMessages: Record<string, QueuedSessionMessage[]>;
/** Client-initiated session creation requests waiting for the server. */
startingSessionCount: number;
isLoadingProjects: boolean;
@@ -74,6 +76,7 @@ export type AuthDialogState =
export type WorkspaceScopedStateReset = Pick<AppState,
| "sessions"
| "clientQueuedSessionMessages"
| "startingSessionCount"
| "fileTree"
| "expandedDirs"
@@ -92,6 +95,7 @@ export type WorkspaceScopedStateReset = Pick<AppState,
export function resetWorkspaceScopedState(): WorkspaceScopedStateReset {
return {
sessions: [],
clientQueuedSessionMessages: {},
startingSessionCount: 0,
fileTree: [],
expandedDirs: {},
@@ -125,6 +129,7 @@ export function initialAppState(): AppState {
isLoadingEarlierMessages: false,
isReceivingPartialStream: false,
sendingPrompts: {},
clientQueuedSessionMessages: {},
startingSessionCount: 0,
isLoadingProjects: false,
isLoadingWorkspaces: false,
+1
View File
@@ -48,6 +48,7 @@ export function stripCachedNewSessionMarker(session: SessionInfo): SessionInfo {
id: session.id,
path: session.path,
cwd: session.cwd,
...(session.persisted === undefined ? {} : { persisted: session.persisted }),
...(session.name === undefined ? {} : { name: session.name }),
created: session.created,
modified: session.modified,
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { chatQueuedMessageSections } from "./ChatView";
describe("chatQueuedMessageSections", () => {
it("labels client-side pending-start sends separately from server queued messages", () => {
const sections = chatQueuedMessageSections(
[{ kind: "followUp", text: "queued before start" }],
[{ kind: "steer", text: "server queued" }],
);
expect(sections).toEqual([
{
heading: "Queued until session starts",
detail: "Will send once the backend session is ready",
messages: [{ kind: "followUp", text: "queued before start" }],
},
{
heading: "Queued messages",
detail: "1 pending · Stop clears the queue",
messages: [{ kind: "steer", text: "server queued" }],
},
]);
});
});
+23 -6
View File
@@ -6,7 +6,7 @@ import { groupChatMessages, summarizeChatGroup, type ChatGroup } from "../chatGr
import { capturePrependScrollAnchor, PREPEND_RESTORE_SETTLE_FRAMES, restorePrependScrollAnchor, type PrependScrollAnchor } from "../chatScrollAnchoring";
import { shouldRequestEarlierMessages } from "../chatHistoryLoading";
import { ChatScrollController, distanceFromScrollBottom, findFirstVisibleArticle, isNearScrollBottom, type ChatAnchorScrollPosition, type ChatScrollRestoreResult } from "../chatScrollPosition";
import type { SessionActivity, SessionStatus } from "../api";
import type { QueuedSessionMessage, SessionActivity, SessionStatus } from "../api";
import type { ChatLine, ChatPart } from "./shared";
import { chatStyles } from "./shared";
import "./ConversationMeter";
@@ -38,6 +38,19 @@ function clampNumber(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
export interface QueuedMessageSection {
heading: string;
detail: string;
messages: QueuedSessionMessage[];
}
export function chatQueuedMessageSections(clientQueued: QueuedSessionMessage[], serverQueued: QueuedSessionMessage[]): QueuedMessageSection[] {
return [
clientQueued.length === 0 ? undefined : { heading: "Queued until session starts", detail: "Will send once the backend session is ready", messages: clientQueued },
serverQueued.length === 0 ? undefined : { heading: "Queued messages", detail: `${String(serverQueued.length)} pending · Stop clears the queue`, messages: serverQueued },
].filter((section): section is QueuedMessageSection => section !== undefined);
}
@customElement("chat-view")
export class ChatView extends LitElement {
@property({ attribute: false }) messages: ChatLine[] = [];
@@ -51,6 +64,7 @@ export class ChatView extends LitElement {
@property({ type: Boolean }) isSendingPrompt = false;
@property({ type: Boolean }) isCompacting = false;
@property({ type: Number }) pendingMessageCount = 0;
@property({ attribute: false }) clientQueuedMessages: QueuedSessionMessage[] = [];
@property({ attribute: false }) status?: SessionStatus;
@property({ attribute: false }) activity?: SessionActivity;
@property({ attribute: false }) onLoadMore?: () => void;
@@ -220,15 +234,18 @@ export class ChatView extends LitElement {
}
private renderQueuedMessages() {
const queued = this.status?.queuedMessages ?? [];
if (queued.length === 0) return null;
const serverQueued = this.status?.queuedMessages ?? [];
return html`${chatQueuedMessageSections(this.clientQueuedMessages, serverQueued).map((section) => this.renderQueuedMessageList(section))}`;
}
private renderQueuedMessageList(section: QueuedMessageSection) {
return html`
<aside class="queued-messages" aria-live="polite">
<div class="queued-header">
<strong>Queued messages</strong>
<small>${queued.length} pending · Stop clears the queue</small>
<strong>${section.heading}</strong>
<small>${section.detail}</small>
</div>
${queued.map((message, index) => html`
${section.messages.map((message, index) => html`
<div class="queued-message">
<span class="queued-kind">${message.kind === "steer" ? "Steer" : "Follow-up"} ${String(index + 1)}</span>
<formatted-text .text=${message.text}></formatted-text>
+21 -3
View File
@@ -1142,7 +1142,7 @@ export class PiWebApp extends LitElement {
.onSelectWorkspace=${(workspace: Workspace) => this.selectNavigationItem("workspaces", "sessions", () => this.workspaces.selectWorkspace(workspace))}
.onDeleteWorkspace=${(workspace: Workspace) => { void this.deleteWorkspace(workspace); }}
.onArchivedCollapsed=${() => { this.sessions.clearSelectionAfterArchivedCollapse(); }}
.onStartSession=${() => this.selectNavigationItem("sessions", "chat", () => this.sessions.startSession())}
.onStartSession=${() => this.startSessionFromNavigation()}
.onSelectSession=${(session: SessionInfo) => this.selectNavigationItem("sessions", "chat", () => this.sessions.selectSession(session))}
.onArchiveSession=${(session: SessionInfo) => this.sessions.archiveSession(session)}
.onArchiveSessionWithDescendants=${(session: SessionInfo) => this.sessions.archiveSessionWithDescendants(session)}
@@ -1177,6 +1177,24 @@ export class PiWebApp extends LitElement {
await this.focusNavigationTarget(nextTarget);
}
private async startSessionFromNavigation(): Promise<void> {
const seq = ++this.navigationSelectionSeq;
const isCurrentSelection = () => seq === this.navigationSelectionSeq;
this.navigationSections.advanceAfterSelection("sessions");
await this.startSessionAndOpenChat(isCurrentSelection);
}
private async startSessionAndOpenChat(shouldComplete: () => boolean = () => true): Promise<void> {
// `startSession()` remains in flight until the backend session resolves;
// open the chat as soon as the controller has inserted the temporary row.
const start = this.sessions.startSession().catch((error: unknown) => {
if (shouldComplete()) this.setState({ error: String(error) });
});
if (shouldComplete()) await this.focusChatComposer();
void start;
}
private async focusNavigationTarget(target: NavigationFocusTarget): Promise<void> {
if (target === "chat") {
await this.focusChatComposer();
@@ -1548,7 +1566,7 @@ export class PiWebApp extends LitElement {
refreshAppData: () => this.refreshAppData(),
reloadPage: () => { this.hardReloadApp(); },
deleteWorkspace: (workspace) => this.deleteWorkspace(workspace),
startSession: () => this.withChatScrollTransition(() => this.sessions.startSession()),
startSession: () => this.withChatScrollTransition(() => this.startSessionAndOpenChat()),
archiveSession: () => this.sessions.archiveSession(),
reloadSession: () => this.sessions.reloadSession(),
deleteCachedNewSession: () => this.sessions.deleteCachedNewSession(),
@@ -1908,7 +1926,7 @@ export class PiWebApp extends LitElement {
${state.error ? html`<div class="error">${state.error}</div>` : null}
<div class="mobile-navigation-panel">${this.appShell.isMobileNavigationLayout ? this.renderNavigationPanel() : null}</div>
${state.selectedSession ? html`
<chat-view .sessionId=${state.selectedSession.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageEnd=${state.messagePageEnd} .messageTotal=${state.messagePageTotal} .hasMore=${state.messagePageStart > 0} .loadingMore=${state.isLoadingEarlierMessages} .isReceivingPartialStream=${state.isReceivingPartialStream} .isSendingPrompt=${state.sendingPrompts[state.selectedSession.id] === true} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .status=${state.status} .activity=${state.activity} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}></chat-view>
<chat-view .sessionId=${state.selectedSession.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageEnd=${state.messagePageEnd} .messageTotal=${state.messagePageTotal} .hasMore=${state.messagePageStart > 0} .loadingMore=${state.isLoadingEarlierMessages} .isReceivingPartialStream=${state.isReceivingPartialStream} .isSendingPrompt=${state.sendingPrompts[state.selectedSession.id] === true} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .clientQueuedMessages=${state.clientQueuedSessionMessages[state.selectedSession.id] ?? []} .status=${state.status} .activity=${state.activity} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}></chat-view>
<prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .machineId=${selectedMachineId(state)} .projectId=${state.selectedWorkspace?.projectId} .workspaceId=${state.selectedWorkspace?.id} .workspaceScopedFileSuggestions=${this.supportsWorkspaceFileSuggestions()} .disabled=${state.selectedSession.archived === true} .canSteer=${state.status?.isStreaming === true} .isCompacting=${state.status?.isCompacting === true} .canStop=${state.status?.isStreaming === true || state.status?.isBashRunning === true || state.status?.isCompacting === true || (state.status?.pendingMessageCount ?? 0) > 0} .status=${state.status} .availableThinkingLevels=${state.availableThinkingLevels} .sending=${state.sendingPrompts[state.selectedSession.id] === true} .onSend=${this.handleSendPrompt} .onStop=${this.handleStopActiveWork} .onSelectModel=${this.handleSelectModel} .onSelectThinking=${this.handleSelectThinking}></prompt-editor>
<status-bar .status=${state.status}></status-bar>
${state.commandDialog !== undefined ? html`<command-picker .title=${state.commandDialog.title} .options=${state.commandDialog.options} .onPick=${(value: string) => this.sessions.respondToCommand(state.commandDialog?.requestId ?? "", value)} .onCancel=${() => { this.sessions.cancelCommand(); }}></command-picker>` : null}
+44 -1
View File
@@ -1,10 +1,11 @@
import { describe, expect, it } from "vitest";
import type { SessionInfo, SessionStatus } from "../api";
import { markCachedNewSessionInfo } from "../cachedNewSessions";
import { isArchivableSessionInfo, isTransientNewSessionInfo } from "../sessionPersistence";
import { sessionRowActivityKind, sessionRowsForCurrentTree } from "./SessionList";
describe("sessionRowActivityKind", () => {
const idle: SessionStatus = { sessionId: "s", isStreaming: false, isCompacting: false, isBashRunning: false, pendingMessageCount: 0, queuedMessages: [], tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, cost: 0 };
const idle = sessionStatus("s");
it("reports 'sending' for an uploading session, taking precedence over server activity", () => {
expect(sessionRowActivityKind(session("s"), idle, undefined, true)).toBe("sending");
@@ -25,6 +26,34 @@ describe("sessionRowActivityKind", () => {
});
});
describe("session action eligibility", () => {
it("requires a persisted server signal before archiving", () => {
expect(isArchivableSessionInfo(session("persisted", { persisted: true }))).toBe(true);
expect(isArchivableSessionInfo(session("unknown"))).toBe(false);
expect(isArchivableSessionInfo(session("transient", { persisted: false }))).toBe(false);
expect(isArchivableSessionInfo({ ...session("archived", { persisted: true }), archived: true, archivedAt: "2026-06-09T00:00:00.000Z" })).toBe(false);
});
it("allows deleting transient non-archived sessions from server or browser-cached signals", () => {
expect(isTransientNewSessionInfo(session("transient", { persisted: false }))).toBe(true);
expect(isTransientNewSessionInfo(markCachedNewSessionInfo(session("cached")))).toBe(true);
expect(isTransientNewSessionInfo(session("persisted", { persisted: true }))).toBe(false);
expect(isTransientNewSessionInfo({ ...session("archived", { persisted: false }), archived: true, archivedAt: "2026-06-09T00:00:00.000Z" })).toBe(false);
});
it("uses matching status as the freshest persistence signal", () => {
const staleTransient = session("s", { persisted: false });
expect(isArchivableSessionInfo(staleTransient, sessionStatus("s", { persisted: true }))).toBe(true);
expect(isTransientNewSessionInfo(staleTransient, sessionStatus("s", { persisted: true }))).toBe(false);
const stalePersisted = session("s", { persisted: true });
expect(isArchivableSessionInfo(stalePersisted, sessionStatus("s", { persisted: false }))).toBe(false);
expect(isTransientNewSessionInfo(stalePersisted, sessionStatus("s", { persisted: false }))).toBe(true);
expect(isArchivableSessionInfo(staleTransient, sessionStatus("other", { persisted: true }))).toBe(false);
});
});
describe("sessionRowsForCurrentTree", () => {
it("keeps archived ancestors visible while they have unarchived descendants", () => {
const parent = { ...session("parent"), archived: true, archivedAt: "2026-06-09T00:00:00.000Z" };
@@ -58,6 +87,20 @@ function rowSummaries(rows: ReturnType<typeof sessionRowsForCurrentTree>) {
return rows.map((row) => ({ id: row.session.id, depth: row.depth, hasMissingParent: row.hasMissingParent }));
}
function sessionStatus(sessionId: string, overrides: Partial<SessionStatus> = {}): 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,
...overrides,
};
}
function session(id: string, overrides: Partial<SessionInfo> = {}): SessionInfo {
return {
id,
+27 -15
View File
@@ -3,6 +3,7 @@ import { customElement, property, state } from "lit/decorators.js";
import type { SessionActivity, SessionInfo, SessionStatus } from "../api";
import { isCachedNewSessionInfo } from "../cachedNewSessions";
import { shortSessionId } from "../sessionLabels";
import { isArchivableSessionInfo, isTransientNewSessionInfo } from "../sessionPersistence";
import { isSessionActive } from "../../../shared/activity";
import { actionMenuPanelStyle } from "./actionMenu";
import { renderActionActivityIndicator, type ActivityIndicatorKind } from "./activityBadge";
@@ -192,7 +193,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
if (visibleSessions.length === 0 || !this.selectionScopes.has("current")) return null;
const selectedSessions = this.selectedSessions("current");
const archivableSessions = selectedSessions.filter((session) => !isCachedNewSessionInfo(session));
const archivableSessions = selectedSessions.filter((session) => isArchivableSessionInfo(session, this.statuses[session.id]));
const allVisibleSelected = visibleSessions.length > 0 && visibleSessions.every((session) => this.selectedSessionIds.has(session.id));
const visibleSelectedCount = visibleSessions.filter((session) => this.selectedSessionIds.has(session.id)).length;
return html`
@@ -231,6 +232,11 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
const selectionActive = this.selectionScopes.has(scope);
const showsCheckbox = selectionActive && canBulkSelect;
const bulkSelected = showsCheckbox && this.selectedSessionIds.has(session.id);
const status = this.statuses[session.id];
const activity = this.activities[session.id];
const canArchive = isArchivableSessionInfo(session, status);
const canDeleteTransient = isTransientNewSessionInfo(session, status);
const canReloadSession = canArchive && this.canReload;
return html`
<div
class="action-row ${this.selected?.id === session.id ? "selected" : ""} ${bulkSelected ? "bulk-selected" : ""} ${session.archived === true ? "archived" : ""} ${selectionActive ? "selecting" : ""}"
@@ -242,25 +248,27 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
>
<div class="action-main ${selectionActive ? "selecting" : ""}">
${showsCheckbox ? html`<input class="session-checkbox" type="checkbox" aria-label=${`Select ${sessionLabel(session)}`} .checked=${bulkSelected} @click=${(event: MouseEvent) => { event.stopPropagation(); }} @change=${() => { this.toggleSelected(session.id); }}>` : null}
<span class="action-name" dir="auto">${row.depth > 0 ? html`<span class="tree-marker">↳</span>` : null}${sessionLabel(session)}${row.depth > 2 ? html` <span class="badge">depth ${row.depth}</span>` : null}${row.hasMissingParent ? html` <span class="badge">parent unavailable</span>` : null}</span><small>${this.renderSessionMetaPrefix(session)}${String(session.messageCount)} messages</small>
<span class="action-name" dir="auto">${row.depth > 0 ? html`<span class="tree-marker">↳</span>` : null}${sessionLabel(session)}${row.depth > 2 ? html` <span class="badge">depth ${row.depth}</span>` : null}${row.hasMissingParent ? html` <span class="badge">parent unavailable</span>` : null}</span><small>${this.renderSessionMetaPrefix(session, status, activity)}${String(session.messageCount)} messages</small>
${this.renderActivity(session)}
</div>
<div class="action-menu">
<button class="action-menu-toggle" title="Session actions" @click=${(event: MouseEvent) => { event.stopPropagation(); this.toggleMenu(session.id, event.currentTarget); }}>⋯</button>
${this.openMenuSessionId === session.id ? html`
<div class="action-menu-panel" style=${this.menuStyle}>
${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>
<button class="danger" title=${this.canDeleteArchived ? "Permanently delete archived session" : this.archivedDeleteUnavailableMessage} ?disabled=${!this.canDeleteArchived} @click=${() => { this.openMenuSessionId = undefined; this.confirmDeleteArchived(session); }}>Delete archived session</button>
`
${session.archived === true
? html`
<button title="Restore session" @click=${() => { this.openMenuSessionId = undefined; this.onRestore?.(session); }}>Restore</button>
<button class="danger" title=${this.canDeleteArchived ? "Permanently delete archived session" : this.archivedDeleteUnavailableMessage} ?disabled=${!this.canDeleteArchived} @click=${() => { this.openMenuSessionId = undefined; this.confirmDeleteArchived(session); }}>Delete archived session</button>
`
: canDeleteTransient
? html`<button title="Delete transient new session" @click=${() => { this.openMenuSessionId = undefined; this.onDelete?.(session); }}>Delete</button>`
: html`
<button title="Archive session" @click=${() => { this.openMenuSessionId = undefined; this.onArchive?.(session); }}>Archive</button>
${descendantCount > 0 ? html`<button title="Archive this session and its descendants" @click=${() => { this.openMenuSessionId = undefined; this.confirmArchiveWithDescendants(session, descendantCount); }}>Archive with descendants (${descendantCount})</button>` : null}
${canArchive ? html`
<button title="Archive session" @click=${() => { this.openMenuSessionId = undefined; this.onArchive?.(session); }}>Archive</button>
${descendantCount > 0 ? html`<button title="Archive this session and its descendants" @click=${() => { this.openMenuSessionId = undefined; this.confirmArchiveWithDescendants(session, descendantCount); }}>Archive with descendants (${descendantCount})</button>` : null}
` : null}
${session.parentSessionPath !== undefined ? html`<button title="Detach from parent" @click=${() => { this.openMenuSessionId = undefined; this.onDetachParent?.(session); }}>Detach from parent</button>` : null}
${this.canReload ? html`<button title=${isSessionActive(this.statuses[session.id], this.activities[session.id]) ? "Stop current session activity before reloading from disk" : "Reload session from disk without refreshing Pi runtime resources"} ?disabled=${isSessionActive(this.statuses[session.id], this.activities[session.id])} @click=${() => { this.openMenuSessionId = undefined; this.onReload?.(session); }}>Reload from disk</button>` : null}
${canReloadSession ? html`<button title=${isSessionActive(this.statuses[session.id], this.activities[session.id]) ? "Stop current session activity before reloading from disk" : "Reload session from disk without refreshing Pi runtime resources"} ?disabled=${isSessionActive(this.statuses[session.id], this.activities[session.id])} @click=${() => { this.openMenuSessionId = undefined; this.onReload?.(session); }}>Reload from disk</button>` : null}
`}
</div>
` : null}
@@ -307,7 +315,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
}
private archiveSelectedCurrent(): void {
const sessions = this.selectedSessions("current").filter((session) => !isCachedNewSessionInfo(session));
const sessions = this.selectedSessions("current").filter((session) => isArchivableSessionInfo(session, this.statuses[session.id]));
this.selectedSessionIds = removeSessionIds(this.selectedSessionIds, sessions.map((session) => session.id));
void this.onArchiveMany?.(sessions);
}
@@ -386,8 +394,12 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
this.renderRoot.querySelector<HTMLElement>(".action-row.selected")?.scrollIntoView({ block: "nearest" });
}
private renderSessionMetaPrefix(session: SessionInfo) {
if (isCachedNewSessionInfo(session)) return "new · ";
private renderSessionMetaPrefix(session: SessionInfo, status: SessionStatus | undefined, activity: SessionActivity | undefined) {
if (isTransientNewSessionInfo(session, status)) {
if (activity?.phase === "active") return "creating · ";
if (activity?.phase === "error") return "error · ";
return "new · ";
}
if (session.archived === true) return "read-only · ";
return "";
}
@@ -321,10 +321,50 @@ describe("SessionController", () => {
expect(state.sessions.map((session) => session.id)).toEqual(["old-session"]);
});
it("creates and selects a temporary editable session before backend start resolves", async () => {
const started: SessionInfo = { ...oldSession, id: "started-session", path: "/tmp/started-session.jsonl" };
const startRequest = deferred<SessionInfo>();
const messageCalls: string[] = [];
const statusCalls: string[] = [];
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] };
const api: typeof defaultApi = {
...defaultApi,
startSession: () => startRequest.promise,
messages: (session) => { messageCalls.push(sessionLookupId(session)); return Promise.resolve(emptyPage); },
status: (session) => { statusCalls.push(sessionLookupId(session)); return Promise.resolve(status(sessionLookupId(session))); },
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ api, socket: new FakeSocket() },
);
const start = controller.startSession();
const temporarySession = state.selectedSession;
expect(temporarySession?.id).toMatch(/^pending-session-/);
expect(temporarySession?.persisted).toBe(false);
expect(state.sessions.map((session) => session.id)).toEqual([temporarySession?.id]);
expect(state.activity).toMatchObject({ sessionId: temporarySession?.id, phase: "active", label: "Creating session" });
expect(messageCalls).toEqual([]);
expect(statusCalls).toEqual([]);
startRequest.resolve(started);
await start;
expect(state.sessions.map((session) => session.id)).toEqual(["started-session"]);
expect(state.selectedSession?.id).toBe("started-session");
expect(messageCalls).toEqual(["started-session"]);
expect(statusCalls).toEqual(["started-session"]);
});
it("does not duplicate a started session when its session.created broadcast races the HTTP response", async () => {
const storage = new MemoryStorage();
Object.defineProperty(globalThis, "localStorage", { value: storage, configurable: true });
const started: SessionInfo = { ...oldSession, id: "started-session", path: "/tmp/started-session.jsonl" };
const startRequest = deferred<SessionInfo>();
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] };
const socket = new FakeSocket();
const api: typeof defaultApi = {
@@ -332,7 +372,7 @@ describe("SessionController", () => {
startSession: () => {
// Simulate the broadcast arriving before the HTTP response resolves.
controller.applyGlobalEvent({ type: "session.created", session: started });
return Promise.resolve(started);
return startRequest.promise;
},
messages: () => Promise.resolve(emptyPage),
status: (session) => Promise.resolve(status(sessionLookupId(session))),
@@ -345,12 +385,51 @@ describe("SessionController", () => {
{ api, socket },
);
await controller.startSession();
const start = controller.startSession();
const temporaryId = state.selectedSession?.id;
expect(state.sessions.map((session) => session.id)).toEqual([temporaryId]);
startRequest.resolve(started);
await start;
expect(state.sessions.map((session) => session.id)).toEqual(["started-session"]);
expect(isCachedNewSessionInfo(state.sessions[0])).toBe(true);
});
it("preserves temporary start rows across session-list refreshes before backend resolution", async () => {
const started: SessionInfo = { ...oldSession, id: "started-session", path: "/tmp/started-session.jsonl" };
const startRequest = deferred<SessionInfo>();
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] };
const api: typeof defaultApi = {
...defaultApi,
startSession: () => startRequest.promise,
sessions: () => Promise.resolve([oldSession]),
messages: () => Promise.resolve(emptyPage),
status: (session) => Promise.resolve(status(sessionLookupId(session))),
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ api, socket: new FakeSocket() },
);
const start = controller.startSession();
const temporaryId = state.selectedSession?.id;
await controller.refreshCurrentWorkspaceSessions();
expect(state.sessions.map((session) => session.id)).toEqual([temporaryId, oldSession.id]);
expect(state.selectedSession?.id).toBe(temporaryId);
startRequest.resolve(started);
await start;
expect(state.sessions.map((session) => session.id)).toEqual([started.id, oldSession.id]);
expect(state.selectedSession?.id).toBe(started.id);
});
it("tracks multiple pending session starts without blocking another start", async () => {
const firstStarted: SessionInfo = { ...oldSession, id: "started-session-1", path: "/tmp/started-session-1.jsonl" };
const secondStarted: SessionInfo = { ...oldSession, id: "started-session-2", path: "/tmp/started-session-2.jsonl" };
@@ -371,17 +450,21 @@ describe("SessionController", () => {
);
const firstStart = controller.startSession();
const firstTemporaryId = state.selectedSession?.id;
const secondStart = controller.startSession();
const secondTemporaryId = state.selectedSession?.id;
expect(startResolvers).toHaveLength(2);
expect(state.startingSessionCount).toBe(2);
expect(state.sessions).toEqual([]);
expect(state.startingSessionCount).toBe(0);
expect(state.sessions.map((session) => session.id)).toEqual([secondTemporaryId, firstTemporaryId]);
expect(state.selectedSession?.id).toBe(secondTemporaryId);
expect(state.sessions.every((session) => session.persisted === false)).toBe(true);
startResolvers[0]?.(firstStarted);
await firstStart;
expect(state.startingSessionCount).toBe(1);
expect(state.sessions.map((session) => session.id)).toEqual(["started-session-1"]);
expect(state.sessions.map((session) => session.id)).toEqual([secondTemporaryId, "started-session-1"]);
expect(state.selectedSession?.id).toBe(secondTemporaryId);
startResolvers[1]?.(secondStarted);
await secondStart;
@@ -391,26 +474,17 @@ describe("SessionController", () => {
expect(state.selectedSession?.id).toBe("started-session-2");
});
it("removes a resolved session start from the pending count when inserting its row", async () => {
const firstStarted: SessionInfo = { ...oldSession, id: "started-session-1", path: "/tmp/started-session-1.jsonl" };
const secondStarted: SessionInfo = { ...oldSession, id: "started-session-2", path: "/tmp/started-session-2.jsonl" };
const startResolvers: ((session: SessionInfo) => void)[] = [];
const messageRequests = new Map<string, Deferred<MessagePage>>();
const statusRequests = new Map<string, Deferred<SessionStatus>>();
it("moves a temporary session draft and cached-new marker to the resolved session", async () => {
const storage = new MemoryStorage();
Object.defineProperty(globalThis, "localStorage", { value: storage, configurable: true });
const started: SessionInfo = { ...oldSession, id: "started-session", path: "/tmp/started-session.jsonl" };
const startRequest = deferred<SessionInfo>();
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] };
const api: typeof defaultApi = {
...defaultApi,
startSession: () => new Promise<SessionInfo>((resolve) => { startResolvers.push(resolve); }),
messages: (session) => {
const request = deferred<MessagePage>();
messageRequests.set(sessionLookupId(session), request);
return request.promise;
},
status: (session) => {
const request = deferred<SessionStatus>();
statusRequests.set(sessionLookupId(session), request);
return request.promise;
},
startSession: () => startRequest.promise,
messages: () => Promise.resolve(emptyPage),
status: (session) => Promise.resolve(status(sessionLookupId(session))),
};
const controller = new SessionController(
() => state,
@@ -420,32 +494,47 @@ describe("SessionController", () => {
{ api, socket: new FakeSocket() },
);
const firstStart = controller.startSession();
const secondStart = controller.startSession();
expect(startResolvers).toHaveLength(2);
expect(state.startingSessionCount).toBe(2);
const start = controller.startSession();
const temporaryId = state.selectedSession?.id;
if (temporaryId === undefined) throw new Error("Expected temporary session id");
saveDraft(sessionKey(temporaryId), "draft text");
startResolvers[0]?.(firstStarted);
await Promise.resolve();
await Promise.resolve();
startRequest.resolve(started);
await start;
expect(state.sessions.map((session) => session.id)).toEqual(["started-session-1"]);
expect(state.startingSessionCount).toBe(1);
expect(loadDraft(sessionKey(temporaryId))).toBe("");
expect(loadDraft(sessionKey(started.id))).toBe("draft text");
expect(loadCachedNewSessions().map((session) => session.id)).toEqual([started.id]);
expect(isCachedNewSessionInfo(state.sessions[0])).toBe(true);
});
messageRequests.get(firstStarted.id)?.resolve(emptyPage);
statusRequests.get(firstStarted.id)?.resolve(status(firstStarted.id));
await firstStart;
it("keeps a failed temporary start selected with a discardable transient row", async () => {
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] };
const api: typeof defaultApi = {
...defaultApi,
startSession: () => Promise.reject(new Error("backend unavailable")),
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ api, socket: new FakeSocket() },
);
startResolvers[1]?.(secondStarted);
await Promise.resolve();
await Promise.resolve();
await controller.startSession();
const temporaryId = state.selectedSession?.id;
expect(state.sessions.map((session) => session.id)).toEqual(["started-session-2", "started-session-1"]);
expect(state.startingSessionCount).toBe(0);
expect(temporaryId).toMatch(/^pending-session-/);
expect(state.sessions.map((session) => session.id)).toEqual([temporaryId]);
expect(state.sessions[0]?.persisted).toBe(false);
expect(state.activity).toMatchObject({ sessionId: temporaryId, phase: "error", label: "Session creation failed" });
expect(state.error).toContain("backend unavailable");
messageRequests.get(secondStarted.id)?.resolve(emptyPage);
statusRequests.get(secondStarted.id)?.resolve(status(secondStarted.id));
await secondStart;
await controller.deleteCachedNewSession(state.sessions[0]);
expect(state.sessions).toEqual([]);
expect(state.selectedSession).toBeUndefined();
});
it("toggles the per-session sending state around an inline attachment send and forwards attachments", async () => {
@@ -582,6 +671,220 @@ describe("SessionController", () => {
expect(state.sendingPrompts).toEqual({});
});
it("queues prompt sends for a pending session start and flushes them after resolution", async () => {
const started: SessionInfo = { ...oldSession, id: "started-session", path: "/tmp/started-session.jsonl" };
const startRequest = deferred<SessionInfo>();
const promptCalls: { sessionId: string; text: string; behavior?: "steer" | "followUp" }[] = [];
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] };
const api: typeof defaultApi = {
...defaultApi,
startSession: () => startRequest.promise,
messages: () => Promise.resolve(emptyPage),
status: (session) => Promise.resolve(status(sessionLookupId(session))),
prompt: (session, text, behavior) => {
promptCalls.push({ sessionId: sessionLookupId(session), text, ...(behavior === undefined ? {} : { behavior }) });
return Promise.resolve({ accepted: true });
},
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ api, socket: new FakeSocket() },
);
const start = controller.startSession();
const temporaryId = state.selectedSession?.id;
if (temporaryId === undefined) throw new Error("Expected temporary session id");
await controller.send("first");
await controller.send("second", "steer");
expect(promptCalls).toEqual([]);
expect(state.clientQueuedSessionMessages[temporaryId]).toEqual([
{ kind: "followUp", text: "first" },
{ kind: "steer", text: "second" },
]);
expect(state.activity?.detail).toContain("2 queued messages");
startRequest.resolve(started);
await start;
expect(promptCalls).toEqual([
{ sessionId: started.id, text: "first" },
{ sessionId: started.id, text: "second", behavior: "steer" },
]);
expect(state.clientQueuedSessionMessages[temporaryId]).toBeUndefined();
expect(state.clientQueuedSessionMessages[started.id]).toBeUndefined();
expect(state.sendingPrompts).toEqual({});
expect(state.selectedSession?.id).toBe(started.id);
});
it("queues slash commands, shell input, and attachments for a pending session start", async () => {
const started: SessionInfo = { ...oldSession, id: "started-session", path: "/tmp/started-session.jsonl" };
const startRequest = deferred<SessionInfo>();
const calls: string[] = [];
const promptCalls: { text: string; attachments?: PromptAttachment[] }[] = [];
const attachments: PromptAttachment[] = [{ kind: "image", mimeType: "image/png", data: "QUJD", name: "shot.png" }];
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] };
const api: typeof defaultApi = {
...defaultApi,
startSession: () => startRequest.promise,
messages: () => Promise.resolve(emptyPage),
status: (session) => Promise.resolve(status(sessionLookupId(session))),
runCommand: (session, text) => {
calls.push(`command:${sessionLookupId(session)}:${text}`);
return Promise.resolve({ type: "done" });
},
shell: (session, text) => {
calls.push(`shell:${sessionLookupId(session)}:${text}`);
return Promise.resolve({ accepted: true });
},
saveAttachments: (session, sentAttachments) => {
calls.push(`save:${sessionLookupId(session)}:${sentAttachments[0]?.name ?? ""}`);
return Promise.resolve([{ path: ".pi-web/attachments/shot.png", mimeType: "image/png", size: 3 }]);
},
prompt: (session, text, _behavior, _machineId, sentAttachments) => {
calls.push(`prompt:${sessionLookupId(session)}:${text}`);
promptCalls.push({ text, ...(sentAttachments === undefined ? {} : { attachments: sentAttachments }) });
return Promise.resolve({ accepted: true });
},
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ api, socket: new FakeSocket() },
);
const start = controller.startSession();
const temporaryId = state.selectedSession?.id;
if (temporaryId === undefined) throw new Error("Expected temporary session id");
await controller.send("/help");
await controller.send("!pwd");
await controller.send("look", undefined, attachments, "inline");
await controller.send("save", undefined, attachments, "folder");
expect(calls).toEqual([]);
expect(state.clientQueuedSessionMessages[temporaryId]).toEqual([
{ kind: "followUp", text: "/help" },
{ kind: "followUp", text: "!pwd" },
{ kind: "followUp", text: "look\n\n[1 attachment queued: shot.png]" },
{ kind: "followUp", text: "save\n\n[1 attachment queued: shot.png]" },
]);
startRequest.resolve(started);
await start;
expect(calls).toEqual([
`command:${started.id}:/help`,
`shell:${started.id}:!pwd`,
`prompt:${started.id}:look`,
`save:${started.id}:shot.png`,
`prompt:${started.id}:save\n\[email protected]/attachments/shot.png`,
]);
expect(promptCalls).toEqual([
{ text: "look", attachments },
{ text: "save\n\[email protected]/attachments/shot.png" },
]);
expect(state.clientQueuedSessionMessages[started.id]).toBeUndefined();
});
it("keeps queued sends visible when backend session creation fails", async () => {
const startRequest = deferred<SessionInfo>();
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] };
const api: typeof defaultApi = {
...defaultApi,
startSession: () => startRequest.promise,
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ api, socket: new FakeSocket() },
);
const start = controller.startSession();
const temporaryId = state.selectedSession?.id;
if (temporaryId === undefined) throw new Error("Expected temporary session id");
await controller.send("recover me");
startRequest.reject(new Error("backend unavailable"));
await start;
expect(state.selectedSession?.id).toBe(temporaryId);
expect(state.clientQueuedSessionMessages[temporaryId]).toEqual([{ kind: "followUp", text: "recover me" }]);
expect(state.activity).toMatchObject({ sessionId: temporaryId, phase: "error", label: "Session creation failed" });
expect(state.activity?.detail).toContain("1 queued message kept below");
await controller.deleteCachedNewSession(state.selectedSession);
expect(state.clientQueuedSessionMessages[temporaryId]).toBeUndefined();
expect(state.selectedSession).toBeUndefined();
});
it("keeps queued sends scoped to their originating pending start", async () => {
const firstStarted: SessionInfo = { ...oldSession, id: "started-session-1", path: "/tmp/started-session-1.jsonl" };
const secondStarted: SessionInfo = { ...oldSession, id: "started-session-2", path: "/tmp/started-session-2.jsonl" };
const startRequests: Deferred<SessionInfo>[] = [];
const promptCalls: { sessionId: string; text: string }[] = [];
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] };
const api: typeof defaultApi = {
...defaultApi,
startSession: () => {
const request = deferred<SessionInfo>();
startRequests.push(request);
return request.promise;
},
messages: () => Promise.resolve(emptyPage),
status: (session) => Promise.resolve(status(sessionLookupId(session))),
prompt: (session, text) => {
promptCalls.push({ sessionId: sessionLookupId(session), text });
return Promise.resolve({ accepted: true });
},
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ api, socket: new FakeSocket() },
);
const firstStart = controller.startSession();
const firstTemporary = state.selectedSession;
if (firstTemporary === undefined) throw new Error("Expected first temporary session");
const secondStart = controller.startSession();
const secondTemporary = state.selectedSession;
if (secondTemporary === undefined) throw new Error("Expected second temporary session");
await controller.send("second prompt");
await controller.selectSession(firstTemporary, { updateUrl: false });
await controller.send("first prompt");
startRequests[1]?.resolve(secondStarted);
await secondStart;
expect(promptCalls).toEqual([{ sessionId: secondStarted.id, text: "second prompt" }]);
expect(state.selectedSession?.id).toBe(firstTemporary.id);
expect(state.clientQueuedSessionMessages[secondStarted.id]).toBeUndefined();
expect(state.clientQueuedSessionMessages[firstTemporary.id]).toEqual([{ kind: "followUp", text: "first prompt" }]);
startRequests[0]?.resolve(firstStarted);
await firstStart;
expect(promptCalls).toEqual([
{ sessionId: secondStarted.id, text: "second prompt" },
{ sessionId: firstStarted.id, text: "first prompt" },
]);
expect(state.selectedSession?.id).toBe(firstStarted.id);
expect(state.clientQueuedSessionMessages[firstStarted.id]).toBeUndefined();
});
it("keeps live message count updates when a cached new session becomes persisted", async () => {
const cachedSession = markCachedNewSessionInfo(oldSession);
let resolvePrompt: (() => void) | undefined;
@@ -609,6 +912,47 @@ describe("SessionController", () => {
expect(state.selectedSession?.messageCount).toBe(1);
});
it("deletes transient server-reported new sessions and clears local state", async () => {
const storage = new MemoryStorage();
Object.defineProperty(globalThis, "localStorage", { value: storage, configurable: true });
const transientSession = { ...oldSession, persisted: false };
const nextSession = { ...oldSession, id: "next-session", path: "/tmp/next-session.jsonl", persisted: true };
const stoppedIds: string[] = [];
let state: AppState = {
...initialAppState(),
selectedWorkspace: workspace,
selectedSession: transientSession,
sessions: [transientSession, nextSession],
sessionStatuses: { [transientSession.id]: { ...status(transientSession.id), persisted: false } },
sessionActivities: { [transientSession.id]: { sessionId: transientSession.id, phase: "active", label: "Starting", at: "2026-05-20T00:00:00.000Z" } },
sendingPrompts: { [transientSession.id]: true },
};
const api: typeof defaultApi = {
...defaultApi,
stop: (session) => { stoppedIds.push(sessionLookupId(session)); return Promise.resolve({ stopped: true }); },
messages: () => Promise.resolve(emptyPage),
status: (session) => Promise.resolve(status(sessionLookupId(session))),
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ api, socket: new FakeSocket() },
);
saveDraft(sessionKey(transientSession.id), "discard me");
await controller.deleteCachedNewSession(transientSession);
expect(stoppedIds).toEqual([transientSession.id]);
expect(state.sessions.map((session) => session.id)).toEqual([nextSession.id]);
expect(state.sessionStatuses[transientSession.id]).toBeUndefined();
expect(state.sessionActivities[transientSession.id]).toBeUndefined();
expect(state.sendingPrompts[transientSession.id]).toBeUndefined();
expect(loadDraft(sessionKey(transientSession.id))).toBe("");
expect(state.selectedSession?.id).toBe(nextSession.id);
});
it("recreates missing browser-cached new sessions and moves their draft", async () => {
const storage = new MemoryStorage();
Object.defineProperty(globalThis, "localStorage", { value: storage, configurable: true });
@@ -679,7 +1023,8 @@ describe("SessionController", () => {
});
it("forgets the selected active session when archiving leaves only archived sessions", async () => {
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [oldSession] };
const persistedSession = { ...oldSession, persisted: true };
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [persistedSession] };
const urlUpdates: ({ replace?: boolean | undefined } | undefined)[] = [];
const api: typeof defaultApi = {
...defaultApi,
@@ -695,7 +1040,7 @@ describe("SessionController", () => {
{ api, socket: new FakeSocket() },
);
await controller.selectSession(oldSession, { updateUrl: false });
await controller.selectSession(persistedSession, { updateUrl: false });
await controller.archiveSession();
expect(state.selectedSession).toBeUndefined();
@@ -707,12 +1052,13 @@ describe("SessionController", () => {
});
it("archives selected session descendants and selects the next active session", async () => {
const childSession = { ...oldSession, id: "child-session", path: "/tmp/child-session.jsonl", parentSessionPath: oldSession.path };
const nextSession = { ...oldSession, id: "next-session", path: "/tmp/next-session.jsonl" };
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [oldSession, childSession, nextSession] };
const persistedSession = { ...oldSession, persisted: true };
const childSession = { ...oldSession, id: "child-session", path: "/tmp/child-session.jsonl", parentSessionPath: persistedSession.path, persisted: true };
const nextSession = { ...oldSession, id: "next-session", path: "/tmp/next-session.jsonl", persisted: true };
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [persistedSession, childSession, nextSession] };
const api: typeof defaultApi = {
...defaultApi,
archiveWithDescendants: () => Promise.resolve({ archived: true, sessionIds: [oldSession.id, childSession.id], archivedCount: 2, skippedAlreadyArchivedCount: 0 }),
archiveWithDescendants: () => Promise.resolve({ archived: true, sessionIds: [persistedSession.id, childSession.id], archivedCount: 2, skippedAlreadyArchivedCount: 0 }),
messages: () => Promise.resolve(emptyPage),
status: (session) => Promise.resolve(status(sessionLookupId(session))),
};
@@ -724,8 +1070,8 @@ describe("SessionController", () => {
{ api, socket: new FakeSocket() },
);
await controller.selectSession(oldSession, { updateUrl: false });
await controller.archiveSessionWithDescendants(oldSession);
await controller.selectSession(persistedSession, { updateUrl: false });
await controller.archiveSessionWithDescendants(persistedSession);
expect(state.sessions.find((session) => session.id === oldSession.id)).toMatchObject({ archived: true });
expect(state.sessions.find((session) => session.id === childSession.id)).toMatchObject({ archived: true });
@@ -733,10 +1079,11 @@ describe("SessionController", () => {
});
it("archives selected sessions in bulk", async () => {
const secondSession = { ...oldSession, id: "second-session", path: "/tmp/second-session.jsonl" };
const nextSession = { ...oldSession, id: "next-session", path: "/tmp/next-session.jsonl" };
const persistedSession = { ...oldSession, persisted: true };
const secondSession = { ...oldSession, id: "second-session", path: "/tmp/second-session.jsonl", persisted: true };
const nextSession = { ...oldSession, id: "next-session", path: "/tmp/next-session.jsonl", persisted: true };
const archivedIds: string[] = [];
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [oldSession, secondSession, nextSession] };
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [persistedSession, secondSession, nextSession] };
const api: typeof defaultApi = {
...defaultApi,
archive: (session) => {
@@ -754,8 +1101,8 @@ describe("SessionController", () => {
{ api, socket: new FakeSocket() },
);
await controller.selectSession(oldSession, { updateUrl: false });
await controller.archiveSessions([oldSession, secondSession]);
await controller.selectSession(persistedSession, { updateUrl: false });
await controller.archiveSessions([persistedSession, secondSession]);
expect(archivedIds).toEqual([oldSession.id, secondSession.id]);
expect(state.sessions.find((session) => session.id === oldSession.id)).toMatchObject({ archived: true });
@@ -764,19 +1111,20 @@ describe("SessionController", () => {
});
it("uses true bulk archive when the selected runtime supports it and applies partial failures", async () => {
const failedSession = { ...oldSession, id: "failed-session", path: "/tmp/failed-session.jsonl" };
const persistedSession = { ...oldSession, persisted: true };
const failedSession = { ...oldSession, id: "failed-session", path: "/tmp/failed-session.jsonl", persisted: true };
const archiveCalls: { ids: string[]; machineId: string }[] = [];
let state: AppState = {
...initialAppState(),
selectedWorkspace: workspace,
sessions: [oldSession, failedSession],
sessions: [persistedSession, failedSession],
machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsBulkMutations] } },
};
const api: typeof defaultApi = {
...defaultApi,
archiveMany: (sessions, machineId) => {
archiveCalls.push({ ids: sessions.map(sessionLookupId), machineId: machineId ?? "local" });
return Promise.resolve({ archived: true, archivedSessionIds: [oldSession.id], failures: [{ sessionId: failedSession.id, error: "busy" }], generatedAt: "now" });
return Promise.resolve({ archived: true, archivedSessionIds: [persistedSession.id], failures: [{ sessionId: failedSession.id, error: "busy" }], generatedAt: "now" });
},
archive: () => { throw new Error("single archive should not be used"); },
messages: () => Promise.resolve(emptyPage),
@@ -790,8 +1138,8 @@ describe("SessionController", () => {
{ api, socket: new FakeSocket() },
);
await controller.selectSession(oldSession, { updateUrl: false });
await controller.archiveSessions([oldSession, failedSession]);
await controller.selectSession(persistedSession, { updateUrl: false });
await controller.archiveSessions([persistedSession, failedSession]);
expect(archiveCalls).toEqual([{ ids: [oldSession.id, failedSession.id], machineId: "local" }]);
expect(state.sessions.find((session) => session.id === oldSession.id)).toMatchObject({ archived: true });
@@ -801,7 +1149,7 @@ describe("SessionController", () => {
});
it("throttles per-session archive fallback when bulk mutations are unsupported", async () => {
const sessions = Array.from({ length: 6 }, (_value, index) => ({ ...oldSession, id: `session-${String(index)}`, path: `/tmp/session-${String(index)}.jsonl` }));
const sessions = Array.from({ length: 6 }, (_value, index) => ({ ...oldSession, id: `session-${String(index)}`, path: `/tmp/session-${String(index)}.jsonl`, persisted: true }));
const resolvers: (() => void)[] = [];
const startedIds: string[] = [];
let activeCount = 0;
@@ -995,13 +1343,14 @@ describe("SessionController", () => {
it("reloads the selected session from disk, discards the cached transcript, and re-fetches history", async () => {
Object.defineProperty(globalThis, "localStorage", { value: new MemoryStorage(), configurable: true });
const persistedSession = { ...oldSession, persisted: true };
const reloadCalls: string[] = [];
const messageCalls: string[] = [];
let state: AppState = {
...initialAppState(),
selectedWorkspace: workspace,
selectedSession: oldSession,
sessions: [oldSession],
selectedSession: persistedSession,
sessions: [persistedSession],
machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsReload] } },
};
const api: typeof defaultApi = {
@@ -1024,7 +1373,7 @@ describe("SessionController", () => {
{ api, socket: new FakeSocket() },
);
await controller.reloadSession(oldSession);
await controller.reloadSession(persistedSession);
expect(reloadCalls).toEqual([oldSession.id]);
expect(messageCalls).toContain(oldSession.id);
@@ -1032,12 +1381,43 @@ describe("SessionController", () => {
});
it("does not reload sessions from disk when the selected machine runtime does not support it", async () => {
const persistedSession = { ...oldSession, persisted: true };
const reloadCalls: string[] = [];
let state: AppState = {
...initialAppState(),
selectedWorkspace: workspace,
selectedSession: persistedSession,
sessions: [persistedSession],
};
const api: typeof defaultApi = {
...defaultApi,
reloadSession: (session) => {
reloadCalls.push(sessionLookupId(session));
return Promise.resolve({ reloaded: true });
},
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
new InMemorySessionSelectionMemory(),
{ api, socket: new FakeSocket() },
);
await controller.reloadSession(persistedSession);
expect(reloadCalls).toEqual([]);
expect(state.error).toContain("Reloading sessions from disk requires an updated Pi-Web runtime");
});
it("does not reload sessions from disk without a persisted server signal", async () => {
const reloadCalls: string[] = [];
let state: AppState = {
...initialAppState(),
selectedWorkspace: workspace,
selectedSession: oldSession,
sessions: [oldSession],
machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsReload] } },
};
const api: typeof defaultApi = {
...defaultApi,
@@ -1055,9 +1435,10 @@ describe("SessionController", () => {
);
await controller.reloadSession(oldSession);
await controller.reloadSession({ ...oldSession, persisted: false });
expect(reloadCalls).toEqual([]);
expect(state.error).toContain("Reloading sessions from disk requires an updated Pi-Web runtime");
expect(state.error).toBe("");
});
it("forgets archived selections when the archived section collapse clears selection", async () => {
+412 -81
View File
@@ -1,4 +1,4 @@
import { api as defaultApi, type CommandResult, type PromptAttachment, type SessionActivity, type SessionBulkFailure, type SessionCleanupExecuteResponse, type SessionInfo, type SessionRef, type SessionStatus } from "../api";
import { api as defaultApi, type CommandResult, type PromptAttachment, type QueuedSessionMessage, type SessionActivity, type SessionBulkFailure, type SessionCleanupExecuteResponse, type SessionInfo, type SessionRef, type SessionStatus, type Workspace } from "../api";
import type { AppState } from "../appState";
import { forgetCachedNewSession, isCachedNewSessionInfo, markCachedNewSessionInfo, mergeCachedNewSessions, rememberCachedNewSession, stripCachedNewSessionMarker } from "../cachedNewSessions";
import { textMessage } from "../chatMessages";
@@ -8,8 +8,10 @@ import { ChatTranscriptStore } from "../chatTranscriptStore";
import { isShellInput } from "../inputModes";
import { fileCompletionInsertText } from "../promptCompletions";
import { SessionSocket, type GlobalSessionEvent, type SessionUiEvent } from "../sessionSocket";
import { isArchivableSessionInfo, isTransientNewSessionInfo } from "../sessionPersistence";
import { isSessionActive } from "../../../shared/activity";
import { PI_WEB_CAPABILITIES, supportsPiWebCapability } from "../../../shared/capabilities";
import type { PromptAttachmentDelivery } from "../../../shared/apiTypes";
import { InMemorySessionSelectionMemory, markSessionArchived, markSessionsArchived, selectPreferredSession, selectionAfterArchivingSession, selectionAfterArchivingSessions, shouldDeselectAfterArchivedCollapse, type SessionSelectionMemory } from "./sessionSelection";
import { selectedMachineId, type GetState, type SetState, type UpdateUrl } from "./types";
@@ -34,6 +36,30 @@ interface BulkSessionMutationResult {
generatedAt?: string;
}
type ClientPendingStartSessionInfo = SessionInfo & { clientPendingStart: true; machineId: string };
type QueuedPendingSessionSendInput =
| { type: "prompt"; text: string; streamingBehavior?: "steer" | "followUp" | undefined; attachments?: PromptAttachment[] | undefined; delivery: PromptAttachmentDelivery }
| { type: "shell"; text: string }
| { type: "command"; text: string };
type QueuedPendingSessionSend = QueuedPendingSessionSendInput & { id: string };
interface PendingSessionStart {
tempId: string;
workspaceId: string;
cwd: string;
machineId: string;
session: ClientPendingStartSessionInfo;
queuedSends: QueuedPendingSessionSend[];
discarded: boolean;
}
interface SuppressedCreatedSession {
cwd: string;
machineId: string;
}
export class SessionController {
private readonly socket: SessionEventSocket;
private readonly api: typeof defaultApi;
@@ -44,6 +70,10 @@ export class SessionController {
private pendingStatusBySession = new Map<string, SessionStatus>();
private pendingActivityBySession = new Map<string, SessionActivity>();
private pendingFrame: number | undefined;
private pendingSessionStartSeq = 0;
private pendingQueuedSendSeq = 0;
private readonly pendingSessionStarts = new Map<string, PendingSessionStart>();
private readonly suppressedCreatedSessions = new Map<string, SuppressedCreatedSession>();
constructor(
private readonly getState: GetState,
@@ -99,36 +129,14 @@ export class SessionController {
const workspace = this.getState().selectedWorkspace;
if (!workspace) return;
const machineId = selectedMachineId(this.getState());
const isCurrentWorkspace = () => selectedMachineId(this.getState()) === machineId && this.getState().selectedWorkspace?.id === workspace.id;
this.setState({ startingSessionCount: this.getState().startingSessionCount + 1, error: "" });
let shouldDecrementStartingCount = true;
const pending = this.createPendingSessionStart(workspace, machineId);
this.pendingSessionStarts.set(pending.tempId, pending);
this.insertAndSelectPendingSession(pending.session);
try {
const session = await this.api.startSession(workspace.path, machineId);
rememberCachedNewSession(session, machineId);
const cachedSession = markCachedNewSessionInfo(session, machineId);
if (!isCurrentWorkspace()) return;
const state = this.getState();
// Drop any entry the session.created broadcast may have inserted for this
// same session before the HTTP response resolved, so the cached marker
// (and its delete action) wins instead of leaving a duplicate badge. The
// pending count for this completed request is consumed in the same patch
// so the list never renders both the real session and its placeholder.
this.setState({
sessions: [cachedSession, ...state.sessions.filter((candidate) => candidate.id !== cachedSession.id)],
startingSessionCount: decrementStartingSessionCount(state.startingSessionCount),
});
shouldDecrementStartingCount = false;
await this.selectSession(cachedSession);
await this.resolvePendingSessionStart(pending.tempId, session);
} catch (error) {
if (!isCurrentWorkspace()) return;
if (!shouldDecrementStartingCount) {
this.setState({ error: String(error) });
return;
}
this.setState({ error: String(error), startingSessionCount: decrementStartingSessionCount(this.getState().startingSessionCount) });
shouldDecrementStartingCount = false;
} finally {
if (shouldDecrementStartingCount && isCurrentWorkspace()) this.setState({ startingSessionCount: decrementStartingSessionCount(this.getState().startingSessionCount) });
this.failPendingSessionStart(pending.tempId, error);
}
}
@@ -137,6 +145,10 @@ export class SessionController {
}
async selectSession(session: SessionInfo, options?: { updateUrl?: boolean | undefined }) {
if (isClientPendingStartSessionInfo(session)) {
this.selectClientPendingStartSession(session, options);
return;
}
this.sessionSelection.rememberSession({ ...session, cwd: this.workspaceSelectionKey(session.cwd) });
const seq = ++this.selectionSeq;
this.socket.close();
@@ -204,37 +216,25 @@ export class SessionController {
}
}
async send(text: string, streamingBehavior?: "steer" | "followUp", attachments?: PromptAttachment[], delivery: "inline" | "folder" = "inline") {
const trimmed = text.trim();
const hasAttachments = attachments !== undefined && attachments.length > 0;
if (!hasAttachments && trimmed.startsWith("/")) return this.runCommand(text);
if (!hasAttachments && isShellInput(text)) return this.runShell(text);
async send(text: string, streamingBehavior?: "steer" | "followUp", attachments?: PromptAttachment[], delivery: PromptAttachmentDelivery = "inline") {
const session = this.getState().selectedSession;
if (!session || session.archived === true) return;
const trimmed = text.trim();
const hasAttachments = attachments !== undefined && attachments.length > 0;
if (isClientPendingStartSessionInfo(session)) {
if (!hasAttachments && trimmed.startsWith("/")) this.enqueuePendingSessionSend(session, { type: "command", text });
else if (!hasAttachments && isShellInput(text)) this.enqueuePendingSessionSend(session, { type: "shell", text });
else this.enqueuePendingSessionSend(session, { type: "prompt", text, streamingBehavior, attachments, delivery });
return;
}
if (!hasAttachments && trimmed.startsWith("/")) return this.runCommand(text);
if (!hasAttachments && isShellInput(text)) return this.runShell(text);
// Capture the originating session/machine before any await so the request
// and its sending indicator stay bound to the right session even if the
// user navigates elsewhere mid-upload.
const sessionId = session.id;
const machineId = selectedMachineId(this.getState());
// Surface a per-session optimistic sending state. It covers the pre-receipt
// window (upload, server-side image resizing, first-session open) and is
// superseded by real server activity/messages once api.prompt resolves.
if (hasAttachments) this.markSendingPrompt(sessionId, true);
try {
if (hasAttachments && delivery === "folder") {
const saved = await this.api.saveAttachments(session, attachments, machineId);
const references = saved.map((file) => fileCompletionInsertText(file.path, false)).join(" ");
const body = text === "" ? references : `${text}\n\n${references}`;
await this.api.prompt(session, body, streamingBehavior, machineId);
} else {
await this.api.prompt(session, text, streamingBehavior, machineId, attachments);
}
this.markCachedNewSessionPersisted(session);
} catch (error) {
this.setState({ error: String(error) });
} finally {
if (hasAttachments) this.markSendingPrompt(sessionId, false);
}
await this.deliverPromptToSession(session, text, streamingBehavior, attachments, delivery, selectedMachineId(this.getState()), { markSending: hasAttachments });
}
private markSendingPrompt(sessionId: string, sending: boolean): void {
@@ -249,36 +249,124 @@ export class SessionController {
async runShell(text: string) {
const session = this.getState().selectedSession;
if (!session || session.archived === true) return;
this.setState({ messages: [...this.getState().messages, textMessage("user", text)] });
try {
await this.api.shell(session, text, selectedMachineId(this.getState()));
this.markCachedNewSessionPersisted(session);
} catch (error) {
this.setState({ messages: [...this.getState().messages, textMessage("system", String(error))], error: String(error) });
if (isClientPendingStartSessionInfo(session)) {
this.enqueuePendingSessionSend(session, { type: "shell", text });
return;
}
await this.deliverShellToSession(session, text, selectedMachineId(this.getState()), { optimisticLine: true });
}
async runCommand(text: string) {
const session = this.getState().selectedSession;
if (!session || session.archived === true) return;
if (isClientPendingStartSessionInfo(session)) {
this.enqueuePendingSessionSend(session, { type: "command", text });
return;
}
await this.deliverCommandToSession(session, text, selectedMachineId(this.getState()), { applyResult: true });
}
private enqueuePendingSessionSend(session: ClientPendingStartSessionInfo, input: QueuedPendingSessionSendInput): void {
const pending = this.pendingSessionStarts.get(session.id);
if (pending === undefined || pending.discarded) {
this.setState({ error: "The backend session is not ready for queued sends. Copy your message before discarding this failed start." });
return;
}
const queued: QueuedPendingSessionSend = { ...input, id: `pending-send-${String(++this.pendingQueuedSendSeq)}` };
pending.queuedSends.push(queued);
const state = this.getState();
const current = state.clientQueuedSessionMessages[session.id] ?? [];
const activity = creatingPendingSessionActivity(session.id, pending.queuedSends.length);
this.setState({
clientQueuedSessionMessages: { ...state.clientQueuedSessionMessages, [session.id]: [...current, queuedSessionMessagePreview(queued)] },
sessionActivities: { ...state.sessionActivities, [session.id]: activity },
activity: state.selectedSession?.id === session.id ? activity : state.activity,
error: "",
});
}
private async flushQueuedPendingSends(session: SessionInfo, machineId: string, queuedSends: readonly QueuedPendingSessionSend[]): Promise<void> {
for (const queued of queuedSends) {
const delivered = await this.deliverQueuedPendingSend(session, machineId, queued);
if (!delivered) return;
this.dropNextQueuedSessionMessage(session.id);
}
}
private async deliverQueuedPendingSend(session: SessionInfo, machineId: string, queued: QueuedPendingSessionSend): Promise<boolean> {
if (queued.type === "prompt") return this.deliverPromptToSession(session, queued.text, queued.streamingBehavior, queued.attachments, queued.delivery, machineId, { markSending: true });
if (queued.type === "shell") return this.deliverShellToSession(session, queued.text, machineId, { optimisticLine: true });
return this.deliverCommandToSession(session, queued.text, machineId, { applyResult: true });
}
private async deliverPromptToSession(session: SessionInfo, text: string, streamingBehavior: "steer" | "followUp" | undefined, attachments: PromptAttachment[] | undefined, delivery: PromptAttachmentDelivery, machineId: string, options: { markSending: boolean }): Promise<boolean> {
const hasAttachments = attachments !== undefined && attachments.length > 0;
if (options.markSending) this.markSendingPrompt(session.id, true);
try {
if (hasAttachments && delivery === "folder") {
const saved = await this.api.saveAttachments(session, attachments, machineId);
const references = saved.map((file) => fileCompletionInsertText(file.path, false)).join(" ");
const body = text === "" ? references : `${text}\n\n${references}`;
await this.api.prompt(session, body, streamingBehavior, machineId);
} else {
await this.api.prompt(session, text, streamingBehavior, machineId, attachments);
}
this.markCachedNewSessionPersisted(session);
return true;
} catch (error) {
this.setState({ error: String(error) });
return false;
} finally {
if (options.markSending) this.markSendingPrompt(session.id, false);
}
}
private async deliverShellToSession(session: SessionInfo, text: string, machineId: string, options: { optimisticLine: boolean }): Promise<boolean> {
if (options.optimisticLine && this.getState().selectedSession?.id === session.id) {
this.setState({ messages: [...this.getState().messages, textMessage("user", text)] });
}
try {
await this.api.shell(session, text, machineId);
this.markCachedNewSessionPersisted(session);
return true;
} catch (error) {
if (this.getState().selectedSession?.id === session.id) this.setState({ messages: [...this.getState().messages, textMessage("system", String(error))] });
this.setState({ error: String(error) });
return false;
}
}
private async deliverCommandToSession(session: SessionInfo, text: string, machineId: string, options: { applyResult: boolean }): Promise<boolean> {
// Commands are not inserted into the transcript optimistically: a builtin
// command produces its own result line, and a runtime/skill command is
// forwarded to the agent, which streams back the canonical (expanded)
// message. Inserting the raw text here would leave a line that doesn't
// converge with server history and disappears on reload. Surface the same
// per-session sending indicator that send() uses for the pre-receipt window.
const sessionId = session.id;
this.markSendingPrompt(sessionId, true);
this.markSendingPrompt(session.id, true);
try {
this.applyCommandResult(await this.api.runCommand(session, text, selectedMachineId(this.getState())));
const result = await this.api.runCommand(session, text, machineId);
if (options.applyResult && this.getState().selectedSession?.id === session.id) this.applyCommandResult(result);
else if (result.type === "select") this.setState({ error: `Queued command “${text}” needs input; open the session and run it again.` });
this.markCachedNewSessionPersisted(session);
return true;
} catch (error) {
this.setState({ messages: [...this.getState().messages, textMessage("system", String(error))], error: String(error) });
if (this.getState().selectedSession?.id === session.id) this.setState({ messages: [...this.getState().messages, textMessage("system", String(error))] });
this.setState({ error: String(error) });
return false;
} finally {
this.markSendingPrompt(sessionId, false);
this.markSendingPrompt(session.id, false);
}
}
private dropNextQueuedSessionMessage(sessionId: string): void {
const state = this.getState();
const current = state.clientQueuedSessionMessages[sessionId] ?? [];
if (current.length === 0) return;
const remaining = current.slice(1);
this.setState({ clientQueuedSessionMessages: remaining.length === 0 ? omitKey(state.clientQueuedSessionMessages, sessionId) : { ...state.clientQueuedSessionMessages, [sessionId]: remaining } });
}
async respondToCommand(requestId: string, value: string) {
const session = this.getState().selectedSession;
if (!session) return;
@@ -300,10 +388,12 @@ export class SessionController {
async archiveSession(session = this.getState().selectedSession) {
if (!session) return;
if (isCachedNewSessionInfo(session)) {
const status = this.statusForSession(session);
if (isTransientNewSessionInfo(session, status)) {
await this.deleteCachedNewSession(session);
return;
}
if (!isArchivableSessionInfo(session, status)) return;
try {
await this.api.archive(session, selectedMachineId(this.getState()));
const state = this.getState();
@@ -319,7 +409,7 @@ export class SessionController {
}
async archiveSessionWithDescendants(session = this.getState().selectedSession) {
if (!session || isCachedNewSessionInfo(session)) return;
if (session === undefined || !isArchivableSessionInfo(session, this.statusForSession(session))) return;
try {
const response = await this.api.archiveWithDescendants(session, selectedMachineId(this.getState()));
const archivedIds = response.sessionIds !== undefined && response.sessionIds.length > 0 ? response.sessionIds : [session.id];
@@ -336,7 +426,7 @@ export class SessionController {
}
async archiveSessions(sessions: readonly SessionInfo[]): Promise<void> {
const candidates = uniqueSessionsById(sessions).filter((session) => session.archived !== true && !isCachedNewSessionInfo(session));
const candidates = uniqueSessionsById(sessions).filter((session) => isArchivableSessionInfo(session, this.statusForSession(session)));
if (candidates.length === 0) return;
try {
@@ -448,8 +538,10 @@ export class SessionController {
const workspace = this.getState().selectedWorkspace;
if (workspace === undefined) return;
try {
const sessions = mergeCachedNewSessions(workspace.path, await this.api.sessions(workspace.path, machineId), machineId);
const listedSessions = mergeCachedNewSessions(workspace.path, await this.api.sessions(workspace.path, machineId), machineId)
.filter((session) => !this.isSuppressedCreatedSession(session, machineId));
if (selectedMachineId(this.getState()) !== machineId || this.getState().selectedWorkspace?.id !== workspace.id) return;
const sessions = this.mergePendingStartSessions(workspace.path, listedSessions, machineId);
const selectedSession = this.getState().selectedSession;
this.setState({ sessions });
if (selectedSession === undefined) return;
@@ -467,14 +559,28 @@ export class SessionController {
}
async deleteCachedNewSession(session = this.getState().selectedSession) {
if (!isCachedNewSessionInfo(session)) return;
void this.api.stop(session, selectedMachineId(this.getState())).catch(() => {
// Best-effort cleanup for browser-cached sessions that may not exist server-side anymore.
});
if (session === undefined || !isTransientNewSessionInfo(session, this.statusForSession(session))) return;
const pendingStart = isClientPendingStartSessionInfo(session) ? this.pendingSessionStarts.get(session.id) : undefined;
if (pendingStart !== undefined) {
pendingStart.discarded = true;
pendingStart.queuedSends = [];
}
else {
void this.api.stop(session, selectedMachineId(this.getState())).catch(() => {
// Best-effort cleanup for transient sessions that may not exist server-side anymore.
});
}
forgetCachedNewSession(session.id, selectedMachineId(this.getState()));
clearDraft(this.sessionCacheKey(session.id));
const sessions = this.getState().sessions.filter((candidate) => candidate.id !== session.id);
this.setState({ sessions });
const state = this.getState();
const sessions = state.sessions.filter((candidate) => candidate.id !== session.id);
this.setState({
sessions,
sessionStatuses: omitKey(state.sessionStatuses, session.id),
sessionActivities: omitSessionActivity(state.sessionActivities, session.id),
sendingPrompts: omitKey(state.sendingPrompts, session.id),
clientQueuedSessionMessages: omitKey(state.clientQueuedSessionMessages, session.id),
});
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);
@@ -499,7 +605,7 @@ export class SessionController {
}
async reloadSession(session = this.getState().selectedSession) {
if (session === undefined || isCachedNewSessionInfo(session) || session.archived === true) return;
if (session === undefined || !isArchivableSessionInfo(session, this.statusForSession(session))) return;
const machineId = selectedMachineId(this.getState());
const runtime = this.getState().machineRuntimes[machineId];
if (runtime?.ok !== true || !supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsReload)) {
@@ -617,7 +723,7 @@ export class SessionController {
async refreshSelectedSession(sessionId = this.getState().selectedSession?.id): Promise<void> {
const session = this.getState().selectedSession;
if (sessionId === undefined || session?.id !== sessionId || session.archived === true) return;
if (sessionId === undefined || session?.id !== sessionId || session.archived === true || isClientPendingStartSessionInfo(session)) return;
try {
this.flushPendingUpdates();
const [page, status] = await Promise.all([this.api.messages(session, { limit: MESSAGE_PAGE_SIZE }, selectedMachineId(this.getState())), this.api.status(session, selectedMachineId(this.getState()))]);
@@ -644,6 +750,13 @@ export class SessionController {
return machineSessionKey(selectedMachineId(this.getState()), sessionId);
}
private statusForSession(session: SessionInfo | undefined): SessionStatus | undefined {
if (session === undefined) return undefined;
const state = this.getState();
if (state.status?.sessionId === session.id && state.selectedSession?.id === session.id) return state.status;
return state.sessionStatuses[session.id];
}
private workspaceSelectionKey(cwd: string): string {
return `${selectedMachineId(this.getState())}:${cwd}`;
}
@@ -656,6 +769,148 @@ export class SessionController {
});
}
private createPendingSessionStart(workspace: Workspace, machineId: string): PendingSessionStart {
const tempId = `pending-session-${String(++this.pendingSessionStartSeq)}-${Date.now().toString(36)}`;
const now = new Date().toISOString();
const session: ClientPendingStartSessionInfo = {
id: tempId,
path: `pi-web://pending-session/${tempId}`,
cwd: workspace.path,
persisted: false,
name: "New session",
created: now,
modified: now,
messageCount: 0,
firstMessage: "",
clientPendingStart: true,
machineId,
};
return { tempId, workspaceId: workspace.id, cwd: workspace.path, machineId, session, queuedSends: [], discarded: false };
}
private insertAndSelectPendingSession(session: ClientPendingStartSessionInfo): void {
const state = this.getState();
this.selectClientPendingStartSession(session, {
activity: creatingPendingSessionActivity(session.id),
sessions: [session, ...state.sessions.filter((candidate) => candidate.id !== session.id)],
});
}
private selectClientPendingStartSession(session: ClientPendingStartSessionInfo, options?: { updateUrl?: boolean | undefined; activity?: SessionActivity | undefined; sessions?: SessionInfo[] | undefined }): void {
this.sessionSelection.rememberSession({ ...session, cwd: this.workspaceSelectionKey(session.cwd) });
this.selectionSeq += 1;
this.socket.close();
this.catchupStreamSessionId = undefined;
this.clearPendingUpdates();
const state = this.getState();
const pendingStart = this.pendingSessionStarts.get(session.id);
const activity = options?.activity ?? state.sessionActivities[session.id] ?? (pendingStart !== undefined ? creatingPendingSessionActivity(session.id, pendingStart.queuedSends.length) : undefined);
this.setState({
...(options?.sessions === undefined ? {} : { sessions: options.sessions }),
selectedSession: session,
messages: [],
messagePageStart: 0,
messagePageEnd: 0,
messagePageTotal: 0,
isLoadingEarlierMessages: false,
isReceivingPartialStream: false,
status: undefined,
activity,
availableThinkingLevels: [],
...(activity === undefined ? {} : { sessionActivities: { ...state.sessionActivities, [session.id]: activity } }),
error: "",
});
if (options?.updateUrl !== false) this.updateUrl();
}
private async resolvePendingSessionStart(tempId: string, session: SessionInfo): Promise<void> {
const pending = this.pendingSessionStarts.get(tempId);
if (pending === undefined) return;
this.pendingSessionStarts.delete(tempId);
const queuedSends = pending.queuedSends.splice(0);
this.clearSuppressedCreatedSessionsFor(pending.cwd, pending.machineId, session.id);
if (pending.discarded) {
clearDraft(machineSessionKey(pending.machineId, tempId));
this.setState({ clientQueuedSessionMessages: omitKey(this.getState().clientQueuedSessionMessages, tempId) });
void this.api.stop(session, pending.machineId).catch(() => {
// Best-effort cleanup for a backend session whose temporary UI row was discarded before creation finished.
});
return;
}
rememberCachedNewSession(session, pending.machineId);
moveDraft(machineSessionKey(pending.machineId, tempId), machineSessionKey(pending.machineId, session.id));
const cachedSession = markCachedNewSessionInfo(session, pending.machineId);
if (!this.isCurrentPendingStart(pending)) {
this.setState({ clientQueuedSessionMessages: omitKey(this.getState().clientQueuedSessionMessages, tempId) });
await this.flushQueuedPendingSends(cachedSession, pending.machineId, queuedSends);
return;
}
const state = this.getState();
const wasSelected = state.selectedSession?.id === tempId;
this.setState({
sessions: replacePendingSessionInList(state.sessions, tempId, cachedSession),
sessionActivities: omitSessionActivity(state.sessionActivities, tempId),
sendingPrompts: moveRecordKey(state.sendingPrompts, tempId, cachedSession.id),
clientQueuedSessionMessages: moveRecordKey(state.clientQueuedSessionMessages, tempId, cachedSession.id),
...(wasSelected ? { selectedSession: cachedSession, status: state.sessionStatuses[cachedSession.id], activity: state.sessionActivities[cachedSession.id] } : {}),
error: "",
});
if (wasSelected) {
this.updateUrl({ replace: true });
await this.selectSession(cachedSession, { updateUrl: false });
}
await this.flushQueuedPendingSends(cachedSession, pending.machineId, queuedSends);
}
private failPendingSessionStart(tempId: string, error: unknown): void {
const pending = this.pendingSessionStarts.get(tempId);
if (pending === undefined) return;
this.pendingSessionStarts.delete(tempId);
this.clearSuppressedCreatedSessionsFor(pending.cwd, pending.machineId);
if (pending.discarded || !this.isCurrentPendingStart(pending)) return;
const state = this.getState();
const message = errorMessage(error);
const activity = failedPendingSessionActivity(tempId, message, pending.queuedSends.length);
const hasPendingRow = state.sessions.some((session) => session.id === tempId);
this.setState({
sessions: hasPendingRow ? state.sessions : [pending.session, ...state.sessions],
sessionActivities: { ...state.sessionActivities, [tempId]: activity },
activity: state.selectedSession?.id === tempId ? activity : state.activity,
error: `Failed to start session: ${message}`,
});
}
private isCurrentPendingStart(pending: PendingSessionStart): boolean {
const state = this.getState();
return selectedMachineId(state) === pending.machineId && state.selectedWorkspace?.id === pending.workspaceId;
}
private hasPendingStartFor(cwd: string, machineId: string): boolean {
return Array.from(this.pendingSessionStarts.values()).some((pending) => pending.cwd === cwd && pending.machineId === machineId);
}
private isSuppressedCreatedSession(session: SessionInfo, machineId: string): boolean {
const suppressed = this.suppressedCreatedSessions.get(session.id);
return suppressed?.cwd === session.cwd && suppressed.machineId === machineId;
}
private clearSuppressedCreatedSessionsFor(cwd: string, machineId: string, resolvedSessionId?: string): void {
if (resolvedSessionId !== undefined) this.suppressedCreatedSessions.delete(resolvedSessionId);
if (this.hasPendingStartFor(cwd, machineId)) return;
for (const [sessionId, suppressed] of this.suppressedCreatedSessions) {
if (suppressed.cwd === cwd && suppressed.machineId === machineId) this.suppressedCreatedSessions.delete(sessionId);
}
}
private mergePendingStartSessions(cwd: string, sessions: SessionInfo[], machineId: string): SessionInfo[] {
const pending = this.getState().sessions.filter((session): session is ClientPendingStartSessionInfo => isClientPendingStartSessionInfo(session) && session.cwd === cwd && session.machineId === machineId);
if (pending.length === 0) return sessions;
const pendingIds = new Set(pending.map((session) => session.id));
return [...pending, ...sessions.filter((session) => !pendingIds.has(session.id))];
}
private async recreateCachedNewSession(session: SessionInfo, options?: { updateUrl?: boolean | undefined }): Promise<void> {
try {
const machineId = selectedMachineId(this.getState());
@@ -701,6 +956,11 @@ export class SessionController {
// the optimistic insert from startSession in this same tab).
if (state.selectedWorkspace?.path !== session.cwd) return;
if (state.sessions.some((candidate) => candidate.id === session.id)) return;
const machineId = selectedMachineId(state);
if (this.hasPendingStartFor(session.cwd, machineId)) {
this.suppressedCreatedSessions.set(session.id, { cwd: session.cwd, machineId });
return;
}
this.setState({ sessions: [session, ...state.sessions] });
}
@@ -873,10 +1133,6 @@ function omitSessionActivity(activities: Record<string, SessionActivity>, sessio
return omitKey(activities, sessionId);
}
function decrementStartingSessionCount(count: number): number {
return Math.max(0, count - 1);
}
function omitKey<T>(record: Record<string, T>, key: string): Record<string, T> {
return Object.fromEntries(Object.entries(record).filter(([id]) => id !== key));
}
@@ -887,6 +1143,81 @@ function omitKeys<T>(record: Record<string, T>, keys: readonly string[]): Record
return Object.fromEntries(Object.entries(record).filter(([id]) => !removed.has(id)));
}
function moveRecordKey<T>(record: Record<string, T>, fromKey: string, toKey: string): Record<string, T> {
if (fromKey === toKey || !(fromKey in record)) return record;
const value = record[fromKey];
if (value === undefined) return record;
return { ...omitKey(record, fromKey), [toKey]: value };
}
function replacePendingSessionInList(sessions: readonly SessionInfo[], pendingSessionId: string, resolvedSession: SessionInfo): SessionInfo[] {
const next: SessionInfo[] = [];
let inserted = false;
for (const session of sessions) {
if (session.id === pendingSessionId) {
if (!inserted) {
next.push(resolvedSession);
inserted = true;
}
continue;
}
if (session.id === resolvedSession.id) continue;
next.push(session);
}
if (!inserted) return [resolvedSession, ...next];
return next;
}
function isClientPendingStartSessionInfo(session: SessionInfo | undefined): session is ClientPendingStartSessionInfo {
return session !== undefined && "clientPendingStart" in session && session.clientPendingStart === true;
}
function creatingPendingSessionActivity(sessionId: string, queuedCount = 0): SessionActivity {
return {
sessionId,
phase: "active",
label: "Creating session",
detail: queuedCount > 0 ? `${String(queuedCount)} queued ${queuedCount === 1 ? "message" : "messages"} will send when the backend session is ready` : "Waiting for the backend session to be ready",
at: new Date().toISOString(),
};
}
function failedPendingSessionActivity(sessionId: string, message: string, queuedCount = 0): SessionActivity {
const queuedDetail = queuedCount > 0 ? ` · ${String(queuedCount)} queued ${queuedCount === 1 ? "message" : "messages"} kept below` : "";
return {
sessionId,
phase: "error",
label: "Session creation failed",
detail: `${message}${queuedDetail}`,
at: new Date().toISOString(),
};
}
function queuedSessionMessagePreview(queued: QueuedPendingSessionSend): QueuedSessionMessage {
if (queued.type === "prompt") {
return { kind: queued.streamingBehavior === "steer" ? "steer" : "followUp", text: queuedPromptPreviewText(queued.text, queued.attachments) };
}
return { kind: "followUp", text: queued.text };
}
function queuedPromptPreviewText(text: string, attachments: PromptAttachment[] | undefined): string {
const attachmentText = queuedAttachmentSummary(attachments);
if (attachmentText === undefined) return text;
const trimmed = text.trim();
return trimmed === "" ? attachmentText : `${text}\n\n${attachmentText}`;
}
function queuedAttachmentSummary(attachments: PromptAttachment[] | undefined): string | undefined {
if (attachments === undefined || attachments.length === 0) return undefined;
const names = attachments.map((attachment) => attachment.name?.trim()).filter((name): name is string => name !== undefined && name !== "");
const count = attachments.length;
const label = `${String(count)} ${count === 1 ? "attachment" : "attachments"}`;
if (names.length === 0) return `[${label} queued]`;
const shownNames = names.slice(0, 3).join(", ");
const suffix = names.length > 3 ? `, +${String(names.length - 3)} more` : "";
return `[${label} queued: ${shownNames}${suffix}]`;
}
function uniqueSessionsById(sessions: readonly SessionInfo[]): SessionInfo[] {
const seen = new Set<string>();
const unique: SessionInfo[] = [];
+8 -11
View File
@@ -1,8 +1,8 @@
import { isSessionActive } from "../../../../shared/activity";
import { PI_WEB_CAPABILITIES, supportsPiWebCapability, type PiWebCapability } from "../../../../shared/capabilities";
import type { AppState } from "../../appState";
import { isCachedNewSessionInfo } from "../../cachedNewSessions";
import { selectedMachineId } from "../../controllers/types";
import { isArchivableSessionInfo, isTransientNewSessionInfo } from "../../sessionPersistence";
import { isWorkspaceDeletionPending } from "../../workspaceDeletion";
import type { PluginAction } from "../types";
@@ -187,9 +187,9 @@ export function createCoreActions(): PluginAction[] {
{
id: "session.delete",
title: "Delete New Session",
description: "Delete the selected browser-cached new session",
description: "Delete the selected transient new session",
group: "Session",
enabled: hasCachedNewSession,
enabled: hasTransientNewSession,
run: (context) => context.deleteCachedNewSession(),
},
{
@@ -217,24 +217,21 @@ function hasDeletableWorkspace(context: { state: AppState }): boolean {
}
function hasArchivableSession(context: { state: AppState }): boolean {
const session = context.state.selectedSession;
return session !== undefined && session.archived !== true && !isCachedNewSessionInfo(session);
return isArchivableSessionInfo(context.state.selectedSession, context.state.status);
}
function hasCachedNewSession(context: { state: AppState }): boolean {
return isCachedNewSessionInfo(context.state.selectedSession);
function hasTransientNewSession(context: { state: AppState }): boolean {
return isTransientNewSessionInfo(context.state.selectedSession, context.state.status);
}
function hasReloadableSession(context: { state: AppState }): boolean {
const session = context.state.selectedSession;
if (session === undefined || session.archived === true || isCachedNewSessionInfo(session)) return false;
if (!isArchivableSessionInfo(context.state.selectedSession, context.state.status)) return false;
if (reloadSessionDisabledReason(context) !== undefined) return false;
return !isSessionActive(context.state.status, context.state.activity);
}
function reloadSessionDisabledReason(context: { state: AppState }): string | undefined {
const session = context.state.selectedSession;
if (session === undefined || session.archived === true || isCachedNewSessionInfo(session)) return undefined;
if (!isArchivableSessionInfo(context.state.selectedSession, context.state.status)) return undefined;
if (isSessionActive(context.state.status, context.state.activity)) return undefined;
return missingCapabilityReason(context.state, PI_WEB_CAPABILITIES.sessionsReload, "reload sessions from disk");
}
+37 -10
View File
@@ -170,50 +170,77 @@ describe("PluginRegistry", () => {
expect(calls).toEqual(["deleteWorkspace"]);
});
it("offers archive only for persisted sessions and delete only for browser-cached new sessions", () => {
it("offers archive only for persisted sessions and delete only for transient new sessions", () => {
const registry = new PluginRegistry();
registry.register({ id: "core", plugin: corePlugin });
const persistedActions = registry.getActions(createContext({ selectedSession: testSession() }).context);
const persistedActions = registry.getActions(createContext({ selectedSession: testSession({ persisted: true }) }).context);
expect(persistedActions.find((action) => action.id === "core:session.archive")?.enabled).toBe(true);
expect(persistedActions.find((action) => action.id === "core:session.delete")?.enabled).toBe(false);
const unknownActions = registry.getActions(createContext({ selectedSession: testSession() }).context);
expect(unknownActions.find((action) => action.id === "core:session.archive")?.enabled).toBe(false);
expect(unknownActions.find((action) => action.id === "core:session.delete")?.enabled).toBe(false);
const transientActions = registry.getActions(createContext({ selectedSession: testSession({ persisted: false }) }).context);
expect(transientActions.find((action) => action.id === "core:session.archive")?.enabled).toBe(false);
expect(transientActions.find((action) => action.id === "core:session.delete")?.enabled).toBe(true);
const cachedActions = registry.getActions(createContext({ selectedSession: markCachedNewSessionInfo(testSession()) }).context);
expect(cachedActions.find((action) => action.id === "core:session.archive")?.enabled).toBe(false);
expect(cachedActions.find((action) => action.id === "core:session.delete")?.enabled).toBe(true);
const archivedActions = registry.getActions(createContext({ selectedSession: { ...testSession(), archived: true, archivedAt: "2026-05-20T00:00:00.000Z" } }).context);
const archivedActions = registry.getActions(createContext({ selectedSession: { ...testSession({ persisted: true }), archived: true, archivedAt: "2026-05-20T00:00:00.000Z" } }).context);
expect(archivedActions.find((action) => action.id === "core:session.archive")?.enabled).toBe(false);
expect(archivedActions.find((action) => action.id === "core:session.delete")?.enabled).toBe(false);
});
it("uses selected session status as the freshest archive/delete persistence signal", () => {
const registry = new PluginRegistry();
registry.register({ id: "core", plugin: corePlugin });
const statusPersisted = registry.getActions(createContext({ selectedSession: testSession({ persisted: false }), status: testStatus({ persisted: true }) }).context);
expect(statusPersisted.find((action) => action.id === "core:session.archive")?.enabled).toBe(true);
expect(statusPersisted.find((action) => action.id === "core:session.delete")?.enabled).toBe(false);
const statusTransient = registry.getActions(createContext({ selectedSession: testSession({ persisted: true }), status: testStatus({ persisted: false }) }).context);
expect(statusTransient.find((action) => action.id === "core:session.archive")?.enabled).toBe(false);
expect(statusTransient.find((action) => action.id === "core:session.delete")?.enabled).toBe(true);
});
it("enables session disk reload only for a writable session on a capable, idle runtime", () => {
const registry = new PluginRegistry();
registry.register({ id: "core", plugin: corePlugin });
const reloadRuntime = { local: { machineId: "local", ok: true as const, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsReload] } };
const reloadable = registry.getActions(createContext({ selectedSession: testSession(), machineRuntimes: reloadRuntime }).context);
const reloadable = registry.getActions(createContext({ selectedSession: testSession({ persisted: true }), machineRuntimes: reloadRuntime }).context);
const reloadableAction = reloadable.find((action) => action.id === "core:session.reload");
expect(reloadableAction?.enabled).toBe(true);
expect(reloadableAction?.title).toBe("Reload Session from Disk");
expect(reloadableAction?.description).toContain("Use /reload in the prompt for Pi runtime resources");
const noCapability = registry.getActions(createContext({ selectedSession: testSession() }).context);
const noCapability = registry.getActions(createContext({ selectedSession: testSession({ persisted: true }) }).context);
const noCapabilityReload = noCapability.find((action) => action.id === "core:session.reload");
expect(noCapabilityReload?.enabled).toBe(false);
expect(noCapabilityReload?.disabledReason).toBe("Update and restart Pi-Web on this machine to reload sessions from disk.");
const archived = registry.getActions(createContext({ selectedSession: { ...testSession(), archived: true, archivedAt: "2026-05-20T00:00:00.000Z" }, machineRuntimes: reloadRuntime }).context);
const unknown = registry.getActions(createContext({ selectedSession: testSession(), machineRuntimes: reloadRuntime }).context);
expect(unknown.find((action) => action.id === "core:session.reload")?.enabled).toBe(false);
const transient = registry.getActions(createContext({ selectedSession: testSession({ persisted: false }), machineRuntimes: reloadRuntime }).context);
expect(transient.find((action) => action.id === "core:session.reload")?.enabled).toBe(false);
const archived = registry.getActions(createContext({ selectedSession: { ...testSession({ persisted: true }), archived: true, archivedAt: "2026-05-20T00:00:00.000Z" }, machineRuntimes: reloadRuntime }).context);
expect(archived.find((action) => action.id === "core:session.reload")?.enabled).toBe(false);
const busy = registry.getActions(createContext({ selectedSession: testSession(), machineRuntimes: reloadRuntime, status: testStatus({ isStreaming: true }) }).context);
const busy = registry.getActions(createContext({ selectedSession: testSession({ persisted: true }), machineRuntimes: reloadRuntime, status: testStatus({ persisted: true, isStreaming: true }) }).context);
expect(busy.find((action) => action.id === "core:session.reload")?.enabled).toBe(false);
});
it("routes session reload through the runtime context", () => {
const registry = new PluginRegistry();
registry.register({ id: "core", plugin: corePlugin });
const { context, calls } = createContext({ selectedSession: testSession(), machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsReload] } } });
const { context, calls } = createContext({ selectedSession: testSession({ persisted: true }), machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsReload] } } });
const action = registry.getActions(context).find((candidate) => candidate.id === "core:session.reload");
if (action !== undefined) void action.run();
@@ -221,10 +248,10 @@ describe("PluginRegistry", () => {
expect(calls).toEqual(["reloadSession"]);
});
it("routes browser-cached new session delete through the runtime context", () => {
it("routes transient new session delete through the runtime context", () => {
const registry = new PluginRegistry();
registry.register({ id: "core", plugin: corePlugin });
const { context, calls } = createContext({ selectedSession: markCachedNewSessionInfo(testSession()) });
const { context, calls } = createContext({ selectedSession: testSession({ persisted: false }) });
const action = registry.getActions(context).find((candidate) => candidate.id === "core:session.delete");
if (action !== undefined) void action.run();
+21
View File
@@ -0,0 +1,21 @@
import type { SessionInfo, SessionStatus } from "./api";
import { isCachedNewSessionInfo } from "./cachedNewSessions";
export type SessionPersistenceState = "persisted" | "transient" | "unknown";
export function sessionPersistenceState(session: SessionInfo | undefined, status?: SessionStatus): SessionPersistenceState {
if (session === undefined) return "unknown";
const statusPersisted = status?.sessionId === session.id ? status.persisted : undefined;
const persisted = statusPersisted ?? session.persisted;
if (persisted === true) return "persisted";
if (persisted === false || isCachedNewSessionInfo(session)) return "transient";
return "unknown";
}
export function isArchivableSessionInfo(session: SessionInfo | undefined, status?: SessionStatus): boolean {
return session !== undefined && session.archived !== true && sessionPersistenceState(session, status) === "persisted";
}
export function isTransientNewSessionInfo(session: SessionInfo | undefined, status?: SessionStatus): boolean {
return session !== undefined && session.archived !== true && sessionPersistenceState(session, status) === "transient";
}
+30 -1
View File
@@ -215,6 +215,35 @@ describe("PiSessionService", () => {
expect(fake.calls.dispose).toBe(1);
});
it("reports persistence from actual session-file existence for fresh active sessions", async () => {
const dir = await mkdtemp(join(tmpdir(), "pi-web-persisted-"));
const sessionFile = join(dir, "new-session.jsonl");
const hub = new CapturingSessionEventHub();
const fake = fakeRuntime("new-session", { sessionFile });
let service: PiSessionService | undefined;
try {
service = new PiSessionService(hub, {
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([]),
heartbeatIntervalMs: 60_000,
});
const session = await service.start("/workspace");
const createdEvent = hub.globalEvents.find((event) => event.type === "session.created");
expect(session).toMatchObject({ id: "new-session", path: sessionFile, persisted: false });
expect(createdEvent).toMatchObject({ type: "session.created", session: { id: "new-session", persisted: false } });
await expect(service.status(sessionRef("new-session"))).resolves.toMatchObject({ sessionId: "new-session", persisted: false });
await writeFile(sessionFile, '{"type":"session","id":"new-session"}\n', "utf8");
await expect(service.status(sessionRef("new-session"))).resolves.toMatchObject({ sessionId: "new-session", persisted: true });
} finally {
await service?.dispose();
await rm(dir, { recursive: true, force: true });
}
});
it("opens legacy id-only lookups from the default session store gateway", async () => {
const hub = new CapturingSessionEventHub();
const fake = fakeRuntime("legacy-session");
@@ -376,7 +405,7 @@ describe("PiSessionService", () => {
const sessions = await service.list("/workspace");
expect(sessions).toHaveLength(2);
expect(sessions[0]).toMatchObject({ id: "active" });
expect(sessions[0]).toMatchObject({ id: "active", persisted: true });
expect(sessions[0]?.archived).toBeUndefined();
expect(sessions[1]).toMatchObject({ id: "archived", archived: true, archivedAt: "2026-01-01T00:00:00.000Z" });
+13
View File
@@ -1,3 +1,4 @@
import { statSync } from "node:fs";
import { open, readFile, writeFile } from "node:fs/promises";
import type { ImageContent } from "@earendil-works/pi-ai";
import type { StreamFn } from "@earendil-works/pi-agent-core";
@@ -540,6 +541,7 @@ export class PiSessionService {
id: session.sessionId,
path: session.sessionFile ?? "",
cwd,
persisted: sessionFileExists(session.sessionFile),
created: new Date().toISOString(),
modified: new Date().toISOString(),
messageCount: session.messages.length,
@@ -1857,6 +1859,7 @@ export class PiSessionService {
const contextUsage = session.getContextUsage();
return {
sessionId: session.sessionId,
persisted: sessionFileExists(session.sessionFile),
...(model === undefined ? {} : { model }),
thinkingLevel: session.thinkingLevel,
isStreaming: session.isStreaming,
@@ -1949,6 +1952,7 @@ function clientSessionFromListEntry(session: PiSessionListEntry): ClientSession
id: session.id,
path: session.path,
cwd: session.cwd,
persisted: true,
...(session.name === undefined ? {} : { name: session.name }),
created: session.created.toISOString(),
modified: session.modified.toISOString(),
@@ -2151,6 +2155,15 @@ function sessionPathsEqual(a: string, b: string): boolean {
return cwdPathsEqual(a, b);
}
function sessionFileExists(sessionFile: string | undefined): sessionFile is string {
if (sessionFile === undefined || sessionFile === "") return false;
try {
return statSync(sessionFile).isFile();
} catch {
return false;
}
}
function sessionFileMatches(session: PiAgentSession, expectedSessionFile: string | undefined): boolean {
const sessionFile = nonEmptyString(session.sessionFile);
return sessionFile !== undefined && expectedSessionFile !== undefined && sessionPathsEqual(sessionFile, expectedSessionFile);
+4
View File
@@ -186,6 +186,8 @@ export interface SessionRef {
export interface SessionInfo extends SessionRef {
path: string;
/** True when the server has verified a backing session file exists; false when known transient. */
persisted?: boolean;
name?: string;
created: string;
modified: string;
@@ -380,6 +382,8 @@ export interface ThinkingLevelsResponse {
export interface SessionStatus {
sessionId: string;
/** True when the server has verified a backing session file exists; false when known transient. */
persisted?: boolean;
model?: SessionModel;
thinkingLevel?: string;
isStreaming: boolean;