Archived
Add session archiving
This commit is contained in:
+20
-1
@@ -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;
|
||||
|
||||
@@ -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>`}
|
||||
|
||||
@@ -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} · `;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user