diff --git a/src/client/src/api.ts b/src/client/src/api.ts index f2296ae..c0a582a 100644 --- a/src/client/src/api.ts +++ b/src/client/src/api.ts @@ -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(url: string, parse: (value: unknown) => T, init?: RequestInit): Promise { 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, key: string): number | undefined { const value = record[key]; if (value === undefined) return undefined; diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index c5b5672..79b937f 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -109,13 +109,13 @@ export class PiWebApp extends LitElement { this.withChatScrollTransition(() => this.workspaces.selectProject(project))}> this.withChatScrollTransition(() => this.workspaces.selectWorkspace(workspace))}> - this.withChatScrollTransition(() => this.sessions.startSession())} .onSelect=${(session: SessionInfo) => this.withChatScrollTransition(() => this.sessions.selectSession(session))}> + 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)}>
${state.error ? html`
${state.error}
` : null} ${state.selectedSession ? html` 0} .loadingMore=${state.isLoadingEarlierMessages} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}> - this.sessions.send(text, streamingBehavior)} .onStopSession=${() => this.sessions.stopSession()}> + this.sessions.send(text, streamingBehavior)} .onStopSession=${() => this.sessions.stopSession()}> ${state.commandDialog !== undefined ? html` this.sessions.respondToCommand(state.commandDialog?.requestId ?? "", value)} .onCancel=${() => { this.sessions.cancelCommand(); }}>` : null} ` : html`
Select or start a session.
`} diff --git a/src/client/src/components/SessionList.ts b/src/client/src/components/SessionList.ts index 6888c2b..e842199 100644 --- a/src/client/src/components/SessionList.ts +++ b/src/client/src/components/SessionList.ts @@ -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): 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`

Sessions

- ${this.sessions.map((session) => html` - - `)} + ${active.map((session) => this.renderSession(session))} + ${archived.length > 0 ? html` +

Archived

