Archived
Add session archiving
This commit is contained in:
+20
-1
@@ -24,6 +24,8 @@ export interface SessionInfo {
|
|||||||
modified: string;
|
modified: string;
|
||||||
messageCount: number;
|
messageCount: number;
|
||||||
firstMessage: string;
|
firstMessage: string;
|
||||||
|
archived?: boolean;
|
||||||
|
archivedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionActivity {
|
export interface SessionActivity {
|
||||||
@@ -77,7 +79,7 @@ export type CommandResult =
|
|||||||
|
|
||||||
async function request<T>(url: string, parse: (value: unknown) => T, init?: RequestInit): Promise<T> {
|
async function request<T>(url: string, parse: (value: unknown) => T, init?: RequestInit): Promise<T> {
|
||||||
const headers = new Headers(init?.headers);
|
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 });
|
const response = await fetch(url, { ...init, headers });
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const body: unknown = await response.json().catch((): unknown => ({}));
|
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 }) }),
|
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 }) }),
|
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" }),
|
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 {
|
export function sessionEvents(sessionId: string): WebSocket {
|
||||||
@@ -204,6 +208,7 @@ function parseWorkspace(value: unknown): Workspace {
|
|||||||
function parseSessionInfo(value: unknown): SessionInfo {
|
function parseSessionInfo(value: unknown): SessionInfo {
|
||||||
const record = requireRecord(value);
|
const record = requireRecord(value);
|
||||||
const name = optionalString(record, "name");
|
const name = optionalString(record, "name");
|
||||||
|
const archivedAt = optionalString(record, "archivedAt");
|
||||||
return {
|
return {
|
||||||
id: requireString(record, "id"),
|
id: requireString(record, "id"),
|
||||||
path: requireString(record, "path"),
|
path: requireString(record, "path"),
|
||||||
@@ -213,6 +218,8 @@ function parseSessionInfo(value: unknown): SessionInfo {
|
|||||||
modified: requireString(record, "modified"),
|
modified: requireString(record, "modified"),
|
||||||
messageCount: requireNumber(record, "messageCount"),
|
messageCount: requireNumber(record, "messageCount"),
|
||||||
firstMessage: requireString(record, "firstMessage"),
|
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 };
|
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 {
|
function optionalNumber(record: Record<string, unknown>, key: string): number | undefined {
|
||||||
const value = record[key];
|
const value = record[key];
|
||||||
if (value === undefined) return undefined;
|
if (value === undefined) return undefined;
|
||||||
|
|||||||
@@ -109,13 +109,13 @@ export class PiWebApp extends LitElement {
|
|||||||
</header>
|
</header>
|
||||||
<project-list .projects=${state.projects} .selected=${state.selectedProject} .onSelect=${(project: Project) => this.withChatScrollTransition(() => this.workspaces.selectProject(project))}></project-list>
|
<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>
|
<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>
|
</aside>
|
||||||
<main>
|
<main>
|
||||||
${state.error ? html`<div class="error">${state.error}</div>` : null}
|
${state.error ? html`<div class="error">${state.error}</div>` : null}
|
||||||
${state.selectedSession ? html`
|
${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>
|
<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>
|
<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}
|
${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>`}
|
` : html`<div class="empty">Select or start a session.</div>`}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { LitElement, html } from "lit";
|
import { LitElement, html, type PropertyValues } from "lit";
|
||||||
import { customElement, property } from "lit/decorators.js";
|
import { customElement, property, state } from "lit/decorators.js";
|
||||||
import type { SessionActivity, SessionInfo, SessionStatus } from "../api";
|
import type { SessionActivity, SessionInfo, SessionStatus } from "../api";
|
||||||
import { listStyles } from "./shared";
|
import { listStyles } from "./shared";
|
||||||
|
|
||||||
@@ -17,21 +17,69 @@ export class SessionList extends LitElement {
|
|||||||
@property({ type: Boolean }) canStart = false;
|
@property({ type: Boolean }) canStart = false;
|
||||||
@property({ attribute: false }) onSelect?: (session: SessionInfo) => void;
|
@property({ attribute: false }) onSelect?: (session: SessionInfo) => void;
|
||||||
@property({ attribute: false }) onStart?: () => 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() {
|
override render() {
|
||||||
|
const active = this.sessions.filter((session) => session.archived !== true);
|
||||||
|
const archived = this.sessions.filter((session) => session.archived === true);
|
||||||
return html`
|
return html`
|
||||||
<section>
|
<section>
|
||||||
<h2>Sessions <button ?disabled=${!this.canStart} @click=${() => this.onStart?.()}>+</button></h2>
|
<h2>Sessions <button ?disabled=${!this.canStart} @click=${() => this.onStart?.()}>+</button></h2>
|
||||||
${this.sessions.map((session) => html`
|
${active.map((session) => this.renderSession(session))}
|
||||||
<button class=${this.selected?.id === session.id ? "selected" : ""} @click=${() => this.onSelect?.(session)}>
|
${archived.length > 0 ? html`
|
||||||
<span>${sessionLabel(session)}</span><small>${this.renderStatus(session)}${String(session.messageCount)} messages</small>
|
<h2 class="subheading">Archived</h2>
|
||||||
</button>
|
${archived.map((session) => this.renderSession(session))}
|
||||||
`)}
|
` : null}
|
||||||
</section>
|
</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) {
|
private renderStatus(session: SessionInfo) {
|
||||||
|
if (session.archived === true) return "read-only · ";
|
||||||
const status = this.statuses[session.id];
|
const status = this.statuses[session.id];
|
||||||
const activity = this.activities[session.id];
|
const activity = this.activities[session.id];
|
||||||
if (activity?.phase === "active") return `● ${activity.label} · `;
|
if (activity?.phase === "active") return `● ${activity.label} · `;
|
||||||
|
|||||||
@@ -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; }
|
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; }
|
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; }
|
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.selected { border-color: #58a6ff; background: #0d2847; }
|
||||||
button:disabled { opacity: .5; cursor: not-allowed; }
|
button:disabled { opacity: .5; cursor: not-allowed; }
|
||||||
small { display: block; color: #8b949e; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
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 }) {
|
async selectSession(session: SessionInfo, options?: { updateUrl?: boolean | undefined }) {
|
||||||
this.socket.close();
|
this.socket.close();
|
||||||
try {
|
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[] = [];
|
const buffered: SessionUiEvent[] = [];
|
||||||
this.socket.connect(session.id, (event) => buffered.push(event));
|
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)]);
|
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 (trimmed.startsWith("/")) return this.runCommand(text);
|
||||||
if (isShellInput(text)) return this.runShell(text);
|
if (isShellInput(text)) return this.runShell(text);
|
||||||
const session = this.getState().selectedSession;
|
const session = this.getState().selectedSession;
|
||||||
if (!session) return;
|
if (!session || session.archived === true) return;
|
||||||
this.setState({ messages: [...this.getState().messages, textMessage("user", text)] });
|
this.setState({ messages: [...this.getState().messages, textMessage("user", text)] });
|
||||||
try {
|
try {
|
||||||
await api.prompt(session.id, text, streamingBehavior);
|
await api.prompt(session.id, text, streamingBehavior);
|
||||||
@@ -97,7 +104,7 @@ export class SessionController {
|
|||||||
|
|
||||||
async runShell(text: string) {
|
async runShell(text: string) {
|
||||||
const session = this.getState().selectedSession;
|
const session = this.getState().selectedSession;
|
||||||
if (!session) return;
|
if (!session || session.archived === true) return;
|
||||||
this.setState({ messages: [...this.getState().messages, textMessage("user", text)] });
|
this.setState({ messages: [...this.getState().messages, textMessage("user", text)] });
|
||||||
try {
|
try {
|
||||||
await api.shell(session.id, text);
|
await api.shell(session.id, text);
|
||||||
@@ -108,7 +115,7 @@ export class SessionController {
|
|||||||
|
|
||||||
async runCommand(text: string) {
|
async runCommand(text: string) {
|
||||||
const session = this.getState().selectedSession;
|
const session = this.getState().selectedSession;
|
||||||
if (!session) return;
|
if (!session || session.archived === true) return;
|
||||||
this.setState({ messages: [...this.getState().messages, textMessage("user", text)] });
|
this.setState({ messages: [...this.getState().messages, textMessage("user", text)] });
|
||||||
try {
|
try {
|
||||||
this.applyCommandResult(await api.runCommand(session.id, text));
|
this.applyCommandResult(await api.runCommand(session.id, text));
|
||||||
@@ -132,6 +139,34 @@ export class SessionController {
|
|||||||
this.setState({ commandDialog: undefined });
|
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() {
|
async stopSession() {
|
||||||
const session = this.getState().selectedSession;
|
const session = this.getState().selectedSession;
|
||||||
if (!session) return;
|
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 {
|
private mergeAndCacheHistory(sessionId: string, page: RawMessagePage): RawMessagePage {
|
||||||
const history = mergeChatHistory(readChatHistoryCache(sessionId), page);
|
const history = mergeChatHistory(readChatHistoryCache(sessionId), page);
|
||||||
writeChatHistoryCache(sessionId, history);
|
writeChatHistoryCache(sessionId, history);
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export class WorkspaceController {
|
|||||||
const sessions = await api.sessions(workspace.path);
|
const sessions = await api.sessions(workspace.path);
|
||||||
this.setState({ sessions });
|
this.setState({ sessions });
|
||||||
const sessionId = target?.sessionId;
|
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 });
|
if (session) await this.sessions.selectSession(session, { updateUrl: target?.updateUrl });
|
||||||
else if (target?.updateUrl !== false) this.updateUrl();
|
else if (target?.updateUrl !== false) this.updateUrl();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -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 }; 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/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/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) => {
|
app.get<{ Params: { sessionId: string } }>("/api/sessions/:sessionId/events", { websocket: true }, (socket, request) => {
|
||||||
bridgeSockets(socket, daemon.connectWebSocket(`/sessions/${request.params.sessionId}/events`));
|
bridgeSockets(socket, daemon.connectWebSocket(`/sessions/${request.params.sessionId}/events`));
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type { ClientCommand, ClientCommandResult, ClientMessagePage, ClientSessi
|
|||||||
import type { SessionEventHub } from "../realtime/sessionEventHub.js";
|
import type { SessionEventHub } from "../realtime/sessionEventHub.js";
|
||||||
import { BUILTIN_COMMANDS } from "./builtinCommands.js";
|
import { BUILTIN_COMMANDS } from "./builtinCommands.js";
|
||||||
import { SessionCommandService } from "./sessionCommandService.js";
|
import { SessionCommandService } from "./sessionCommandService.js";
|
||||||
|
import { SessionArchiveStore } from "./sessionArchiveStore.js";
|
||||||
import type { ActiveSession } from "./sessionRuntimeStore.js";
|
import type { ActiveSession } from "./sessionRuntimeStore.js";
|
||||||
|
|
||||||
function noop(): void {
|
function noop(): void {
|
||||||
@@ -24,6 +25,7 @@ export class PiSessionService {
|
|||||||
private readonly activities = new Map<string, { phase: "active" | "idle" | "error"; label: string; detail?: string; at: string }>();
|
private readonly activities = new Map<string, { phase: "active" | "idle" | "error"; label: string; detail?: string; at: string }>();
|
||||||
private readonly heartbeat: NodeJS.Timeout;
|
private readonly heartbeat: NodeJS.Timeout;
|
||||||
private readonly commandService: SessionCommandService;
|
private readonly commandService: SessionCommandService;
|
||||||
|
private readonly archiveStore = new SessionArchiveStore();
|
||||||
private readonly agentDir = getAgentDir();
|
private readonly agentDir = getAgentDir();
|
||||||
private readonly authStorage = AuthStorage.create();
|
private readonly authStorage = AuthStorage.create();
|
||||||
private readonly modelRegistry = ModelRegistry.create(this.authStorage);
|
private readonly modelRegistry = ModelRegistry.create(this.authStorage);
|
||||||
@@ -46,8 +48,11 @@ export class PiSessionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async list(cwd: string): Promise<ClientSession[]> {
|
async list(cwd: string): Promise<ClientSession[]> {
|
||||||
const sessions = await SessionManager.list(cwd);
|
const [sessions, archivedRecords] = await Promise.all([SessionManager.list(cwd), this.archiveStore.list()]);
|
||||||
return sessions.map((s) => ({
|
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,
|
id: s.id,
|
||||||
path: s.path,
|
path: s.path,
|
||||||
cwd: s.cwd,
|
cwd: s.cwd,
|
||||||
@@ -56,7 +61,9 @@ export class PiSessionService {
|
|||||||
modified: s.modified.toISOString(),
|
modified: s.modified.toISOString(),
|
||||||
messageCount: s.messageCount,
|
messageCount: s.messageCount,
|
||||||
firstMessage: s.firstMessage,
|
firstMessage: s.firstMessage,
|
||||||
}));
|
...(archived === undefined ? {} : { archived: true, archivedAt: archived.archivedAt }),
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async start(cwd: string): Promise<ClientSession> {
|
async start(cwd: string): Promise<ClientSession> {
|
||||||
@@ -104,6 +111,7 @@ export class PiSessionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async prompt(sessionId: string, text: string, streamingBehavior?: "steer" | "followUp"): Promise<void> {
|
async prompt(sessionId: string, text: string, streamingBehavior?: "steer" | "followUp"): Promise<void> {
|
||||||
|
await this.assertWritable(sessionId);
|
||||||
const session = await this.getOrOpen(sessionId);
|
const session = await this.getOrOpen(sessionId);
|
||||||
const behavior = session.isStreaming || session.isCompacting ? streamingBehavior ?? "followUp" : undefined;
|
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");
|
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<void> {
|
async shell(sessionId: string, text: string): Promise<void> {
|
||||||
|
await this.assertWritable(sessionId);
|
||||||
const active = await this.getActive(sessionId);
|
const active = await this.getActive(sessionId);
|
||||||
const { session } = active.runtime;
|
const { session } = active.runtime;
|
||||||
const isExcluded = text.startsWith("!!");
|
const isExcluded = text.startsWith("!!");
|
||||||
@@ -149,13 +158,26 @@ export class PiSessionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async runCommand(sessionId: string, text: string): Promise<ClientCommandResult> {
|
async runCommand(sessionId: string, text: string): Promise<ClientCommandResult> {
|
||||||
|
await this.assertWritable(sessionId);
|
||||||
return this.commandService.run(sessionId, text);
|
return this.commandService.run(sessionId, text);
|
||||||
}
|
}
|
||||||
|
|
||||||
async respondToCommand(sessionId: string, requestId: string, value: string): Promise<ClientCommandResult> {
|
async respondToCommand(sessionId: string, requestId: string, value: string): Promise<ClientCommandResult> {
|
||||||
|
await this.assertWritable(sessionId);
|
||||||
return this.commandService.respond(sessionId, requestId, value);
|
return this.commandService.respond(sessionId, requestId, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async archive(sessionId: string): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
await this.archiveStore.restore(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
async abort(sessionId: string): Promise<void> {
|
async abort(sessionId: string): Promise<void> {
|
||||||
const active = this.active.get(sessionId);
|
const active = this.active.get(sessionId);
|
||||||
if (active) await active.runtime.session.abort();
|
if (active) await active.runtime.session.abort();
|
||||||
@@ -170,6 +192,10 @@ export class PiSessionService {
|
|||||||
this.activities.delete(sessionId);
|
this.activities.delete(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async assertWritable(sessionId: string): Promise<void> {
|
||||||
|
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<AgentSession> {
|
private async getOrOpen(sessionId: string): Promise<AgentSession> {
|
||||||
return (await this.getActive(sessionId)).runtime.session;
|
return (await this.getActive(sessionId)).runtime.session;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<ArchivedSessionRecord[]> {
|
||||||
|
return (await this.read()).sessions;
|
||||||
|
}
|
||||||
|
|
||||||
|
async archive(sessionId: string, cwd: string): Promise<ArchivedSessionRecord> {
|
||||||
|
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<void> {
|
||||||
|
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<boolean> {
|
||||||
|
return (await this.list()).some((session) => session.sessionId === sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async read(): Promise<ArchiveFile> {
|
||||||
|
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<void> {
|
||||||
|
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<string, unknown> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -85,6 +85,24 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionS
|
|||||||
return { stopped: true };
|
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) => {
|
app.get<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/events`, { websocket: true }, (socket, request) => {
|
||||||
eventHub.add(request.params.sessionId, socket);
|
eventHub.add(request.params.sessionId, socket);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export interface ClientSession {
|
|||||||
modified: string;
|
modified: string;
|
||||||
messageCount: number;
|
messageCount: number;
|
||||||
firstMessage: string;
|
firstMessage: string;
|
||||||
|
archived?: boolean;
|
||||||
|
archivedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ClientMessagePage {
|
export interface ClientMessagePage {
|
||||||
|
|||||||
Reference in New Issue
Block a user