Merge pull request #37 from jmfederico/feat/manual-session-cleanup

Add manual session cleanup
This commit is contained in:
Federico Jaramillo Martinez
2026-06-26 16:16:34 +02:00
committed by GitHub
37 changed files with 1502 additions and 27 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Add a manual sessions cleanup flow that previews and confirms archiving idle sessions and deleting old archived sessions, with per-project selection and capability guidance for unsupported machines. Actions can now expose disabled reasons so unavailable remote-machine actions stay visible with an explanation.
+3
View File
@@ -426,10 +426,13 @@ interface PluginAction {
shortcut?: string; shortcut?: string;
group?: string; group?: string;
enabled?: (context: PluginRuntimeContext) => boolean; enabled?: (context: PluginRuntimeContext) => boolean;
disabledReason?: (context: PluginRuntimeContext) => string | undefined;
run: (context: PluginRuntimeContext) => void | Promise<void>; run: (context: PluginRuntimeContext) => void | Promise<void>;
} }
``` ```
If an action is disabled and returns `disabledReason`, PI WEB can keep it visible in the action palette with that explanation instead of hiding it.
Stable runtime context fields: Stable runtime context fields:
```ts ```ts
+2
View File
@@ -5,5 +5,7 @@ export interface AppAction {
shortcut?: string; shortcut?: string;
group?: string; group?: string;
enabled?: boolean; enabled?: boolean;
/** When present on a disabled action, keep it visible and explain why it cannot run. */
disabledReason?: string;
run: () => void | Promise<void>; run: () => void | Promise<void>;
} }
+1 -1
View File
@@ -2,4 +2,4 @@ export { activityApi, api, configApi, filesApi, gitApi, machinesApi, piWebApi, p
export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets"; export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets";
export { DEFAULT_WORKSPACE_UPLOADS_FOLDER, effectiveWorkspaceUploadFolder, uploadWorkspaceFile, uploadWorkspaceFiles, workspaceEffectiveUploadFolder, workspaceUploadPath, WorkspaceUploadBatchError, WorkspaceUploadCancelledError } from "./api/workspaceUploads"; export { DEFAULT_WORKSPACE_UPLOADS_FOLDER, effectiveWorkspaceUploadFolder, uploadWorkspaceFile, uploadWorkspaceFiles, workspaceEffectiveUploadFolder, workspaceUploadPath, WorkspaceUploadBatchError, WorkspaceUploadCancelledError } from "./api/workspaceUploads";
export type { UploadWorkspaceFileOptions, UploadWorkspaceFilesOptions, WorkspaceFileUploadProgress, WorkspaceUploadBatchFileProgress, WorkspaceUploadBatchProgress, WorkspaceUploadFileFailure, WorkspaceUploadFileInput, WorkspaceUploadFolderConfig, WorkspaceUploadTask, WorkspaceUploadXhr, WorkspaceUploadXhrFactory } from "./api/workspaceUploads"; export type { UploadWorkspaceFileOptions, UploadWorkspaceFilesOptions, WorkspaceFileUploadProgress, WorkspaceUploadBatchFileProgress, WorkspaceUploadBatchProgress, WorkspaceUploadFileFailure, WorkspaceUploadFileInput, WorkspaceUploadFolderConfig, WorkspaceUploadTask, WorkspaceUploadXhr, WorkspaceUploadXhrFactory } from "./api/workspaceUploads";
export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebUploadsConfig, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionInfo, SessionModel, SessionRef, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes"; export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebUploadsConfig, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupRequest, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionRef, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
+17
View File
@@ -61,6 +61,23 @@ describe("machine-scoped runtime API", () => {
}); });
describe("session API compatibility", () => { describe("session API compatibility", () => {
it("posts session cleanup preview and execute requests through the selected machine", async () => {
const preview = { generatedAt: "2026-06-25T12:00:00.000Z", thresholds: { archiveIdleDays: 7 }, projects: [{ cwd: "/repo", archiveCount: 2, deleteCount: 0 }], totals: { archiveCount: 2, deleteCount: 0 } };
const executed = { ...preview, archivedSessionIds: ["s1", "s2"], deletedSessionIds: [] };
const fetchMock = stubSequenceFetch([jsonResponse(preview), jsonResponse(executed)]);
await expect(sessionsApi.cleanupPreview({ archiveIdleDays: 7, deleteArchivedDays: null }, "remote a")).resolves.toEqual(preview);
await expect(sessionsApi.cleanup({ archiveIdleDays: 7, projectCwds: ["/repo"] }, "remote a")).resolves.toEqual(executed);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote%20a/sessions/cleanup/preview");
expect(fetchCall(fetchMock, 0)[1]?.method).toBe("POST");
expect(JSON.parse(requestBody(fetchCall(fetchMock, 0)[1]))).toEqual({ archiveIdleDays: 7, deleteArchivedDays: null });
expect(fetchCall(fetchMock, 1)[0]).toBe("/api/machines/remote%20a/sessions/cleanup");
expect(fetchCall(fetchMock, 1)[1]?.method).toBe("POST");
expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ archiveIdleDays: 7, projectCwds: ["/repo"] });
});
it("keeps legacy session-id calls free of cwd context", async () => { it("keeps legacy session-id calls free of cwd context", async () => {
const fetchMock = stubJsonFetch({ accepted: true }); const fetchMock = stubJsonFetch({ accepted: true });
+5 -1
View File
@@ -1,4 +1,4 @@
import type { DeleteWorkspaceFileResponse, FileSuggestion, MoveWorkspaceFileOptions, PiWebConfigValues, PromptAttachment, RunTerminalCommandInput, SessionRef, TerminalCommandRun, TerminalCommandRunFilter, WriteWorkspaceFileOptions } from "../../../shared/apiTypes"; import type { DeleteWorkspaceFileResponse, FileSuggestion, MoveWorkspaceFileOptions, PiWebConfigValues, PromptAttachment, RunTerminalCommandInput, SessionCleanupRequest, SessionRef, TerminalCommandRun, TerminalCommandRunFilter, WriteWorkspaceFileOptions } from "../../../shared/apiTypes";
import { request } from "./http"; import { request } from "./http";
import { import {
arrayOf, arrayOf,
@@ -32,6 +32,8 @@ import {
parseReloaded, parseReloaded,
parseRestored, parseRestored,
parseSavedAttachments, parseSavedAttachments,
parseSessionCleanupExecuteResponse,
parseSessionCleanupPreviewResponse,
parseSessionInfo, parseSessionInfo,
parseSessionStatus, parseSessionStatus,
parseSlashCommand, parseSlashCommand,
@@ -152,6 +154,8 @@ export const workspacesApi = {
export const sessionsApi = { export const sessionsApi = {
sessions: (cwd: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions?cwd=${encodeURIComponent(cwd)}`, arrayOf(parseSessionInfo)), sessions: (cwd: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions?cwd=${encodeURIComponent(cwd)}`, arrayOf(parseSessionInfo)),
startSession: (cwd: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions`, parseSessionInfo, { method: "POST", body: JSON.stringify({ cwd }) }), startSession: (cwd: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions`, parseSessionInfo, { method: "POST", body: JSON.stringify({ cwd }) }),
cleanupPreview: (input: SessionCleanupRequest, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/cleanup/preview`, parseSessionCleanupPreviewResponse, { method: "POST", body: JSON.stringify(input) }),
cleanup: (input: SessionCleanupRequest, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/cleanup`, parseSessionCleanupExecuteResponse, { method: "POST", body: JSON.stringify(input) }),
messages: (session: SessionLookup, options?: { limit?: number; before?: number }, machineId = "local") => request(messageUrl(session, options, machineId), parseMessagePage), messages: (session: SessionLookup, options?: { limit?: number; before?: number }, machineId = "local") => request(messageUrl(session, options, machineId), parseMessagePage),
status: (session: SessionLookup, machineId = "local") => request(sessionQueryUrl(session, "status", machineId), parseSessionStatus), status: (session: SessionLookup, machineId = "local") => request(sessionQueryUrl(session, "status", machineId), parseSessionStatus),
models: (session: SessionLookup, machineId = "local") => request(sessionQueryUrl(session, "models", machineId), parseModelSelectionResponse), models: (session: SessionLookup, machineId = "local") => request(sessionQueryUrl(session, "models", machineId), parseModelSelectionResponse),
@@ -46,6 +46,8 @@ describe("federated route contract", () => {
ignoreParseFailure(gitApi.gitDiff("p 1", "w 1", { path: "README.md", staged: true }, machineId)), ignoreParseFailure(gitApi.gitDiff("p 1", "w 1", { path: "README.md", staged: true }, machineId)),
ignoreParseFailure(sessionsApi.sessions("/repo", machineId)), ignoreParseFailure(sessionsApi.sessions("/repo", machineId)),
ignoreParseFailure(sessionsApi.startSession("/repo", machineId)), ignoreParseFailure(sessionsApi.startSession("/repo", machineId)),
ignoreParseFailure(sessionsApi.cleanupPreview({ archiveIdleDays: 14 }, machineId)),
ignoreParseFailure(sessionsApi.cleanup({ archiveIdleDays: 14, deleteArchivedDays: 30, projectCwds: ["/repo"] }, machineId)),
ignoreParseFailure(sessionsApi.messages(session, { limit: 20, before: 10 }, machineId)), ignoreParseFailure(sessionsApi.messages(session, { limit: 20, before: 10 }, machineId)),
ignoreParseFailure(sessionsApi.status(session, machineId)), ignoreParseFailure(sessionsApi.status(session, machineId)),
ignoreParseFailure(sessionsApi.models(session, machineId)), ignoreParseFailure(sessionsApi.models(session, machineId)),
+26 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities"; import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMessagePage, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parseSessionStatus, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers"; import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMessagePage, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionStatus, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers";
describe("API parsers", () => { describe("API parsers", () => {
it("parses PI WEB config responses", () => { it("parses PI WEB config responses", () => {
@@ -44,6 +44,31 @@ describe("API parsers", () => {
expect(parseMessagePage({ messages: ["c"], start: 3, total: 9 })).toEqual({ messages: ["c"], start: 3, total: 9 }); expect(parseMessagePage({ messages: ["c"], start: 3, total: 9 })).toEqual({ messages: ["c"], start: 3, total: 9 });
}); });
it("parses session cleanup preview and execute responses", () => {
const preview = {
generatedAt: "2026-06-25T12:00:00.000Z",
thresholds: { archiveIdleDays: 14, deleteArchivedDays: 30 },
projects: [
{ cwd: "/repo-a", archiveCount: 2, deleteCount: 1 },
{ cwd: "/repo-b", archiveCount: 0, deleteCount: 3 },
],
totals: { archiveCount: 2, deleteCount: 4 },
skippedBusySessionIds: ["busy-1"],
};
expect(parseSessionCleanupPreviewResponse(preview)).toEqual(preview);
expect(parseSessionCleanupExecuteResponse({ ...preview, archivedSessionIds: ["s1", "s2"], deletedSessionIds: ["a1"] })).toEqual({
...preview,
archivedSessionIds: ["s1", "s2"],
deletedSessionIds: ["a1"],
});
});
it("rejects malformed session cleanup responses", () => {
expect(() => parseSessionCleanupPreviewResponse({ generatedAt: "now", thresholds: {}, projects: [{ cwd: "/repo", archiveCount: "2", deleteCount: 0 }], totals: { archiveCount: 2, deleteCount: 0 } })).toThrow("Expected number field: archiveCount");
expect(() => parseSessionCleanupExecuteResponse({ generatedAt: "now", thresholds: {}, projects: [], totals: { archiveCount: 0, deleteCount: 0 }, archivedSessionIds: ["s1"], deletedSessionIds: [1] })).toThrow("Expected string array field: deletedSessionIds");
});
it("validates session status including optional model and nullable context usage", () => { it("validates session status including optional model and nullable context usage", () => {
expect(parseSessionStatus({ expect(parseSessionStatus({
sessionId: "s1", sessionId: "s1",
+47 -1
View File
@@ -1,4 +1,4 @@
import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SavedPromptAttachment, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevelsResponse, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes"; import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SavedPromptAttachment, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevelsResponse, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes";
import { isPiWebCapability } from "../../../shared/capabilities"; import { isPiWebCapability } from "../../../shared/capabilities";
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
@@ -191,6 +191,52 @@ export function parseSessionStatus(value: unknown): SessionStatus {
}; };
} }
export function parseSessionCleanupPreviewResponse(value: unknown): SessionCleanupPreviewResponse {
const record = requireRecord(value);
const skippedBusySessionIds = record["skippedBusySessionIds"] === undefined ? undefined : arrayOfString(record["skippedBusySessionIds"], "skippedBusySessionIds");
return {
generatedAt: requireString(record, "generatedAt"),
thresholds: parseSessionCleanupThresholds(record["thresholds"]),
projects: arrayOf(parseSessionCleanupProjectSummary)(record["projects"]),
totals: parseSessionCleanupTotals(record["totals"]),
...(skippedBusySessionIds === undefined ? {} : { skippedBusySessionIds }),
};
}
export function parseSessionCleanupExecuteResponse(value: unknown): SessionCleanupExecuteResponse {
const record = requireRecord(value);
return {
...parseSessionCleanupPreviewResponse(record),
archivedSessionIds: arrayOfString(record["archivedSessionIds"], "archivedSessionIds"),
deletedSessionIds: arrayOfString(record["deletedSessionIds"], "deletedSessionIds"),
};
}
function parseSessionCleanupThresholds(value: unknown): SessionCleanupThresholds {
const record = requireRecord(value);
return {
...optionalField("archiveIdleDays", optionalNumber(record, "archiveIdleDays")),
...optionalField("deleteArchivedDays", optionalNumber(record, "deleteArchivedDays")),
};
}
function parseSessionCleanupProjectSummary(value: unknown): SessionCleanupProjectSummary {
const record = requireRecord(value);
return {
cwd: requireString(record, "cwd"),
archiveCount: requireNumber(record, "archiveCount"),
deleteCount: requireNumber(record, "deleteCount"),
};
}
function parseSessionCleanupTotals(value: unknown): SessionCleanupTotals {
const record = requireRecord(value);
return {
archiveCount: requireNumber(record, "archiveCount"),
deleteCount: requireNumber(record, "deleteCount"),
};
}
function parseQueuedSessionMessage(value: unknown): QueuedSessionMessage { function parseQueuedSessionMessage(value: unknown): QueuedSessionMessage {
const record = requireRecord(value); const record = requireRecord(value);
const kind = requireString(record, "kind"); const kind = requireString(record, "kind");
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import type { AppAction } from "../actions";
import { filterActionPaletteActions } from "./ActionPalette";
describe("filterActionPaletteActions", () => {
it("keeps disabled actions visible when they have an explanation", () => {
const actions: AppAction[] = [
action("enabled", "Enabled action"),
action("hidden", "Disabled without reason", { enabled: false }),
action("explained", "Disabled with reason", { enabled: false, disabledReason: "Update and restart the selected machine." }),
];
expect(filterActionPaletteActions(actions, "").map((item) => item.id)).toEqual(["enabled", "explained"]);
});
it("matches disabled reasons in search", () => {
const actions: AppAction[] = [
action("cleanup", "Clean Up Sessions", { enabled: false, disabledReason: "Selected server does not support cleanup." }),
];
expect(filterActionPaletteActions(actions, "support cleanup").map((item) => item.id)).toEqual(["cleanup"]);
});
});
function action(id: string, title: string, patch: Partial<AppAction> = {}): AppAction {
return { id, title, run: () => undefined, ...patch };
}
+15 -9
View File
@@ -34,10 +34,11 @@ export class ActionPalette extends LitElement {
</header> </header>
<div class="options"> <div class="options">
${actions.length === 0 ? html`<div class="empty">No actions found.</div>` : actions.map((action, index) => html` ${actions.length === 0 ? html`<div class="empty">No actions found.</div>` : actions.map((action, index) => html`
<button class=${index === this.selectedIndex ? "selected" : ""} ${scrollWhenSelected(index === this.selectedIndex, action.id)} @click=${() => { this.run(action); }}> <button class=${`${index === this.selectedIndex ? "selected" : ""} ${action.enabled === false ? "disabled" : ""}`} ?disabled=${action.enabled === false} title=${action.disabledReason ?? action.title} ${scrollWhenSelected(index === this.selectedIndex, action.id)} @click=${() => { this.run(action); }}>
<span class="main"> <span class="main">
<strong>${action.title}</strong> <strong>${action.title}</strong>
${action.description !== undefined && action.description !== "" ? html`<small>${action.description}</small>` : null} ${action.description !== undefined && action.description !== "" ? html`<small>${action.description}</small>` : null}
${action.enabled === false && action.disabledReason !== undefined ? html`<small class="disabled-reason">${action.disabledReason}</small>` : null}
</span> </span>
${action.shortcut !== undefined ? html`<kbd>${formatShortcut(action.shortcut)}</kbd>` : null} ${action.shortcut !== undefined ? html`<kbd>${formatShortcut(action.shortcut)}</kbd>` : null}
${action.group !== undefined && action.group !== "" ? html`<small class="group">${action.group}</small>` : null} ${action.group !== undefined && action.group !== "" ? html`<small class="group">${action.group}</small>` : null}
@@ -60,14 +61,7 @@ export class ActionPalette extends LitElement {
} }
private filteredActions(): AppAction[] { private filteredActions(): AppAction[] {
const query = this.queryText.trim().toLowerCase(); return filterActionPaletteActions(this.actions, this.queryText);
return this.actions
.filter((action) => action.enabled !== false)
.filter((action) => {
if (query === "") return true;
const haystack = [action.title, action.description ?? "", action.group ?? "", action.shortcut ?? ""].join(" ").toLowerCase();
return haystack.includes(query);
});
} }
private handleKeyDown(event: KeyboardEvent) { private handleKeyDown(event: KeyboardEvent) {
@@ -89,8 +83,20 @@ export class ActionPalette extends LitElement {
} }
private run(action: AppAction) { private run(action: AppAction) {
if (action.enabled === false) return;
this.onRun?.(action); this.onRun?.(action);
} }
static override styles = actionPaletteStyles; static override styles = actionPaletteStyles;
} }
export function filterActionPaletteActions(actions: readonly AppAction[], queryText: string): AppAction[] {
const query = queryText.trim().toLowerCase();
return actions
.filter((action) => action.enabled !== false || action.disabledReason !== undefined)
.filter((action) => {
if (query === "") return true;
const haystack = [action.title, action.description ?? "", action.disabledReason ?? "", action.group ?? "", action.shortcut ?? ""].join(" ").toLowerCase();
return haystack.includes(query);
});
}
+88 -2
View File
@@ -1,6 +1,6 @@
import { LitElement, html } from "lit"; import { LitElement, html } from "lit";
import { customElement, query, state } from "lit/decorators.js"; import { customElement, query, state } from "lit/decorators.js";
import { configApi, effectiveWorkspaceUploadFolder, piWebApi, terminalsApi, workspacesApi, workspaceEffectiveUploadFolder, type Machine, type MachineHealth, type PiWebConfigValues, type PiWebShortcutConfig, type Project, type RealtimeEvent, type SessionInfo, type TerminalCommandRun, type TerminalUiEvent, type Workspace } from "../api"; import { configApi, effectiveWorkspaceUploadFolder, piWebApi, sessionsApi, terminalsApi, workspacesApi, workspaceEffectiveUploadFolder, type Machine, type MachineHealth, type PiWebConfigValues, type PiWebShortcutConfig, type Project, type RealtimeEvent, type SessionCleanupExecuteResponse, type SessionCleanupPreviewResponse, type SessionCleanupRequest, type SessionInfo, type TerminalCommandRun, type TerminalUiEvent, type Workspace } from "../api";
import type { AppAction } from "../actions"; import type { AppAction } from "../actions";
import { initialAppState, type AppState } from "../appState"; import { initialAppState, type AppState } from "../appState";
import { isSessionActive } from "../../../shared/activity"; import { isSessionActive } from "../../../shared/activity";
@@ -19,6 +19,7 @@ import { SessionStorageTerminalSelectionMemory } from "../controllers/terminalSe
import { SessionStorageWorkspaceSelectionMemory } from "../controllers/workspaceSelection"; import { SessionStorageWorkspaceSelectionMemory } from "../controllers/workspaceSelection";
import { KeyboardShortcutDispatcher } from "../keyboardShortcuts"; import { KeyboardShortcutDispatcher } from "../keyboardShortcuts";
import { selectedMachineId } from "../controllers/types"; import { selectedMachineId } from "../controllers/types";
import { sessionCleanupRequestKey, sessionCleanupUnavailableMessage } from "../sessionCleanupUi";
import { RealtimeSocket } from "../sessionSocket"; import { RealtimeSocket } from "../sessionSocket";
import type { PiWebPluginRegistration, PluginMachine, PluginPromptEditor, QualifiedContributionId, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspacePanelContribution, PluginRuntimeContext, TerminalCommandRunsInternalRuntime, WorkspaceFiles, WorkspaceHost, WorkspaceLabelContext, WorkspaceLabelItem, WorkspacePanelContext } from "../plugins/types"; import type { PiWebPluginRegistration, PluginMachine, PluginPromptEditor, QualifiedContributionId, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspacePanelContribution, PluginRuntimeContext, TerminalCommandRunsInternalRuntime, WorkspaceFiles, WorkspaceHost, WorkspaceLabelContext, WorkspaceLabelItem, WorkspacePanelContext } from "../plugins/types";
import { CLASSIC_THEME_ID, DEFAULT_THEME_PREFERENCE, applyPiWebTheme, findThemePairForTheme, readStoredThemePreference, resolveThemePreference, writeStoredThemePreference, type ThemePreference, type ThemePreferenceResolution } from "../theme"; import { CLASSIC_THEME_ID, DEFAULT_THEME_PREFERENCE, applyPiWebTheme, findThemePairForTheme, readStoredThemePreference, resolveThemePreference, writeStoredThemePreference, type ThemePreference, type ThemePreferenceResolution } from "../theme";
@@ -40,6 +41,7 @@ import "./MachineList";
import "./ProjectList"; import "./ProjectList";
import "./WorkspaceList"; import "./WorkspaceList";
import "./SessionList"; import "./SessionList";
import "./SessionCleanupDialog";
import "./ChatView"; import "./ChatView";
import type { ChatView } from "./ChatView"; import type { ChatView } from "./ChatView";
import "./PromptEditor"; import "./PromptEditor";
@@ -77,6 +79,15 @@ const MIN_RESIZABLE_CHAT_WIDTH_PX = 320;
const PANEL_EDGE_COLUMNS_WIDTH_PX = 2; const PANEL_EDGE_COLUMNS_WIDTH_PX = 2;
const DESKTOP_SIDE_BY_SIDE_MEDIA_QUERY = "(min-width: 1181px)"; const DESKTOP_SIDE_BY_SIDE_MEDIA_QUERY = "(min-width: 1181px)";
interface SessionCleanupDialogState {
preview?: SessionCleanupPreviewResponse | undefined;
previewRequest?: SessionCleanupRequest | undefined;
result?: SessionCleanupExecuteResponse | undefined;
loading?: boolean | undefined;
running?: boolean | undefined;
error?: string | undefined;
}
@customElement("pi-web-app") @customElement("pi-web-app")
export class PiWebApp extends LitElement { export class PiWebApp extends LitElement {
@state() private state: AppState = initialAppState(); @state() private state: AppState = initialAppState();
@@ -167,6 +178,7 @@ export class PiWebApp extends LitElement {
private themePreference: ThemePreference = readStoredThemePreference() ?? DEFAULT_THEME_PREFERENCE; private themePreference: ThemePreference = readStoredThemePreference() ?? DEFAULT_THEME_PREFERENCE;
@state() private activeThemeId: QualifiedContributionId = CLASSIC_THEME_ID; @state() private activeThemeId: QualifiedContributionId = CLASSIC_THEME_ID;
@state() private isRefreshingApp = false; @state() private isRefreshingApp = false;
@state() private sessionCleanupDialog: SessionCleanupDialogState | undefined;
@state() private settingsSection: SettingsSection | undefined = readSettingsSection(); @state() private settingsSection: SettingsSection | undefined = readSettingsSection();
@state() private shortcutConfig: PiWebShortcutConfig = {}; @state() private shortcutConfig: PiWebShortcutConfig = {};
@state() private workspaceUploadDefaultFolder = effectiveWorkspaceUploadFolder(undefined); @state() private workspaceUploadDefaultFolder = effectiveWorkspaceUploadFolder(undefined);
@@ -875,6 +887,7 @@ export class PiWebApp extends LitElement {
this.realtime.close(); this.realtime.close();
this.connectRealtime(); this.connectRealtime();
this.activeTerminalIds.clear(); this.activeTerminalIds.clear();
this.sessionCleanupDialog = undefined;
this.setState({ piWebStatus: undefined }); this.setState({ piWebStatus: undefined });
this.git.updatePolling(); this.git.updatePolling();
void this.loadPluginsForSelectedMachine(); void this.loadPluginsForSelectedMachine();
@@ -1011,6 +1024,11 @@ export class PiWebApp extends LitElement {
return runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsReload); return runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsReload);
} }
private canCleanupSessions(): boolean {
const runtime = this.selectedMachineRuntime();
return runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsCleanup);
}
private supportsWorkspaceFileSuggestions(machineId = selectedMachineId(this.state)): boolean { private supportsWorkspaceFileSuggestions(machineId = selectedMachineId(this.state)): boolean {
if (machineId === "local") return true; if (machineId === "local") return true;
// COMPAT-CAP workspace.fileSuggestions: remote machines without this // COMPAT-CAP workspace.fileSuggestions: remote machines without this
@@ -1024,10 +1042,60 @@ export class PiWebApp extends LitElement {
return `Update and restart Pi-Web on ${machineName} to delete archived sessions.`; return `Update and restart Pi-Web on ${machineName} to delete archived sessions.`;
} }
private sessionCleanupUnavailableMessage(): string {
return sessionCleanupUnavailableMessage(this.state.selectedMachine?.name);
}
private selectedMachineRuntime() { private selectedMachineRuntime() {
return this.state.machineRuntimes[selectedMachineId(this.state)]; return this.state.machineRuntimes[selectedMachineId(this.state)];
} }
private openSessionCleanupDialog(): void {
this.sessionCleanupDialog = { error: "" };
}
private closeSessionCleanupDialog(): void {
this.sessionCleanupDialog = undefined;
}
private async previewSessionCleanup(request: SessionCleanupRequest): Promise<void> {
if (!this.canCleanupSessions()) {
this.sessionCleanupDialog = { ...(this.sessionCleanupDialog ?? {}), error: this.sessionCleanupUnavailableMessage(), preview: undefined, previewRequest: undefined, result: undefined, loading: false };
return;
}
const machineId = selectedMachineId(this.state);
this.sessionCleanupDialog = { ...(this.sessionCleanupDialog ?? {}), loading: true, error: "", preview: undefined, previewRequest: undefined, result: undefined };
try {
const preview = await sessionsApi.cleanupPreview(request, machineId);
if (selectedMachineId(this.state) !== machineId) return;
this.sessionCleanupDialog = { ...this.sessionCleanupDialog, preview, previewRequest: request, result: undefined, loading: false, error: "" };
} catch (error) {
if (selectedMachineId(this.state) === machineId) this.sessionCleanupDialog = { ...this.sessionCleanupDialog, loading: false, error: `Failed to preview cleanup: ${errorMessage(error)}` };
}
}
private async runSessionCleanup(request: SessionCleanupRequest): Promise<void> {
const dialog = this.sessionCleanupDialog;
if (dialog?.preview === undefined || sessionCleanupRequestKey(dialog.previewRequest) !== sessionCleanupRequestKey(request)) {
this.sessionCleanupDialog = { ...(dialog ?? {}), error: "Preview cleanup before running it." };
return;
}
if (!this.canCleanupSessions()) {
this.sessionCleanupDialog = { ...dialog, error: this.sessionCleanupUnavailableMessage(), running: false };
return;
}
const machineId = selectedMachineId(this.state);
this.sessionCleanupDialog = { ...dialog, running: true, error: "" };
try {
const result = await sessionsApi.cleanup(request, machineId);
if (selectedMachineId(this.state) !== machineId) return;
this.sessionCleanupDialog = { ...this.sessionCleanupDialog, preview: result, previewRequest: request, result, running: false, error: "" };
await this.sessions.applySessionCleanupResult(result, machineId);
} catch (error) {
if (selectedMachineId(this.state) === machineId) this.sessionCleanupDialog = { ...this.sessionCleanupDialog, running: false, error: `Failed to run cleanup: ${errorMessage(error)}` };
}
}
private renderNavigationPanel() { private renderNavigationPanel() {
return html` return html`
<app-navigation-panel <app-navigation-panel
@@ -1054,7 +1122,9 @@ export class PiWebApp extends LitElement {
.canStartSession=${!!this.state.selectedWorkspace} .canStartSession=${!!this.state.selectedWorkspace}
.canDeleteArchivedSessions=${this.canDeleteArchivedSessions()} .canDeleteArchivedSessions=${this.canDeleteArchivedSessions()}
.canReloadSessions=${this.canReloadSessions()} .canReloadSessions=${this.canReloadSessions()}
.canCleanupSessions=${this.canCleanupSessions()}
.archivedDeleteUnavailableMessage=${this.archivedDeleteUnavailableMessage()} .archivedDeleteUnavailableMessage=${this.archivedDeleteUnavailableMessage()}
.cleanupUnavailableMessage=${this.sessionCleanupUnavailableMessage()}
.collapsible=${true} .collapsible=${true}
.compact=${this.appShell.isMobileNavigationLayout} .compact=${this.appShell.isMobileNavigationLayout}
.projectsCollapsed=${this.navigationSections.isCollapsed("projects")} .projectsCollapsed=${this.navigationSections.isCollapsed("projects")}
@@ -1082,6 +1152,7 @@ export class PiWebApp extends LitElement {
.onDeleteArchivedSessions=${(sessions: SessionInfo[]) => this.sessions.deleteArchivedSessions(sessions)} .onDeleteArchivedSessions=${(sessions: SessionInfo[]) => this.sessions.deleteArchivedSessions(sessions)}
.onDetachParentSession=${(session: SessionInfo) => this.sessions.detachParent(session)} .onDetachParentSession=${(session: SessionInfo) => this.sessions.detachParent(session)}
.onReloadSession=${(session: SessionInfo) => this.sessions.reloadSession(session)} .onReloadSession=${(session: SessionInfo) => this.sessions.reloadSession(session)}
.onCleanupSessions=${() => { this.openSessionCleanupDialog(); }}
.onFocusNavigationTarget=${(target: NavigationFocusTarget) => { void this.focusNavigationTarget(target); }} .onFocusNavigationTarget=${(target: NavigationFocusTarget) => { void this.focusNavigationTarget(target); }}
.onCancelKeyboardNavigation=${() => { void this.focusChatComposer(); }} .onCancelKeyboardNavigation=${() => { void this.focusChatComposer(); }}
></app-navigation-panel> ></app-navigation-panel>
@@ -1293,7 +1364,21 @@ export class PiWebApp extends LitElement {
} }
private getDefaultActions(): AppAction[] { private getDefaultActions(): AppAction[] {
return [...this.plugins.getActions(this.createPluginRuntimeContext()), ...this.navigationFocusActions(), ...this.panelLayoutActions()]; return [...this.plugins.getActions(this.createPluginRuntimeContext()), ...this.sessionActions(), ...this.navigationFocusActions(), ...this.panelLayoutActions()];
}
private sessionActions(): AppAction[] {
const canCleanup = this.canCleanupSessions();
return [
{
id: "app.sessions.cleanup",
title: "Clean Up Sessions",
description: "Preview and manually clean up idle or archived sessions on the selected machine",
group: "Sessions",
...(canCleanup ? {} : { enabled: false, disabledReason: this.sessionCleanupUnavailableMessage() }),
run: () => { this.openSessionCleanupDialog(); },
},
];
} }
private panelLayoutActions(): AppAction[] { private panelLayoutActions(): AppAction[] {
@@ -1817,6 +1902,7 @@ export class PiWebApp extends LitElement {
${state.actionPaletteOpen ? html`<action-palette .actions=${this.getActions()} .onRun=${(action: AppAction) => { this.setState({ actionPaletteOpen: false }); this.runAction(action); }} .onCancel=${() => { this.setState({ actionPaletteOpen: false }); }}></action-palette>` : null} ${state.actionPaletteOpen ? html`<action-palette .actions=${this.getActions()} .onRun=${(action: AppAction) => { this.setState({ actionPaletteOpen: false }); this.runAction(action); }} .onCancel=${() => { this.setState({ actionPaletteOpen: false }); }}></action-palette>` : null}
${state.projectDialogOpen ? html`<project-dialog .machineId=${selectedMachineId(state)} .onSubmit=${(path: string, create: boolean) => this.projects.addProject(path, create)} .onCancel=${() => { this.setState({ projectDialogOpen: false }); }}></project-dialog>` : null} ${state.projectDialogOpen ? html`<project-dialog .machineId=${selectedMachineId(state)} .onSubmit=${(path: string, create: boolean) => this.projects.addProject(path, create)} .onCancel=${() => { this.setState({ projectDialogOpen: false }); }}></project-dialog>` : null}
${state.machineDialogOpen ? html`<machine-dialog .error=${state.error} .onSubmit=${(input: MachineDialogSubmit) => this.submitMachineDialog(input)} .onCancel=${() => { this.setState({ machineDialogOpen: false }); }}></machine-dialog>` : null} ${state.machineDialogOpen ? html`<machine-dialog .error=${state.error} .onSubmit=${(input: MachineDialogSubmit) => this.submitMachineDialog(input)} .onCancel=${() => { this.setState({ machineDialogOpen: false }); }}></machine-dialog>` : null}
${this.sessionCleanupDialog !== undefined ? html`<session-cleanup-dialog .canCleanup=${this.canCleanupSessions()} .unavailableMessage=${this.sessionCleanupUnavailableMessage()} .preview=${this.sessionCleanupDialog.preview} .previewRequest=${this.sessionCleanupDialog.previewRequest} .result=${this.sessionCleanupDialog.result} .loading=${this.sessionCleanupDialog.loading === true} .running=${this.sessionCleanupDialog.running === true} .error=${this.sessionCleanupDialog.error ?? ""} .onPreview=${(request: SessionCleanupRequest) => { void this.previewSessionCleanup(request); }} .onRun=${(request: SessionCleanupRequest) => { void this.runSessionCleanup(request); }} .onClose=${() => { this.closeSessionCleanupDialog(); }}></session-cleanup-dialog>` : null}
${state.themeDialog !== undefined ? html`<command-picker title=${state.themeDialog.title} .options=${state.themeDialog.options} .selectedValue=${state.themeDialog.selectedValue} .onPick=${(value: string) => { this.pickTheme(value); }} .onCancel=${() => { this.setState({ themeDialog: undefined }); }}></command-picker>` : null} ${state.themeDialog !== undefined ? html`<command-picker title=${state.themeDialog.title} .options=${state.themeDialog.options} .selectedValue=${state.themeDialog.selectedValue} .onPick=${(value: string) => { this.pickTheme(value); }} .onCancel=${() => { this.setState({ themeDialog: undefined }); }}></command-picker>` : null}
${this.settingsSection !== undefined ? html`<settings-dialog .section=${this.settingsSection} .actions=${this.getDefaultActions()} .onNavigate=${(section: SettingsSection) => { this.navigateSettings(section); }} .onClose=${() => { this.closeSettings(); }} .onConfigSaved=${(config: PiWebConfigValues) => { this.applyClientConfig(config); }}></settings-dialog>` : null} ${this.settingsSection !== undefined ? html`<settings-dialog .section=${this.settingsSection} .actions=${this.getDefaultActions()} .onNavigate=${(section: SettingsSection) => { this.navigateSettings(section); }} .onClose=${() => { this.closeSettings(); }} .onConfigSaved=${(config: PiWebConfigValues) => { this.applyClientConfig(config); }}></settings-dialog>` : null}
</div> </div>
@@ -0,0 +1,280 @@
import { LitElement, css, html, type PropertyValues, type TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import type { SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupRequest } from "../api";
import { canRunSessionCleanup, confirmSessionCleanup, DEFAULT_SESSION_CLEANUP_DRAFT, selectedSessionCleanupProjectCwds, sessionCleanupPreviewForSelectedProjects, sessionCleanupPreviewHasTargets, sessionCleanupRequestKey, validateSessionCleanupDraft, type SessionCleanupDraft } from "../sessionCleanupUi";
@customElement("session-cleanup-dialog")
export class SessionCleanupDialog extends LitElement {
@property({ type: Boolean }) canCleanup = true;
@property({ type: String }) unavailableMessage = "Update and restart Pi-Web on this machine to clean up sessions.";
@property({ attribute: false }) preview?: SessionCleanupPreviewResponse;
@property({ attribute: false }) previewRequest?: SessionCleanupRequest;
@property({ attribute: false }) result?: SessionCleanupExecuteResponse;
@property({ type: Boolean }) loading = false;
@property({ type: Boolean }) running = false;
@property({ type: String }) error = "";
@property({ attribute: false }) onPreview?: (request: SessionCleanupRequest) => void | Promise<void>;
@property({ attribute: false }) onRun?: (request: SessionCleanupRequest) => void | Promise<void>;
@property({ attribute: false }) onClose?: () => void;
@state() private draft: SessionCleanupDraft = { ...DEFAULT_SESSION_CLEANUP_DRAFT };
@state() private formError = "";
@state() private selectedProjectCwds: string[] | undefined;
override willUpdate(changedProperties: PropertyValues<this>): void {
if (changedProperties.has("preview")) this.selectedProjectCwds = this.preview?.projects.map((project) => project.cwd);
}
override render(): TemplateResult {
const validation = validateSessionCleanupDraft(this.draft);
const selectedPreview = this.selectedPreview();
const runEnabled = canRunSessionCleanup({ canCleanup: this.canCleanup, draft: this.draft, preview: selectedPreview, previewRequest: this.previewRequest, loading: this.loading, running: this.running });
const runTitle = runEnabled ? "Run cleanup" : selectedPreview !== undefined && !sessionCleanupPreviewHasTargets(selectedPreview) ? "Select at least one project to run cleanup" : "Preview cleanup before running it";
return html`
<div class="backdrop" @mousedown=${() => { this.onClose?.(); }}>
<section role="dialog" aria-modal="true" aria-label="Clean up sessions" @mousedown=${(event: MouseEvent) => { event.stopPropagation(); }} @keydown=${(event: KeyboardEvent) => { this.handleKeyDown(event); }}>
<header>
<div>
<span class="eyebrow">Sessions</span>
<h1>Clean up sessions</h1>
</div>
<button class="close-button" title="Close cleanup" aria-label="Close cleanup" @click=${() => { this.onClose?.(); }}>×</button>
</header>
<div class="body">
<p class="intro">Preview manual cleanup for this machine before archiving idle sessions or permanently deleting old archived sessions.</p>
${this.canCleanup ? this.renderForm(validation.ok ? "" : validation.error) : this.renderUnavailable()}
${this.renderMessage()}
${this.preview === undefined ? null : this.renderPreview(this.preview)}
${this.result === undefined ? null : this.renderResult(this.result)}
</div>
<footer>
<button @click=${() => { this.onClose?.(); }}>${this.result === undefined ? "Cancel" : "Close"}</button>
<button ?disabled=${!this.canCleanup || this.loading || this.running} @click=${() => { this.previewCleanup(); }}>${this.loading ? "Previewing…" : "Preview"}</button>
<button class="danger" ?disabled=${!runEnabled} title=${runTitle} @click=${() => { this.runCleanup(); }}>${this.running ? "Running…" : "Run cleanup"}</button>
</footer>
</section>
</div>
`;
}
private renderForm(validationError: string): TemplateResult {
const disabled = this.loading || this.running;
const validation = validateSessionCleanupDraft(this.draft);
const previewOutOfDate = this.preview !== undefined && validation.ok && sessionCleanupRequestKey(validation.request) !== sessionCleanupRequestKey(this.previewRequest) && sessionCleanupPreviewHasTargets(this.preview);
return html`
<fieldset ?disabled=${disabled}>
<label class="toggle-row">
<input type="checkbox" .checked=${this.draft.archiveIdleEnabled} @change=${(event: Event) => { this.updateDraft({ archiveIdleEnabled: checkedValue(event) }); }}>
<span>Archive non-archived sessions idle for more than</span>
<input class="days" type="number" min="0" step="1" inputmode="numeric" .value=${this.draft.archiveIdleDays} ?disabled=${disabled || !this.draft.archiveIdleEnabled} @input=${(event: Event) => { this.updateDraft({ archiveIdleDays: inputValue(event) }); }}>
<span>days</span>
</label>
<label class="toggle-row delete-row">
<input type="checkbox" .checked=${this.draft.deleteArchivedEnabled} @change=${(event: Event) => { this.updateDraft({ deleteArchivedEnabled: checkedValue(event) }); }}>
<span>Delete archived sessions archived for more than</span>
<input class="days" type="number" min="0" step="1" inputmode="numeric" .value=${this.draft.deleteArchivedDays} ?disabled=${disabled || !this.draft.deleteArchivedEnabled} @input=${(event: Event) => { this.updateDraft({ deleteArchivedDays: inputValue(event) }); }}>
<span>days</span>
</label>
</fieldset>
<p class="warning"><strong>Deletion is permanent.</strong> Cleanup only deletes sessions that are already archived.</p>
${validationError === "" ? null : html`<div class="dialog-error" role="alert">${validationError}</div>`}
${previewOutOfDate ? html`<div class="hint" role="status">Thresholds changed. Preview again before running cleanup.</div>` : null}
`;
}
private renderUnavailable(): TemplateResult {
return html`<div class="unavailable" role="status">${this.unavailableMessage}</div>`;
}
private renderMessage(): TemplateResult | null {
const message = this.formError || this.error;
return message === "" ? null : html`<div class="dialog-error" role="alert">${message}</div>`;
}
private renderPreview(preview: SessionCleanupPreviewResponse): TemplateResult {
const selectedCwds = this.selectedProjectCwdsForPreview();
const selected = new Set(selectedCwds);
const selectedPreview = sessionCleanupPreviewForSelectedProjects(preview, selectedCwds);
return html`
<section class="preview" aria-label="Cleanup preview">
<h2>Preview</h2>
${preview.projects.length === 0 ? html`<p class="empty">No sessions match these thresholds.</p>` : html`
${this.renderSelectionControls(preview, selectedCwds)}
<div class="table-scroll" tabindex="0" aria-label="Cleanup projects table">
<table>
<thead>
<tr><th>Clean up</th><th>Project/workspace path</th><th>Archive</th><th>Delete archived</th></tr>
</thead>
<tbody>
${preview.projects.map((project) => this.renderProjectRow(project, selected.has(project.cwd)))}
</tbody>
<tfoot>
<tr><th colspan="2">Selected totals</th><td>${selectedPreview.totals.archiveCount}</td><td>${selectedPreview.totals.deleteCount}</td></tr>
</tfoot>
</table>
</div>
`}
${preview.skippedBusySessionIds === undefined || preview.skippedBusySessionIds.length === 0 ? null : html`<p class="hint">${preview.skippedBusySessionIds.length} busy ${preview.skippedBusySessionIds.length === 1 ? "session was" : "sessions were"} skipped.</p>`}
</section>
`;
}
private renderSelectionControls(preview: SessionCleanupPreviewResponse, selectedCwds: readonly string[]): TemplateResult {
const disabled = this.loading || this.running;
return html`
<div class="selection-controls" role="group" aria-label="Project selection">
<span>${selectedCwds.length} of ${preview.projects.length} projects selected</span>
<button ?disabled=${disabled || selectedCwds.length === preview.projects.length} @click=${() => { this.selectAllProjects(); }}>Select all</button>
<button ?disabled=${disabled || selectedCwds.length === 0} @click=${() => { this.deselectAllProjects(); }}>Deselect all</button>
</div>
${selectedCwds.length === 0 ? html`<p class="hint" role="status">Select at least one project to run cleanup.</p>` : null}
`;
}
private renderProjectRow(project: SessionCleanupProjectSummary, selected: boolean): TemplateResult {
return html`
<tr class=${selected ? "" : "unselected"}>
<td class="select-cell"><input type="checkbox" aria-label=${`Clean up ${project.cwd}`} .checked=${selected} ?disabled=${this.running} @change=${(event: Event) => { this.setProjectSelected(project.cwd, checkedValue(event)); }}></td>
<th title=${project.cwd} dir="auto">${project.cwd}</th>
<td>${project.archiveCount}</td>
<td>${project.deleteCount}</td>
</tr>
`;
}
private renderResult(result: SessionCleanupExecuteResponse): TemplateResult {
return html`
<section class="result" aria-label="Cleanup result">
<h2>Cleanup complete</h2>
<p>Archived ${result.archivedSessionIds.length} ${result.archivedSessionIds.length === 1 ? "session" : "sessions"}; permanently deleted ${result.deletedSessionIds.length} archived ${result.deletedSessionIds.length === 1 ? "session" : "sessions"}.</p>
</section>
`;
}
private updateDraft(patch: Partial<SessionCleanupDraft>): void {
this.draft = { ...this.draft, ...patch };
this.formError = "";
}
private selectedPreview(): SessionCleanupPreviewResponse | undefined {
return this.preview === undefined ? undefined : sessionCleanupPreviewForSelectedProjects(this.preview, this.selectedProjectCwdsForPreview());
}
private selectedProjectCwdsForPreview(): string[] {
return this.preview === undefined ? [] : selectedSessionCleanupProjectCwds(this.preview, this.selectedProjectCwds);
}
private selectAllProjects(): void {
this.selectedProjectCwds = this.preview?.projects.map((project) => project.cwd) ?? [];
this.formError = "";
}
private deselectAllProjects(): void {
this.selectedProjectCwds = [];
this.formError = "";
}
private setProjectSelected(cwd: string, selected: boolean): void {
const preview = this.preview;
if (preview === undefined) return;
const selectedCwds = new Set(this.selectedProjectCwdsForPreview());
if (selected) selectedCwds.add(cwd);
else selectedCwds.delete(cwd);
this.selectedProjectCwds = preview.projects.map((project) => project.cwd).filter((projectCwd) => selectedCwds.has(projectCwd));
this.formError = "";
}
private previewCleanup(): void {
const validation = validateSessionCleanupDraft(this.draft);
if (!validation.ok) {
this.formError = validation.error;
return;
}
this.formError = "";
void this.onPreview?.(validation.request);
}
private runCleanup(): void {
const validation = validateSessionCleanupDraft(this.draft);
if (!validation.ok) {
this.formError = validation.error;
return;
}
const selectedPreview = this.selectedPreview();
const selectedProjectCwds = this.selectedProjectCwdsForPreview();
if (!canRunSessionCleanup({ canCleanup: this.canCleanup, draft: this.draft, preview: selectedPreview, previewRequest: this.previewRequest })) {
this.formError = selectedPreview !== undefined && !sessionCleanupPreviewHasTargets(selectedPreview) ? "Select at least one project to run cleanup." : "Preview cleanup before running it.";
return;
}
if (selectedPreview === undefined || !confirmSessionCleanup(selectedPreview, (message) => confirm(message))) return;
this.formError = "";
void this.onRun?.({ ...validation.request, projectCwds: selectedProjectCwds });
}
private handleKeyDown(event: KeyboardEvent): void {
if (event.key !== "Escape") return;
event.preventDefault();
event.stopPropagation();
this.onClose?.();
}
static override styles = css`
:host { position: fixed; inset: 0; z-index: 30; color: var(--pi-text); font: 14px system-ui, sans-serif; }
.backdrop { box-sizing: border-box; width: 100%; height: 100dvh; display: grid; place-items: center; padding: max(20px, env(safe-area-inset-top)) max(20px, env(safe-area-inset-right)) max(20px, env(safe-area-inset-bottom)) max(20px, env(safe-area-inset-left)); background: var(--pi-overlay); overflow: hidden; }
section[role="dialog"] { width: min(760px, 100%); max-height: min(760px, 100%); display: grid; grid-template-rows: auto minmax(0, 1fr) auto; border: 1px solid var(--pi-border); border-radius: 14px; background: var(--pi-bg); box-shadow: 0 20px 60px var(--pi-shadow-strong); overflow: hidden; }
header, footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--pi-border); }
footer { border-top: 1px solid var(--pi-border); border-bottom: 0; justify-content: end; }
.body { min-height: 0; overflow: auto; display: grid; gap: 14px; padding: 16px; }
.eyebrow { display: block; color: var(--pi-muted); font-size: 11px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
h1, h2, p { margin: 0; }
h1 { font-size: 20px; line-height: 1.2; }
h2 { font-size: 15px; }
.intro, .hint, .empty { color: var(--pi-muted); }
fieldset { margin: 0; padding: 0; border: 0; display: grid; gap: 10px; }
.toggle-row { display: grid; grid-template-columns: auto minmax(0, max-content) 88px auto; align-items: center; gap: 8px; color: var(--pi-text); }
input[type="checkbox"] { width: 16px; height: 16px; accent-color: var(--pi-accent); }
input.days { box-sizing: border-box; width: 88px; min-width: 0; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-bg); color: var(--pi-text); padding: 8px 9px; font: inherit; }
input.days:disabled { opacity: .55; }
.warning, .unavailable, .dialog-error, .result { border: 1px solid var(--pi-border); border-radius: 10px; padding: 10px 12px; }
.warning { border-color: var(--pi-warning-border); background: var(--pi-warning-surface); color: var(--pi-text); }
.unavailable { border-color: var(--pi-warning-border); background: var(--pi-warning-surface); color: var(--pi-warning); }
.dialog-error { border-color: var(--pi-danger); background: color-mix(in srgb, var(--pi-danger) 10%, var(--pi-bg)); color: var(--pi-danger); }
.result { border-color: var(--pi-success-border); background: var(--pi-success-bg); }
.preview { display: grid; gap: 10px; min-width: 0; }
.selection-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.selection-controls span { color: var(--pi-muted); }
.selection-controls button { padding: 5px 7px; font-size: 12px; }
.table-scroll { max-width: 100%; overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scrollbar-width: thin; -webkit-overflow-scrolling: touch; border: 1px solid var(--pi-border); border-radius: 10px; }
table { width: 100%; min-width: 620px; border-collapse: collapse; }
th, td { border-bottom: 1px solid var(--pi-border-muted); padding: 8px 10px; text-align: right; }
thead th:first-child, td.select-cell { width: 72px; text-align: center; }
th:nth-child(2), td:nth-child(2) { text-align: left; }
tbody tr.unselected { opacity: .58; }
tbody th { max-width: 380px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-weight: 500; }
tfoot th, tfoot td { border-bottom: 0; font-weight: 700; }
button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; font: inherit; cursor: pointer; }
button:disabled { opacity: .5; cursor: not-allowed; }
button.danger { color: var(--pi-danger); }
button.danger:not(:disabled):hover { background: color-mix(in srgb, var(--pi-danger) 14%, transparent); }
.close-button { width: 34px; height: 34px; display: grid; place-items: center; border: 0; background: transparent; color: var(--pi-muted); padding: 0; font-size: 24px; }
.close-button:hover, .close-button:focus { color: var(--pi-text); background: var(--pi-surface-hover); }
@media (max-width: 680px) {
.backdrop { padding: 0; place-items: stretch; }
section[role="dialog"] { width: 100%; height: 100dvh; max-height: none; border: 0; border-radius: 0; }
.toggle-row { grid-template-columns: auto minmax(0, 1fr); }
.toggle-row input.days { grid-column: 2; }
.toggle-row span:last-child { grid-column: 2; }
table { min-width: 560px; }
thead th:first-child, td.select-cell { width: 58px; }
}
`;
}
function checkedValue(event: Event): boolean {
return event.target instanceof HTMLInputElement ? event.target.checked : false;
}
function inputValue(event: Event): string {
return event.target instanceof HTMLInputElement ? event.target.value : "";
}
+10
View File
@@ -32,7 +32,9 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
@property({ type: Boolean }) canStart = false; @property({ type: Boolean }) canStart = false;
@property({ type: Boolean }) canDeleteArchived = false; @property({ type: Boolean }) canDeleteArchived = false;
@property({ type: Boolean }) canReload = false; @property({ type: Boolean }) canReload = false;
@property({ type: Boolean }) canCleanup = false;
@property({ type: String }) archivedDeleteUnavailableMessage = "Update and restart Pi-Web on this machine to delete archived sessions."; @property({ type: String }) archivedDeleteUnavailableMessage = "Update and restart Pi-Web on this machine to delete archived sessions.";
@property({ type: String }) cleanupUnavailableMessage = "Update and restart Pi-Web on this machine to clean up sessions.";
@property({ type: Boolean, reflect: true }) collapsible = false; @property({ type: Boolean, reflect: true }) collapsible = false;
@property({ type: Boolean, reflect: true }) collapsed = false; @property({ type: Boolean, reflect: true }) collapsed = false;
@property({ attribute: false }) onSelect?: (session: SessionInfo) => void; @property({ attribute: false }) onSelect?: (session: SessionInfo) => void;
@@ -51,6 +53,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
@property({ attribute: false }) onDeleteArchivedMany?: (sessions: SessionInfo[]) => void | Promise<void>; @property({ attribute: false }) onDeleteArchivedMany?: (sessions: SessionInfo[]) => void | Promise<void>;
@property({ attribute: false }) onDetachParent?: (session: SessionInfo) => void; @property({ attribute: false }) onDetachParent?: (session: SessionInfo) => void;
@property({ attribute: false }) onReload?: (session: SessionInfo) => void; @property({ attribute: false }) onReload?: (session: SessionInfo) => void;
@property({ attribute: false }) onCleanup?: () => void;
@state() private openMenuSessionId: string | undefined; @state() private openMenuSessionId: string | undefined;
@state() private menuStyle = ""; @state() private menuStyle = "";
@@ -126,6 +129,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
<h2> <h2>
Sessions Sessions
${this.renderCurrentSelectionButton(currentSessions)} ${this.renderCurrentSelectionButton(currentSessions)}
${this.renderCleanupButton()}
<button ?disabled=${!this.canStart} @click=${() => this.onStart?.()}>+</button> <button ?disabled=${!this.canStart} @click=${() => this.onStart?.()}>+</button>
</h2> </h2>
`; `;
@@ -137,6 +141,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
<button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span class="section-title"><span class="section-name">${this.collapsed ? "▸" : "▾"} Sessions</span>${this.collapsed ? html`<small class="section-selected" dir="auto" title=${selectedTitle}>${selectedSummary}</small>` : null}</span></button> <button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span class="section-title"><span class="section-name">${this.collapsed ? "▸" : "▾"} Sessions</span>${this.collapsed ? html`<small class="section-selected" dir="auto" title=${selectedTitle}>${selectedSummary}</small>` : null}</span></button>
${this.renderCurrentSelectionButton(currentSessions)} ${this.renderCurrentSelectionButton(currentSessions)}
<small class="section-count">${sessionCount}</small> <small class="section-count">${sessionCount}</small>
${this.renderCleanupButton()}
<button ?disabled=${!this.canStart} @click=${(event: MouseEvent) => { event.stopPropagation(); this.onStart?.(); }}>+</button> <button ?disabled=${!this.canStart} @click=${(event: MouseEvent) => { event.stopPropagation(); this.onStart?.(); }}>+</button>
</h2> </h2>
`; `;
@@ -148,6 +153,10 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
return html`<button class="bulk-select-entry ${active ? "selected" : ""}" title=${active ? "Close current session selection" : "Select current sessions"} aria-label=${active ? "Close current session selection" : "Select current sessions"} aria-expanded=${String(active)} aria-pressed=${String(active)} @click=${(event: MouseEvent) => { event.stopPropagation(); this.toggleSelection("current", currentSessions); }}>☑</button>`; return html`<button class="bulk-select-entry ${active ? "selected" : ""}" title=${active ? "Close current session selection" : "Select current sessions"} aria-label=${active ? "Close current session selection" : "Select current sessions"} aria-expanded=${String(active)} aria-pressed=${String(active)} @click=${(event: MouseEvent) => { event.stopPropagation(); this.toggleSelection("current", currentSessions); }}>☑</button>`;
} }
private renderCleanupButton() {
return html`<button class="cleanup-entry" title=${this.canCleanup ? "Preview session cleanup" : this.cleanupUnavailableMessage} @click=${(event: MouseEvent) => { event.stopPropagation(); this.onCleanup?.(); }}>Clean up</button>`;
}
private renderArchivedHeading(archivedSessions: SessionInfo[]) { private renderArchivedHeading(archivedSessions: SessionInfo[]) {
const active = this.selectionScopes.has("archived"); const active = this.selectionScopes.has("archived");
return html` return html`
@@ -372,6 +381,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
h2 { min-height: 30px; } h2 { min-height: 30px; }
h2 > .section-count { flex: 0 0 auto; display: inline; color: var(--pi-muted); font-size: inherit; } h2 > .section-count { flex: 0 0 auto; display: inline; color: var(--pi-muted); font-size: inherit; }
.bulk-select-entry { box-sizing: border-box; flex: 0 0 auto; display: inline-grid; place-items: center; width: 30px; height: 30px; padding: 0; font-size: 13px; line-height: 1; text-transform: none; } .bulk-select-entry { box-sizing: border-box; flex: 0 0 auto; display: inline-grid; place-items: center; width: 30px; height: 30px; padding: 0; font-size: 13px; line-height: 1; text-transform: none; }
.cleanup-entry { flex: 0 0 auto; padding: 5px 7px; font-size: 12px; text-transform: none; }
.bulk-row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 0 0 6px; } .bulk-row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 0 0 6px; }
.bulk-row button { padding: 5px 7px; font-size: 12px; } .bulk-row button { padding: 5px 7px; font-size: 12px; }
.bulk-row small { display: inline; min-width: 0; color: var(--pi-muted); } .bulk-row small { display: inline; min-width: 0; color: var(--pi-muted); }
@@ -42,7 +42,9 @@ export class AppNavigationPanel extends LitElement {
@property({ type: Boolean }) canStartSession = false; @property({ type: Boolean }) canStartSession = false;
@property({ type: Boolean }) canDeleteArchivedSessions = false; @property({ type: Boolean }) canDeleteArchivedSessions = false;
@property({ type: Boolean }) canReloadSessions = false; @property({ type: Boolean }) canReloadSessions = false;
@property({ type: Boolean }) canCleanupSessions = false;
@property({ type: String }) archivedDeleteUnavailableMessage = "Update and restart Pi-Web on this machine to delete archived sessions."; @property({ type: String }) archivedDeleteUnavailableMessage = "Update and restart Pi-Web on this machine to delete archived sessions.";
@property({ type: String }) cleanupUnavailableMessage = "Update and restart Pi-Web on this machine to clean up sessions.";
@property({ attribute: false }) onShowActions?: () => void; @property({ attribute: false }) onShowActions?: () => void;
@property({ attribute: false }) onToggleMachines?: () => void; @property({ attribute: false }) onToggleMachines?: () => void;
@property({ attribute: false }) onToggleProjects?: () => void; @property({ attribute: false }) onToggleProjects?: () => void;
@@ -63,6 +65,7 @@ export class AppNavigationPanel extends LitElement {
@property({ attribute: false }) onDeleteArchivedSessions?: (sessions: SessionInfo[]) => void | Promise<void>; @property({ attribute: false }) onDeleteArchivedSessions?: (sessions: SessionInfo[]) => void | Promise<void>;
@property({ attribute: false }) onDetachParentSession?: (session: SessionInfo) => void | Promise<void>; @property({ attribute: false }) onDetachParentSession?: (session: SessionInfo) => void | Promise<void>;
@property({ attribute: false }) onReloadSession?: (session: SessionInfo) => void | Promise<void>; @property({ attribute: false }) onReloadSession?: (session: SessionInfo) => void | Promise<void>;
@property({ attribute: false }) onCleanupSessions?: () => void | Promise<void>;
@property({ attribute: false }) onArchivedCollapsed?: () => void | Promise<void>; @property({ attribute: false }) onArchivedCollapsed?: () => void | Promise<void>;
@property({ attribute: false }) onSelectMachine?: (machine: Machine) => void | Promise<void>; @property({ attribute: false }) onSelectMachine?: (machine: Machine) => void | Promise<void>;
@property({ attribute: false }) onRemoveMachine?: (machine: Machine) => void | Promise<void>; @property({ attribute: false }) onRemoveMachine?: (machine: Machine) => void | Promise<void>;
@@ -159,7 +162,9 @@ export class AppNavigationPanel extends LitElement {
.canStart=${this.canStartSession} .canStart=${this.canStartSession}
.canDeleteArchived=${this.canDeleteArchivedSessions} .canDeleteArchived=${this.canDeleteArchivedSessions}
.canReload=${this.canReloadSessions} .canReload=${this.canReloadSessions}
.canCleanup=${this.canCleanupSessions}
.archivedDeleteUnavailableMessage=${this.archivedDeleteUnavailableMessage} .archivedDeleteUnavailableMessage=${this.archivedDeleteUnavailableMessage}
.cleanupUnavailableMessage=${this.cleanupUnavailableMessage}
.collapsible=${this.collapsible} .collapsible=${this.collapsible}
.collapsed=${this.sessionsCollapsed} .collapsed=${this.sessionsCollapsed}
.onToggleCollapsed=${() => { this.onToggleSessions?.(); }} .onToggleCollapsed=${() => { this.onToggleSessions?.(); }}
@@ -175,6 +180,7 @@ export class AppNavigationPanel extends LitElement {
.onDeleteArchivedMany=${(sessions: SessionInfo[]) => this.onDeleteArchivedSessions?.(sessions)} .onDeleteArchivedMany=${(sessions: SessionInfo[]) => this.onDeleteArchivedSessions?.(sessions)}
.onDetachParent=${(session: SessionInfo) => this.onDetachParentSession?.(session)} .onDetachParent=${(session: SessionInfo) => this.onDetachParentSession?.(session)}
.onReload=${(session: SessionInfo) => this.onReloadSession?.(session)} .onReload=${(session: SessionInfo) => this.onReloadSession?.(session)}
.onCleanup=${() => this.onCleanupSessions?.()}
.onFocusPreviousSection=${() => { this.focusPreviousFrom("sessions"); }} .onFocusPreviousSection=${() => { this.focusPreviousFrom("sessions"); }}
.onFocusNextSection=${() => { this.focusNextFrom("sessions"); }} .onFocusNextSection=${() => { this.focusNextFrom("sessions"); }}
.onCancelKeyboardNavigation=${() => { this.cancelKeyboardNavigation(); }} .onCancelKeyboardNavigation=${() => { this.cancelKeyboardNavigation(); }}
+4 -1
View File
@@ -437,10 +437,13 @@ export const actionPaletteStyles = css`
header button { color: var(--pi-muted); font-size: 22px; padding: 2px 8px; } header button { color: var(--pi-muted); font-size: 22px; padding: 2px 8px; }
.options { flex: 1 1 auto; min-height: 0; overflow: auto; } .options { flex: 1 1 auto; min-height: 0; overflow: auto; }
.options button { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 3px 12px; width: 100%; padding: 10px 12px; border-bottom: 1px solid var(--pi-border-muted); text-align: left; } .options button { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 3px 12px; width: 100%; padding: 10px 12px; border-bottom: 1px solid var(--pi-border-muted); text-align: left; }
.options button.selected, .options button:hover { background: var(--pi-selection-bg); } .options button.selected, .options button:hover:not(:disabled) { background: var(--pi-selection-bg); }
.options button:disabled { cursor: not-allowed; opacity: .68; }
.options button.disabled.selected { background: color-mix(in srgb, var(--pi-selection-bg) 55%, transparent); }
.main { min-width: 0; } .main { min-width: 0; }
strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
small { display: block; color: var(--pi-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } small { display: block; color: var(--pi-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.disabled-reason { color: var(--pi-warning); }
.group { grid-column: 1 / -1; font-size: 12px; } .group { grid-column: 1 / -1; font-size: 12px; }
kbd { align-self: center; border: 1px solid var(--pi-border); border-radius: 6px; background: var(--pi-surface); color: var(--pi-muted); padding: 2px 6px; font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: nowrap; } kbd { align-self: center; border: 1px solid var(--pi-border); border-radius: 6px; background: var(--pi-surface); color: var(--pi-muted); padding: 2px 6px; font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: nowrap; }
.empty { padding: 24px; color: var(--pi-muted); text-align: center; } .empty { padding: 24px; color: var(--pi-muted); text-align: center; }
@@ -553,6 +553,55 @@ describe("SessionController", () => {
expect(state.selectedSession?.id).toBe(nextSession.id); expect(state.selectedSession?.id).toBe(nextSession.id);
}); });
it("applies cleanup execution results and refreshes the current workspace sessions", async () => {
const archivedAt = "2026-06-25T12:00:00.000Z";
const deletedArchived = { ...oldSession, id: "deleted-archived", path: "/tmp/deleted-archived.jsonl", archived: true, archivedAt: "2026-05-01T00:00:00.000Z" };
const nextSession = { ...oldSession, id: "next-session", path: "/tmp/next-session.jsonl" };
const refreshedArchived = { ...oldSession, archived: true, archivedAt };
const sessionsCalls: { cwd: string; machineId: string }[] = [];
let state: AppState = {
...initialAppState(),
selectedWorkspace: workspace,
selectedSession: oldSession,
sessions: [oldSession, deletedArchived, nextSession],
sessionStatuses: { [oldSession.id]: status(oldSession.id), [deletedArchived.id]: status(deletedArchived.id), [nextSession.id]: status(nextSession.id) },
sessionActivities: { [oldSession.id]: { sessionId: oldSession.id, phase: "idle", label: "idle", at: archivedAt } },
};
const api: typeof defaultApi = {
...defaultApi,
sessions: (cwd, machineId) => {
sessionsCalls.push({ cwd, machineId: machineId ?? "local" });
return Promise.resolve([refreshedArchived, nextSession]);
},
messages: () => Promise.resolve(emptyPage),
status: (session) => Promise.resolve(status(sessionLookupId(session))),
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
new InMemorySessionSelectionMemory(),
{ api, socket: new FakeSocket() },
);
await controller.applySessionCleanupResult({
generatedAt: archivedAt,
thresholds: { archiveIdleDays: 30, deleteArchivedDays: 60 },
projects: [{ cwd: workspace.path, archiveCount: 1, deleteCount: 1 }],
totals: { archiveCount: 1, deleteCount: 1 },
archivedSessionIds: [oldSession.id],
deletedSessionIds: [deletedArchived.id],
});
expect(sessionsCalls).toEqual([{ cwd: workspace.path, machineId: "local" }]);
expect(state.sessions.map((session) => session.id)).toEqual([oldSession.id, nextSession.id]);
expect(state.sessions[0]).toMatchObject({ id: oldSession.id, archived: true, archivedAt });
expect(state.selectedSession?.id).toBe(nextSession.id);
expect(state.sessionStatuses[oldSession.id]).toBeUndefined();
expect(state.sessionStatuses[deletedArchived.id]).toBeUndefined();
expect(state.sessionActivities[oldSession.id]).toBeUndefined();
});
it("does not delete archived sessions when the selected machine runtime does not support it", async () => { it("does not delete archived sessions when the selected machine runtime does not support it", async () => {
const archivedSession = { ...oldSession, archived: true, archivedAt: "later" }; const archivedSession = { ...oldSession, archived: true, archivedAt: "later" };
const deletedIds: string[] = []; const deletedIds: string[] = [];
@@ -1,6 +1,6 @@
import { api as defaultApi, type CommandResult, type PromptAttachment, type SessionActivity, type SessionInfo, type SessionRef, type SessionStatus } from "../api"; import { api as defaultApi, type CommandResult, type PromptAttachment, type SessionActivity, type SessionCleanupExecuteResponse, type SessionInfo, type SessionRef, type SessionStatus } from "../api";
import type { AppState } from "../appState"; import type { AppState } from "../appState";
import { forgetCachedNewSession, isCachedNewSessionInfo, markCachedNewSessionInfo, rememberCachedNewSession, stripCachedNewSessionMarker } from "../cachedNewSessions"; import { forgetCachedNewSession, isCachedNewSessionInfo, markCachedNewSessionInfo, mergeCachedNewSessions, rememberCachedNewSession, stripCachedNewSessionMarker } from "../cachedNewSessions";
import { textMessage } from "../chatMessages"; import { textMessage } from "../chatMessages";
import { machineSessionKey } from "../machineKeys"; import { machineSessionKey } from "../machineKeys";
import { clearDraft, moveDraft, saveDraft } from "../promptDraftStorage"; import { clearDraft, moveDraft, saveDraft } from "../promptDraftStorage";
@@ -358,6 +358,58 @@ export class SessionController {
this.applyBulkSessionError("Delete", results); this.applyBulkSessionError("Delete", results);
} }
async applySessionCleanupResult(result: SessionCleanupExecuteResponse, machineId = selectedMachineId(this.getState())): Promise<void> {
if (selectedMachineId(this.getState()) !== machineId) return;
const archivedIds = result.archivedSessionIds;
const deletedIds = result.deletedSessionIds;
if (archivedIds.length > 0 || deletedIds.length > 0) {
const state = this.getState();
const deletedIdSet = new Set(deletedIds);
const affectedIds = [...archivedIds, ...deletedIds];
const nextSessions = markSessionsArchived(state.sessions, archivedIds, result.generatedAt).filter((session) => !deletedIdSet.has(session.id));
const selectedAffected = state.selectedSession !== undefined && affectedIds.includes(state.selectedSession.id);
this.setState({
sessions: nextSessions,
sessionStatuses: omitKeys(state.sessionStatuses, affectedIds),
sessionActivities: omitKeys(state.sessionActivities, affectedIds),
...(selectedAffected ? { status: undefined, activity: undefined } : {}),
});
if (state.selectedSession !== undefined && deletedIdSet.has(state.selectedSession.id)) {
const next = nextSessions.find((session) => session.archived !== true) ?? nextSessions[0];
if (next !== undefined) await this.selectSession(next);
else this.deselectSession({ forgetRememberedSelection: true });
} else {
const selectionChange = selectionAfterArchivingSessions(nextSessions, state.selectedSession?.id, archivedIds);
if (selectionChange.type === "select") await this.selectSession(selectionChange.session);
else if (selectionChange.type === "clear") this.deselectSession({ forgetRememberedSelection: true });
}
}
await this.refreshCurrentWorkspaceSessions(machineId);
}
async refreshCurrentWorkspaceSessions(machineId = selectedMachineId(this.getState())): Promise<void> {
const workspace = this.getState().selectedWorkspace;
if (workspace === undefined) return;
try {
const sessions = mergeCachedNewSessions(workspace.path, await this.api.sessions(workspace.path, machineId), machineId);
if (selectedMachineId(this.getState()) !== machineId || this.getState().selectedWorkspace?.id !== workspace.id) return;
const selectedSession = this.getState().selectedSession;
this.setState({ sessions });
if (selectedSession === undefined) return;
const refreshedSelected = sessions.find((session) => session.id === selectedSession.id);
if (refreshedSelected !== undefined) {
if (refreshedSelected !== selectedSession) this.setState({ selectedSession: refreshedSelected });
return;
}
const next = sessions.find((session) => session.archived !== true) ?? sessions[0];
if (next !== undefined) await this.selectSession(next);
else this.deselectSession({ forgetRememberedSelection: true });
} catch (error) {
if (selectedMachineId(this.getState()) === machineId && this.getState().selectedWorkspace?.id === workspace.id) this.setState({ error: String(error) });
}
}
async deleteCachedNewSession(session = this.getState().selectedSession) { async deleteCachedNewSession(session = this.getState().selectedSession) {
if (!isCachedNewSessionInfo(session)) return; if (!isCachedNewSessionInfo(session)) return;
void this.api.stop(session, selectedMachineId(this.getState())).catch(() => { void this.api.stop(session, selectedMachineId(this.getState())).catch(() => {
@@ -727,6 +779,12 @@ function omitKey<T>(record: Record<string, T>, key: string): Record<string, T> {
return Object.fromEntries(Object.entries(record).filter(([id]) => id !== key)); return Object.fromEntries(Object.entries(record).filter(([id]) => id !== key));
} }
function omitKeys<T>(record: Record<string, T>, keys: readonly string[]): Record<string, T> {
if (keys.length === 0) return record;
const removed = new Set(keys);
return Object.fromEntries(Object.entries(record).filter(([id]) => !removed.has(id)));
}
function uniqueSessionsById(sessions: readonly SessionInfo[]): SessionInfo[] { function uniqueSessionsById(sessions: readonly SessionInfo[]): SessionInfo[] {
const seen = new Set<string>(); const seen = new Set<string>();
const unique: SessionInfo[] = []; const unique: SessionInfo[] = [];
+16 -3
View File
@@ -1,5 +1,5 @@
import { isSessionActive } from "../../../../shared/activity"; import { isSessionActive } from "../../../../shared/activity";
import { PI_WEB_CAPABILITIES, supportsPiWebCapability } from "../../../../shared/capabilities"; import { PI_WEB_CAPABILITIES, supportsPiWebCapability, type PiWebCapability } from "../../../../shared/capabilities";
import type { AppState } from "../../appState"; import type { AppState } from "../../appState";
import { isCachedNewSessionInfo } from "../../cachedNewSessions"; import { isCachedNewSessionInfo } from "../../cachedNewSessions";
import { selectedMachineId } from "../../controllers/types"; import { selectedMachineId } from "../../controllers/types";
@@ -181,6 +181,7 @@ export function createCoreActions(): PluginAction[] {
description: "Re-read the selected session from disk to pick up entries written by another process", description: "Re-read the selected session from disk to pick up entries written by another process",
group: "Session", group: "Session",
enabled: hasReloadableSession, enabled: hasReloadableSession,
disabledReason: reloadSessionDisabledReason,
run: (context) => context.reloadSession(), run: (context) => context.reloadSession(),
}, },
{ {
@@ -227,7 +228,19 @@ function hasCachedNewSession(context: { state: AppState }): boolean {
function hasReloadableSession(context: { state: AppState }): boolean { function hasReloadableSession(context: { state: AppState }): boolean {
const session = context.state.selectedSession; const session = context.state.selectedSession;
if (session === undefined || session.archived === true || isCachedNewSessionInfo(session)) return false; if (session === undefined || session.archived === true || isCachedNewSessionInfo(session)) return false;
const runtime = context.state.machineRuntimes[selectedMachineId(context.state)]; if (reloadSessionDisabledReason(context) !== undefined) return false;
if (runtime?.ok !== true || !supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsReload)) return false;
return !isSessionActive(context.state.status, context.state.activity); return !isSessionActive(context.state.status, context.state.activity);
} }
function reloadSessionDisabledReason(context: { state: AppState }): string | undefined {
const session = context.state.selectedSession;
if (session === undefined || session.archived === true || isCachedNewSessionInfo(session)) return undefined;
if (isSessionActive(context.state.status, context.state.activity)) return undefined;
return missingCapabilityReason(context.state, PI_WEB_CAPABILITIES.sessionsReload, "reload sessions");
}
function missingCapabilityReason(state: AppState, capability: PiWebCapability, action: string): string | undefined {
const runtime = state.machineRuntimes[selectedMachineId(state)];
if (runtime?.ok === true && supportsPiWebCapability(runtime, capability)) return undefined;
return `Update and restart Pi-Web on ${state.selectedMachine?.name ?? "this machine"} to ${action}.`;
}
+3 -1
View File
@@ -196,7 +196,9 @@ describe("PluginRegistry", () => {
expect(reloadable.find((action) => action.id === "core:session.reload")?.enabled).toBe(true); expect(reloadable.find((action) => action.id === "core:session.reload")?.enabled).toBe(true);
const noCapability = registry.getActions(createContext({ selectedSession: testSession() }).context); const noCapability = registry.getActions(createContext({ selectedSession: testSession() }).context);
expect(noCapability.find((action) => action.id === "core:session.reload")?.enabled).toBe(false); const noCapabilityReload = noCapability.find((action) => action.id === "core:session.reload");
expect(noCapabilityReload?.enabled).toBe(false);
expect(noCapabilityReload?.disabledReason).toBe("Update and restart Pi-Web on this machine to reload sessions.");
const archived = registry.getActions(createContext({ selectedSession: { ...testSession(), archived: true, archivedAt: "2026-05-20T00:00:00.000Z" }, machineRuntimes: reloadRuntime }).context); const archived = registry.getActions(createContext({ selectedSession: { ...testSession(), archived: true, archivedAt: "2026-05-20T00:00:00.000Z" }, machineRuntimes: reloadRuntime }).context);
expect(archived.find((action) => action.id === "core:session.reload")?.enabled).toBe(false); expect(archived.find((action) => action.id === "core:session.reload")?.enabled).toBe(false);
+2
View File
@@ -60,6 +60,7 @@ export class PluginRegistry {
return this.actions.filter((action) => this.isContributionActive(action.pluginId, action.machineId, selectedMachineId, action.sourcePluginId)).map((action) => { return this.actions.filter((action) => this.isContributionActive(action.pluginId, action.machineId, selectedMachineId, action.sourcePluginId)).map((action) => {
const scopedContext = pluginRuntimeContextFor(context, action.pluginId); const scopedContext = pluginRuntimeContextFor(context, action.pluginId);
const enabled = action.enabled?.(scopedContext); const enabled = action.enabled?.(scopedContext);
const disabledReason = enabled === false ? action.disabledReason?.(scopedContext) : undefined;
const qualified: QualifiedPluginAction = { const qualified: QualifiedPluginAction = {
id: action.id, id: action.id,
pluginId: action.pluginId, pluginId: action.pluginId,
@@ -72,6 +73,7 @@ export class PluginRegistry {
if (action.shortcut !== undefined) qualified.shortcut = action.shortcut; if (action.shortcut !== undefined) qualified.shortcut = action.shortcut;
if (action.group !== undefined) qualified.group = action.group; if (action.group !== undefined) qualified.group = action.group;
if (enabled !== undefined) qualified.enabled = enabled; if (enabled !== undefined) qualified.enabled = enabled;
if (disabledReason !== undefined && disabledReason !== "") qualified.disabledReason = disabledReason;
return qualified; return qualified;
}); });
} }
+2
View File
@@ -128,6 +128,8 @@ export interface PluginAction {
shortcut?: string; shortcut?: string;
group?: string; group?: string;
enabled?: (context: PluginRuntimeContext) => boolean; enabled?: (context: PluginRuntimeContext) => boolean;
/** Explain why a disabled action is visible but unavailable. */
disabledReason?: (context: PluginRuntimeContext) => string | undefined;
run: (context: PluginRuntimeContext) => void | Promise<void>; run: (context: PluginRuntimeContext) => void | Promise<void>;
} }
+83
View File
@@ -0,0 +1,83 @@
import { describe, expect, it } from "vitest";
import type { SessionCleanupPreviewResponse } from "./api";
import { canRunSessionCleanup, confirmSessionCleanup, selectedSessionCleanupProjectCwds, sessionCleanupConfirmationMessage, sessionCleanupPreviewForSelectedProjects, sessionCleanupRequestKey, sessionCleanupUnavailableMessage, validateSessionCleanupDraft, type SessionCleanupDraft } from "./sessionCleanupUi";
const draft: SessionCleanupDraft = {
archiveIdleEnabled: true,
archiveIdleDays: "30",
deleteArchivedEnabled: true,
deleteArchivedDays: "90",
};
const preview: SessionCleanupPreviewResponse = {
generatedAt: "2026-06-25T12:00:00.000Z",
thresholds: { archiveIdleDays: 30, deleteArchivedDays: 90 },
projects: [{ cwd: "/repo", archiveCount: 2, deleteCount: 1 }],
totals: { archiveCount: 2, deleteCount: 1 },
};
describe("session cleanup UI helpers", () => {
it("builds request thresholds from enabled runtime inputs", () => {
expect(validateSessionCleanupDraft(draft)).toEqual({
ok: true,
request: { archiveIdleDays: 30, deleteArchivedDays: 90 },
});
expect(validateSessionCleanupDraft({ ...draft, archiveIdleEnabled: false })).toEqual({
ok: true,
request: { archiveIdleDays: null, deleteArchivedDays: 90 },
});
});
it("validates threshold inputs before preview or execution", () => {
expect(validateSessionCleanupDraft({ ...draft, archiveIdleDays: "1.5" })).toEqual({ ok: false, error: "Archive idle sessions after must be a non-negative whole number of days." });
expect(validateSessionCleanupDraft({ ...draft, deleteArchivedDays: "-1" })).toEqual({ ok: false, error: "Delete archived sessions after must be a non-negative whole number of days." });
expect(validateSessionCleanupDraft({ ...draft, archiveIdleEnabled: false, deleteArchivedEnabled: false })).toEqual({ ok: false, error: "Enable at least one cleanup action." });
});
it("requires a current preview before cleanup can run", () => {
const validation = validateSessionCleanupDraft(draft);
if (!validation.ok) throw new Error(validation.error);
expect(canRunSessionCleanup({ canCleanup: true, draft, preview, previewRequest: validation.request })).toBe(true);
expect(canRunSessionCleanup({ canCleanup: true, draft: { ...draft, archiveIdleDays: "31" }, preview, previewRequest: validation.request })).toBe(false);
expect(canRunSessionCleanup({ canCleanup: true, draft, preview: { ...preview, totals: { archiveCount: 0, deleteCount: 0 } }, previewRequest: validation.request })).toBe(false);
expect(canRunSessionCleanup({ canCleanup: false, draft, preview, previewRequest: validation.request })).toBe(false);
});
it("normalizes request keys for null, omitted disabled actions, and selected projects", () => {
expect(sessionCleanupRequestKey({ archiveIdleDays: 30 })).toBe(sessionCleanupRequestKey({ archiveIdleDays: 30, deleteArchivedDays: null, projectCwds: ["/repo"] }));
});
it("summarizes the preview for selected projects", () => {
const multiProjectPreview: SessionCleanupPreviewResponse = {
...preview,
projects: [
{ cwd: "/repo-a", archiveCount: 2, deleteCount: 1 },
{ cwd: "/repo-b", archiveCount: 0, deleteCount: 3 },
],
totals: { archiveCount: 2, deleteCount: 4 },
};
expect(selectedSessionCleanupProjectCwds(multiProjectPreview, undefined)).toEqual(["/repo-a", "/repo-b"]);
expect(selectedSessionCleanupProjectCwds(multiProjectPreview, ["/missing", "/repo-b"])).toEqual(["/repo-b"]);
expect(sessionCleanupPreviewForSelectedProjects(multiProjectPreview, ["/repo-b"])).toMatchObject({
projects: [{ cwd: "/repo-b", archiveCount: 0, deleteCount: 3 }],
totals: { archiveCount: 0, deleteCount: 3 },
});
expect(sessionCleanupPreviewForSelectedProjects(multiProjectPreview, [])).toMatchObject({
projects: [],
totals: { archiveCount: 0, deleteCount: 0 },
});
});
it("uses explicit permanent deletion copy in confirmation and unavailable messages", () => {
const confirmMessages: string[] = [];
expect(confirmSessionCleanup(preview, (message) => {
confirmMessages.push(message);
return true;
})).toBe(true);
expect(confirmMessages[0]).toContain("permanently delete 1 archived session");
expect(sessionCleanupConfirmationMessage(preview)).toContain("cannot be undone");
expect(sessionCleanupUnavailableMessage("Remote Dev")).toBe("Update and restart Pi-Web on Remote Dev to clean up sessions.");
});
});
+116
View File
@@ -0,0 +1,116 @@
import type { SessionCleanupPreviewResponse, SessionCleanupRequest } from "./api";
export interface SessionCleanupDraft {
archiveIdleEnabled: boolean;
archiveIdleDays: string;
deleteArchivedEnabled: boolean;
deleteArchivedDays: string;
}
export type SessionCleanupDraftValidation =
| { ok: true; request: SessionCleanupRequest }
| { ok: false; error: string };
export const DEFAULT_SESSION_CLEANUP_DRAFT: SessionCleanupDraft = {
archiveIdleEnabled: true,
archiveIdleDays: "30",
deleteArchivedEnabled: false,
deleteArchivedDays: "90",
};
export function validateSessionCleanupDraft(draft: SessionCleanupDraft): SessionCleanupDraftValidation {
if (!draft.archiveIdleEnabled && !draft.deleteArchivedEnabled) return { ok: false, error: "Enable at least one cleanup action." };
const request: SessionCleanupRequest = {
archiveIdleDays: null,
deleteArchivedDays: null,
};
if (draft.archiveIdleEnabled) {
const archiveIdleDays = parseDayThreshold(draft.archiveIdleDays, "Archive idle sessions after");
if (typeof archiveIdleDays === "string") return { ok: false, error: archiveIdleDays };
request.archiveIdleDays = archiveIdleDays;
}
if (draft.deleteArchivedEnabled) {
const deleteArchivedDays = parseDayThreshold(draft.deleteArchivedDays, "Delete archived sessions after");
if (typeof deleteArchivedDays === "string") return { ok: false, error: deleteArchivedDays };
request.deleteArchivedDays = deleteArchivedDays;
}
return { ok: true, request };
}
export function sessionCleanupRequestKey(request: SessionCleanupRequest | undefined): string {
// The preview freshness key is threshold-only: project selection is applied
// to the already-previewed project list and sent separately when running.
return JSON.stringify({
archiveIdleDays: request?.archiveIdleDays ?? null,
deleteArchivedDays: request?.deleteArchivedDays ?? null,
});
}
export function canRunSessionCleanup(input: {
canCleanup: boolean;
draft: SessionCleanupDraft;
preview: SessionCleanupPreviewResponse | undefined;
previewRequest: SessionCleanupRequest | undefined;
loading?: boolean;
running?: boolean;
}): boolean {
if (!input.canCleanup || input.loading === true || input.running === true || input.preview === undefined) return false;
const validation = validateSessionCleanupDraft(input.draft);
if (!validation.ok) return false;
if (sessionCleanupRequestKey(validation.request) !== sessionCleanupRequestKey(input.previewRequest)) return false;
return sessionCleanupPreviewHasTargets(input.preview);
}
export function sessionCleanupPreviewHasTargets(preview: Pick<SessionCleanupPreviewResponse, "totals">): boolean {
return preview.totals.archiveCount > 0 || preview.totals.deleteCount > 0;
}
export function selectedSessionCleanupProjectCwds(preview: Pick<SessionCleanupPreviewResponse, "projects">, selectedProjectCwds: readonly string[] | undefined): string[] {
const previewCwds = preview.projects.map((project) => project.cwd);
if (selectedProjectCwds === undefined) return previewCwds;
const selected = new Set(selectedProjectCwds);
return previewCwds.filter((cwd) => selected.has(cwd));
}
export function sessionCleanupPreviewForSelectedProjects(preview: SessionCleanupPreviewResponse, selectedProjectCwds: readonly string[] | undefined): SessionCleanupPreviewResponse {
const selected = new Set(selectedSessionCleanupProjectCwds(preview, selectedProjectCwds));
const projects = preview.projects.filter((project) => selected.has(project.cwd));
return {
...preview,
projects,
totals: projects.reduce((totals, project) => ({
archiveCount: totals.archiveCount + project.archiveCount,
deleteCount: totals.deleteCount + project.deleteCount,
}), { archiveCount: 0, deleteCount: 0 }),
};
}
export function confirmSessionCleanup(preview: Pick<SessionCleanupPreviewResponse, "totals">, confirmCleanup: (message: string) => boolean): boolean {
return confirmCleanup(sessionCleanupConfirmationMessage(preview));
}
export function sessionCleanupConfirmationMessage(preview: Pick<SessionCleanupPreviewResponse, "totals">): string {
const archiveCount = preview.totals.archiveCount;
const deleteCount = preview.totals.deleteCount;
const parts: string[] = [];
if (archiveCount > 0) parts.push(`archive ${String(archiveCount)} idle ${archiveCount === 1 ? "session" : "sessions"}`);
if (deleteCount > 0) parts.push(`permanently delete ${String(deleteCount)} archived ${deleteCount === 1 ? "session" : "sessions"}`);
const action = parts.length === 0 ? "run cleanup" : parts.join(" and ");
return `Run cleanup and ${action}?\n\nPermanent deletion only applies to archived sessions and cannot be undone.`;
}
export function sessionCleanupUnavailableMessage(machineName: string | undefined): string {
return `Update and restart Pi-Web on ${machineName ?? "this machine"} to clean up sessions.`;
}
function parseDayThreshold(value: string, label: string): number | string {
const trimmed = value.trim();
if (trimmed === "") return `${label} must be set.`;
const parsed = Number(trimmed);
if (!Number.isInteger(parsed) || parsed < 0) return `${label} must be a non-negative whole number of days.`;
return parsed;
}
+2
View File
@@ -111,6 +111,8 @@ export interface PluginAction {
shortcut?: string; shortcut?: string;
group?: string; group?: string;
enabled?: (context: PluginRuntimeContext) => boolean; enabled?: (context: PluginRuntimeContext) => boolean;
/** Explain why a disabled action is visible but unavailable. */
disabledReason?: (context: PluginRuntimeContext) => string | undefined;
run: (context: PluginRuntimeContext) => void | Promise<void>; run: (context: PluginRuntimeContext) => void | Promise<void>;
} }
@@ -72,6 +72,16 @@ describe("Pi session manager gateway", () => {
await expect(gateway.listAll()).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: "session-a", cwd }), expect.objectContaining({ id: "session-b", cwd: otherCwd })])); await expect(gateway.listAll()).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: "session-a", cwd }), expect.objectContaining({ id: "session-b", cwd: otherCwd })]));
}); });
it("includes an absolute env-configured session directory in global listing", async () => {
const envSessionDir = join(tempDir, "env-sessions");
await writeSessionFile(defaultPiSessionDir(cwd, agentDir), "default-session", cwd);
await writeSessionFile(envSessionDir, "env-session", cwd);
const gateway = createPiSessionManagerGateway({ agentDir, env: { PI_CODING_AGENT_SESSION_DIR: envSessionDir } });
if (gateway.listAll === undefined) throw new Error("Expected legacy listing support");
await expect(gateway.listAll()).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: "default-session", cwd }), expect.objectContaining({ id: "env-session", cwd })]));
});
it("lists only sessions for the requested cwd when a custom Pi sessionDir is shared", async () => { it("lists only sessions for the requested cwd when a custom Pi sessionDir is shared", async () => {
const sharedSessionDir = join(tempDir, "shared-sessions"); const sharedSessionDir = join(tempDir, "shared-sessions");
const otherCwd = join(tempDir, "other-workspace"); const otherCwd = join(tempDir, "other-workspace");
+20 -2
View File
@@ -34,6 +34,13 @@ export class SessionDirResolver {
return defaultPiSessionsRoot(this.agentDir); return defaultPiSessionsRoot(this.agentDir);
} }
globalEnvSessionDir(): string | undefined {
const envSessionDir = this.env[PI_SESSION_DIR_ENV];
if (envSessionDir === undefined || envSessionDir === "") return undefined;
const expanded = expandTildePath(envSessionDir);
return isAbsolute(expanded) ? expanded : undefined;
}
resolve(cwd: string): SessionDirResolution { resolve(cwd: string): SessionDirResolution {
const envSessionDir = this.env[PI_SESSION_DIR_ENV]; const envSessionDir = this.env[PI_SESSION_DIR_ENV];
if (envSessionDir !== undefined && envSessionDir !== "") { if (envSessionDir !== undefined && envSessionDir !== "") {
@@ -68,8 +75,13 @@ class SettingsAwarePiSessionManagerGateway implements PiSessionManagerGateway {
return SessionManager.create(cwd, resolution.sessionDir, options?.parentSession === undefined ? undefined : { parentSession: options.parentSession }); return SessionManager.create(cwd, resolution.sessionDir, options?.parentSession === undefined ? undefined : { parentSession: options.parentSession });
} }
listAll(): Promise<PiSessionListEntry[]> { async listAll(): Promise<PiSessionListEntry[]> {
return listSessionsInDefaultPiStore(this.resolver.defaultSessionsRoot()); const envSessionDir = this.resolver.globalEnvSessionDir();
const [defaultSessions, envSessions] = await Promise.all([
listSessionsInDefaultPiStore(this.resolver.defaultSessionsRoot()),
envSessionDir === undefined ? Promise.resolve([]) : listSessionsInDir(envSessionDir),
]);
return uniqueSessionsByPath([...defaultSessions, ...envSessions]);
} }
open(path: string): PiSessionManager { open(path: string): PiSessionManager {
@@ -106,6 +118,12 @@ export function filterSessionsForCwd(sessions: readonly PiSessionListEntry[], cw
return sessions.filter((session) => session.cwd !== "" && cwdPathsEqual(session.cwd, cwd)); return sessions.filter((session) => session.cwd !== "" && cwdPathsEqual(session.cwd, cwd));
} }
function uniqueSessionsByPath(sessions: readonly PiSessionListEntry[]): PiSessionListEntry[] {
const byPath = new Map<string, PiSessionListEntry>();
for (const session of sessions) byPath.set(session.path, session);
return [...byPath.values()].sort((a, b) => b.modified.getTime() - a.modified.getTime());
}
export function defaultPiSessionsRoot(agentDir = getAgentDir()): string { export function defaultPiSessionsRoot(agentDir = getAgentDir()): string {
return join(agentDir, "sessions"); return join(agentDir, "sessions");
} }
@@ -446,6 +446,94 @@ describe("PiSessionService", () => {
await service.dispose(); await service.dispose();
}); });
it("previews session cleanup without mutating and executes a recomputed plan", async () => {
const archivedInputs: string[] = [];
const deletedSessionIds: string[] = [];
let listAllCalls = 0;
const archived = { sessionId: "archived-old", cwd: "/old-project", archivedAt: "2026-04-01T00:00:00.000Z", archivePath: "/archive/archived-old.jsonl" };
const otherArchived = { sessionId: "archived-other", cwd: "/other-project", archivedAt: "2026-04-01T00:00:00.000Z", archivePath: "/archive/archived-other.jsonl" };
const service = new PiSessionService(new CapturingSessionEventHub(), {
now: () => new Date("2026-06-25T00:00:00.000Z"),
archiveStore: {
list: () => Promise.resolve([archived, otherArchived]),
get: () => Promise.resolve(undefined),
archive: (input) => {
archivedInputs.push(input.sessionId);
return Promise.resolve({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-06-25T00:00:00.000Z" });
},
restore: () => Promise.resolve(),
isArchived: () => Promise.resolve(false),
deleteArchived: (sessionId) => {
deletedSessionIds.push(sessionId);
return Promise.resolve();
},
},
sessionManager: {
create: () => fakeSessionManager(),
list: () => Promise.resolve([]),
listAll: () => {
listAllCalls += 1;
return Promise.resolve([
listAllCalls === 1 ? sessionRecord("preview-only", "/old-project") : sessionRecord("execute-only", "/old-project"),
listAllCalls === 1 ? sessionRecord("preview-other", "/other-project") : sessionRecord("execute-other", "/other-project"),
]);
},
open: () => fakeSessionManager(),
},
heartbeatIntervalMs: 60_000,
});
const preview = await service.cleanupPreview({ thresholds: { archiveIdleDays: 30, deleteArchivedDays: 30 }, projectCwds: ["/old-project"] });
expect(preview.totals).toEqual({ archiveCount: 1, deleteCount: 1 });
expect(preview.projects).toEqual([{ cwd: "/old-project", archiveCount: 1, deleteCount: 1 }]);
expect(archivedInputs).toEqual([]);
expect(deletedSessionIds).toEqual([]);
const result = await service.cleanup({ thresholds: { archiveIdleDays: 30, deleteArchivedDays: 30 }, projectCwds: ["/old-project"] });
expect(result.archivedSessionIds).toEqual(["execute-only"]);
expect(result.deletedSessionIds).toEqual(["archived-old"]);
expect(archivedInputs).toEqual(["execute-only"]);
expect(deletedSessionIds).toEqual(["archived-old"]);
await service.dispose();
});
it("skips busy active sessions during cleanup execution", async () => {
const fake = fakeRuntime("busy-open", { isStreaming: true, sessionManager: fakeSessionManager("/old-project"), sessionFile: "/sessions/busy-open.jsonl" });
const archivedInputs: string[] = [];
const service = new PiSessionService(new CapturingSessionEventHub(), {
now: () => new Date("2026-06-25T00:00:00.000Z"),
createAgentRuntime: runtimeCreator(fake.runtime),
archiveStore: {
list: () => Promise.resolve([]),
get: () => Promise.resolve(undefined),
archive: (input) => {
archivedInputs.push(input.sessionId);
return Promise.resolve({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-06-25T00:00:00.000Z" });
},
restore: () => Promise.resolve(),
isArchived: () => Promise.resolve(false),
},
sessionManager: {
create: () => fakeSessionManager("/old-project"),
list: () => Promise.resolve([sessionRecord("busy-open", "/old-project")]),
listAll: () => Promise.resolve([sessionRecord("busy-open", "/old-project")]),
open: () => fakeSessionManager("/old-project"),
},
heartbeatIntervalMs: 60_000,
});
await service.status("busy-open");
const result = await service.cleanup({ thresholds: { archiveIdleDays: 1 } });
expect(result.archivedSessionIds).toEqual([]);
expect(result.skippedBusySessionIds).toEqual(["busy-open"]);
expect(archivedInputs).toEqual([]);
expect(fake.calls.abort).toBe(0);
await service.dispose();
});
it("reloads a session by closing the active runtime and re-opening it from disk", async () => { it("reloads a session by closing the active runtime and re-opening it from disk", async () => {
const first = fakeRuntime("reload-session"); const first = fakeRuntime("reload-session");
const second = fakeRuntime("reload-session"); const second = fakeRuntime("reload-session");
+82 -1
View File
@@ -13,7 +13,7 @@ import {
type CreateAgentSessionRuntimeFactory, type CreateAgentSessionRuntimeFactory,
type EditToolDetails, type EditToolDetails,
} from "@earendil-works/pi-coding-agent"; } from "@earendil-works/pi-coding-agent";
import type { ClientArchiveSessionsResponse, ClientCommand, ClientCommandResult, ClientMessagePage, ClientSession, ClientSessionModel, ClientSessionRef, ClientSessionStatus, ClientThinkingLevel, SessionUiEvent } from "../types.js"; import type { ClientArchiveSessionsResponse, ClientCommand, ClientCommandResult, ClientMessagePage, ClientSession, ClientSessionCleanupExecuteResponse, ClientSessionCleanupPreviewResponse, ClientSessionModel, ClientSessionRef, ClientSessionStatus, ClientThinkingLevel, SessionUiEvent } from "../types.js";
import { pageMessagesAtSafeBoundary } from "./messagePaging.js"; import { pageMessagesAtSafeBoundary } from "./messagePaging.js";
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";
@@ -34,6 +34,7 @@ import type { WorkspaceActivityService } from "../activity/workspaceActivityServ
import { createSpawnSessionToolDefinition, type SpawnSessionInvocation, type SpawnSessionResult } from "./spawnSessionTool.js"; import { createSpawnSessionToolDefinition, type SpawnSessionInvocation, type SpawnSessionResult } from "./spawnSessionTool.js";
import { createSubsessionToolDefinitions, type SpawnSubsessionInvocation, type SpawnSubsessionResult, type SubsessionCheckResult, type SubsessionReadQuery, type SubsessionReadResult, type SubsessionStatus, type SubsessionSummary, type SubsessionToolDeps } from "./spawnSubsessionTool.js"; import { createSubsessionToolDefinitions, type SpawnSubsessionInvocation, type SpawnSubsessionResult, type SubsessionCheckResult, type SubsessionReadQuery, type SubsessionReadResult, type SubsessionStatus, type SubsessionSummary, type SubsessionToolDeps } from "./spawnSubsessionTool.js";
import { buildTranscriptView } from "./subsessionTranscript.js"; import { buildTranscriptView } from "./subsessionTranscript.js";
import { planSessionCleanup, summarizeSessionCleanupExecution, type NormalizedSessionCleanupRequest, type SessionCleanupPlan } from "./sessionCleanup.js";
import type { SpawnTargetDecision, SpawnTargetResolver } from "./spawnTargetResolver.js"; import type { SpawnTargetDecision, SpawnTargetResolver } from "./spawnTargetResolver.js";
/** /**
@@ -298,6 +299,8 @@ export interface PiSessionServiceDependencies {
subsessionsEnabled?: boolean; subsessionsEnabled?: boolean;
/** Structured logger for notable runtime events (e.g. spawns). */ /** Structured logger for notable runtime events (e.g. spawns). */
logger?: PiSessionLogger; logger?: PiSessionLogger;
/** Clock seam for cleanup planning tests. */
now?: () => Date;
} }
export class PiSessionService { export class PiSessionService {
@@ -331,6 +334,7 @@ export class PiSessionService {
private readonly workspaceActivity: Pick<WorkspaceActivityService, "applySessionStatus" | "applySessionActivity" | "removeSession" | "reconcileSessionActivity"> | undefined; private readonly workspaceActivity: Pick<WorkspaceActivityService, "applySessionStatus" | "applySessionActivity" | "removeSession" | "reconcileSessionActivity"> | undefined;
private readonly spawnTargets: SpawnTargetResolver | undefined; private readonly spawnTargets: SpawnTargetResolver | undefined;
private readonly logger: PiSessionLogger; private readonly logger: PiSessionLogger;
private readonly now: () => Date;
constructor(private readonly events: SessionEventHub, deps: PiSessionServiceDependencies = {}) { constructor(private readonly events: SessionEventHub, deps: PiSessionServiceDependencies = {}) {
this.archiveStore = deps.archiveStore ?? new SessionArchiveStore(); this.archiveStore = deps.archiveStore ?? new SessionArchiveStore();
@@ -339,6 +343,7 @@ export class PiSessionService {
this.modelRegistry = deps.modelRegistry ?? ModelRegistry.create(AuthStorage.create()); this.modelRegistry = deps.modelRegistry ?? ModelRegistry.create(AuthStorage.create());
this.spawnTargets = deps.spawnTargets; this.spawnTargets = deps.spawnTargets;
this.logger = deps.logger ?? noopLogger; this.logger = deps.logger ?? noopLogger;
this.now = deps.now ?? (() => new Date());
// Subsessions are a beta capability gated behind their own flag, and they // Subsessions are a beta capability gated behind their own flag, and they
// also require the spawn capability (they share its project-scope resolver). // also require the spawn capability (they share its project-scope resolver).
const subsessionsActive = this.spawnTargets !== undefined && deps.subsessionsEnabled === true; const subsessionsActive = this.spawnTargets !== undefined && deps.subsessionsEnabled === true;
@@ -378,6 +383,48 @@ export class PiSessionService {
return this.active.size; return this.active.size;
} }
async cleanupPreview(request: NormalizedSessionCleanupRequest): Promise<ClientSessionCleanupPreviewResponse> {
return previewResponseFromPlan(await this.cleanupPlan(request));
}
async cleanup(request: NormalizedSessionCleanupRequest): Promise<ClientSessionCleanupExecuteResponse> {
const plan = await this.cleanupPlan(request);
if (plan.deleteRecords.length > 0 && this.archiveStore.deleteArchived === undefined) throw new Error("Archive store does not support deletion");
const archiveInputs: ArchiveSessionInput[] = [];
const deleteRecords: ArchivedSessionRecord[] = [];
const skippedBusySessionIds = new Set(plan.skippedBusySessionIds);
for (const input of plan.archiveInputs) {
if (this.activeSessionHasWork(input.sessionId)) {
skippedBusySessionIds.add(input.sessionId);
continue;
}
await this.closeActive(input.sessionId);
await this.archiveStore.archive(input);
archiveInputs.push(input);
}
for (const record of plan.deleteRecords) {
if (this.activeSessionHasWork(record.sessionId)) {
skippedBusySessionIds.add(record.sessionId);
continue;
}
await this.closeActive(record.sessionId);
if (record.archivePath === undefined) await this.ensureArchivedRecordMoved(record);
await this.archiveStore.deleteArchived?.(record.sessionId);
deleteRecords.push(record);
}
return summarizeSessionCleanupExecution({
archiveInputs,
deleteRecords,
thresholds: plan.thresholds,
generatedAt: plan.generatedAt,
skippedBusySessionIds: [...skippedBusySessionIds],
});
}
async dispose(): Promise<void> { async dispose(): Promise<void> {
clearInterval(this.heartbeat); clearInterval(this.heartbeat);
this.clearCompactionDrainTimers(); this.clearCompactionDrainTimers();
@@ -1093,6 +1140,30 @@ export class PiSessionService {
}); });
} }
private async cleanupPlan(request: NormalizedSessionCleanupRequest) {
const [sessions, archivedRecords] = await Promise.all([this.sessionManager.listAll?.() ?? [], this.archiveStore.list()]);
return planSessionCleanup({
sessions,
archivedRecords,
activeSessions: this.cleanupActiveSessionStatuses(),
thresholds: request.thresholds,
...(request.projectCwds === undefined ? {} : { projectCwds: request.projectCwds }),
now: this.now(),
});
}
private cleanupActiveSessionStatuses(): { sessionId: string; hasActiveWork: boolean }[] {
return [...new Set(this.active.values())].map((active) => ({
sessionId: active.runtime.session.sessionId,
hasActiveWork: this.hasActiveWork(active.runtime.session),
}));
}
private activeSessionHasWork(sessionId: string): boolean {
const active = this.active.get(sessionId);
return active !== undefined && this.hasActiveWork(active.runtime.session);
}
private reconcilableSessionIds(cwd: string, listedSessionIds: string[], archivedById: Map<string, ArchivedSessionRecord>): string[] { private reconcilableSessionIds(cwd: string, listedSessionIds: string[], archivedById: Map<string, ArchivedSessionRecord>): string[] {
const sessionIds = new Set(listedSessionIds); const sessionIds = new Set(listedSessionIds);
for (const active of new Set(this.active.values())) { for (const active of new Set(this.active.values())) {
@@ -1523,6 +1594,16 @@ export class PiSessionService {
} }
} }
function previewResponseFromPlan(plan: SessionCleanupPlan): ClientSessionCleanupPreviewResponse {
return {
generatedAt: plan.generatedAt,
thresholds: plan.thresholds,
projects: plan.projects,
totals: plan.totals,
...(plan.skippedBusySessionIds.length === 0 ? {} : { skippedBusySessionIds: plan.skippedBusySessionIds }),
};
}
function modelToClientModel(model: PiAgentSession["model"]): ClientSessionModel { function modelToClientModel(model: PiAgentSession["model"]): ClientSessionModel {
if (model === undefined) return {}; if (model === undefined) return {};
const name = getString(model, "name"); const name = getString(model, "name");
+108
View File
@@ -0,0 +1,108 @@
import { describe, expect, it } from "vitest";
import { normalizeSessionCleanupRequest, normalizeSessionCleanupThresholds, planSessionCleanup } from "./sessionCleanup.js";
import type { PiSessionListEntry } from "./piSessionService.js";
import type { ArchivedSessionRecord } from "./sessionArchiveStore.js";
describe("session cleanup planning", () => {
it("plans cleanup by strict cutoffs and groups counts by stored cwd", () => {
const now = new Date("2026-06-25T00:00:00.000Z");
const archivedRecords: ArchivedSessionRecord[] = [
archivedRecord("already-archived", "/unregistered", "2026-06-20T00:00:00.000Z"),
archivedRecord("delete-old", "/other", "2026-06-14T23:59:59.999Z"),
archivedRecord("keep-exact", "/other", "2026-06-15T00:00:00.000Z"),
];
const plan = planSessionCleanup({
now,
thresholds: { archiveIdleDays: 30, deleteArchivedDays: 10 },
archivedRecords,
sessions: [
sessionEntry("archive-old", "/unregistered", "2026-05-25T23:59:59.999Z"),
sessionEntry("keep-exact", "/unregistered", "2026-05-26T00:00:00.000Z"),
sessionEntry("keep-new", "/unregistered", "2026-05-26T00:00:00.001Z"),
sessionEntry("already-archived", "/unregistered", "2026-05-01T00:00:00.000Z"),
],
});
expect(plan.archiveInputs.map((input) => input.sessionId)).toEqual(["archive-old"]);
expect(plan.deleteRecords.map((record) => record.sessionId)).toEqual(["delete-old"]);
expect(plan.projects).toEqual([
{ cwd: "/other", archiveCount: 0, deleteCount: 1 },
{ cwd: "/unregistered", archiveCount: 1, deleteCount: 0 },
]);
expect(plan.totals).toEqual({ archiveCount: 1, deleteCount: 1 });
});
it("filters cleanup candidates to selected project cwd paths", () => {
const plan = planSessionCleanup({
now: new Date("2026-06-25T00:00:00.000Z"),
thresholds: { archiveIdleDays: 30, deleteArchivedDays: 30 },
projectCwds: ["/repo-a"],
sessions: [
sessionEntry("archive-a", "/repo-a", "2026-05-01T00:00:00.000Z"),
sessionEntry("archive-b", "/repo-b", "2026-05-01T00:00:00.000Z"),
],
archivedRecords: [
archivedRecord("delete-a", "/repo-a", "2026-05-01T00:00:00.000Z"),
archivedRecord("delete-b", "/repo-b", "2026-05-01T00:00:00.000Z"),
],
});
expect(plan.archiveInputs.map((input) => input.sessionId)).toEqual(["archive-a"]);
expect(plan.deleteRecords.map((record) => record.sessionId)).toEqual(["delete-a"]);
expect(plan.projects).toEqual([{ cwd: "/repo-a", archiveCount: 1, deleteCount: 1 }]);
expect(plan.totals).toEqual({ archiveCount: 1, deleteCount: 1 });
});
it("skips archive and delete candidates that are busy in memory", () => {
const plan = planSessionCleanup({
now: new Date("2026-06-25T00:00:00.000Z"),
thresholds: { archiveIdleDays: 1, deleteArchivedDays: 1 },
sessions: [sessionEntry("busy-open", "/repo", "2026-06-01T00:00:00.000Z")],
archivedRecords: [archivedRecord("busy-archived", "/repo", "2026-06-01T00:00:00.000Z")],
activeSessions: [
{ sessionId: "busy-open", hasActiveWork: true },
{ sessionId: "busy-archived", hasActiveWork: true },
],
});
expect(plan.archiveInputs).toHaveLength(0);
expect(plan.deleteRecords).toHaveLength(0);
expect(plan.skippedBusySessionIds).toEqual(["busy-archived", "busy-open"]);
expect(plan.totals).toEqual({ archiveCount: 0, deleteCount: 0 });
});
it("validates optional runtime thresholds", () => {
expect(normalizeSessionCleanupThresholds({ archiveIdleDays: 30, deleteArchivedDays: null })).toEqual({ archiveIdleDays: 30 });
expect(normalizeSessionCleanupThresholds({})).toEqual({});
expect(() => normalizeSessionCleanupThresholds({ archiveIdleDays: -1 })).toThrow("archiveIdleDays field must be a non-negative integer");
expect(() => normalizeSessionCleanupThresholds({ deleteArchivedDays: 1.5 })).toThrow("deleteArchivedDays field must be a non-negative integer");
expect(() => normalizeSessionCleanupThresholds({ archiveIdleDays: "30" })).toThrow("archiveIdleDays field must be a non-negative integer");
});
it("validates optional selected project cwd paths", () => {
expect(normalizeSessionCleanupRequest({ archiveIdleDays: 30, projectCwds: ["/repo", "/repo"] })).toEqual({
thresholds: { archiveIdleDays: 30 },
projectCwds: ["/repo"],
});
expect(normalizeSessionCleanupRequest({ projectCwds: null })).toEqual({ thresholds: {} });
expect(() => normalizeSessionCleanupRequest({ projectCwds: ["/repo", 1] })).toThrow("projectCwds field must be an array of strings");
});
});
function sessionEntry(id: string, cwd: string, modified: string): PiSessionListEntry {
return {
id,
cwd,
path: `/sessions/${id}.jsonl`,
created: new Date("2026-01-01T00:00:00.000Z"),
modified: new Date(modified),
messageCount: 1,
firstMessage: "hello",
allMessagesText: "hello",
};
}
function archivedRecord(sessionId: string, cwd: string, archivedAt: string): ArchivedSessionRecord {
return { sessionId, cwd, archivedAt };
}
+203
View File
@@ -0,0 +1,203 @@
import type { SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupThresholds } from "../../shared/apiTypes.js";
import type { PiSessionListEntry } from "./piSessionService.js";
import type { ArchivedSessionRecord, ArchiveSessionInput } from "./sessionArchiveStore.js";
const DAY_MS = 24 * 60 * 60 * 1000;
export interface CleanupActiveSessionStatus {
sessionId: string;
hasActiveWork: boolean;
}
export interface PlanSessionCleanupInput {
sessions: readonly PiSessionListEntry[];
archivedRecords: readonly ArchivedSessionRecord[];
activeSessions?: readonly CleanupActiveSessionStatus[];
thresholds: SessionCleanupThresholds;
projectCwds?: readonly string[];
now: Date;
}
export interface SessionCleanupPlan extends SessionCleanupPreviewResponse {
archiveInputs: ArchiveSessionInput[];
deleteRecords: ArchivedSessionRecord[];
skippedBusySessionIds: string[];
}
export interface NormalizedSessionCleanupRequest {
thresholds: SessionCleanupThresholds;
/** Stored cwd paths to include. Undefined means all discovered projects/workspaces. */
projectCwds?: string[];
}
export function normalizeSessionCleanupRequest(record: Record<string, unknown>): NormalizedSessionCleanupRequest {
const projectCwds = optionalProjectCwds(record);
return {
thresholds: normalizeSessionCleanupThresholds(record),
...(projectCwds === undefined ? {} : { projectCwds }),
};
}
export function normalizeSessionCleanupThresholds(record: Record<string, unknown>): SessionCleanupThresholds {
const thresholds: SessionCleanupThresholds = {};
const archiveIdleDays = optionalDayThreshold(record, "archiveIdleDays");
const deleteArchivedDays = optionalDayThreshold(record, "deleteArchivedDays");
if (archiveIdleDays !== undefined) thresholds.archiveIdleDays = archiveIdleDays;
if (deleteArchivedDays !== undefined) thresholds.deleteArchivedDays = deleteArchivedDays;
return thresholds;
}
export function planSessionCleanup(input: PlanSessionCleanupInput): SessionCleanupPlan {
const thresholds = copyThresholds(input.thresholds);
const archiveCutoff = cutoffTime(input.now, thresholds.archiveIdleDays);
const deleteCutoff = cutoffTime(input.now, thresholds.deleteArchivedDays);
const archivedIds = new Set(input.archivedRecords.map((record) => record.sessionId));
const includedCwds = input.projectCwds === undefined ? undefined : new Set(input.projectCwds);
const busySessionIds = new Set((input.activeSessions ?? []).filter((session) => session.hasActiveWork).map((session) => session.sessionId));
const skippedBusy = new Set<string>();
const archiveInputs: ArchiveSessionInput[] = [];
const deleteRecords: ArchivedSessionRecord[] = [];
if (archiveCutoff !== undefined) {
for (const session of uniqueSessionsById(input.sessions)) {
if (archivedIds.has(session.id)) continue;
if (includedCwds !== undefined && !includedCwds.has(session.cwd)) continue;
if (!isBefore(session.modified, archiveCutoff)) continue;
if (busySessionIds.has(session.id)) {
skippedBusy.add(session.id);
continue;
}
archiveInputs.push(archiveInputFromListEntry(session));
}
}
if (deleteCutoff !== undefined) {
for (const record of input.archivedRecords) {
if (includedCwds !== undefined && !includedCwds.has(record.cwd)) continue;
if (!isTimestampBefore(record.archivedAt, deleteCutoff)) continue;
if (busySessionIds.has(record.sessionId)) {
skippedBusy.add(record.sessionId);
continue;
}
deleteRecords.push(record);
}
}
return {
...summarizeSessionCleanupTargets({ archiveInputs, deleteRecords, thresholds, generatedAt: input.now.toISOString(), skippedBusySessionIds: [...skippedBusy] }),
archiveInputs,
deleteRecords,
skippedBusySessionIds: [...skippedBusy].sort(),
};
}
export function summarizeSessionCleanupTargets(input: {
archiveInputs: readonly ArchiveSessionInput[];
deleteRecords: readonly ArchivedSessionRecord[];
thresholds: SessionCleanupThresholds;
generatedAt: string;
skippedBusySessionIds?: readonly string[];
}): SessionCleanupPreviewResponse {
const projectsByCwd = new Map<string, SessionCleanupProjectSummary>();
let archiveCount = 0;
let deleteCount = 0;
for (const session of input.archiveInputs) {
archiveCount += 1;
projectSummary(projectsByCwd, session.cwd).archiveCount += 1;
}
for (const record of input.deleteRecords) {
deleteCount += 1;
projectSummary(projectsByCwd, record.cwd).deleteCount += 1;
}
const skippedBusySessionIds = [...new Set(input.skippedBusySessionIds ?? [])].sort();
return {
generatedAt: input.generatedAt,
thresholds: copyThresholds(input.thresholds),
projects: [...projectsByCwd.values()].sort((a, b) => a.cwd.localeCompare(b.cwd)),
totals: { archiveCount, deleteCount },
...(skippedBusySessionIds.length === 0 ? {} : { skippedBusySessionIds }),
};
}
export function summarizeSessionCleanupExecution(input: {
archiveInputs: readonly ArchiveSessionInput[];
deleteRecords: readonly ArchivedSessionRecord[];
thresholds: SessionCleanupThresholds;
generatedAt: string;
skippedBusySessionIds?: readonly string[];
}): SessionCleanupExecuteResponse {
return {
...summarizeSessionCleanupTargets(input),
archivedSessionIds: input.archiveInputs.map((session) => session.sessionId),
deletedSessionIds: input.deleteRecords.map((record) => record.sessionId),
};
}
function optionalDayThreshold(record: Record<string, unknown>, field: keyof SessionCleanupThresholds): number | undefined {
const value = record[field];
if (value === undefined || value === null) return undefined;
if (typeof value !== "number" || !Number.isInteger(value) || value < 0) throw new Error(`${field} field must be a non-negative integer`);
return value;
}
function optionalProjectCwds(record: Record<string, unknown>): string[] | undefined {
const value = record["projectCwds"];
if (value === undefined || value === null) return undefined;
if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) throw new Error("projectCwds field must be an array of strings");
return [...new Set(value)];
}
function cutoffTime(now: Date, days: number | undefined): number | undefined {
return days === undefined ? undefined : now.getTime() - days * DAY_MS;
}
function isBefore(value: Date, cutoff: number): boolean {
const time = value.getTime();
return Number.isFinite(time) && time < cutoff;
}
function isTimestampBefore(value: string, cutoff: number): boolean {
const time = Date.parse(value);
return Number.isFinite(time) && time < cutoff;
}
function uniqueSessionsById(sessions: readonly PiSessionListEntry[]): PiSessionListEntry[] {
const sessionsById = new Map<string, PiSessionListEntry>();
for (const session of sessions) {
const existing = sessionsById.get(session.id);
if (existing === undefined || session.modified.getTime() > existing.modified.getTime()) sessionsById.set(session.id, session);
}
return [...sessionsById.values()];
}
function archiveInputFromListEntry(session: PiSessionListEntry): ArchiveSessionInput {
return {
sessionId: session.id,
cwd: session.cwd,
path: session.path,
created: session.created.toISOString(),
modified: session.modified.toISOString(),
messageCount: session.messageCount,
firstMessage: session.firstMessage,
...(session.name === undefined ? {} : { name: session.name }),
...(session.parentSessionPath === undefined ? {} : { parentSessionPath: session.parentSessionPath }),
};
}
function projectSummary(projectsByCwd: Map<string, SessionCleanupProjectSummary>, cwd: string): SessionCleanupProjectSummary {
const existing = projectsByCwd.get(cwd);
if (existing !== undefined) return existing;
const created = { cwd, archiveCount: 0, deleteCount: 0 };
projectsByCwd.set(cwd, created);
return created;
}
function copyThresholds(thresholds: SessionCleanupThresholds): SessionCleanupThresholds {
const copy: SessionCleanupThresholds = {};
if (thresholds.archiveIdleDays !== undefined) copy.archiveIdleDays = thresholds.archiveIdleDays;
if (thresholds.deleteArchivedDays !== undefined) copy.deleteArchivedDays = thresholds.deleteArchivedDays;
return copy;
}
+54
View File
@@ -2,9 +2,11 @@ import { resolve } from "node:path";
import Fastify, { type FastifyInstance } from "fastify"; import Fastify, { type FastifyInstance } from "fastify";
import fastifyWebsocket from "@fastify/websocket"; import fastifyWebsocket from "@fastify/websocket";
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { SessionCleanupExecuteResponse, SessionCleanupPreviewResponse } from "../../shared/apiTypes.js";
import { SessionEventHub } from "../realtime/sessionEventHub.js"; import { SessionEventHub } from "../realtime/sessionEventHub.js";
import { PiSessionService, type PiSessionManagerGateway, type PiSessionRef } from "./piSessionService.js"; import { PiSessionService, type PiSessionManagerGateway, type PiSessionRef } from "./piSessionService.js";
import { registerSessionRoutes } from "./sessionRoutes.js"; import { registerSessionRoutes } from "./sessionRoutes.js";
import type { NormalizedSessionCleanupRequest } from "./sessionCleanup.js";
let app: FastifyInstance; let app: FastifyInstance;
let service: PiSessionService; let service: PiSessionService;
@@ -136,17 +138,69 @@ describe("session routes", () => {
await routeApp.close(); await routeApp.close();
} }
}); });
it("normalizes cleanup requests for preview and execute routes", async () => {
const routeApp = Fastify({ logger: false });
await routeApp.register(fastifyWebsocket);
const eventHub = new SessionEventHub();
const routeService = new CapturingRouteSessionService(eventHub);
registerSessionRoutes(routeApp, routeService, eventHub);
try {
const previewResponse = await routeApp.inject({ method: "POST", url: "/sessions/cleanup/preview", payload: { archiveIdleDays: 30, deleteArchivedDays: null, projectCwds: ["/repo-a", "/repo-a"] } });
const executeResponse = await routeApp.inject({ method: "POST", url: "/sessions/cleanup", payload: { archiveIdleDays: null, deleteArchivedDays: 7, projectCwds: ["/repo-b"] } });
expect(previewResponse.statusCode).toBe(200);
expect(executeResponse.statusCode).toBe(200);
expect(routeService.cleanupPreviewCalls).toEqual([{ thresholds: { archiveIdleDays: 30 }, projectCwds: ["/repo-a"] }]);
expect(routeService.cleanupCalls).toEqual([{ thresholds: { deleteArchivedDays: 7 }, projectCwds: ["/repo-b"] }]);
} finally {
await routeService.dispose();
await routeApp.close();
}
});
it("rejects invalid cleanup thresholds before calling the service", async () => {
const routeApp = Fastify({ logger: false });
await routeApp.register(fastifyWebsocket);
const eventHub = new SessionEventHub();
const routeService = new CapturingRouteSessionService(eventHub);
registerSessionRoutes(routeApp, routeService, eventHub);
try {
const response = await routeApp.inject({ method: "POST", url: "/sessions/cleanup", payload: { archiveIdleDays: -1 } });
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({ error: "archiveIdleDays field must be a non-negative integer" });
expect(routeService.cleanupCalls).toEqual([]);
} finally {
await routeService.dispose();
await routeApp.close();
}
});
}); });
class CapturingRouteSessionService extends PiSessionService { class CapturingRouteSessionService extends PiSessionService {
readonly calls: unknown[] = []; readonly calls: unknown[] = [];
readonly reloadCalls: (string | PiSessionRef)[] = []; readonly reloadCalls: (string | PiSessionRef)[] = [];
readonly cleanupPreviewCalls: NormalizedSessionCleanupRequest[] = [];
readonly cleanupCalls: NormalizedSessionCleanupRequest[] = [];
reloadError: Error | undefined; reloadError: Error | undefined;
constructor(eventHub: SessionEventHub) { constructor(eventHub: SessionEventHub) {
super(eventHub, { sessionManager: new RejectingSessionManager(), heartbeatIntervalMs: 60_000 }); super(eventHub, { sessionManager: new RejectingSessionManager(), heartbeatIntervalMs: 60_000 });
} }
override cleanupPreview(request: NormalizedSessionCleanupRequest): Promise<SessionCleanupPreviewResponse> {
this.cleanupPreviewCalls.push(request);
return Promise.resolve({ generatedAt: "2026-06-25T00:00:00.000Z", thresholds: request.thresholds, projects: [], totals: { archiveCount: 0, deleteCount: 0 } });
}
override cleanup(request: NormalizedSessionCleanupRequest): Promise<SessionCleanupExecuteResponse> {
this.cleanupCalls.push(request);
return Promise.resolve({ generatedAt: "2026-06-25T00:00:00.000Z", thresholds: request.thresholds, projects: [], totals: { archiveCount: 0, deleteCount: 0 }, archivedSessionIds: [], deletedSessionIds: [] });
}
override reload(lookup: string | PiSessionRef): Promise<void> { override reload(lookup: string | PiSessionRef): Promise<void> {
this.reloadCalls.push(lookup); this.reloadCalls.push(lookup);
if (this.reloadError !== undefined) return Promise.reject(this.reloadError); if (this.reloadError !== undefined) return Promise.reject(this.reloadError);
+18
View File
@@ -1,7 +1,9 @@
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import type { SessionCleanupRequest } from "../../shared/apiTypes.js";
import { normalizeRequestCwd } from "../workingDirectory.js"; import { normalizeRequestCwd } from "../workingDirectory.js";
import type { SessionEventHub } from "../realtime/sessionEventHub.js"; import type { SessionEventHub } from "../realtime/sessionEventHub.js";
import type { PiSessionRef, PiSessionService } from "./piSessionService.js"; import type { PiSessionRef, PiSessionService } from "./piSessionService.js";
import { normalizeSessionCleanupRequest } from "./sessionCleanup.js";
type SessionLookup = string | PiSessionRef; type SessionLookup = string | PiSessionRef;
@@ -46,6 +48,22 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionS
} }
}); });
app.post<{ Body: SessionCleanupRequest | undefined }>(`${prefix}/sessions/cleanup/preview`, async (request, reply) => {
try {
return await sessions.cleanupPreview(normalizeSessionCleanupRequest(optionalRecord(request.body)));
} catch (error) {
return reply.code(400).send({ error: errorMessage(error) });
}
});
app.post<{ Body: SessionCleanupRequest | undefined }>(`${prefix}/sessions/cleanup`, async (request, reply) => {
try {
return await sessions.cleanup(normalizeSessionCleanupRequest(optionalRecord(request.body)));
} catch (error) {
return reply.code(400).send({ error: errorMessage(error) });
}
});
app.get<{ Params: { sessionId: string }; Querystring: MessageQuery }>(`${prefix}/sessions/:sessionId/messages`, async (request, reply) => { app.get<{ Params: { sessionId: string }; Querystring: MessageQuery }>(`${prefix}/sessions/:sessionId/messages`, async (request, reply) => {
try { try {
const page = { ...optionalField("before", optionalNumber(request.query.before)), ...optionalField("limit", optionalNumber(request.query.limit)) }; const page = { ...optionalField("before", optionalNumber(request.query.before)), ...optionalField("limit", optionalNumber(request.query.limit)) };
+4
View File
@@ -4,6 +4,10 @@ export type {
SessionRef as ClientSessionRef, SessionRef as ClientSessionRef,
SessionInfo as ClientSession, SessionInfo as ClientSession,
ArchiveSessionsResponse as ClientArchiveSessionsResponse, ArchiveSessionsResponse as ClientArchiveSessionsResponse,
SessionCleanupRequest as ClientSessionCleanupRequest,
SessionCleanupThresholds as ClientSessionCleanupThresholds,
SessionCleanupPreviewResponse as ClientSessionCleanupPreviewResponse,
SessionCleanupExecuteResponse as ClientSessionCleanupExecuteResponse,
MessagePage as ClientMessagePage, MessagePage as ClientMessagePage,
SessionStatus as ClientSessionStatus, SessionStatus as ClientSessionStatus,
SessionModel as ClientSessionModel, SessionModel as ClientSessionModel,
+39
View File
@@ -3,6 +3,7 @@ export type MachineStatus = "unknown" | "online" | "offline" | "error";
export const PI_WEB_CAPABILITIES = { export const PI_WEB_CAPABILITIES = {
sessionsDeleteArchived: "sessions.deleteArchived", sessionsDeleteArchived: "sessions.deleteArchived",
sessionsCleanup: "sessions.cleanup",
sessionsReload: "sessions.reload", sessionsReload: "sessions.reload",
promptAttachments: "prompt.attachments", promptAttachments: "prompt.attachments",
workspaceFileSuggestions: "workspace.fileSuggestions", workspaceFileSuggestions: "workspace.fileSuggestions",
@@ -162,6 +163,44 @@ export interface ArchiveSessionsResponse {
skippedAlreadyArchivedCount?: number; skippedAlreadyArchivedCount?: number;
} }
export interface SessionCleanupRequest {
/** Archive non-archived sessions whose modified time is older than this many days. Omit/null to disable. */
archiveIdleDays?: number | null;
/** Permanently delete archived sessions whose archivedAt time is older than this many days. Omit/null to disable. */
deleteArchivedDays?: number | null;
/** Stored cwd paths selected from a preview. Omit/null to include all discovered project/workspace paths. */
projectCwds?: string[] | null;
}
export interface SessionCleanupThresholds {
archiveIdleDays?: number;
deleteArchivedDays?: number;
}
export interface SessionCleanupProjectSummary {
cwd: string;
archiveCount: number;
deleteCount: number;
}
export interface SessionCleanupTotals {
archiveCount: number;
deleteCount: number;
}
export interface SessionCleanupPreviewResponse {
generatedAt: string;
thresholds: SessionCleanupThresholds;
projects: SessionCleanupProjectSummary[];
totals: SessionCleanupTotals;
skippedBusySessionIds?: string[];
}
export interface SessionCleanupExecuteResponse extends SessionCleanupPreviewResponse {
archivedSessionIds: string[];
deletedSessionIds: string[];
}
export interface SessionActivity { export interface SessionActivity {
sessionId: string; sessionId: string;
phase: "active" | "idle" | "error"; phase: "active" | "idle" | "error";
+3 -2
View File
@@ -6,11 +6,12 @@ export type { PiWebCapability };
export const KNOWN_PI_WEB_CAPABILITIES = Object.values(PI_WEB_CAPABILITIES); export const KNOWN_PI_WEB_CAPABILITIES = Object.values(PI_WEB_CAPABILITIES);
const knownPiWebCapabilities: ReadonlySet<string> = new Set(KNOWN_PI_WEB_CAPABILITIES); const knownPiWebCapabilities: ReadonlySet<string> = new Set(KNOWN_PI_WEB_CAPABILITIES);
export const WEB_RUNTIME_CAPABILITIES = [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.sessionsReload, PI_WEB_CAPABILITIES.promptAttachments, PI_WEB_CAPABILITIES.workspaceFileSuggestions] as const satisfies readonly PiWebCapability[]; export const WEB_RUNTIME_CAPABILITIES = [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.sessionsCleanup, PI_WEB_CAPABILITIES.sessionsReload, PI_WEB_CAPABILITIES.promptAttachments, PI_WEB_CAPABILITIES.workspaceFileSuggestions] as const satisfies readonly PiWebCapability[];
export const SESSIOND_RUNTIME_CAPABILITIES = [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.sessionsReload, PI_WEB_CAPABILITIES.promptAttachments] as const satisfies readonly PiWebCapability[]; export const SESSIOND_RUNTIME_CAPABILITIES = [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.sessionsCleanup, PI_WEB_CAPABILITIES.sessionsReload, PI_WEB_CAPABILITIES.promptAttachments] as const satisfies readonly PiWebCapability[];
const EFFECTIVE_CAPABILITY_REQUIREMENTS = { const EFFECTIVE_CAPABILITY_REQUIREMENTS = {
[PI_WEB_CAPABILITIES.sessionsDeleteArchived]: ["web", "sessiond"], [PI_WEB_CAPABILITIES.sessionsDeleteArchived]: ["web", "sessiond"],
[PI_WEB_CAPABILITIES.sessionsCleanup]: ["web", "sessiond"],
[PI_WEB_CAPABILITIES.sessionsReload]: ["web", "sessiond"], [PI_WEB_CAPABILITIES.sessionsReload]: ["web", "sessiond"],
[PI_WEB_CAPABILITIES.promptAttachments]: ["web", "sessiond"], [PI_WEB_CAPABILITIES.promptAttachments]: ["web", "sessiond"],
[PI_WEB_CAPABILITIES.workspaceFileSuggestions]: ["web"], [PI_WEB_CAPABILITIES.workspaceFileSuggestions]: ["web"],
+2
View File
@@ -35,6 +35,8 @@ export const FEDERATED_HTTP_ROUTES = [
{ method: "GET", path: "/activity" }, { method: "GET", path: "/activity" },
{ method: "GET", path: "/sessions" }, { method: "GET", path: "/sessions" },
{ method: "POST", path: "/sessions" }, { method: "POST", path: "/sessions" },
{ method: "POST", path: "/sessions/cleanup/preview" },
{ method: "POST", path: "/sessions/cleanup" },
{ method: "GET", path: "/sessions/:sessionId/messages" }, { method: "GET", path: "/sessions/:sessionId/messages" },
{ method: "GET", path: "/sessions/:sessionId/status" }, { method: "GET", path: "/sessions/:sessionId/status" },
{ method: "GET", path: "/sessions/:sessionId/models" }, { method: "GET", path: "/sessions/:sessionId/models" },