+ ${archived.map((session) => this.renderSession(session))} + ` : null}
`; } + private renderSession(session: SessionInfo) { + return html` +
+ +
+ + ${this.openMenuSessionId === session.id ? html` +
+ ${session.archived === true + ? html`` + : html``} +
+ ` : null} +
+
+ `; + } + + 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} · `; diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index c7d47ea..68465e1 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -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; } diff --git a/src/client/src/controllers/sessionController.ts b/src/client/src/controllers/sessionController.ts index a6b2230..dfdb32e 100644 --- a/src/client/src/controllers/sessionController.ts +++ b/src/client/src/controllers/sessionController.ts @@ -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); diff --git a/src/client/src/controllers/workspaceController.ts b/src/client/src/controllers/workspaceController.ts index 57c79fd..27e39b9 100644 --- a/src/client/src/controllers/workspaceController.ts +++ b/src/client/src/controllers/workspaceController.ts @@ -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) { diff --git a/src/server/sessiond/sessionProxyRoutes.ts b/src/server/sessiond/sessionProxyRoutes.ts index b47cd29..23ec538 100644 --- a/src/server/sessiond/sessionProxyRoutes.ts +++ b/src/server/sessiond/sessionProxyRoutes.ts @@ -27,6 +27,8 @@ export function registerSessionProxyRoutes(app: FastifyInstance, daemon = new Se app.post<{ Params: { sessionId: string }; Body: { requestId: string; value: string } }>("/api/sessions/:sessionId/commands/respond", (request, reply) => proxy(request, reply)); app.post<{ Params: { sessionId: string } }>("/api/sessions/:sessionId/abort", (request, reply) => proxy(request, reply)); app.post<{ Params: { sessionId: string } }>("/api/sessions/:sessionId/stop", (request, reply) => proxy(request, reply)); + app.post<{ Params: { sessionId: string } }>("/api/sessions/:sessionId/archive", (request, reply) => proxy(request, reply)); + app.post<{ Params: { sessionId: string } }>("/api/sessions/:sessionId/restore", (request, reply) => proxy(request, reply)); app.get<{ Params: { sessionId: string } }>("/api/sessions/:sessionId/events", { websocket: true }, (socket, request) => { bridgeSockets(socket, daemon.connectWebSocket(`/sessions/${request.params.sessionId}/events`)); diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index 5a7b9f5..f42617e 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -13,6 +13,7 @@ import type { ClientCommand, ClientCommandResult, ClientMessagePage, ClientSessi import type { SessionEventHub } from "../realtime/sessionEventHub.js"; import { BUILTIN_COMMANDS } from "./builtinCommands.js"; import { SessionCommandService } from "./sessionCommandService.js"; +import { SessionArchiveStore } from "./sessionArchiveStore.js"; import type { ActiveSession } from "./sessionRuntimeStore.js"; function noop(): void { @@ -24,6 +25,7 @@ export class PiSessionService { private readonly activities = new Map(); private readonly heartbeat: NodeJS.Timeout; private readonly commandService: SessionCommandService; + private readonly archiveStore = new SessionArchiveStore(); private readonly agentDir = getAgentDir(); private readonly authStorage = AuthStorage.create(); private readonly modelRegistry = ModelRegistry.create(this.authStorage); @@ -46,17 +48,22 @@ export class PiSessionService { } async list(cwd: string): Promise { - const sessions = await SessionManager.list(cwd); - return sessions.map((s) => ({ - id: s.id, - path: s.path, - cwd: s.cwd, - ...(s.name === undefined ? {} : { name: s.name }), - created: s.created.toISOString(), - modified: s.modified.toISOString(), - messageCount: s.messageCount, - firstMessage: s.firstMessage, - })); + const [sessions, archivedRecords] = await Promise.all([SessionManager.list(cwd), this.archiveStore.list()]); + const archivedById = new Map(archivedRecords.filter((record) => record.cwd === cwd).map((record) => [record.sessionId, record])); + return sessions.map((s) => { + const archived = archivedById.get(s.id); + return { + id: s.id, + path: s.path, + cwd: s.cwd, + ...(s.name === undefined ? {} : { name: s.name }), + created: s.created.toISOString(), + modified: s.modified.toISOString(), + messageCount: s.messageCount, + firstMessage: s.firstMessage, + ...(archived === undefined ? {} : { archived: true, archivedAt: archived.archivedAt }), + }; + }); } async start(cwd: string): Promise { @@ -104,6 +111,7 @@ export class PiSessionService { } async prompt(sessionId: string, text: string, streamingBehavior?: "steer" | "followUp"): Promise { + await this.assertWritable(sessionId); const session = await this.getOrOpen(sessionId); const behavior = session.isStreaming || session.isCompacting ? streamingBehavior ?? "followUp" : undefined; this.publishActivity(session, session.isCompacting ? "message queued during compaction" : behavior === "steer" ? "steering queued" : behavior === "followUp" ? "message queued" : "prompt accepted", "active"); @@ -115,6 +123,7 @@ export class PiSessionService { } async shell(sessionId: string, text: string): Promise { + await this.assertWritable(sessionId); const active = await this.getActive(sessionId); const { session } = active.runtime; const isExcluded = text.startsWith("!!"); @@ -149,13 +158,26 @@ export class PiSessionService { } async runCommand(sessionId: string, text: string): Promise { + await this.assertWritable(sessionId); return this.commandService.run(sessionId, text); } async respondToCommand(sessionId: string, requestId: string, value: string): Promise { + await this.assertWritable(sessionId); return this.commandService.respond(sessionId, requestId, value); } + async archive(sessionId: string): Promise { + const session = await this.getOrOpen(sessionId); + if (session.isStreaming || session.isCompacting || session.isBashRunning || session.pendingMessageCount > 0) throw new Error("Stop current session activity before archiving"); + await this.archiveStore.archive(sessionId, session.sessionManager.getCwd()); + this.stop(sessionId); + } + + async restore(sessionId: string): Promise { + await this.archiveStore.restore(sessionId); + } + async abort(sessionId: string): Promise { const active = this.active.get(sessionId); if (active) await active.runtime.session.abort(); @@ -170,6 +192,10 @@ export class PiSessionService { this.activities.delete(sessionId); } + private async assertWritable(sessionId: string): Promise { + if (await this.archiveStore.isArchived(sessionId)) throw new Error("Archived sessions are read-only. Restore the session to continue."); + } + private async getOrOpen(sessionId: string): Promise { return (await this.getActive(sessionId)).runtime.session; } diff --git a/src/server/sessions/sessionArchiveStore.ts b/src/server/sessions/sessionArchiveStore.ts new file mode 100644 index 0000000..f6c2da4 --- /dev/null +++ b/src/server/sessions/sessionArchiveStore.ts @@ -0,0 +1,79 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { homedir } from "node:os"; + +export interface ArchivedSessionRecord { + sessionId: string; + cwd: string; + archivedAt: string; +} + +interface ArchiveFile { + sessions: ArchivedSessionRecord[]; +} + +export class SessionArchiveStore { + constructor(private readonly filePath = join(homedir(), ".pi-web", "archived-sessions.json")) {} + + async list(): Promise { + return (await this.read()).sessions; + } + + async archive(sessionId: string, cwd: string): Promise { + const data = await this.read(); + const existing = data.sessions.find((session) => session.sessionId === sessionId); + if (existing !== undefined) return existing; + const record = { sessionId, cwd, archivedAt: new Date().toISOString() }; + data.sessions.push(record); + await this.write(data); + return record; + } + + async restore(sessionId: string): Promise { + const data = await this.read(); + const sessions = data.sessions.filter((session) => session.sessionId !== sessionId); + if (sessions.length === data.sessions.length) return; + await this.write({ sessions }); + } + + async isArchived(sessionId: string): Promise { + return (await this.list()).some((session) => session.sessionId === sessionId); + } + + private async read(): Promise { + try { + const value: unknown = JSON.parse(await readFile(this.filePath, "utf8")); + return parseArchiveFile(value); + } catch (error: unknown) { + if (isNodeErrorWithCode(error, "ENOENT")) return { sessions: [] }; + throw error; + } + } + + private async write(data: ArchiveFile): Promise { + await mkdir(dirname(this.filePath), { recursive: true }); + await writeFile(this.filePath, `${JSON.stringify(data, null, 2)}\n`, "utf8"); + } +} + +function parseArchiveFile(value: unknown): ArchiveFile { + if (!isRecord(value) || !Array.isArray(value["sessions"])) throw new Error("Invalid archive file"); + return { sessions: value["sessions"].map(parseArchivedSessionRecord) }; +} + +function parseArchivedSessionRecord(value: unknown): ArchivedSessionRecord { + if (!isRecord(value)) throw new Error("Invalid archived session record"); + const sessionId = value["sessionId"]; + const cwd = value["cwd"]; + const archivedAt = value["archivedAt"]; + if (typeof sessionId !== "string" || typeof cwd !== "string" || typeof archivedAt !== "string") throw new Error("Invalid archived session record"); + return { sessionId, cwd, archivedAt }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isNodeErrorWithCode(error: unknown, code: string): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error && error.code === code; +} diff --git a/src/server/sessions/sessionRoutes.ts b/src/server/sessions/sessionRoutes.ts index 563ca83..5722c14 100644 --- a/src/server/sessions/sessionRoutes.ts +++ b/src/server/sessions/sessionRoutes.ts @@ -85,6 +85,24 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionS return { stopped: true }; }); + app.post<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/archive`, async (request, reply) => { + try { + await sessions.archive(request.params.sessionId); + return { archived: true }; + } catch (error) { + return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) }); + } + }); + + app.post<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/restore`, async (request, reply) => { + try { + await sessions.restore(request.params.sessionId); + return { restored: true }; + } catch (error) { + return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) }); + } + }); + app.get<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/events`, { websocket: true }, (socket, request) => { eventHub.add(request.params.sessionId, socket); }); diff --git a/src/server/types.ts b/src/server/types.ts index 22f0614..e0ab9e4 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -24,6 +24,8 @@ export interface ClientSession { modified: string; messageCount: number; firstMessage: string; + archived?: boolean; + archivedAt?: string; } export interface ClientMessagePage {