Add session archiving

This commit is contained in:
Federico Jaramillo Martinez
2026-05-07 21:06:11 +02:00
parent b5b1814383
commit 402d5b17aa
11 changed files with 272 additions and 25 deletions
+20 -1
View File
@@ -24,6 +24,8 @@ export interface SessionInfo {
modified: string;
messageCount: number;
firstMessage: string;
archived?: boolean;
archivedAt?: string;
}
export interface SessionActivity {
@@ -77,7 +79,7 @@ export type CommandResult =
async function request<T>(url: string, parse: (value: unknown) => T, init?: RequestInit): Promise<T> {
const headers = new Headers(init?.headers);
headers.set("content-type", "application/json");
if (init?.body !== undefined) headers.set("content-type", "application/json");
const response = await fetch(url, { ...init, headers });
if (!response.ok) {
const body: unknown = await response.json().catch((): unknown => ({}));
@@ -107,6 +109,8 @@ export const api = {
runCommand: (sessionId: string, text: string) => request(`/api/sessions/${sessionId}/commands/run`, parseCommandResult, { method: "POST", body: JSON.stringify({ text }) }),
respondToCommand: (sessionId: string, requestId: string, value: string) => request(`/api/sessions/${sessionId}/commands/respond`, parseCommandResult, { method: "POST", body: JSON.stringify({ requestId, value }) }),
stop: (sessionId: string) => request(`/api/sessions/${sessionId}/stop`, parseStopped, { method: "POST" }),
archive: (sessionId: string) => request(`/api/sessions/${sessionId}/archive`, parseArchived, { method: "POST" }),
restore: (sessionId: string) => request(`/api/sessions/${sessionId}/restore`, parseRestored, { method: "POST" }),
};
export function sessionEvents(sessionId: string): WebSocket {
@@ -204,6 +208,7 @@ function parseWorkspace(value: unknown): Workspace {
function parseSessionInfo(value: unknown): SessionInfo {
const record = requireRecord(value);
const name = optionalString(record, "name");
const archivedAt = optionalString(record, "archivedAt");
return {
id: requireString(record, "id"),
path: requireString(record, "path"),
@@ -213,6 +218,8 @@ function parseSessionInfo(value: unknown): SessionInfo {
modified: requireString(record, "modified"),
messageCount: requireNumber(record, "messageCount"),
firstMessage: requireString(record, "firstMessage"),
...(record["archived"] === true ? { archived: true } : {}),
...(archivedAt === undefined ? {} : { archivedAt }),
};
}
@@ -299,6 +306,18 @@ function parseStopped(value: unknown): { stopped: true } {
return { stopped: true };
}
function parseArchived(value: unknown): { archived: true } {
const record = requireRecord(value);
if (record["archived"] !== true) throw new Error("Expected archived response");
return { archived: true };
}
function parseRestored(value: unknown): { restored: true } {
const record = requireRecord(value);
if (record["restored"] !== true) throw new Error("Expected restored response");
return { restored: true };
}
function optionalNumber(record: Record<string, unknown>, key: string): number | undefined {
const value = record[key];
if (value === undefined) return undefined;
+2 -2
View File
@@ -109,13 +109,13 @@ export class PiWebApp extends LitElement {
</header>
<project-list .projects=${state.projects} .selected=${state.selectedProject} .onSelect=${(project: Project) => this.withChatScrollTransition(() => this.workspaces.selectProject(project))}></project-list>
<workspace-list .workspaces=${state.workspaces} .selected=${state.selectedWorkspace} .onSelect=${(workspace: Workspace) => this.withChatScrollTransition(() => this.workspaces.selectWorkspace(workspace))}></workspace-list>
<session-list .sessions=${state.sessions} .statuses=${state.sessionStatuses} .activities=${state.sessionActivities} .selected=${state.selectedSession} .canStart=${!!state.selectedWorkspace} .onStart=${() => this.withChatScrollTransition(() => this.sessions.startSession())} .onSelect=${(session: SessionInfo) => this.withChatScrollTransition(() => this.sessions.selectSession(session))}></session-list>
<session-list .sessions=${state.sessions} .statuses=${state.sessionStatuses} .activities=${state.sessionActivities} .selected=${state.selectedSession} .canStart=${!!state.selectedWorkspace} .onStart=${() => this.withChatScrollTransition(() => this.sessions.startSession())} .onSelect=${(session: SessionInfo) => this.withChatScrollTransition(() => this.sessions.selectSession(session))} .onArchive=${(session: SessionInfo) => this.sessions.archiveSession(session)} .onRestore=${(session: SessionInfo) => this.sessions.restoreSession(session)}></session-list>
</aside>
<main>
${state.error ? html`<div class="error">${state.error}</div>` : null}
${state.selectedSession ? html`
<chat-view .sessionId=${state.selectedSession.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageTotal=${state.messagePageTotal} .hasMore=${state.messagePageStart > 0} .loadingMore=${state.isLoadingEarlierMessages} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}></chat-view>
<prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .canSteer=${state.status?.isStreaming === true} .isCompacting=${state.status?.isCompacting === true} .onSend=${(text: string, streamingBehavior?: "steer" | "followUp") => this.sessions.send(text, streamingBehavior)} .onStopSession=${() => this.sessions.stopSession()}></prompt-editor>
<prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .disabled=${state.selectedSession.archived === true} .canSteer=${state.status?.isStreaming === true} .isCompacting=${state.status?.isCompacting === true} .onSend=${(text: string, streamingBehavior?: "steer" | "followUp") => this.sessions.send(text, streamingBehavior)} .onStopSession=${() => this.sessions.stopSession()}></prompt-editor>
<status-bar .status=${state.status} .activity=${state.activity} .workspace=${state.selectedWorkspace}></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}
` : html`<div class="empty">Select or start a session.</div>`}
+55 -7
View File
@@ -1,5 +1,5 @@
import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { LitElement, html, type PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import type { SessionActivity, SessionInfo, SessionStatus } from "../api";
import { listStyles } from "./shared";
@@ -17,21 +17,69 @@ export class SessionList extends LitElement {
@property({ type: Boolean }) canStart = false;
@property({ attribute: false }) onSelect?: (session: SessionInfo) => void;
@property({ attribute: false }) onStart?: () => void;
@state() private openMenuSessionId: string | undefined;
private readonly onDocumentClick = (event: MouseEvent) => {
if (event.composedPath().includes(this)) return;
this.openMenuSessionId = undefined;
};
@property({ attribute: false }) onArchive?: (session: SessionInfo) => void;
@property({ attribute: false }) onRestore?: (session: SessionInfo) => void;
override connectedCallback(): void {
super.connectedCallback();
document.addEventListener("click", this.onDocumentClick);
}
override disconnectedCallback(): void {
document.removeEventListener("click", this.onDocumentClick);
super.disconnectedCallback();
}
protected override updated(changed: PropertyValues<this>): void {
if (changed.has("sessions") && this.openMenuSessionId !== undefined && !this.sessions.some((session) => session.id === this.openMenuSessionId)) this.openMenuSessionId = undefined;
}
override render() {
const active = this.sessions.filter((session) => session.archived !== true);
const archived = this.sessions.filter((session) => session.archived === true);
return html`
<section>
<h2>Sessions <button ?disabled=${!this.canStart} @click=${() => this.onStart?.()}>+</button></h2>
${this.sessions.map((session) => html`
<button class=${this.selected?.id === session.id ? "selected" : ""} @click=${() => this.onSelect?.(session)}>
<span>${sessionLabel(session)}</span><small>${this.renderStatus(session)}${String(session.messageCount)} messages</small>
</button>
`)}
${active.map((session) => this.renderSession(session))}
${archived.length > 0 ? html`
<h2 class="subheading">Archived</h2>
${archived.map((session) => this.renderSession(session))}
` : null}
</section>
`;
}
private renderSession(session: SessionInfo) {
return html`
<div class="session-row ${this.selected?.id === session.id ? "selected" : ""} ${session.archived === true ? "archived" : ""}">
<button class="session-main" @click=${() => this.onSelect?.(session)}>
<span>${sessionLabel(session)}</span><small>${this.renderStatus(session)}${String(session.messageCount)} messages</small>
</button>
<div class="session-menu">
<button class="session-menu-toggle" title="Session actions" @click=${(event: MouseEvent) => { event.stopPropagation(); this.toggleMenu(session.id); }}>⋯</button>
${this.openMenuSessionId === session.id ? html`
<div class="session-menu-panel">
${session.archived === true
? html`<button title="Restore session" @click=${() => { this.openMenuSessionId = undefined; this.onRestore?.(session); }}>Restore</button>`
: html`<button title="Archive session" @click=${() => { this.openMenuSessionId = undefined; this.onArchive?.(session); }}>Archive</button>`}
</div>
` : null}
</div>
</div>
`;
}
private toggleMenu(sessionId: string) {
this.openMenuSessionId = this.openMenuSessionId === sessionId ? undefined : sessionId;
}
private renderStatus(session: SessionInfo) {
if (session.archived === true) return "read-only · ";
const status = this.statuses[session.id];
const activity = this.activities[session.id];
if (activity?.phase === "active") return `${activity.label} · `;
+10
View File
@@ -45,6 +45,16 @@ export const listStyles = css`
h2 { display: flex; justify-content: space-between; align-items: center; margin: 0 0 8px; color: #8b949e; font-size: 12px; text-transform: uppercase; }
button { border: 1px solid #30363d; border-radius: 8px; background: #161b22; color: #e6edf3; padding: 7px 9px; cursor: pointer; }
section > button { display: block; width: 100%; text-align: left; margin: 6px 0; }
.subheading { margin-top: 14px; }
.session-row { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 6px; margin: 6px 0; }
.session-row.selected .session-main { border-color: #58a6ff; background: #0d2847; }
.session-row.archived .session-main { color: #8b949e; }
.session-main { min-width: 0; text-align: left; }
.session-menu { position: relative; align-self: stretch; }
.session-menu-toggle { display: grid; place-items: center; height: 100%; min-width: 30px; padding: 0; color: #8b949e; }
.session-menu-panel { position: absolute; right: 0; top: calc(100% + 4px); z-index: 5; min-width: 120px; padding: 4px; border: 1px solid #30363d; border-radius: 8px; background: #161b22; box-shadow: 0 8px 24px #0008; }
.session-menu-panel button { display: block; width: 100%; text-align: left; border: 0; background: transparent; color: #e6edf3; }
.session-menu-panel button:hover { background: #0d2847; }
button.selected { border-color: #58a6ff; background: #0d2847; }
button:disabled { opacity: .5; cursor: not-allowed; }
small { display: block; color: #8b949e; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -46,6 +46,13 @@ export class SessionController {
async selectSession(session: SessionInfo, options?: { updateUrl?: boolean | undefined }) {
this.socket.close();
try {
if (session.archived === true) {
const page = await api.messages(session.id, { limit: MESSAGE_PAGE_SIZE });
const history = this.mergeAndCacheHistory(session.id, page);
this.setState({ selectedSession: session, messages: normalizeMessages(history.messages), messagePageStart: history.start, messagePageTotal: history.total, isLoadingEarlierMessages: false, status: undefined, activity: undefined });
if (options?.updateUrl !== false) this.updateUrl();
return;
}
const buffered: SessionUiEvent[] = [];
this.socket.connect(session.id, (event) => buffered.push(event));
const [page, status] = await Promise.all([api.messages(session.id, { limit: MESSAGE_PAGE_SIZE }), api.status(session.id)]);
@@ -86,7 +93,7 @@ export class SessionController {
if (trimmed.startsWith("/")) return this.runCommand(text);
if (isShellInput(text)) return this.runShell(text);
const session = this.getState().selectedSession;
if (!session) return;
if (!session || session.archived === true) return;
this.setState({ messages: [...this.getState().messages, textMessage("user", text)] });
try {
await api.prompt(session.id, text, streamingBehavior);
@@ -97,7 +104,7 @@ export class SessionController {
async runShell(text: string) {
const session = this.getState().selectedSession;
if (!session) return;
if (!session || session.archived === true) return;
this.setState({ messages: [...this.getState().messages, textMessage("user", text)] });
try {
await api.shell(session.id, text);
@@ -108,7 +115,7 @@ export class SessionController {
async runCommand(text: string) {
const session = this.getState().selectedSession;
if (!session) return;
if (!session || session.archived === true) return;
this.setState({ messages: [...this.getState().messages, textMessage("user", text)] });
try {
this.applyCommandResult(await api.runCommand(session.id, text));
@@ -132,6 +139,34 @@ export class SessionController {
this.setState({ commandDialog: undefined });
}
async archiveSession(session = this.getState().selectedSession) {
if (!session) return;
try {
await api.archive(session.id);
this.replaceSession({ ...session, archived: true, archivedAt: new Date().toISOString() });
if (this.getState().selectedSession?.id === session.id) {
this.socket.close();
this.setState({ status: undefined, activity: undefined });
}
} catch (error) {
this.setState({ error: String(error) });
}
}
async restoreSession(session = this.getState().selectedSession) {
if (!session) return;
try {
await api.restore(session.id);
const restored = { ...session };
delete restored.archived;
delete restored.archivedAt;
this.replaceSession(restored);
if (this.getState().selectedSession?.id === restored.id) await this.selectSession(restored);
} catch (error) {
this.setState({ error: String(error) });
}
}
async stopSession() {
const session = this.getState().selectedSession;
if (!session) return;
@@ -145,6 +180,14 @@ export class SessionController {
}
}
private replaceSession(session: SessionInfo) {
const current = this.getState().selectedSession;
this.setState({
sessions: this.getState().sessions.map((candidate) => candidate.id === session.id ? session : candidate),
selectedSession: current?.id === session.id ? session : current,
});
}
private mergeAndCacheHistory(sessionId: string, page: RawMessagePage): RawMessagePage {
const history = mergeChatHistory(readChatHistoryCache(sessionId), page);
writeChatHistoryCache(sessionId, history);
@@ -31,7 +31,7 @@ export class WorkspaceController {
const sessions = await api.sessions(workspace.path);
this.setState({ sessions });
const sessionId = target?.sessionId;
const session = sessionId !== undefined && sessionId !== "" ? sessions.find((s) => s.id === sessionId || s.id.startsWith(sessionId)) : sessions[0];
const session = sessionId !== undefined && sessionId !== "" ? sessions.find((s) => s.id === sessionId || s.id.startsWith(sessionId)) : sessions.find((s) => s.archived !== true);
if (session) await this.sessions.selectSession(session, { updateUrl: target?.updateUrl });
else if (target?.updateUrl !== false) this.updateUrl();
} catch (error) {