Archived
feat: add hierarchical session tree navigator
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Add a hierarchical `/tree` navigator for switching conversation branches in place while retaining abandoned branches, with optional branch summaries.
|
||||
@@ -2,4 +2,4 @@ export { activityApi, api, configApi, filesApi, gitApi, machinesApi, piPackagesA
|
||||
export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets";
|
||||
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 { ActiveAgentProfileDescriptor, 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, PiPackageInfo, PiPackageInstallRequest, PiPackageMutationAction, PiPackageMutationResponse, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiPackagesResponse, PiWebAgentDirEnvSource, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebDockerMode, PiWebInstallationInfo, PiWebInstallationKind, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebUploadsConfig, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef, SessionBulkMutationRequest, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupRequest, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionRef, SessionStatus, SessionStreamSnapshot, SessionWarning, SessionWarningSeverity, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
|
||||
export type { ActiveAgentProfileDescriptor, 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, PiPackageInfo, PiPackageInstallRequest, PiPackageMutationAction, PiPackageMutationResponse, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiPackagesResponse, PiWebAgentDirEnvSource, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebDockerMode, PiWebInstallationInfo, PiWebInstallationKind, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebUploadsConfig, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef, SessionBulkMutationRequest, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupRequest, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionRef, SessionStatus, SessionStreamSnapshot, SessionTreeNavigateRequest, SessionTreeNavigateResult, SessionTreeNode, SessionTreeNodeKind, SessionTreeSnapshot, SessionTreeSummaryChoice, SessionWarning, SessionWarningSeverity, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
|
||||
|
||||
@@ -46,6 +46,7 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("machine-scoped runtime API", () => {
|
||||
@@ -281,6 +282,34 @@ describe("session API compatibility", () => {
|
||||
expect(JSON.parse(requestBody(init))).toEqual({ cwd: "/repo with spaces" });
|
||||
});
|
||||
|
||||
it("posts session tree navigation through an encoded cwd-scoped machine route", async () => {
|
||||
const fetchMock = stubJsonFetch({ cancelled: false, editorText: "edit this" });
|
||||
const navigation = { targetId: "entry /?", expectedLeafId: "leaf-1", summary: { mode: "custom" as const, instructions: "focus on tests" } };
|
||||
|
||||
await expect(sessionsApi.navigateTree({ id: "s /?", cwd: "/repo with spaces" }, navigation, "remote /?")).resolves.toEqual({ cancelled: false, editorText: "edit this" });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const [url, init] = fetchCall(fetchMock, 0);
|
||||
expect(url).toBe("https://pi.example.test/api/machines/remote%20%2F%3F/sessions/s%20%2F%3F/tree/navigate");
|
||||
expect(init?.method).toBe("POST");
|
||||
expect(JSON.parse(requestBody(init))).toEqual({ cwd: "/repo with spaces", ...navigation });
|
||||
});
|
||||
|
||||
it("keeps session tree navigation under a canonical nested deployment base", async () => {
|
||||
vi.stubEnv("BASE_URL", "./");
|
||||
vi.stubGlobal("document", { baseURI: "https://pi.example.test/nested/pi-web/" });
|
||||
const fetchMock = stubJsonFetch({ cancelled: false });
|
||||
|
||||
await sessionsApi.navigateTree(
|
||||
{ id: "session /?", cwd: "/nested/repo" },
|
||||
{ targetId: "entry-1", expectedLeafId: "leaf-1", summary: { mode: "none" } },
|
||||
"remote /?",
|
||||
);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("https://pi.example.test/nested/pi-web/api/machines/remote%20%2F%3F/sessions/session%20%2F%3F/tree/navigate");
|
||||
});
|
||||
|
||||
it("reads a session stream snapshot through an encoded machine route with cwd context", async () => {
|
||||
const fetchMock = stubJsonFetch({ seq: 12, partial: { role: "assistant", content: [{ type: "text", text: "streaming" }] } });
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { DeleteWorkspaceFileResponse, FileSuggestion, MoveWorkspaceFileOptions, PiPackageInstallRequest, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiWebConfigValues, PromptAttachment, RunTerminalCommandInput, SessionBulkMutationRef, SessionCleanupRequest, SessionNotificationDismissThrough, SessionRef, TerminalCommandRun, TerminalCommandRunFilter, WriteWorkspaceFileOptions } from "../../../shared/apiTypes";
|
||||
import type { DeleteWorkspaceFileResponse, FileSuggestion, MoveWorkspaceFileOptions, PiPackageInstallRequest, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiWebConfigValues, PromptAttachment, RunTerminalCommandInput, SessionBulkMutationRef, SessionCleanupRequest, SessionNotificationDismissThrough, SessionRef, SessionTreeNavigateRequest, TerminalCommandRun, TerminalCommandRunFilter, WriteWorkspaceFileOptions } from "../../../shared/apiTypes";
|
||||
import { resolveAppUrl } from "../appUrl";
|
||||
import { request } from "./http";
|
||||
import {
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
parseSessionNotificationInboxSnapshot,
|
||||
parseSessionStatus,
|
||||
parseSessionStreamSnapshot,
|
||||
parseSessionTreeNavigateResult,
|
||||
parseSlashCommand,
|
||||
parseStopped,
|
||||
parseTerminalCommandRun,
|
||||
@@ -229,6 +230,10 @@ export const sessionsApi = {
|
||||
shell: (session: SessionLookup, text: string, machineId = "local") => request(sessionPath(session, "shell", machineId), parseAccepted, { method: "POST", body: sessionBody(session, { text }) }),
|
||||
runCommand: (session: SessionLookup, text: string, machineId = "local") => request(sessionPath(session, "commands/run", machineId), parseCommandResult, { method: "POST", body: sessionBody(session, { text }) }),
|
||||
respondToCommand: (session: SessionLookup, requestId: string, value: string, machineId = "local") => request(sessionPath(session, "commands/respond", machineId), parseCommandResult, { method: "POST", body: sessionBody(session, { requestId, value }) }),
|
||||
navigateTree: (session: SessionLookup, navigation: SessionTreeNavigateRequest, machineId = "local") => request(sessionPath(session, "tree/navigate", machineId), parseSessionTreeNavigateResult, {
|
||||
method: "POST",
|
||||
body: sessionBody(session, { targetId: navigation.targetId, expectedLeafId: navigation.expectedLeafId, summary: navigation.summary }),
|
||||
}),
|
||||
abort: (session: SessionLookup, machineId = "local") => request(sessionPath(session, "abort", machineId), parseAborted, { method: "POST", body: sessionBody(session) }),
|
||||
stop: (session: SessionLookup, machineId = "local") => request(sessionPath(session, "stop", machineId), parseStopped, { method: "POST", body: sessionBody(session) }),
|
||||
archive: (session: SessionLookup, machineId = "local") => request(sessionPath(session, "archive", machineId), parseArchived, { method: "POST", body: sessionBody(session) }),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Workspace } from "../../../shared/apiTypes";
|
||||
import { FEDERATED_HTTP_ROUTES, FEDERATED_WEBSOCKET_ROUTES, type FederatedHttpRouteSpec } from "../../../shared/federatedRoutes";
|
||||
import { FEDERATED_HTTP_ROUTES, FEDERATED_WEBSOCKET_ROUTES, SESSION_TREE_NAVIGATION_PROXY_TIMEOUT_MS, type FederatedHttpRouteSpec } from "../../../shared/federatedRoutes";
|
||||
import { activityApi, configApi, filesApi, gitApi, piPackagesApi, piWebApi, pluginsApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./clients";
|
||||
import { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./sockets";
|
||||
import { workspaceImagePreviewUrl } from "./urls";
|
||||
@@ -36,6 +36,16 @@ describe("federated route contract", () => {
|
||||
expect(FEDERATED_WEBSOCKET_ROUTES.some((path) => path.includes("notifications"))).toBe(false);
|
||||
});
|
||||
|
||||
it("allowlists session tree navigation with a long model-operation timeout and no new WebSocket", () => {
|
||||
expect(FEDERATED_HTTP_ROUTES.find((route) => route.path === "/sessions/:sessionId/tree/navigate")).toEqual({
|
||||
method: "POST",
|
||||
path: "/sessions/:sessionId/tree/navigate",
|
||||
timeoutMs: SESSION_TREE_NAVIGATION_PROXY_TIMEOUT_MS,
|
||||
});
|
||||
expect(SESSION_TREE_NAVIGATION_PROXY_TIMEOUT_MS).toBe(5 * 60_000);
|
||||
expect(FEDERATED_WEBSOCKET_ROUTES.some((path) => path.includes("tree"))).toBe(false);
|
||||
});
|
||||
|
||||
it("covers machine-scoped client HTTP calls with remote proxy routes", async () => {
|
||||
const fetchMock = vi.fn<FetchLike>(() => Promise.resolve(jsonResponse({})));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
@@ -88,6 +98,7 @@ describe("federated route contract", () => {
|
||||
ignoreParseFailure(sessionsApi.shell(session, "ls", machineId)),
|
||||
ignoreParseFailure(sessionsApi.runCommand(session, "/help", machineId)),
|
||||
ignoreParseFailure(sessionsApi.respondToCommand(session, "req 1", "yes", machineId)),
|
||||
ignoreParseFailure(sessionsApi.navigateTree(session, { targetId: "entry-1", expectedLeafId: "leaf-1", summary: { mode: "none" } }, machineId)),
|
||||
ignoreParseFailure(sessionsApi.abort(session, machineId)),
|
||||
ignoreParseFailure(sessionsApi.stop(session, machineId)),
|
||||
ignoreParseFailure(sessionsApi.archive(session, machineId)),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
|
||||
import { SESSION_NOTIFICATION_LIMIT, SESSION_NOTIFICATION_MESSAGE_BYTES } from "../../../shared/apiTypes";
|
||||
import { parseAuthProvidersResponse, parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMachineRuntime, parseMessagePage, parseOAuthFlowState, parsePiPackageMutationResponse, parsePiPackagesResponse, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parsePiWebStatusResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionInfo, parseSessionNotificationInboxEvent, parseSessionNotificationInboxSnapshot, parseSessionStatus, parseSessionStreamSnapshot, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers";
|
||||
import { parseAuthProvidersResponse, parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMachineRuntime, parseMessagePage, parseOAuthFlowState, parsePiPackageMutationResponse, parsePiPackagesResponse, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parsePiWebStatusResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionInfo, parseSessionNotificationInboxEvent, parseSessionNotificationInboxSnapshot, parseSessionStatus, parseSessionStreamSnapshot, parseSessionTreeNavigateResult, parseSessionTreeSnapshot, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers";
|
||||
|
||||
describe("API parsers", () => {
|
||||
it("preserves additive interactive API-key flow hints and defaults legacy options", () => {
|
||||
@@ -508,12 +508,43 @@ describe("API parsers", () => {
|
||||
});
|
||||
|
||||
it("parses command result variants", () => {
|
||||
const tree = sessionTreeWire();
|
||||
expect(parseCommandResult({ type: "unsupported", message: "nope" })).toEqual({ type: "unsupported", message: "nope" });
|
||||
expect(parseCommandResult({ type: "select", requestId: "r1", title: "Pick", options: [{ value: "v", label: "Label", description: "desc" }] })).toEqual({ type: "select", requestId: "r1", title: "Pick", options: [{ value: "v", label: "Label", description: "desc" }] });
|
||||
expect(parseCommandResult({ type: "tree", tree })).toEqual({ type: "tree", tree });
|
||||
expect(parseCommandResult({ type: "done", message: "ok", promptDraft: "resend me" })).toEqual({ type: "done", message: "ok", promptDraft: "resend me" });
|
||||
expect(() => parseCommandResult({ type: "later" })).toThrow("Invalid command result type");
|
||||
});
|
||||
|
||||
it("strictly parses session tree snapshots and navigation results", () => {
|
||||
const tree = sessionTreeWire();
|
||||
expect(parseSessionTreeSnapshot(tree)).toEqual(tree);
|
||||
expect(parseSessionTreeNavigateResult({ cancelled: false, editorText: "edit this" })).toEqual({ cancelled: false, editorText: "edit this" });
|
||||
expect(parseSessionTreeNavigateResult({ cancelled: false })).toEqual({ cancelled: false });
|
||||
expect(parseSessionTreeNavigateResult({ cancelled: true, aborted: true })).toEqual({ cancelled: true, aborted: true });
|
||||
expect(parseSessionTreeNavigateResult({ cancelled: true })).toEqual({ cancelled: true });
|
||||
expect(parseSessionTreeNavigateResult({ cancelled: false, editorText: "edit this", operationId: "future-metadata" })).toEqual({ cancelled: false, editorText: "edit this" });
|
||||
expect(parseSessionTreeNavigateResult({ cancelled: true, aborted: true, operationId: "future-metadata" })).toEqual({ cancelled: true, aborted: true });
|
||||
|
||||
expect(() => parseSessionTreeSnapshot({ ...tree, activeLeafId: undefined })).toThrow("activeLeafId");
|
||||
expect(() => parseSessionTreeSnapshot({ ...tree, activeLeafId: "missing" })).toThrow("activeLeafId");
|
||||
expect(() => parseSessionTreeSnapshot({ ...tree, activeLeafId: " " })).toThrow("activeLeafId");
|
||||
expect(() => parseSessionTreeSnapshot({ ...tree, activePathIds: ["root", 2] })).toThrow("activePathIds");
|
||||
expect(() => parseSessionTreeSnapshot({ ...tree, activePathIds: [" "] })).toThrow("activePathIds");
|
||||
expect(() => parseSessionTreeSnapshot({ ...tree, nodes: [{ ...tree.nodes[0], id: " " }] })).toThrow("id");
|
||||
expect(() => parseSessionTreeSnapshot({ ...tree, nodes: [{ ...tree.nodes[0], parentId: undefined }] })).toThrow("parentId");
|
||||
expect(() => parseSessionTreeSnapshot({ ...tree, nodes: [{ ...tree.nodes[0], parentId: " " }] })).toThrow("parentId");
|
||||
expect(() => parseSessionTreeSnapshot({ ...tree, nodes: [tree.nodes[0], tree.nodes[0]] })).toThrow("Duplicate session tree node id");
|
||||
expect(() => parseSessionTreeSnapshot({ ...tree, nodes: [{ ...tree.nodes[0], kind: "future-kind" }] })).toThrow("Invalid session tree node kind");
|
||||
expect(() => parseSessionTreeNavigateResult({ cancelled: true, editorText: "wrong branch" })).toThrow("editorText");
|
||||
expect(() => parseSessionTreeNavigateResult({ cancelled: false, aborted: true })).toThrow("aborted");
|
||||
expect(() => parseSessionTreeNavigateResult({ cancelled: false, editorText: 42 })).toThrow("editorText");
|
||||
expect(() => parseSessionTreeNavigateResult({ cancelled: true, aborted: "yes" })).toThrow("aborted");
|
||||
expect(() => parseSessionTreeNavigateResult({ cancelled: false, summaryEntry: { raw: true } })).toThrow("summaryEntry");
|
||||
expect(() => parseSessionTreeNavigateResult({ cancelled: true, summaryEntry: { raw: true } })).toThrow("summaryEntry");
|
||||
expect(() => parseSessionTreeNavigateResult({ editorText: "missing discriminator" })).toThrow("cancelled");
|
||||
});
|
||||
|
||||
it("strictly parses selected notification snapshots and realtime events", () => {
|
||||
const inbox = notificationInboxWire();
|
||||
|
||||
@@ -558,6 +589,36 @@ describe("API parsers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
function sessionTreeWire() {
|
||||
const kinds = [
|
||||
"user",
|
||||
"assistant",
|
||||
"tool-result",
|
||||
"bash",
|
||||
"custom-message",
|
||||
"compaction",
|
||||
"branch-summary",
|
||||
"model-change",
|
||||
"thinking-level-change",
|
||||
"session-info",
|
||||
"label",
|
||||
"custom",
|
||||
"other",
|
||||
] as const;
|
||||
const nodes = kinds.map((kind, index) => ({
|
||||
id: `entry-${String(index)}`,
|
||||
parentId: index === 0 ? null : `entry-${String(index - 1)}`,
|
||||
kind,
|
||||
summary: `${kind} summary`,
|
||||
...(index === 0 ? { timestamp: "2026-07-20T00:00:00.000Z", label: "root label" } : {}),
|
||||
}));
|
||||
return {
|
||||
nodes,
|
||||
activeLeafId: nodes.at(-1)?.id ?? null,
|
||||
activePathIds: nodes.map((node) => node.id),
|
||||
};
|
||||
}
|
||||
|
||||
function notificationWire(order: number, severity: "info" | "warning" | "error" = "info") {
|
||||
return {
|
||||
id: `daemon-a:${String(order)}`,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { SESSION_NOTIFICATION_LIMIT, SESSION_NOTIFICATION_MESSAGE_BYTES, type ArchiveSessionsResponse, type AuthProviderOption, type AuthProviderStatus, type AuthProvidersResponse, type AuthStatusSource, type AuthType, type CommandOption, type CommandResult, type DeleteWorkspaceFileResponse, type FileContentResponse, type FileSuggestion, type FileTreeEntry, type FileTreeResponse, type GitDiffResponse, type GitFileState, type GitStatusFile, type GitStatusResponse, type Machine, type MachineHealth, type MachineKind, type MachineRuntime, type MachineStatus, type MessagePage, type ModelSelectionResponse, type MoveWorkspaceFileResponse, type OAuthFlowState, type PiWebAgentDirEnvSource, type PiWebCapability, type PiWebComponentStatus, type PiWebConfigEnvOverrides, type PiWebConfigResponse, type PiWebConfigValues, type PiWebInstallationInfo, type PiWebPluginConfigMap, type PiWebPluginInfo, type PiWebPluginsResponse, type PiWebPluginScope, type PiWebReleaseStatus, type PiWebRuntimeComponent, type PiWebRuntimeResponse, type PiWebServiceComponent, type PiWebShortcutConfig, type PiWebStatusMessage, type PiWebStatusResponse, type PiWebStatusSeverity, type Project, type QueuedSessionMessage, type SavedPromptAttachment, type SessionBulkArchiveResponse, type SessionBulkDeleteArchivedResponse, type SessionBulkFailure, type SessionCleanupExecuteResponse, type SessionCleanupPreviewResponse, type SessionCleanupProjectSummary, type SessionCleanupThresholds, type SessionCleanupTotals, type SessionInfo, type SessionModel, type SessionNotification, type SessionNotificationClearReason, type SessionNotificationDismissThrough, type SessionNotificationInboxDelta, type SessionNotificationInboxEvent, type SessionNotificationInboxSnapshot, type SessionNotificationSeverity, type SessionNotificationSummary, type SessionStatus, type SessionStreamSnapshot, type SessionWarning, type SessionWarningSeverity, type SlashCommand, type TerminalCommandRun, type TerminalCommandRunStatus, type TerminalInfo, type ThinkingLevelsResponse, type WriteWorkspaceFileResponse, type Workspace, type WorkspaceActivity, type WorkspaceActivityResponse } from "../../../shared/apiTypes";
|
||||
import type { PiPackageInfo, PiPackageMutationAction, PiPackageMutationResponse, PiPackageScope, PiPackagesResponse } from "../../../shared/apiTypes";
|
||||
import type { PiPackageInfo, PiPackageMutationAction, PiPackageMutationResponse, PiPackageScope, PiPackagesResponse, SessionTreeNavigateResult, SessionTreeNode, SessionTreeNodeKind, SessionTreeSnapshot } from "../../../shared/apiTypes";
|
||||
import { parseActiveAgentProfileDescriptor } from "../../../shared/activeAgentProfile";
|
||||
import { parseKnownPiWebCapabilities } from "../../../shared/capabilities";
|
||||
|
||||
@@ -1136,10 +1136,95 @@ export function parseCommandResult(value: unknown): CommandResult {
|
||||
const type = requireString(record, "type");
|
||||
if (type === "unsupported") return { type, message: requireString(record, "message") };
|
||||
if (type === "select") return { type, requestId: requireString(record, "requestId"), title: requireString(record, "title"), options: arrayOf(parseCommandOption)(record["options"]) };
|
||||
if (type === "tree") return { type, tree: parseSessionTreeSnapshot(record["tree"]) };
|
||||
if (type === "done") return { type, ...optionalField("message", optionalString(record, "message")), ...optionalSession(record["session"]), ...optionalField("promptDraft", optionalString(record, "promptDraft")) };
|
||||
throw new Error("Invalid command result type");
|
||||
}
|
||||
|
||||
export function parseSessionTreeSnapshot(value: unknown): SessionTreeSnapshot {
|
||||
const record = requireRecord(value);
|
||||
const nodes = arrayOf(parseSessionTreeNode)(record["nodes"]);
|
||||
const nodeIds = new Set(nodes.map((node) => node.id));
|
||||
if (nodeIds.size !== nodes.length) throw new Error("Duplicate session tree node id");
|
||||
const activeLeafId = requireNullableString(record, "activeLeafId");
|
||||
if (activeLeafId !== null && !nodeIds.has(activeLeafId)) throw new Error("Invalid session tree activeLeafId");
|
||||
return {
|
||||
nodes,
|
||||
activeLeafId,
|
||||
activePathIds: arrayOfNonBlankString(record["activePathIds"], "activePathIds"),
|
||||
};
|
||||
}
|
||||
|
||||
function parseSessionTreeNode(value: unknown): SessionTreeNode {
|
||||
const record = requireRecord(value);
|
||||
return {
|
||||
id: requireNonBlankString(record, "id"),
|
||||
parentId: requireNullableString(record, "parentId"),
|
||||
kind: parseSessionTreeNodeKind(record["kind"]),
|
||||
summary: requireString(record, "summary"),
|
||||
...optionalField("timestamp", optionalString(record, "timestamp")),
|
||||
...optionalField("label", optionalString(record, "label")),
|
||||
};
|
||||
}
|
||||
|
||||
function parseSessionTreeNodeKind(value: unknown): SessionTreeNodeKind {
|
||||
switch (value) {
|
||||
case "user":
|
||||
case "assistant":
|
||||
case "tool-result":
|
||||
case "bash":
|
||||
case "custom-message":
|
||||
case "compaction":
|
||||
case "branch-summary":
|
||||
case "model-change":
|
||||
case "thinking-level-change":
|
||||
case "session-info":
|
||||
case "label":
|
||||
case "custom":
|
||||
case "other":
|
||||
return value;
|
||||
default:
|
||||
throw new Error("Invalid session tree node kind");
|
||||
}
|
||||
}
|
||||
|
||||
export function parseSessionTreeNavigateResult(value: unknown): SessionTreeNavigateResult {
|
||||
const record = requireRecord(value);
|
||||
const cancelled = requireBoolean(record, "cancelled");
|
||||
if (Object.hasOwn(record, "summaryEntry")) throw new Error("Invalid session tree navigation result field: summaryEntry");
|
||||
if (cancelled) {
|
||||
rejectResponseField(record, "editorText", "session tree cancellation result");
|
||||
const aborted = record["aborted"];
|
||||
if (aborted !== undefined && typeof aborted !== "boolean") throw new Error("Expected optional boolean field: aborted");
|
||||
return { cancelled, ...(aborted === undefined ? {} : { aborted }) };
|
||||
}
|
||||
rejectResponseField(record, "aborted", "session tree navigation result");
|
||||
return { cancelled, ...optionalField("editorText", optionalString(record, "editorText")) };
|
||||
}
|
||||
|
||||
function rejectResponseField(record: Record<string, unknown>, field: string, label: string): void {
|
||||
if (Object.hasOwn(record, field)) throw new Error(`Invalid ${label} field: ${field}`);
|
||||
}
|
||||
|
||||
function requireNullableString(record: Record<string, unknown>, key: string): string | null {
|
||||
const value = record[key];
|
||||
if (value !== null && typeof value !== "string") throw new Error(`Expected string or null field: ${key}`);
|
||||
if (typeof value === "string" && value.trim() === "") throw new Error(`Expected non-blank string or null field: ${key}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireNonBlankString(record: Record<string, unknown>, key: string): string {
|
||||
const value = requireString(record, key);
|
||||
if (value.trim() === "") throw new Error(`Expected non-blank string field: ${key}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function arrayOfNonBlankString(value: unknown, key: string): string[] {
|
||||
const strings = arrayOfString(value, key);
|
||||
if (strings.some((item) => item.trim() === "")) throw new Error(`Expected non-blank string array field: ${key}`);
|
||||
return strings;
|
||||
}
|
||||
|
||||
function parseCommandOption(value: unknown): CommandOption {
|
||||
const record = requireRecord(value);
|
||||
return { value: requireString(record, "value"), label: requireString(record, "label"), ...optionalField("description", optionalString(record, "description")) };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AuthProviderOption, CommandOption, CommandResult, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Machine, MachineHealth, MachineRuntime, OAuthFlowState, PiWebStatusResponse, Project, QueuedSessionMessage, SessionActivity, SessionInfo, SessionStatus, TerminalCommandRun, Workspace, WorkspaceActivity } from "./api";
|
||||
import type { AuthProviderOption, CommandOption, CommandResult, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Machine, MachineHealth, MachineRuntime, OAuthFlowState, PiWebStatusResponse, Project, QueuedSessionMessage, SessionActivity, SessionInfo, SessionStatus, SessionTreeSnapshot, TerminalCommandRun, Workspace, WorkspaceActivity } from "./api";
|
||||
import type { ChatLine } from "./components/shared";
|
||||
import type { QualifiedContributionId } from "./plugins/ids";
|
||||
import type { SelectedSessionNotificationInbox } from "./sessionNotifications";
|
||||
@@ -42,6 +42,7 @@ export interface AppState {
|
||||
workspacesByProjectId: Record<string, Workspace[]>;
|
||||
workspaceDeletionRuns: Record<string, TerminalCommandRun>;
|
||||
commandDialog: Extract<CommandResult, { type: "select" }> | undefined;
|
||||
treeDialog: SessionTreeSnapshot | undefined;
|
||||
modelDialog: { title: string; options: CommandOption[]; selectedValue?: string } | undefined;
|
||||
thinkingDialog: { title: string; options: CommandOption[]; selectedValue?: string } | undefined;
|
||||
themeDialog: { title: string; options: CommandOption[]; selectedValue?: string } | undefined;
|
||||
@@ -81,6 +82,7 @@ export type WorkspaceScopedStateReset = Pick<AppState,
|
||||
| "clientQueuedSessionMessages"
|
||||
| "startingSessionCount"
|
||||
| "selectedNotificationInbox"
|
||||
| "treeDialog"
|
||||
| "fileTree"
|
||||
| "expandedDirs"
|
||||
| "selectedFilePath"
|
||||
@@ -101,6 +103,7 @@ export function resetWorkspaceScopedState(): WorkspaceScopedStateReset {
|
||||
clientQueuedSessionMessages: {},
|
||||
startingSessionCount: 0,
|
||||
selectedNotificationInbox: undefined,
|
||||
treeDialog: undefined,
|
||||
fileTree: [],
|
||||
expandedDirs: {},
|
||||
selectedFilePath: undefined,
|
||||
@@ -150,6 +153,7 @@ export function initialAppState(): AppState {
|
||||
workspacesByProjectId: {},
|
||||
workspaceDeletionRuns: {},
|
||||
commandDialog: undefined,
|
||||
treeDialog: undefined,
|
||||
modelDialog: undefined,
|
||||
thinkingDialog: undefined,
|
||||
themeDialog: undefined,
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SessionTreeNavigateResult, SessionTreeSnapshot, SessionTreeSummaryChoice } from "../api";
|
||||
import { initialAppState, type AppState } from "../appState";
|
||||
import { SessionController } from "../controllers/sessionController";
|
||||
// This node-environment test uses the shared, type-guarded template inspection
|
||||
// escape hatch only to verify PiWebApp's navigator callback boundary.
|
||||
import { templateValueAfterMarker } from "../templateInspection.testSupport";
|
||||
import { PiWebApp } from "./PiWebApp";
|
||||
|
||||
type NavigateHandler = (targetId: string, summaryChoice: SessionTreeSummaryChoice) => Promise<SessionTreeNavigateResult>;
|
||||
type AbortHandler = () => Promise<void>;
|
||||
type CancelHandler = () => void;
|
||||
type RenderSessionTreeNavigator = (this: PiWebApp, state: AppState) => TemplateResult | null;
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("PiWebApp session tree wiring", () => {
|
||||
it("routes navigation, cancellation, abort, and prompt focus through SessionController", async () => {
|
||||
const app = createApp();
|
||||
const state = setAppTree(app, tree());
|
||||
const controller = appSessionController(app);
|
||||
const navigateTree = vi.spyOn(controller, "navigateTree")
|
||||
.mockResolvedValueOnce({ cancelled: false, editorText: "edit" })
|
||||
.mockResolvedValueOnce({ cancelled: true, aborted: true });
|
||||
const abortTreeNavigation = vi.spyOn(controller, "abortTreeNavigation").mockResolvedValue(undefined);
|
||||
const closeTreeDialog = vi.spyOn(controller, "closeTreeDialog").mockReturnValue(undefined);
|
||||
const focusChatComposer = vi.fn(() => Promise.resolve());
|
||||
if (!Reflect.set(app, "focusChatComposer", focusChatComposer)) throw new Error("Could not replace prompt focus boundary");
|
||||
|
||||
const rendered = renderSessionTreeNavigator(app, state);
|
||||
const onNavigate = navigatorNavigateHandler(rendered);
|
||||
const onAbort = navigatorAbortHandler(rendered);
|
||||
const onCancel = navigatorCancelHandler(rendered);
|
||||
|
||||
await expect(onNavigate("side", { mode: "none" })).resolves.toEqual({ cancelled: false, editorText: "edit" });
|
||||
expect(navigateTree).toHaveBeenNthCalledWith(1, "side", { mode: "none" });
|
||||
expect(focusChatComposer).toHaveBeenCalledOnce();
|
||||
|
||||
await expect(onNavigate("root", { mode: "default" })).resolves.toEqual({ cancelled: true, aborted: true });
|
||||
expect(focusChatComposer).toHaveBeenCalledOnce();
|
||||
|
||||
await onAbort();
|
||||
expect(abortTreeNavigation).toHaveBeenCalledOnce();
|
||||
|
||||
onCancel();
|
||||
expect(closeTreeDialog).toHaveBeenCalledOnce();
|
||||
expect(focusChatComposer).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not steal focus after the user selects another session during navigation", async () => {
|
||||
const app = createApp();
|
||||
const state = setAppTree(app, tree());
|
||||
const controller = appSessionController(app);
|
||||
const result = deferred<SessionTreeNavigateResult>();
|
||||
vi.spyOn(controller, "navigateTree").mockReturnValue(result.promise);
|
||||
const focusChatComposer = vi.fn(() => Promise.resolve());
|
||||
if (!Reflect.set(app, "focusChatComposer", focusChatComposer)) throw new Error("Could not replace prompt focus boundary");
|
||||
const onNavigate = navigatorNavigateHandler(renderSessionTreeNavigator(app, state));
|
||||
|
||||
const navigation = onNavigate("side", { mode: "none" });
|
||||
const otherSession = state.selectedSession === undefined ? undefined : { ...state.selectedSession, id: "session-2" };
|
||||
if (!Reflect.set(app, "state", { ...state, selectedSession: otherSession })) throw new Error("Could not change selected session");
|
||||
result.resolve({ cancelled: false });
|
||||
await navigation;
|
||||
|
||||
expect(focusChatComposer).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
function createApp(): PiWebApp {
|
||||
const storage = {
|
||||
getItem: () => null,
|
||||
setItem: () => undefined,
|
||||
removeItem: () => undefined,
|
||||
};
|
||||
vi.stubGlobal("window", { location: { search: "" }, localStorage: storage });
|
||||
return new PiWebApp();
|
||||
}
|
||||
|
||||
function setAppTree(app: PiWebApp, treeSnapshot: SessionTreeSnapshot): AppState {
|
||||
const selectedSession = {
|
||||
id: "session-1",
|
||||
path: "/tmp/session-1.jsonl",
|
||||
cwd: "/repo",
|
||||
created: "2026-01-01T00:00:00.000Z",
|
||||
modified: "2026-01-01T00:00:00.000Z",
|
||||
messageCount: 2,
|
||||
firstMessage: "Initial prompt",
|
||||
};
|
||||
const state = { ...initialAppState(), selectedSession, sessions: [selectedSession], treeDialog: treeSnapshot };
|
||||
if (!Reflect.set(app, "state", state)) throw new Error("Could not set PiWebApp tree state");
|
||||
return state;
|
||||
}
|
||||
|
||||
function renderSessionTreeNavigator(app: PiWebApp, state: AppState): TemplateResult {
|
||||
const method: unknown = Reflect.get(app, "renderSessionTreeNavigator");
|
||||
if (!isRenderSessionTreeNavigator(method)) throw new Error("PiWebApp.renderSessionTreeNavigator was unavailable");
|
||||
const rendered = method.call(app, state);
|
||||
if (rendered === null) throw new Error("Expected a rendered session tree navigator");
|
||||
return rendered;
|
||||
}
|
||||
|
||||
function appSessionController(app: PiWebApp): SessionController {
|
||||
const controller: unknown = Reflect.get(app, "sessions");
|
||||
if (!(controller instanceof SessionController)) throw new Error("PiWebApp SessionController was unavailable");
|
||||
return controller;
|
||||
}
|
||||
|
||||
function navigatorNavigateHandler(template: TemplateResult): NavigateHandler {
|
||||
const value = templateValueAfterMarker(template, ".onNavigate=");
|
||||
if (!isNavigateHandler(value)) throw new Error("Session tree navigate callback was unavailable");
|
||||
return value;
|
||||
}
|
||||
|
||||
function navigatorAbortHandler(template: TemplateResult): AbortHandler {
|
||||
const value = templateValueAfterMarker(template, ".onAbort=");
|
||||
if (!isAbortHandler(value)) throw new Error("Session tree abort callback was unavailable");
|
||||
return value;
|
||||
}
|
||||
|
||||
function navigatorCancelHandler(template: TemplateResult): CancelHandler {
|
||||
const value = templateValueAfterMarker(template, ".onCancel=");
|
||||
if (!isCancelHandler(value)) throw new Error("Session tree cancel callback was unavailable");
|
||||
return value;
|
||||
}
|
||||
|
||||
function isRenderSessionTreeNavigator(value: unknown): value is RenderSessionTreeNavigator {
|
||||
return typeof value === "function";
|
||||
}
|
||||
|
||||
function isNavigateHandler(value: unknown): value is NavigateHandler {
|
||||
return typeof value === "function";
|
||||
}
|
||||
|
||||
function isAbortHandler(value: unknown): value is AbortHandler {
|
||||
return typeof value === "function";
|
||||
}
|
||||
|
||||
function isCancelHandler(value: unknown): value is CancelHandler {
|
||||
return typeof value === "function";
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||
const promise = new Promise<T>((promiseResolve) => {
|
||||
resolve = promiseResolve;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function tree(): SessionTreeSnapshot {
|
||||
return {
|
||||
nodes: [
|
||||
{ id: "root", parentId: null, kind: "user", summary: "Initial prompt" },
|
||||
{ id: "side", parentId: "root", kind: "assistant", summary: "Side branch" },
|
||||
],
|
||||
activeLeafId: "side",
|
||||
activePathIds: ["root", "side"],
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, query, state } from "lit/decorators.js";
|
||||
import { configApi, effectiveWorkspaceUploadFolder, sessionsApi, terminalsApi, workspacesApi, workspaceEffectiveUploadFolder, type Machine, type MachineHealth, type PiWebConfigValues, type PiWebShortcutConfig, type Project, type SessionCleanupExecuteResponse, type SessionCleanupPreviewResponse, type SessionCleanupRequest, type SessionInfo, type TerminalCommandRun, type TerminalUiEvent, type Workspace } from "../api";
|
||||
import { configApi, effectiveWorkspaceUploadFolder, sessionsApi, terminalsApi, workspacesApi, workspaceEffectiveUploadFolder, type Machine, type MachineHealth, type PiWebConfigValues, type PiWebShortcutConfig, type Project, type SessionCleanupExecuteResponse, type SessionCleanupPreviewResponse, type SessionCleanupRequest, type SessionInfo, type SessionTreeNavigateResult, type SessionTreeSummaryChoice, type TerminalCommandRun, type TerminalUiEvent, type Workspace } from "../api";
|
||||
import type { AppAction } from "../actions";
|
||||
import { initialAppState, type AppState } from "../appState";
|
||||
import { isSessionActive } from "../../../shared/activity";
|
||||
@@ -49,6 +49,7 @@ import "./ProjectList";
|
||||
import "./WorkspaceList";
|
||||
import "./SessionList";
|
||||
import "./SessionCleanupDialog";
|
||||
import "./SessionTreeNavigator";
|
||||
import "./ChatView";
|
||||
import type { ChatView } from "./ChatView";
|
||||
import "./PromptEditor";
|
||||
@@ -114,7 +115,14 @@ export class PiWebApp extends LitElement {
|
||||
(patch) => { this.setState(patch); },
|
||||
() => { this.updateUrl(); },
|
||||
new SessionStorageSessionSelectionMemory(),
|
||||
{ notifications: this.notifications },
|
||||
{
|
||||
notifications: this.notifications,
|
||||
replacePromptEditorText: async ({ machineId, sessionId, text }) => {
|
||||
await this.updateComplete;
|
||||
if (selectedMachineId(this.state) !== machineId || this.state.selectedSession?.id !== sessionId) return;
|
||||
this.promptEditor?.replaceText(text);
|
||||
},
|
||||
},
|
||||
);
|
||||
private readonly projectActivityOwnership = new ProjectActivityOwnershipCoordinator(
|
||||
() => this.state,
|
||||
@@ -234,7 +242,7 @@ export class PiWebApp extends LitElement {
|
||||
}
|
||||
|
||||
private readonly onKeyDown = (event: KeyboardEvent) => {
|
||||
if (this.settingsSection !== undefined) return;
|
||||
if (this.settingsSection !== undefined || this.state.treeDialog !== undefined) return;
|
||||
if (this.keyboard.handle(event, this.getDefaultActions(), { shortcuts: this.shortcutConfig })) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -1270,6 +1278,35 @@ export class PiWebApp extends LitElement {
|
||||
this.promptEditor?.focusInput();
|
||||
}
|
||||
|
||||
private async navigateSessionTree(targetId: string, summaryChoice: SessionTreeSummaryChoice): Promise<SessionTreeNavigateResult> {
|
||||
const originMachineId = selectedMachineId(this.state);
|
||||
const originSessionId = this.state.selectedSession?.id;
|
||||
const result = await this.sessions.navigateTree(targetId, summaryChoice);
|
||||
if (!result.cancelled
|
||||
&& originSessionId !== undefined
|
||||
&& selectedMachineId(this.state) === originMachineId
|
||||
&& this.state.selectedSession?.id === originSessionId) {
|
||||
await this.focusChatComposer();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private closeSessionTreeNavigator(): void {
|
||||
this.sessions.closeTreeDialog();
|
||||
void this.focusChatComposer();
|
||||
}
|
||||
|
||||
private renderSessionTreeNavigator(state: AppState) {
|
||||
return state.treeDialog === undefined ? null : html`
|
||||
<session-tree-navigator
|
||||
.tree=${state.treeDialog}
|
||||
.onNavigate=${(targetId: string, summaryChoice: SessionTreeSummaryChoice) => this.navigateSessionTree(targetId, summaryChoice)}
|
||||
.onAbort=${() => this.sessions.abortTreeNavigation()}
|
||||
.onCancel=${() => { this.closeSessionTreeNavigator(); }}
|
||||
></session-tree-navigator>
|
||||
`;
|
||||
}
|
||||
|
||||
private visibleWorkspacePanels(): QualifiedWorkspacePanelContribution[] {
|
||||
const workspace = this.state.selectedWorkspace;
|
||||
if (workspace === undefined) return [];
|
||||
@@ -2031,6 +2068,7 @@ export class PiWebApp extends LitElement {
|
||||
${this.renderWorkspacePanelEdgeControl()}
|
||||
${this.renderWorkspacePanel()}
|
||||
${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}
|
||||
${this.renderSessionTreeNavigator(state)}
|
||||
${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}
|
||||
${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}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { machineSessionKey } from "../machineKeys";
|
||||
import { loadDraft, saveDraft } from "../promptDraftStorage";
|
||||
import { PromptEditor } from "./PromptEditor";
|
||||
|
||||
class MemoryStorage implements Storage {
|
||||
private readonly values = new Map<string, string>();
|
||||
|
||||
get length(): number { return this.values.size; }
|
||||
clear(): void { this.values.clear(); }
|
||||
getItem(key: string): string | null { return this.values.get(key) ?? null; }
|
||||
key(index: number): string | null { return Array.from(this.values.keys())[index] ?? null; }
|
||||
removeItem(key: string): void { this.values.delete(key); }
|
||||
setItem(key: string, value: string): void { this.values.set(key, value); }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(globalThis, "localStorage", { value: new MemoryStorage(), configurable: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(globalThis, "localStorage", { value: undefined, configurable: true });
|
||||
});
|
||||
|
||||
describe("PromptEditor draft replacement", () => {
|
||||
it("replaces durable and CodeMirror text, moves the cursor, and resets completion state", () => {
|
||||
const editor = new PromptEditor();
|
||||
editor.machineId = "remote-a";
|
||||
editor.sessionId = "session-1";
|
||||
const dispatch = vi.fn<(transaction: unknown) => void>();
|
||||
Reflect.set(editor, "draft", "/old");
|
||||
Reflect.set(editor, "currentInputMode", { kind: "command" });
|
||||
Reflect.set(editor, "completions", [{ kind: "command", insertText: "/tree", replaceFrom: 0, replaceTo: 4 }]);
|
||||
Reflect.set(editor, "selectedIndex", 3);
|
||||
Reflect.set(editor, "requestVersion", 7);
|
||||
Reflect.set(editor, "editor", {
|
||||
state: { doc: { toString: () => "/old" } },
|
||||
dispatch,
|
||||
});
|
||||
|
||||
editor.replaceText("!pwd");
|
||||
|
||||
expect(Reflect.get(editor, "draft")).toBe("!pwd");
|
||||
expect(loadDraft(machineSessionKey("remote-a", "session-1"))).toBe("!pwd");
|
||||
expect(Reflect.get(editor, "currentInputMode")).toEqual({ kind: "shell", excludeFromContext: false });
|
||||
expect(Reflect.get(editor, "completions")).toEqual([]);
|
||||
expect(Reflect.get(editor, "selectedIndex")).toBe(0);
|
||||
expect(Reflect.get(editor, "requestVersion")).toBe(8);
|
||||
expect(dispatch).toHaveBeenCalledOnce();
|
||||
const transaction = dispatch.mock.calls[0]?.[0];
|
||||
if (!isRecord(transaction) || !isRecord(transaction["selection"])) throw new Error("Expected a CodeMirror replacement transaction");
|
||||
expect(transaction["changes"]).toEqual({ from: 0, to: 4, insert: "!pwd" });
|
||||
expect(transaction["selection"]["anchor"]).toBe(4);
|
||||
expect(transaction["selection"]["head"]).toBe(4);
|
||||
});
|
||||
|
||||
it("clears an existing durable draft and CodeMirror document", () => {
|
||||
const editor = new PromptEditor();
|
||||
editor.machineId = "local";
|
||||
editor.sessionId = "session-2";
|
||||
const key = machineSessionKey("local", "session-2");
|
||||
const dispatch = vi.fn<(transaction: unknown) => void>();
|
||||
Reflect.set(editor, "draft", "stale text");
|
||||
Reflect.set(editor, "editor", {
|
||||
state: { doc: { toString: () => "stale text" } },
|
||||
dispatch,
|
||||
});
|
||||
saveDraft(key, "stale text");
|
||||
|
||||
editor.replaceText("");
|
||||
|
||||
expect(loadDraft(key)).toBe("");
|
||||
const transaction = dispatch.mock.calls[0]?.[0];
|
||||
if (!isRecord(transaction) || !isRecord(transaction["selection"])) throw new Error("Expected a CodeMirror clearing transaction");
|
||||
expect(transaction["changes"]).toEqual({ from: 0, to: 10, insert: "" });
|
||||
expect(transaction["selection"]["anchor"]).toBe(0);
|
||||
expect(transaction["selection"]["head"]).toBe(0);
|
||||
expect(Reflect.get(editor, "currentInputMode")).toEqual({ kind: "normal" });
|
||||
});
|
||||
});
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
@@ -132,6 +132,28 @@ export class PromptEditor extends LitElement {
|
||||
this.editor?.focus();
|
||||
}
|
||||
|
||||
replaceText(text: string): void {
|
||||
this.draft = text;
|
||||
const key = draftStorageKey(this.machineId, this.sessionId);
|
||||
if (key !== undefined) saveDraft(key, text);
|
||||
|
||||
const editor = this.editor;
|
||||
if (editor !== undefined) {
|
||||
const current = editor.state.doc.toString();
|
||||
editor.dispatch({
|
||||
...(current === text ? {} : { changes: { from: 0, to: current.length, insert: text } }),
|
||||
selection: EditorSelection.cursor(text.length),
|
||||
});
|
||||
}
|
||||
|
||||
// Invalidate completion requests started for either the previous document or
|
||||
// the replacement dispatch, then return the editor to a clean completion state.
|
||||
this.requestVersion += 1;
|
||||
this.currentInputMode = inputModeForDraft(text);
|
||||
this.completions = [];
|
||||
this.selectedIndex = 0;
|
||||
}
|
||||
|
||||
/** Get the underlying CM6 EditorView, or undefined if not yet mounted. */
|
||||
get view(): EditorView | undefined {
|
||||
return this.editor;
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { SessionTreeNavigateResult, SessionTreeSnapshot, SessionTreeSummaryChoice } from "../api";
|
||||
// Genuine Lit callback extraction is limited to pointer row/confirmation wiring;
|
||||
// keyboard state and hierarchy are covered through the pure sessionTreeModel.
|
||||
// A DOM harness would otherwise add a new test environment only for two clicks.
|
||||
import { templateClickHandlerForText, templateEventHandlerNearMarker } from "../templateInspection.testSupport";
|
||||
import { SessionTreeNavigator, sessionTreeEntryReturnsToEditor, sessionTreeVisualDepth } from "./SessionTreeNavigator";
|
||||
|
||||
type NavigateCallback = (targetId: string, summaryChoice: SessionTreeSummaryChoice) => Promise<SessionTreeNavigateResult>;
|
||||
type VoidMethod = (this: SessionTreeNavigator) => void;
|
||||
type PromiseMethod = (this: SessionTreeNavigator) => Promise<void>;
|
||||
type SummaryModeMethod = (this: SessionTreeNavigator, mode: SessionTreeSummaryChoice["mode"]) => void;
|
||||
|
||||
describe("session-tree-navigator interactions", () => {
|
||||
it("uses pointer selection for explicit navigation and retains it after cancellation", async () => {
|
||||
const navigator = initializedNavigator();
|
||||
const onNavigate = vi.fn<NavigateCallback>().mockResolvedValue({ cancelled: true, aborted: true });
|
||||
navigator.onNavigate = onNavigate;
|
||||
|
||||
templateClickHandlerForText(renderNavigator(navigator), "Side branch")(new Event("click"));
|
||||
clickTreeNavigate(navigator);
|
||||
await callPromiseMethod(navigator, "submitNavigation");
|
||||
|
||||
expect(onNavigate).toHaveBeenNthCalledWith(1, "side", { mode: "none" });
|
||||
expect(componentProperty(navigator, "step")).toBe("tree");
|
||||
expect(componentProperty(navigator, "statusMessage")).toContain("selected history entry is unchanged");
|
||||
|
||||
clickTreeNavigate(navigator);
|
||||
await callPromiseMethod(navigator, "submitNavigation");
|
||||
expect(onNavigate).toHaveBeenNthCalledWith(2, "side", { mode: "none" });
|
||||
});
|
||||
|
||||
it("submits trimmed custom focus, exposes busy cancellation, and returns to the same node", async () => {
|
||||
const navigation = deferred<SessionTreeNavigateResult>();
|
||||
const navigator = initializedNavigator();
|
||||
const onNavigate = vi.fn<NavigateCallback>(() => navigation.promise);
|
||||
const onAbort = vi.fn(() => Promise.resolve());
|
||||
navigator.onNavigate = onNavigate;
|
||||
navigator.onAbort = onAbort;
|
||||
|
||||
clickTreeNavigate(navigator);
|
||||
callSummaryModeMethod(navigator, "custom");
|
||||
setComponentProperty(navigator, "customInstructions", " focus on failed tests ");
|
||||
|
||||
const submission = callPromiseMethod(navigator, "submitNavigation");
|
||||
expect(componentProperty(navigator, "busy")).toBe(true);
|
||||
expect(onNavigate).toHaveBeenCalledWith("active", { mode: "custom", instructions: "focus on failed tests" });
|
||||
|
||||
await callPromiseMethod(navigator, "abortNavigation");
|
||||
expect(onAbort).toHaveBeenCalledOnce();
|
||||
expect(componentProperty(navigator, "aborting")).toBe(true);
|
||||
|
||||
navigation.resolve({ cancelled: true, aborted: true });
|
||||
await submission;
|
||||
expect(componentProperty(navigator, "busy")).toBe(false);
|
||||
expect(componentProperty(navigator, "selectedId")).toBe("active");
|
||||
expect(componentProperty(navigator, "step")).toBe("tree");
|
||||
});
|
||||
|
||||
it("clears transient cancelling status if navigation rejects after abort", async () => {
|
||||
const navigation = deferred<SessionTreeNavigateResult>();
|
||||
const navigator = initializedNavigator();
|
||||
navigator.onNavigate = () => navigation.promise;
|
||||
navigator.onAbort = () => Promise.resolve();
|
||||
|
||||
clickTreeNavigate(navigator);
|
||||
callSummaryModeMethod(navigator, "default");
|
||||
const submission = callPromiseMethod(navigator, "submitNavigation");
|
||||
await callPromiseMethod(navigator, "abortNavigation");
|
||||
expect(componentProperty(navigator, "statusMessage")).toBe("Cancelling summarization…");
|
||||
|
||||
navigation.reject(new Error("remote operation failed"));
|
||||
await submission;
|
||||
|
||||
expect(componentProperty(navigator, "statusMessage")).toBe("");
|
||||
expect(componentProperty(navigator, "error")).toBe("Could not navigate session history: remote operation failed");
|
||||
});
|
||||
|
||||
it("keeps navigation failures actionable and local to the confirmation step", async () => {
|
||||
const navigator = initializedNavigator();
|
||||
navigator.onNavigate = () => Promise.reject(new Error("The session changed since /tree was opened. Reopen /tree and try again."));
|
||||
|
||||
clickTreeNavigate(navigator);
|
||||
await callPromiseMethod(navigator, "submitNavigation");
|
||||
|
||||
expect(componentProperty(navigator, "step")).toBe("confirm");
|
||||
expect(componentProperty(navigator, "busy")).toBe(false);
|
||||
expect(componentProperty(navigator, "error")).toBe("Could not navigate session history: The session changed since /tree was opened. Reopen /tree and try again.");
|
||||
});
|
||||
|
||||
it("focuses the active leaf selected when the dialog opens", () => {
|
||||
const navigator = initializedNavigator();
|
||||
const activeFocus = vi.fn();
|
||||
const activeScroll = vi.fn();
|
||||
const root = {
|
||||
querySelector: () => null,
|
||||
querySelectorAll: () => [
|
||||
{ dataset: { treeNodeId: "root" }, focus: vi.fn(), scrollIntoView: vi.fn() },
|
||||
{ dataset: { treeNodeId: "active" }, focus: activeFocus, scrollIntoView: activeScroll },
|
||||
],
|
||||
};
|
||||
if (!Reflect.set(navigator, "renderRoot", root)) throw new Error("Could not install navigator render root");
|
||||
|
||||
callVoidMethod(navigator, "focusSelectedTreeItem");
|
||||
|
||||
expect(activeFocus).toHaveBeenCalledOnce();
|
||||
expect(activeScroll).toHaveBeenCalledWith({ block: "nearest" });
|
||||
});
|
||||
|
||||
it("keeps an empty tree inert and moves initial focus to the close boundary", async () => {
|
||||
const navigator = new SessionTreeNavigator();
|
||||
navigator.tree = { nodes: [], activeLeafId: null, activePathIds: [] };
|
||||
const onNavigate = vi.fn<NavigateCallback>().mockResolvedValue({ cancelled: false });
|
||||
navigator.onNavigate = onNavigate;
|
||||
const closeFocus = vi.fn();
|
||||
const root = {
|
||||
querySelector: (selector: string) => selector === ".close-button" ? { focus: closeFocus } : null,
|
||||
querySelectorAll: () => [],
|
||||
};
|
||||
if (!Reflect.set(navigator, "renderRoot", root)) throw new Error("Could not install navigator render root");
|
||||
callVoidMethod(navigator, "resetTree");
|
||||
|
||||
callVoidMethod(navigator, "focusSelectedTreeItem");
|
||||
callVoidMethod(navigator, "continueToConfirmation");
|
||||
await callPromiseMethod(navigator, "submitNavigation");
|
||||
|
||||
expect(componentProperty(navigator, "selectedId")).toBeUndefined();
|
||||
expect(componentProperty(navigator, "step")).toBe("tree");
|
||||
expect(closeFocus).toHaveBeenCalledOnce();
|
||||
expect(onNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("describes Pi's editor-return semantics and bounds pathological visual indentation", () => {
|
||||
expect(sessionTreeEntryReturnsToEditor("user")).toBe(true);
|
||||
expect(sessionTreeEntryReturnsToEditor("custom-message")).toBe(true);
|
||||
expect(sessionTreeEntryReturnsToEditor("assistant")).toBe(false);
|
||||
expect(sessionTreeEntryReturnsToEditor("tool-result")).toBe(false);
|
||||
expect(sessionTreeVisualDepth(-1)).toBe(0);
|
||||
expect(sessionTreeVisualDepth(12)).toBe(12);
|
||||
expect(sessionTreeVisualDepth(20_000)).toBe(32);
|
||||
});
|
||||
});
|
||||
|
||||
function initializedNavigator(): SessionTreeNavigator {
|
||||
const navigator = new SessionTreeNavigator();
|
||||
navigator.tree = tree();
|
||||
callVoidMethod(navigator, "resetTree");
|
||||
return navigator;
|
||||
}
|
||||
|
||||
function tree(): SessionTreeSnapshot {
|
||||
return {
|
||||
nodes: [
|
||||
{ id: "root", parentId: null, kind: "user", summary: "Initial prompt" },
|
||||
{ id: "active", parentId: "root", kind: "assistant", summary: "Active answer" },
|
||||
{ id: "side", parentId: "root", kind: "assistant", summary: "Side branch" },
|
||||
],
|
||||
activeLeafId: "active",
|
||||
activePathIds: ["root", "active"],
|
||||
};
|
||||
}
|
||||
|
||||
function renderNavigator(navigator: SessionTreeNavigator): TemplateResult {
|
||||
return navigator.render();
|
||||
}
|
||||
|
||||
function clickTreeNavigate(navigator: SessionTreeNavigator): void {
|
||||
templateEventHandlerNearMarker(renderNavigator(navigator), ">Navigate</button>")(new Event("click"));
|
||||
}
|
||||
|
||||
function componentProperty(navigator: SessionTreeNavigator, property: string): unknown {
|
||||
return Reflect.get(navigator, property);
|
||||
}
|
||||
|
||||
function setComponentProperty(navigator: SessionTreeNavigator, property: string, value: unknown): void {
|
||||
if (!Reflect.set(navigator, property, value)) throw new Error(`Could not set navigator property ${property}`);
|
||||
}
|
||||
|
||||
function callVoidMethod(navigator: SessionTreeNavigator, methodName: string): void {
|
||||
const method: unknown = Reflect.get(navigator, methodName);
|
||||
if (!isVoidMethod(method)) throw new Error(`SessionTreeNavigator.${methodName} is not callable`);
|
||||
method.call(navigator);
|
||||
}
|
||||
|
||||
async function callPromiseMethod(navigator: SessionTreeNavigator, methodName: string): Promise<void> {
|
||||
const method: unknown = Reflect.get(navigator, methodName);
|
||||
if (!isPromiseMethod(method)) throw new Error(`SessionTreeNavigator.${methodName} is not callable`);
|
||||
await method.call(navigator);
|
||||
}
|
||||
|
||||
function callSummaryModeMethod(navigator: SessionTreeNavigator, mode: SessionTreeSummaryChoice["mode"]): void {
|
||||
const method: unknown = Reflect.get(navigator, "selectSummaryMode");
|
||||
if (!isSummaryModeMethod(method)) throw new Error("SessionTreeNavigator.selectSummaryMode is not callable");
|
||||
method.call(navigator, mode);
|
||||
}
|
||||
|
||||
function isVoidMethod(value: unknown): value is VoidMethod {
|
||||
return typeof value === "function";
|
||||
}
|
||||
|
||||
function isPromiseMethod(value: unknown): value is PromiseMethod {
|
||||
return typeof value === "function";
|
||||
}
|
||||
|
||||
function isSummaryModeMethod(value: unknown): value is SummaryModeMethod {
|
||||
return typeof value === "function";
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve: (value: T | PromiseLike<T>) => void = () => undefined;
|
||||
let reject: (reason?: unknown) => void = () => undefined;
|
||||
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
||||
resolve = promiseResolve;
|
||||
reject = promiseReject;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
@@ -0,0 +1,554 @@
|
||||
import { LitElement, css, html, nothing, type PropertyValues, type TemplateResult } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import type { SessionTreeNavigateResult, SessionTreeNodeKind, SessionTreeSnapshot, SessionTreeSummaryChoice } from "../api";
|
||||
import { SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH } from "../../../shared/apiTypes";
|
||||
import { buildSessionTreeModel, initialSessionTreeSelection, toggleSessionTreeFold, transitionSessionTreeKey, validateSessionTreeSummaryChoice, visibleSessionTreeRows, type SessionTreeModel, type SessionTreeRow } from "../sessionTreeModel";
|
||||
|
||||
const EMPTY_TREE: SessionTreeSnapshot = { nodes: [], activeLeafId: null, activePathIds: [] };
|
||||
const MAX_SESSION_TREE_VISUAL_DEPTH = 32;
|
||||
type NavigatorStep = "tree" | "confirm";
|
||||
type PendingFocus = "tree" | "summary" | "custom";
|
||||
|
||||
@customElement("session-tree-navigator")
|
||||
export class SessionTreeNavigator extends LitElement {
|
||||
@property({ attribute: false }) tree: SessionTreeSnapshot = EMPTY_TREE;
|
||||
@property({ attribute: false }) onNavigate?: (targetId: string, summaryChoice: SessionTreeSummaryChoice) => Promise<SessionTreeNavigateResult>;
|
||||
@property({ attribute: false }) onAbort?: () => Promise<void>;
|
||||
@property({ attribute: false }) onCancel?: () => void;
|
||||
|
||||
@state() private selectedId: string | undefined;
|
||||
@state() private foldedIds: ReadonlySet<string> = new Set();
|
||||
@state() private step: NavigatorStep = "tree";
|
||||
@state() private summaryMode: SessionTreeSummaryChoice["mode"] = "none";
|
||||
@state() private customInstructions = "";
|
||||
@state() private busy = false;
|
||||
@state() private aborting = false;
|
||||
@state() private error = "";
|
||||
@state() private statusMessage = "";
|
||||
|
||||
private model: SessionTreeModel = buildSessionTreeModel(EMPTY_TREE);
|
||||
private pendingFocus: PendingFocus | undefined;
|
||||
private operationGeneration = 0;
|
||||
|
||||
protected override willUpdate(changedProperties: PropertyValues<this>): void {
|
||||
if (changedProperties.has("tree")) this.resetTree();
|
||||
}
|
||||
|
||||
protected override updated(): void {
|
||||
const pendingFocus = this.pendingFocus;
|
||||
if (pendingFocus === undefined) return;
|
||||
this.pendingFocus = undefined;
|
||||
if (pendingFocus === "tree") this.focusSelectedTreeItem();
|
||||
else if (pendingFocus === "custom") this.renderRoot.querySelector<HTMLTextAreaElement>("#session-tree-custom-focus")?.focus();
|
||||
else this.renderRoot.querySelector<HTMLInputElement>("input[name='session-tree-summary']:checked")?.focus();
|
||||
}
|
||||
|
||||
override render(): TemplateResult {
|
||||
return html`
|
||||
<div class="backdrop" @mousedown=${(event: MouseEvent) => { this.handleBackdropMouseDown(event); }}>
|
||||
<section
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="session-tree-heading"
|
||||
aria-busy=${this.busy ? "true" : "false"}
|
||||
tabindex="-1"
|
||||
@mousedown=${(event: MouseEvent) => { event.stopPropagation(); }}
|
||||
@keydown=${(event: KeyboardEvent) => { this.handleDialogKeyDown(event); }}
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<span class="eyebrow">Conversation history</span>
|
||||
<h1 id="session-tree-heading">Navigate session tree</h1>
|
||||
</div>
|
||||
<button class="close-button" ?disabled=${this.busy} title="Close session tree" aria-label="Close session tree" @click=${() => { this.onCancel?.(); }}>×</button>
|
||||
</header>
|
||||
${this.step === "tree" ? this.renderTreeStep() : this.renderConfirmationStep()}
|
||||
${this.renderFooter()}
|
||||
</section>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderTreeStep(): TemplateResult {
|
||||
const rows = visibleSessionTreeRows(this.model, this.foldedIds);
|
||||
return html`
|
||||
<div class="body tree-step">
|
||||
<div class="tree-intro">
|
||||
<p>Select where conversation context should continue. All retained branches stay in this session file.</p>
|
||||
<div class="legend" aria-label="Session tree markers">
|
||||
<span><span class="marker active-path-marker" aria-hidden="true"></span>Active path</span>
|
||||
<span><span class="marker active-leaf-marker" aria-hidden="true"></span>Active leaf</span>
|
||||
</div>
|
||||
</div>
|
||||
${this.statusMessage === "" ? null : html`<div class="dialog-status" role="status">${this.statusMessage}</div>`}
|
||||
${this.error === "" ? null : html`<div class="dialog-error" role="alert">${this.error}</div>`}
|
||||
${rows.length === 0 ? html`
|
||||
<div class="empty" role="status">This session does not contain any selectable history entries.</div>
|
||||
` : html`
|
||||
<div class="tree" role="tree" aria-label="Complete session history">
|
||||
${rows.map((row) => this.renderTreeRow(row))}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderTreeRow(row: SessionTreeRow): TemplateResult {
|
||||
const selected = row.node.id === this.selectedId;
|
||||
const expanded = row.childIds.length > 0 && !this.foldedIds.has(row.node.id);
|
||||
const classes = [
|
||||
"tree-row",
|
||||
selected ? "selected" : "",
|
||||
row.activePath ? "active-path" : "",
|
||||
row.activeLeaf ? "active-leaf" : "",
|
||||
isBookkeepingKind(row.node.kind) ? "bookkeeping" : "",
|
||||
].filter((value) => value !== "").join(" ");
|
||||
const visualDepth = sessionTreeVisualDepth(row.depth);
|
||||
return html`
|
||||
<div
|
||||
class=${classes}
|
||||
style=${`--tree-indent: ${String(visualDepth * 22)}px; --tree-indent-mobile: ${String(visualDepth * 16)}px;`}
|
||||
role="treeitem"
|
||||
aria-level=${String(row.depth + 1)}
|
||||
aria-selected=${selected ? "true" : "false"}
|
||||
aria-expanded=${row.childIds.length === 0 ? nothing : expanded ? "true" : "false"}
|
||||
aria-current=${row.activeLeaf ? "true" : nothing}
|
||||
tabindex=${selected ? "0" : "-1"}
|
||||
data-tree-node-id=${row.node.id}
|
||||
@click=${() => { this.selectNode(row.node.id); }}
|
||||
@keydown=${(event: KeyboardEvent) => { this.handleTreeKeyDown(event); }}
|
||||
>
|
||||
<span
|
||||
class=${`disclosure${row.childIds.length === 0 ? " leaf" : ""}`}
|
||||
title=${row.childIds.length === 0 ? "No child entries" : expanded ? "Collapse branch" : "Expand branch"}
|
||||
aria-hidden="true"
|
||||
@click=${(event: MouseEvent) => { this.toggleNode(row.node.id, event); }}
|
||||
>${row.childIds.length === 0 ? "·" : expanded ? "▾" : "▸"}</span>
|
||||
<span class="kind">${sessionTreeKindLabel(row.node.kind)}</span>
|
||||
<span class="entry">
|
||||
<span class="summary" dir="auto">${row.node.summary}</span>
|
||||
${row.node.label === undefined ? null : html`<span class="label" title=${row.node.label}>${row.node.label}</span>`}
|
||||
${row.node.timestamp === undefined ? null : html`<time datetime=${row.node.timestamp}>${row.node.timestamp}</time>`}
|
||||
</span>
|
||||
<span class="badges">
|
||||
${row.activePath ? html`<span class="badge path">Active path</span>` : null}
|
||||
${row.activeLeaf ? html`<span class="badge leaf">Active leaf</span>` : null}
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderConfirmationStep(): TemplateResult {
|
||||
const selectedNode = this.selectedId === undefined ? undefined : this.model.nodesById.get(this.selectedId);
|
||||
const validation = validateSessionTreeSummaryChoice(this.summaryMode, this.customInstructions);
|
||||
return html`
|
||||
<div class="body confirmation-step">
|
||||
<div class="confirmation-card">
|
||||
<div>
|
||||
<span class="eyebrow">Selected entry</span>
|
||||
<h2>Confirm navigation</h2>
|
||||
</div>
|
||||
${selectedNode === undefined ? html`<div class="empty">The selected history entry is no longer available.</div>` : html`
|
||||
<div class="selected-entry">
|
||||
<span class="kind">${sessionTreeKindLabel(selectedNode.kind)}</span>
|
||||
<strong dir="auto">${selectedNode.summary}</strong>
|
||||
${sessionTreeEntryReturnsToEditor(selectedNode.kind)
|
||||
? html`<p>This message’s text will return to the prompt editor for optional editing and resubmission.</p>`
|
||||
: html`<p>The prompt editor will be empty after navigating to this entry.</p>`}
|
||||
</div>
|
||||
`}
|
||||
<fieldset ?disabled=${this.busy}>
|
||||
<legend>Abandoned branch summary</legend>
|
||||
${this.renderSummaryOption("none", "No summary", "Switch branches without adding a summary entry.")}
|
||||
${this.renderSummaryOption("default", "Summarize", "Ask Pi to summarize the context being left behind.")}
|
||||
${this.renderSummaryOption("custom", "Summarize with custom focus", "Guide Pi toward the details that matter for the new branch.")}
|
||||
${this.summaryMode === "custom" ? html`
|
||||
<label class="custom-focus" for="session-tree-custom-focus">
|
||||
<span>Custom summary focus</span>
|
||||
<textarea
|
||||
id="session-tree-custom-focus"
|
||||
rows="5"
|
||||
maxlength=${String(SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH)}
|
||||
.value=${this.customInstructions}
|
||||
@input=${(event: InputEvent) => { this.handleCustomInstructionsInput(event); }}
|
||||
></textarea>
|
||||
<span class="character-count">${this.customInstructions.length} / ${SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH}</span>
|
||||
</label>
|
||||
${validation.ok ? null : html`<div class="validation-error" role="alert">${validation.error}</div>`}
|
||||
` : null}
|
||||
</fieldset>
|
||||
<div class="side-effects-note" role="note">
|
||||
<strong>Conversation context only.</strong> Navigation changes the active conversation branch. It does not undo filesystem changes, shell commands, tool calls, or other side effects.
|
||||
</div>
|
||||
${this.statusMessage === "" ? null : html`<div class="dialog-status" role="status">${this.statusMessage}</div>`}
|
||||
${this.error === "" ? null : html`<div class="dialog-error" role="alert">${this.error}</div>`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderSummaryOption(mode: SessionTreeSummaryChoice["mode"], label: string, description: string): TemplateResult {
|
||||
return html`
|
||||
<label class=${`summary-option${this.summaryMode === mode ? " selected" : ""}`}>
|
||||
<input
|
||||
type="radio"
|
||||
name="session-tree-summary"
|
||||
value=${mode}
|
||||
.checked=${this.summaryMode === mode}
|
||||
@change=${() => { this.selectSummaryMode(mode); }}
|
||||
>
|
||||
<span><strong>${label}</strong><small>${description}</small></span>
|
||||
</label>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderFooter(): TemplateResult {
|
||||
if (this.step === "tree") {
|
||||
return html`
|
||||
<footer>
|
||||
<button @click=${() => { this.onCancel?.(); }}>Cancel</button>
|
||||
<button class="primary" ?disabled=${this.selectedId === undefined} @click=${() => { this.continueToConfirmation(); }}>Navigate</button>
|
||||
</footer>
|
||||
`;
|
||||
}
|
||||
|
||||
const validation = validateSessionTreeSummaryChoice(this.summaryMode, this.customInstructions);
|
||||
const summarizing = this.summaryMode !== "none";
|
||||
return html`
|
||||
<footer>
|
||||
<button ?disabled=${this.busy} @click=${() => { this.returnToTree(); }}>Back</button>
|
||||
<span class="footer-spacer"></span>
|
||||
${this.busy && summarizing ? html`
|
||||
<button class="danger" ?disabled=${this.aborting} @click=${() => { void this.abortNavigation(); }}>${this.aborting ? "Cancelling…" : "Cancel summarization"}</button>
|
||||
` : null}
|
||||
<button class="primary" ?disabled=${this.busy || this.selectedId === undefined || !validation.ok} @click=${() => { void this.submitNavigation(); }}>
|
||||
${this.busy ? summarizing ? "Summarizing…" : "Navigating…" : summarizing ? "Summarize and navigate" : "Navigate"}
|
||||
</button>
|
||||
</footer>
|
||||
`;
|
||||
}
|
||||
|
||||
private resetTree(): void {
|
||||
this.operationGeneration += 1;
|
||||
this.model = buildSessionTreeModel(this.tree);
|
||||
this.selectedId = initialSessionTreeSelection(this.model);
|
||||
this.foldedIds = new Set();
|
||||
this.step = "tree";
|
||||
this.summaryMode = "none";
|
||||
this.customInstructions = "";
|
||||
this.busy = false;
|
||||
this.aborting = false;
|
||||
this.error = "";
|
||||
this.statusMessage = "";
|
||||
this.pendingFocus = "tree";
|
||||
}
|
||||
|
||||
private selectNode(id: string): void {
|
||||
if (!this.model.nodesById.has(id)) return;
|
||||
this.selectedId = id;
|
||||
this.error = "";
|
||||
this.statusMessage = "";
|
||||
this.pendingFocus = "tree";
|
||||
}
|
||||
|
||||
private toggleNode(id: string, event: MouseEvent): void {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const next = toggleSessionTreeFold(this.model, { selectedId: this.selectedId, foldedIds: this.foldedIds }, id);
|
||||
this.selectedId = next.selectedId;
|
||||
this.foldedIds = next.foldedIds;
|
||||
this.error = "";
|
||||
this.statusMessage = "";
|
||||
this.pendingFocus = "tree";
|
||||
}
|
||||
|
||||
private handleTreeKeyDown(event: KeyboardEvent): void {
|
||||
const next = transitionSessionTreeKey(this.model, { selectedId: this.selectedId, foldedIds: this.foldedIds }, event.key);
|
||||
if (!next.handled) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (next.action === "cancel") {
|
||||
this.onCancel?.();
|
||||
return;
|
||||
}
|
||||
if (next.action === "confirm") {
|
||||
this.continueToConfirmation();
|
||||
return;
|
||||
}
|
||||
this.selectedId = next.selectedId;
|
||||
this.foldedIds = next.foldedIds;
|
||||
this.pendingFocus = "tree";
|
||||
}
|
||||
|
||||
private continueToConfirmation(): void {
|
||||
if (this.selectedId === undefined || !this.model.nodesById.has(this.selectedId)) return;
|
||||
this.step = "confirm";
|
||||
this.error = "";
|
||||
this.statusMessage = "";
|
||||
this.pendingFocus = "summary";
|
||||
}
|
||||
|
||||
private returnToTree(): void {
|
||||
if (this.busy) return;
|
||||
this.step = "tree";
|
||||
this.error = "";
|
||||
this.statusMessage = "";
|
||||
this.pendingFocus = "tree";
|
||||
}
|
||||
|
||||
private selectSummaryMode(mode: SessionTreeSummaryChoice["mode"]): void {
|
||||
if (this.busy) return;
|
||||
this.summaryMode = mode;
|
||||
this.error = "";
|
||||
this.statusMessage = "";
|
||||
this.pendingFocus = mode === "custom" ? "custom" : "summary";
|
||||
}
|
||||
|
||||
private handleCustomInstructionsInput(event: InputEvent): void {
|
||||
if (!(event.currentTarget instanceof HTMLTextAreaElement)) return;
|
||||
this.customInstructions = event.currentTarget.value;
|
||||
this.error = "";
|
||||
this.statusMessage = "";
|
||||
}
|
||||
|
||||
private async submitNavigation(): Promise<void> {
|
||||
if (this.busy || this.selectedId === undefined) return;
|
||||
const validation = validateSessionTreeSummaryChoice(this.summaryMode, this.customInstructions);
|
||||
if (!validation.ok) {
|
||||
this.error = validation.error;
|
||||
this.pendingFocus = "custom";
|
||||
return;
|
||||
}
|
||||
const navigate = this.onNavigate;
|
||||
if (navigate === undefined) {
|
||||
this.error = "Session tree navigation is unavailable. Close and reopen /tree, then try again.";
|
||||
return;
|
||||
}
|
||||
|
||||
const targetId = this.selectedId;
|
||||
const generation = ++this.operationGeneration;
|
||||
this.busy = true;
|
||||
this.aborting = false;
|
||||
this.error = "";
|
||||
this.statusMessage = "";
|
||||
try {
|
||||
const result = await navigate(targetId, validation.choice);
|
||||
if (generation !== this.operationGeneration) return;
|
||||
this.busy = false;
|
||||
this.aborting = false;
|
||||
if (!result.cancelled) return;
|
||||
this.step = "tree";
|
||||
this.statusMessage = result.aborted === true
|
||||
? "Summarization cancelled. Your selected history entry is unchanged."
|
||||
: "Navigation cancelled. Your selected history entry is unchanged.";
|
||||
this.pendingFocus = "tree";
|
||||
} catch (error: unknown) {
|
||||
if (generation !== this.operationGeneration) return;
|
||||
this.busy = false;
|
||||
this.aborting = false;
|
||||
this.statusMessage = "";
|
||||
this.error = `Could not navigate session history: ${errorMessage(error)}`;
|
||||
}
|
||||
}
|
||||
|
||||
private async abortNavigation(): Promise<void> {
|
||||
if (!this.busy || this.summaryMode === "none" || this.aborting) return;
|
||||
const abort = this.onAbort;
|
||||
if (abort === undefined) {
|
||||
this.error = "Summarization cannot be cancelled from this client.";
|
||||
return;
|
||||
}
|
||||
const generation = this.operationGeneration;
|
||||
this.aborting = true;
|
||||
this.error = "";
|
||||
this.statusMessage = "Cancelling summarization…";
|
||||
try {
|
||||
await abort();
|
||||
} catch (error: unknown) {
|
||||
if (generation !== this.operationGeneration) return;
|
||||
this.aborting = false;
|
||||
this.statusMessage = "";
|
||||
this.error = `Could not cancel summarization: ${errorMessage(error)}`;
|
||||
}
|
||||
}
|
||||
|
||||
private handleBackdropMouseDown(event: MouseEvent): void {
|
||||
if (event.target === event.currentTarget && !this.busy) this.onCancel?.();
|
||||
}
|
||||
|
||||
private handleDialogKeyDown(event: KeyboardEvent): void {
|
||||
if (event.key === "Tab") {
|
||||
this.trapTabFocus(event);
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Escape") return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (this.busy) {
|
||||
if (this.summaryMode !== "none") void this.abortNavigation();
|
||||
return;
|
||||
}
|
||||
if (this.step === "confirm") this.returnToTree();
|
||||
else this.onCancel?.();
|
||||
}
|
||||
|
||||
private trapTabFocus(event: KeyboardEvent): void {
|
||||
const focusable = [...this.renderRoot.querySelectorAll<HTMLElement>("button:not(:disabled), input:not(:disabled), textarea:not(:disabled), [tabindex='0']")];
|
||||
if (focusable.length === 0) {
|
||||
event.preventDefault();
|
||||
this.renderRoot.querySelector<HTMLElement>("section[role='dialog']")?.focus();
|
||||
return;
|
||||
}
|
||||
const active = this.shadowRoot?.activeElement;
|
||||
const activeIndex = focusable.findIndex((element) => element === active);
|
||||
const movingPastEnd = !event.shiftKey && activeIndex === focusable.length - 1;
|
||||
const movingBeforeStart = event.shiftKey && (activeIndex <= 0);
|
||||
if (!movingPastEnd && !movingBeforeStart) return;
|
||||
event.preventDefault();
|
||||
(event.shiftKey ? focusable.at(-1) : focusable[0])?.focus();
|
||||
}
|
||||
|
||||
private focusSelectedTreeItem(): void {
|
||||
const selectedId = this.selectedId;
|
||||
if (selectedId === undefined) {
|
||||
this.renderRoot.querySelector<HTMLElement>(".close-button")?.focus();
|
||||
return;
|
||||
}
|
||||
const rows = this.renderRoot.querySelectorAll<HTMLElement>("[data-tree-node-id]");
|
||||
for (const row of rows) {
|
||||
if (row.dataset["treeNodeId"] !== selectedId) continue;
|
||||
row.focus();
|
||||
row.scrollIntoView({ block: "nearest" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
static override styles = css`
|
||||
:host { position: fixed; inset: 0; z-index: 40; color: var(--pi-text); font: 14px system-ui, sans-serif; }
|
||||
* { box-sizing: border-box; }
|
||||
.backdrop { width: 100%; height: 100dvh; background: var(--pi-overlay); overflow: hidden; }
|
||||
section[role="dialog"] { width: 100%; height: 100dvh; display: grid; grid-template-rows: auto minmax(0, 1fr) auto; background: var(--pi-bg); overflow: hidden; }
|
||||
header, footer { display: flex; align-items: center; gap: 12px; padding: max(14px, env(safe-area-inset-top)) max(18px, env(safe-area-inset-right)) 14px max(18px, env(safe-area-inset-left)); border-bottom: 1px solid var(--pi-border); }
|
||||
footer { min-height: 64px; justify-content: end; padding: 12px max(18px, env(safe-area-inset-right)) max(12px, env(safe-area-inset-bottom)) max(18px, env(safe-area-inset-left)); border-top: 1px solid var(--pi-border); border-bottom: 0; }
|
||||
header > div { min-width: 0; }
|
||||
h1, h2, p { margin: 0; }
|
||||
h1 { font-size: 21px; line-height: 1.25; }
|
||||
h2 { margin-top: 2px; font-size: 18px; }
|
||||
.eyebrow { display: block; color: var(--pi-muted); font-size: 11px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
|
||||
.close-button { width: 36px; height: 36px; margin-inline-start: auto; display: grid; place-items: center; border: 0; background: transparent; color: var(--pi-muted); padding: 0; font-size: 25px; }
|
||||
.close-button:not(:disabled):hover, .close-button:not(:disabled):focus-visible { color: var(--pi-text); background: var(--pi-surface-hover); }
|
||||
.body { min-height: 0; overflow: auto; }
|
||||
.tree-step { display: flex; flex-direction: column; gap: 10px; padding: 14px max(18px, env(safe-area-inset-right)) 16px max(18px, env(safe-area-inset-left)); }
|
||||
.tree-intro { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px 20px; color: var(--pi-muted); }
|
||||
.legend { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; font-size: 12px; }
|
||||
.legend > span { display: inline-flex; align-items: center; gap: 5px; }
|
||||
.marker { width: 9px; height: 9px; border-radius: 999px; background: var(--pi-border); }
|
||||
.active-path-marker { background: var(--pi-accent); }
|
||||
.active-leaf-marker { box-shadow: 0 0 0 2px var(--pi-accent); background: var(--pi-bg); }
|
||||
.tree { min-height: 0; overflow: auto; border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); overscroll-behavior: contain; }
|
||||
.tree-row { min-height: 48px; display: grid; grid-template-columns: 20px minmax(82px, auto) minmax(0, 1fr) auto; align-items: center; gap: 8px; padding: 7px 10px 7px calc(10px + var(--tree-indent)); border-bottom: 1px solid var(--pi-border-muted); cursor: pointer; outline: none; content-visibility: auto; contain-intrinsic-block-size: 48px; }
|
||||
.tree-row:last-child { border-bottom: 0; }
|
||||
.tree-row:hover { background: var(--pi-surface-hover); }
|
||||
.tree-row.selected { background: var(--pi-selection-bg); box-shadow: inset 3px 0 var(--pi-accent); }
|
||||
.tree-row:focus-visible { outline: 2px solid var(--pi-accent); outline-offset: -2px; }
|
||||
.tree-row.active-path:not(.selected) { background: color-mix(in srgb, var(--pi-accent) 7%, var(--pi-surface)); }
|
||||
.tree-row.active-leaf { box-shadow: inset 3px 0 var(--pi-accent); }
|
||||
.tree-row.bookkeeping { color: var(--pi-muted); }
|
||||
.disclosure { width: 20px; height: 28px; display: grid; place-items: center; border-radius: 5px; color: var(--pi-muted); font-size: 15px; user-select: none; }
|
||||
.disclosure:not(.leaf):hover { color: var(--pi-text); background: var(--pi-surface-hover); }
|
||||
.disclosure.leaf { opacity: .5; }
|
||||
.kind { display: inline-flex; align-items: center; width: fit-content; border: 1px solid var(--pi-border); border-radius: 999px; padding: 2px 7px; color: var(--pi-muted); background: var(--pi-bg); font-size: 11px; font-weight: 700; white-space: nowrap; }
|
||||
.entry { min-width: 0; display: flex; align-items: baseline; gap: 8px; }
|
||||
.summary { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--pi-text); }
|
||||
.bookkeeping .summary { color: var(--pi-muted); }
|
||||
.label { max-width: 180px; overflow: hidden; text-overflow: ellipsis; border-radius: 4px; padding: 1px 5px; background: var(--pi-bg-overlay); color: var(--pi-muted); font-size: 11px; white-space: nowrap; }
|
||||
time { color: var(--pi-muted); font-size: 11px; white-space: nowrap; }
|
||||
.badges { display: flex; align-items: center; justify-content: end; gap: 5px; }
|
||||
.badge { border-radius: 999px; padding: 2px 7px; font-size: 11px; font-weight: 700; white-space: nowrap; }
|
||||
.badge.path { background: color-mix(in srgb, var(--pi-accent) 14%, transparent); color: var(--pi-text); }
|
||||
.badge.leaf { border: 1px solid var(--pi-accent); color: var(--pi-text); }
|
||||
.confirmation-step { padding: 24px max(18px, env(safe-area-inset-right)) 24px max(18px, env(safe-area-inset-left)); }
|
||||
.confirmation-card { width: min(760px, 100%); margin: 0 auto; display: grid; gap: 16px; }
|
||||
.selected-entry, .side-effects-note, .dialog-error, .dialog-status, .empty { border: 1px solid var(--pi-border); border-radius: 10px; padding: 12px 14px; }
|
||||
.selected-entry { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: start; gap: 8px 10px; background: var(--pi-surface); }
|
||||
.selected-entry p { grid-column: 2; color: var(--pi-muted); font-size: 12px; }
|
||||
fieldset { min-width: 0; margin: 0; padding: 0; border: 0; display: grid; gap: 9px; }
|
||||
legend { margin-bottom: 8px; font-weight: 700; }
|
||||
.summary-option { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: start; gap: 10px; border: 1px solid var(--pi-border); border-radius: 10px; padding: 11px 12px; background: var(--pi-surface); cursor: pointer; }
|
||||
.summary-option.selected { border-color: var(--pi-accent); background: var(--pi-selection-bg); }
|
||||
.summary-option input { margin-top: 3px; accent-color: var(--pi-accent); }
|
||||
.summary-option span { display: grid; gap: 3px; }
|
||||
.summary-option small { color: var(--pi-muted); }
|
||||
.custom-focus { display: grid; gap: 6px; margin: 2px 0 0 30px; font-weight: 600; }
|
||||
textarea { width: 100%; resize: vertical; min-height: 94px; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-bg); color: var(--pi-text); padding: 9px 10px; font: var(--pi-control-font-size, 16px) var(--pi-control-font-family, system-ui, sans-serif); }
|
||||
textarea:focus-visible { outline: 2px solid var(--pi-accent); outline-offset: 1px; }
|
||||
.character-count { justify-self: end; color: var(--pi-muted); font-size: 11px; font-weight: 400; }
|
||||
.validation-error { margin-inline-start: 30px; color: var(--pi-danger); font-size: 12px; }
|
||||
.side-effects-note { border-color: var(--pi-warning-border); background: var(--pi-warning-surface); }
|
||||
.dialog-error { border-color: var(--pi-danger); background: color-mix(in srgb, var(--pi-danger) 10%, var(--pi-bg)); color: var(--pi-danger); }
|
||||
.dialog-status { border-color: var(--pi-success-border); background: var(--pi-success-bg); }
|
||||
.empty { color: var(--pi-muted); background: var(--pi-surface); }
|
||||
button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 8px 11px; font: inherit; cursor: pointer; }
|
||||
button:not(:disabled):hover { background: var(--pi-surface-hover); }
|
||||
button:focus-visible { outline: 2px solid var(--pi-accent); outline-offset: 1px; }
|
||||
button:disabled { opacity: .52; cursor: not-allowed; }
|
||||
button.primary { border-color: var(--pi-accent); background: var(--pi-accent); color: var(--pi-bg); font-weight: 700; }
|
||||
button.primary:not(:disabled):hover { filter: brightness(1.08); }
|
||||
button.danger { color: var(--pi-danger); }
|
||||
.footer-spacer { flex: 1; }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
header { padding-top: max(12px, env(safe-area-inset-top)); }
|
||||
.tree-step { padding-inline: 8px; }
|
||||
.tree-intro { padding-inline: 4px; }
|
||||
.tree-row { grid-template-columns: 20px minmax(0, 1fr) auto; padding-inline-start: calc(7px + var(--tree-indent-mobile)); }
|
||||
.tree-row .kind { grid-column: 2; }
|
||||
.tree-row .entry { grid-column: 2 / 4; display: grid; gap: 3px; }
|
||||
.tree-row .summary { white-space: normal; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; }
|
||||
.tree-row time { display: none; }
|
||||
.badges { grid-column: 3; grid-row: 1; flex-wrap: wrap; }
|
||||
.confirmation-step { padding: 18px 12px; }
|
||||
.custom-focus, .validation-error { margin-inline-start: 0; }
|
||||
footer { flex-wrap: wrap; }
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
export function sessionTreeVisualDepth(depth: number): number {
|
||||
return Math.min(Math.max(0, depth), MAX_SESSION_TREE_VISUAL_DEPTH);
|
||||
}
|
||||
|
||||
export function sessionTreeEntryReturnsToEditor(kind: SessionTreeNodeKind): boolean {
|
||||
return kind === "user" || kind === "custom-message";
|
||||
}
|
||||
|
||||
export function sessionTreeKindLabel(kind: SessionTreeNodeKind): string {
|
||||
switch (kind) {
|
||||
case "user": return "User";
|
||||
case "assistant": return "Assistant";
|
||||
case "tool-result": return "Tool result";
|
||||
case "bash": return "Shell";
|
||||
case "custom-message": return "Custom message";
|
||||
case "compaction": return "Compaction";
|
||||
case "branch-summary": return "Branch summary";
|
||||
case "model-change": return "Model";
|
||||
case "thinking-level-change": return "Thinking";
|
||||
case "session-info": return "Session info";
|
||||
case "label": return "Label";
|
||||
case "custom": return "Custom";
|
||||
case "other": return "Other";
|
||||
}
|
||||
}
|
||||
|
||||
function isBookkeepingKind(kind: SessionTreeNodeKind): boolean {
|
||||
return kind === "model-change"
|
||||
|| kind === "thinking-level-change"
|
||||
|| kind === "session-info"
|
||||
|| kind === "label"
|
||||
|| kind === "custom"
|
||||
|| kind === "other";
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -0,0 +1,563 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { initialAppState } from "../appState";
|
||||
import { ChatTranscriptStore } from "../chatTranscriptStore";
|
||||
import { machineSessionKey } from "../machineKeys";
|
||||
import { loadDraft, saveDraft } from "../promptDraftStorage";
|
||||
import type { CommandResult, SessionTreeSnapshot } from "../api";
|
||||
import { SessionController } from "./sessionController";
|
||||
import { InMemorySessionSelectionMemory } from "./sessionSelection";
|
||||
import {
|
||||
defaultApi,
|
||||
deferred,
|
||||
EmitSocket,
|
||||
FakeSocket,
|
||||
MemoryStorage,
|
||||
oldSession,
|
||||
replacementSession,
|
||||
runPendingAnimationFrames,
|
||||
sessionLookupId,
|
||||
status,
|
||||
workspace,
|
||||
type AppState,
|
||||
type MessagePage,
|
||||
type SessionStatus,
|
||||
} from "./sessionController.testSupport";
|
||||
|
||||
const tree: SessionTreeSnapshot = {
|
||||
nodes: [
|
||||
{ id: "root", parentId: null, kind: "user", summary: "original prompt" },
|
||||
{ id: "leaf-1", parentId: "root", kind: "assistant", summary: "answer" },
|
||||
],
|
||||
activeLeafId: "leaf-1",
|
||||
activePathIds: ["root", "leaf-1"],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(globalThis, "localStorage", { value: new MemoryStorage(), configurable: true });
|
||||
});
|
||||
|
||||
describe("SessionController session tree navigation", () => {
|
||||
it("opens tree command results and keeps older-server unsupported results inert", async () => {
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: oldSession, sessions: [oldSession] };
|
||||
const navigateTree = vi.fn<typeof defaultApi.navigateTree>();
|
||||
const results: CommandResult[] = [
|
||||
{ type: "tree", tree },
|
||||
{ type: "unsupported", message: "Session tree navigation is unavailable on this server" },
|
||||
];
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
runCommand: () => Promise.resolve(results.shift() ?? { type: "unsupported", message: "missing result" }),
|
||||
navigateTree,
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
new InMemorySessionSelectionMemory(),
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
await controller.send("/tree");
|
||||
expect(state.treeDialog).toEqual(tree);
|
||||
|
||||
controller.closeTreeDialog();
|
||||
await controller.send("/tree");
|
||||
|
||||
expect(state.treeDialog).toBeUndefined();
|
||||
expect(state.messages).toEqual([{
|
||||
role: "system",
|
||||
parts: [{ type: "text", text: "Session tree navigation is unavailable on this server" }],
|
||||
}]);
|
||||
expect(navigateTree).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("discards stale history and pending live updates, runs a trailing authoritative refresh, and replaces the user draft", async () => {
|
||||
const initialPage = page("initial", 1);
|
||||
const stalePage = deferred<MessagePage>();
|
||||
const staleStatus = deferred<SessionStatus>();
|
||||
const freshPage = page("fresh branch", 2);
|
||||
const cacheKey = machineSessionKey("local", oldSession.id);
|
||||
const cachedPages = new Map<string, MessagePage>();
|
||||
const removedKeys: string[] = [];
|
||||
let messageCalls = 0;
|
||||
let statusCalls = 0;
|
||||
const navigationCalls: unknown[] = [];
|
||||
const replacePromptEditorText = vi.fn();
|
||||
const socket = new EmitSocket();
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: oldSession, sessions: [oldSession] };
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
navigateTree: (session, request, machineId) => {
|
||||
navigationCalls.push({ sessionId: sessionLookupId(session), request, machineId });
|
||||
return Promise.resolve({ cancelled: false, editorText: "edit original prompt" });
|
||||
},
|
||||
messages: () => {
|
||||
messageCalls += 1;
|
||||
if (messageCalls === 1) return Promise.resolve(initialPage);
|
||||
if (messageCalls === 2) return stalePage.promise;
|
||||
return Promise.resolve(freshPage);
|
||||
},
|
||||
status: () => {
|
||||
statusCalls += 1;
|
||||
if (statusCalls === 2) return staleStatus.promise;
|
||||
return Promise.resolve({ ...status(oldSession.id), messageCount: statusCalls === 1 ? 1 : 2 });
|
||||
},
|
||||
streamSnapshot: () => Promise.resolve({ seq: 0, partial: null }),
|
||||
thinkingLevels: () => Promise.resolve({ levels: [] }),
|
||||
};
|
||||
const transcripts = new ChatTranscriptStore({
|
||||
read: (key) => cachedPages.get(key),
|
||||
write: (key, value) => { cachedPages.set(key, value); },
|
||||
remove: (key) => { removedKeys.push(key); cachedPages.delete(key); },
|
||||
});
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
new InMemorySessionSelectionMemory(),
|
||||
{ api, socket, transcripts, replacePromptEditorText },
|
||||
);
|
||||
|
||||
await controller.selectSession(oldSession, { updateUrl: false });
|
||||
state = { ...state, treeDialog: tree };
|
||||
socket.emit({ type: "message.append", message: { role: "assistant", content: "stale live event" }, seq: 1 });
|
||||
|
||||
const oldRefresh = controller.refreshSelectedSession();
|
||||
await Promise.resolve();
|
||||
expect(messageCalls).toBe(2);
|
||||
|
||||
const navigation = controller.navigateTree("root", { mode: "custom", instructions: "focus on the prompt" });
|
||||
await Promise.resolve();
|
||||
stalePage.resolve(page("stale refresh", 1));
|
||||
staleStatus.resolve({ ...status(oldSession.id), messageCount: 1 });
|
||||
await Promise.all([oldRefresh, navigation]);
|
||||
runPendingAnimationFrames();
|
||||
|
||||
expect(navigationCalls).toEqual([{
|
||||
sessionId: oldSession.id,
|
||||
request: { targetId: "root", expectedLeafId: "leaf-1", summary: { mode: "custom", instructions: "focus on the prompt" } },
|
||||
machineId: "local",
|
||||
}]);
|
||||
expect(messageCalls).toBe(3);
|
||||
expect(statusCalls).toBe(3);
|
||||
expect(removedKeys).toEqual([cacheKey]);
|
||||
expect(cachedPages.get(cacheKey)).toEqual(freshPage);
|
||||
expect(state.messages).toEqual([{ role: "assistant", parts: [{ type: "text", text: "fresh branch" }] }]);
|
||||
expect(state.treeDialog).toBeUndefined();
|
||||
expect(loadDraft(cacheKey)).toBe("edit original prompt");
|
||||
expect(replacePromptEditorText).toHaveBeenCalledWith({ machineId: "local", sessionId: oldSession.id, text: "edit original prompt" });
|
||||
expect(socket.connectedSessionIds).toEqual([oldSession.id, oldSession.id]);
|
||||
});
|
||||
|
||||
it("keeps the busy tree mounted until authoritative history and editor replacement finish", async () => {
|
||||
const authoritativePage = deferred<MessagePage>();
|
||||
let state: AppState = {
|
||||
...initialAppState(),
|
||||
selectedWorkspace: workspace,
|
||||
selectedSession: oldSession,
|
||||
sessions: [oldSession],
|
||||
treeDialog: tree,
|
||||
};
|
||||
const replacePromptEditorText = vi.fn();
|
||||
const messages = vi.fn<typeof defaultApi.messages>(() => authoritativePage.promise);
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
navigateTree: () => Promise.resolve({ cancelled: false, editorText: "edit after refresh" }),
|
||||
messages,
|
||||
status: () => Promise.resolve(status(oldSession.id)),
|
||||
streamSnapshot: () => Promise.resolve({ seq: 0, partial: null }),
|
||||
thinkingLevels: () => Promise.resolve({ levels: [] }),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket(), replacePromptEditorText },
|
||||
);
|
||||
|
||||
const navigation = controller.navigateTree("root", { mode: "none" });
|
||||
await vi.waitFor(() => { expect(messages).toHaveBeenCalledOnce(); });
|
||||
expect(state.treeDialog).toBe(tree);
|
||||
expect(replacePromptEditorText).not.toHaveBeenCalled();
|
||||
|
||||
authoritativePage.resolve(page("authoritative branch", 1));
|
||||
await navigation;
|
||||
|
||||
expect(replacePromptEditorText).toHaveBeenCalledWith({ machineId: "local", sessionId: oldSession.id, text: "edit after refresh" });
|
||||
expect(state.treeDialog).toBeUndefined();
|
||||
});
|
||||
|
||||
it("retains the navigator when the authoritative post-navigation refresh fails", async () => {
|
||||
const cacheKey = machineSessionKey("local", oldSession.id);
|
||||
let state: AppState = {
|
||||
...initialAppState(),
|
||||
selectedWorkspace: workspace,
|
||||
selectedSession: oldSession,
|
||||
sessions: [oldSession],
|
||||
treeDialog: tree,
|
||||
};
|
||||
const replacePromptEditorText = vi.fn();
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
navigateTree: () => Promise.resolve({ cancelled: false, editorText: "recovered draft" }),
|
||||
messages: () => Promise.reject(new Error("authoritative history refresh failed")),
|
||||
status: () => Promise.resolve(status(oldSession.id)),
|
||||
streamSnapshot: () => Promise.resolve({ seq: 0, partial: null }),
|
||||
thinkingLevels: () => Promise.resolve({ levels: [] }),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket(), replacePromptEditorText },
|
||||
);
|
||||
|
||||
await expect(controller.navigateTree("root", { mode: "none" })).rejects.toThrow("authoritative history refresh failed");
|
||||
|
||||
expect(state.treeDialog).toBe(tree);
|
||||
expect(state.error).toContain("authoritative history refresh failed");
|
||||
expect(loadDraft(cacheKey)).toBe("recovered draft");
|
||||
expect(replacePromptEditorText).toHaveBeenCalledWith({ machineId: "local", sessionId: oldSession.id, text: "recovered draft" });
|
||||
});
|
||||
|
||||
it("retains the navigator when live prompt-editor replacement fails", async () => {
|
||||
let state: AppState = {
|
||||
...initialAppState(),
|
||||
selectedWorkspace: workspace,
|
||||
selectedSession: oldSession,
|
||||
sessions: [oldSession],
|
||||
treeDialog: tree,
|
||||
};
|
||||
const replacePromptEditorText = vi.fn(() => Promise.reject(new Error("prompt editor replacement failed")));
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
navigateTree: () => Promise.resolve({ cancelled: false, editorText: "recovered draft" }),
|
||||
messages: () => Promise.resolve(page("authoritative branch", 1)),
|
||||
status: () => Promise.resolve(status(oldSession.id)),
|
||||
streamSnapshot: () => Promise.resolve({ seq: 0, partial: null }),
|
||||
thinkingLevels: () => Promise.resolve({ levels: [] }),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket(), replacePromptEditorText },
|
||||
);
|
||||
|
||||
await expect(controller.navigateTree("root", { mode: "none" })).rejects.toThrow("prompt editor replacement failed");
|
||||
|
||||
expect(state.messages).toEqual([{ role: "assistant", parts: [{ type: "text", text: "authoritative branch" }] }]);
|
||||
expect(state.treeDialog).toBe(tree);
|
||||
expect(state.error).toContain("prompt editor replacement failed");
|
||||
});
|
||||
|
||||
it("explicitly clears the editor draft when navigating to a non-user entry", async () => {
|
||||
const cacheKey = machineSessionKey("local", oldSession.id);
|
||||
saveDraft(cacheKey, "stale editor text");
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: oldSession, sessions: [oldSession], treeDialog: tree };
|
||||
const replacePromptEditorText = vi.fn();
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
navigateTree: () => Promise.resolve({ cancelled: false }),
|
||||
messages: () => Promise.resolve(page("selected entry", 1)),
|
||||
status: () => Promise.resolve(status(oldSession.id)),
|
||||
streamSnapshot: () => Promise.resolve({ seq: 0, partial: null }),
|
||||
thinkingLevels: () => Promise.resolve({ levels: [] }),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket(), replacePromptEditorText },
|
||||
);
|
||||
|
||||
await controller.navigateTree("leaf-1", { mode: "none" });
|
||||
|
||||
expect(loadDraft(cacheKey)).toBe("");
|
||||
expect(replacePromptEditorText).toHaveBeenCalledWith({ machineId: "local", sessionId: oldSession.id, text: "" });
|
||||
});
|
||||
|
||||
it("retains the tree on cancellation and errors and exposes abort and close lifecycle methods", async () => {
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: oldSession, sessions: [oldSession], treeDialog: tree };
|
||||
const navigateTree = vi.fn<typeof defaultApi.navigateTree>();
|
||||
navigateTree.mockResolvedValueOnce({ cancelled: true, aborted: true }).mockRejectedValueOnce(new Error("The session changed; reopen /tree"));
|
||||
const abort = vi.fn<typeof defaultApi.abort>(() => Promise.resolve({ aborted: true }));
|
||||
const api: typeof defaultApi = { ...defaultApi, navigateTree, abort };
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
await expect(controller.navigateTree("root", { mode: "default" })).resolves.toEqual({ cancelled: true, aborted: true });
|
||||
expect(state.treeDialog).toBe(tree);
|
||||
|
||||
await expect(controller.navigateTree("root", { mode: "none" })).rejects.toThrow("reopen /tree");
|
||||
expect(state.treeDialog).toBe(tree);
|
||||
expect(state.error).toContain("reopen /tree");
|
||||
|
||||
await controller.abortTreeNavigation();
|
||||
expect(abort).toHaveBeenCalledWith(oldSession, "local");
|
||||
controller.closeTreeDialog();
|
||||
expect(state.treeDialog).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps live socket events flowing when a selected-session join refresh fails", async () => {
|
||||
const socket = new EmitSocket();
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: oldSession, sessions: [oldSession] };
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
messages: () => Promise.reject(new Error("history refresh failed")),
|
||||
status: () => Promise.resolve(status(oldSession.id)),
|
||||
streamSnapshot: () => Promise.resolve({ seq: 0, partial: null }),
|
||||
thinkingLevels: () => Promise.resolve({ levels: [] }),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket },
|
||||
);
|
||||
|
||||
await controller.selectSession(oldSession, { updateUrl: false });
|
||||
socket.emit({ type: "message.append", message: { role: "assistant", content: "live after failed refresh" }, seq: 1 });
|
||||
|
||||
expect(state.error).toContain("history refresh failed");
|
||||
expect(state.messages).toEqual([{ role: "assistant", parts: [{ type: "text", text: "live after failed refresh" }] }]);
|
||||
});
|
||||
|
||||
it("does not reopen a disposed controller when navigation settles late", async () => {
|
||||
const navigationResult = deferred<{ cancelled: false; editorText: string }>();
|
||||
const messages = vi.fn<typeof defaultApi.messages>(() => Promise.resolve(page("must not load", 1)));
|
||||
const replacePromptEditorText = vi.fn();
|
||||
let state: AppState = {
|
||||
...initialAppState(),
|
||||
selectedWorkspace: workspace,
|
||||
selectedSession: oldSession,
|
||||
sessions: [oldSession],
|
||||
treeDialog: tree,
|
||||
};
|
||||
const api: typeof defaultApi = { ...defaultApi, navigateTree: () => navigationResult.promise, messages };
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket(), replacePromptEditorText },
|
||||
);
|
||||
|
||||
const navigation = controller.navigateTree("root", { mode: "none" });
|
||||
controller.dispose();
|
||||
navigationResult.resolve({ cancelled: false, editorText: "late result" });
|
||||
await navigation;
|
||||
|
||||
expect(messages).not.toHaveBeenCalled();
|
||||
expect(replacePromptEditorText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refreshes authoritatively when the same session is reselected before navigation completes", async () => {
|
||||
const navigationResult = deferred<{ cancelled: false; editorText: string }>();
|
||||
const replacePromptEditorText = vi.fn();
|
||||
let messageCalls = 0;
|
||||
let state: AppState = {
|
||||
...initialAppState(),
|
||||
selectedWorkspace: workspace,
|
||||
selectedSession: oldSession,
|
||||
sessions: [oldSession],
|
||||
treeDialog: tree,
|
||||
};
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
navigateTree: () => navigationResult.promise,
|
||||
messages: () => {
|
||||
messageCalls += 1;
|
||||
return Promise.resolve(page(messageCalls === 1 ? "pre-navigation reselection" : "authoritative branch", 1));
|
||||
},
|
||||
status: () => Promise.resolve(status(oldSession.id)),
|
||||
streamSnapshot: () => Promise.resolve({ seq: 0, partial: null }),
|
||||
thinkingLevels: () => Promise.resolve({ levels: [] }),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket(), replacePromptEditorText },
|
||||
);
|
||||
|
||||
const navigation = controller.navigateTree("root", { mode: "none" });
|
||||
await controller.selectSession(oldSession, { updateUrl: false });
|
||||
navigationResult.resolve({ cancelled: false, editorText: "recovered draft" });
|
||||
await navigation;
|
||||
|
||||
expect(messageCalls).toBe(2);
|
||||
expect(state.messages).toEqual([{ role: "assistant", parts: [{ type: "text", text: "authoritative branch" }] }]);
|
||||
expect(replacePromptEditorText).toHaveBeenCalledWith({ machineId: "local", sessionId: oldSession.id, text: "recovered draft" });
|
||||
});
|
||||
|
||||
it("replaces live editor text when the same session is reselected during the authoritative refresh", async () => {
|
||||
const firstRefresh = deferred<MessagePage>();
|
||||
const replacePromptEditorText = vi.fn();
|
||||
let messageCalls = 0;
|
||||
let state: AppState = {
|
||||
...initialAppState(),
|
||||
selectedWorkspace: workspace,
|
||||
selectedSession: oldSession,
|
||||
sessions: [oldSession],
|
||||
treeDialog: tree,
|
||||
};
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
navigateTree: () => Promise.resolve({ cancelled: false, editorText: "recovered draft" }),
|
||||
messages: () => {
|
||||
messageCalls += 1;
|
||||
return messageCalls === 1 ? firstRefresh.promise : Promise.resolve(page("reselected authoritative branch", 1));
|
||||
},
|
||||
status: () => Promise.resolve(status(oldSession.id)),
|
||||
streamSnapshot: () => Promise.resolve({ seq: 0, partial: null }),
|
||||
thinkingLevels: () => Promise.resolve({ levels: [] }),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket(), replacePromptEditorText },
|
||||
);
|
||||
|
||||
const navigation = controller.navigateTree("root", { mode: "none" });
|
||||
await vi.waitFor(() => { expect(messageCalls).toBe(1); });
|
||||
const reselection = controller.selectSession(oldSession, { updateUrl: false });
|
||||
firstRefresh.resolve(page("superseded branch", 1));
|
||||
await Promise.all([navigation, reselection]);
|
||||
|
||||
expect(messageCalls).toBe(2);
|
||||
expect(state.messages).toEqual([{ role: "assistant", parts: [{ type: "text", text: "reselected authoritative branch" }] }]);
|
||||
expect(replacePromptEditorText).toHaveBeenCalledWith({ machineId: "local", sessionId: oldSession.id, text: "recovered draft" });
|
||||
});
|
||||
|
||||
it("discards only the originating cache and does not refresh or replace another session after a selection race", async () => {
|
||||
const navigationResult = deferred<{ cancelled: false; editorText: string }>();
|
||||
const oldCacheKey = machineSessionKey("local", oldSession.id);
|
||||
const replacementCacheKey = machineSessionKey("local", replacementSession.id);
|
||||
const cachedPages = new Map<string, MessagePage>([
|
||||
[oldCacheKey, page("old cached branch", 1)],
|
||||
[replacementCacheKey, page("replacement cached", 1)],
|
||||
]);
|
||||
const removedKeys: string[] = [];
|
||||
const replacePromptEditorText = vi.fn();
|
||||
const requestedMessages: string[] = [];
|
||||
let state: AppState = {
|
||||
...initialAppState(),
|
||||
selectedWorkspace: workspace,
|
||||
selectedSession: oldSession,
|
||||
sessions: [oldSession, replacementSession],
|
||||
treeDialog: tree,
|
||||
};
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
navigateTree: () => navigationResult.promise,
|
||||
messages: (session) => {
|
||||
requestedMessages.push(sessionLookupId(session));
|
||||
return Promise.resolve(page("replacement authoritative", 1));
|
||||
},
|
||||
status: (session) => Promise.resolve(status(sessionLookupId(session))),
|
||||
streamSnapshot: () => Promise.resolve({ seq: 0, partial: null }),
|
||||
thinkingLevels: () => Promise.resolve({ levels: [] }),
|
||||
};
|
||||
const transcripts = new ChatTranscriptStore({
|
||||
read: (key) => cachedPages.get(key),
|
||||
write: (key, value) => { cachedPages.set(key, value); },
|
||||
remove: (key) => { removedKeys.push(key); cachedPages.delete(key); },
|
||||
});
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket(), transcripts, replacePromptEditorText },
|
||||
);
|
||||
|
||||
const navigation = controller.navigateTree("root", { mode: "none" });
|
||||
await controller.selectSession(replacementSession, { updateUrl: false });
|
||||
navigationResult.resolve({ cancelled: false, editorText: "originating draft" });
|
||||
await navigation;
|
||||
|
||||
expect(state.selectedSession?.id).toBe(replacementSession.id);
|
||||
expect(state.messages).toEqual([{ role: "assistant", parts: [{ type: "text", text: "replacement authoritative" }] }]);
|
||||
expect(requestedMessages).toEqual([replacementSession.id]);
|
||||
expect(removedKeys).toEqual([oldCacheKey]);
|
||||
expect(cachedPages.get(replacementCacheKey)).toEqual(page("replacement authoritative", 1));
|
||||
expect(loadDraft(oldCacheKey)).toBe("originating draft");
|
||||
expect(replacePromptEditorText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not open a delayed tree snapshot for the same session id on a different machine", async () => {
|
||||
const command = deferred<CommandResult>();
|
||||
const remoteA = { id: "remote-a", name: "Remote A", kind: "remote" as const, createdAt: "now", updatedAt: "now" };
|
||||
const remoteB = { id: "remote-b", name: "Remote B", kind: "remote" as const, createdAt: "now", updatedAt: "now" };
|
||||
let state: AppState = {
|
||||
...initialAppState(),
|
||||
machines: [remoteA, remoteB],
|
||||
selectedMachine: remoteA,
|
||||
selectedWorkspace: workspace,
|
||||
selectedSession: oldSession,
|
||||
sessions: [oldSession],
|
||||
};
|
||||
const api: typeof defaultApi = { ...defaultApi, runCommand: () => command.promise };
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
const run = controller.send("/tree");
|
||||
state = { ...state, selectedMachine: remoteB };
|
||||
command.resolve({ type: "tree", tree });
|
||||
await run;
|
||||
|
||||
expect(state.treeDialog).toBeUndefined();
|
||||
expect(state.error).toContain("needs input; open the session and run it again");
|
||||
});
|
||||
|
||||
it("requires a delayed interactive tree command to be rerun after its session is no longer selected", async () => {
|
||||
const command = deferred<CommandResult>();
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: oldSession, sessions: [oldSession, replacementSession] };
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
runCommand: () => command.promise,
|
||||
messages: () => Promise.resolve(page("replacement", 1)),
|
||||
status: () => Promise.resolve(status(replacementSession.id)),
|
||||
streamSnapshot: () => Promise.resolve({ seq: 0, partial: null }),
|
||||
thinkingLevels: () => Promise.resolve({ levels: [] }),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
const run = controller.send("/tree");
|
||||
await controller.selectSession(replacementSession, { updateUrl: false });
|
||||
command.resolve({ type: "tree", tree });
|
||||
await run;
|
||||
|
||||
expect(state.treeDialog).toBeUndefined();
|
||||
expect(state.error).toContain("needs input; open the session and run it again");
|
||||
});
|
||||
});
|
||||
|
||||
function page(text: string, total: number): MessagePage {
|
||||
return { messages: [{ role: "assistant", content: text }], start: 0, total };
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { api as defaultApi, type CommandResult, type PromptAttachment, type QueuedSessionMessage, type SessionActivity, type SessionBulkFailure, type SessionCleanupExecuteResponse, type SessionInfo, type SessionRef, type SessionStatus, type SessionStreamSnapshot, type Workspace } from "../api";
|
||||
import { api as defaultApi, type CommandResult, type PromptAttachment, type QueuedSessionMessage, type SessionActivity, type SessionBulkFailure, type SessionCleanupExecuteResponse, type SessionInfo, type SessionRef, type SessionStatus, type SessionStreamSnapshot, type SessionTreeNavigateResult, type SessionTreeSummaryChoice, type Workspace } from "../api";
|
||||
import type { AppState } from "../appState";
|
||||
import { forgetCachedNewSession, isCachedNewSessionInfo, markCachedNewSessionInfo, mergeCachedNewSessions, rememberCachedNewSession, stripCachedNewSessionMarker } from "../cachedNewSessions";
|
||||
import { textMessage } from "../chatMessages";
|
||||
@@ -39,11 +39,18 @@ export interface SessionNotificationSessionBridge {
|
||||
shouldFilterLegacyNotification(machineId: string, notificationId: string | undefined): boolean;
|
||||
}
|
||||
|
||||
export interface PromptEditorTextReplacement {
|
||||
machineId: string;
|
||||
sessionId: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface SessionControllerDependencies {
|
||||
api?: typeof defaultApi;
|
||||
socket?: SessionEventSocket;
|
||||
transcripts?: ChatTranscriptStore;
|
||||
notifications?: SessionNotificationSessionBridge;
|
||||
replacePromptEditorText?: (replacement: PromptEditorTextReplacement) => void | Promise<void>;
|
||||
}
|
||||
|
||||
interface BulkSessionMutationResult {
|
||||
@@ -87,7 +94,9 @@ export class SessionController {
|
||||
private readonly api: typeof defaultApi;
|
||||
private readonly transcripts: ChatTranscriptStore;
|
||||
private readonly notifications: SessionNotificationSessionBridge | undefined;
|
||||
private readonly replacePromptEditorText: SessionControllerDependencies["replacePromptEditorText"];
|
||||
private selectionSeq = 0;
|
||||
private disposed = false;
|
||||
// Join-time stream watermark for the selected session. `seq` is the
|
||||
// `SessionEventHub` sequence captured together with the seeded partial by the
|
||||
// stream snapshot: buffered/live events with `seq <= seq` are already reflected
|
||||
@@ -115,6 +124,7 @@ export class SessionController {
|
||||
this.api = deps.api ?? defaultApi;
|
||||
this.transcripts = deps.transcripts ?? new ChatTranscriptStore();
|
||||
this.notifications = deps.notifications;
|
||||
this.replacePromptEditorText = deps.replacePromptEditorText;
|
||||
}
|
||||
|
||||
applyGlobalEvent(event: GlobalSessionEvent): void {
|
||||
@@ -125,6 +135,7 @@ export class SessionController {
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.disposed = true;
|
||||
this.selectionSeq += 1;
|
||||
this.socket.close();
|
||||
this.clearPendingUpdates();
|
||||
@@ -140,7 +151,7 @@ export class SessionController {
|
||||
// session must not cancel the in-flight upload indicator of the session
|
||||
// that is still sending; the per-session entry is cleared by send()'s
|
||||
// finally block when the request settles.
|
||||
this.setState({ selectedSession: undefined, messages: [], messagePageStart: 0, messagePageEnd: 0, messagePageTotal: 0, isLoadingEarlierMessages: false, status: undefined, activity: undefined, availableThinkingLevels: [] });
|
||||
this.setState({ selectedSession: undefined, messages: [], messagePageStart: 0, messagePageEnd: 0, messagePageTotal: 0, isLoadingEarlierMessages: false, status: undefined, activity: undefined, availableThinkingLevels: [], treeDialog: undefined });
|
||||
}
|
||||
|
||||
deselectSession(options?: { forgetRememberedSelection?: boolean | undefined; updateUrl?: boolean | undefined }) {
|
||||
@@ -176,7 +187,8 @@ export class SessionController {
|
||||
return selectPreferredSession(sessions, { targetSessionId, latestSessionId: this.sessionSelection.latestSessionId(this.workspaceSelectionKey(cwd)) });
|
||||
}
|
||||
|
||||
async selectSession(session: SessionInfo, options?: { updateUrl?: boolean | undefined }) {
|
||||
async selectSession(session: SessionInfo, options?: { updateUrl?: boolean | undefined; preserveTreeDialog?: boolean | undefined; propagateRefreshError?: boolean | undefined }) {
|
||||
if (this.disposed) return;
|
||||
if (isClientPendingStartSessionInfo(session)) {
|
||||
this.selectClientPendingStartSession(session, options);
|
||||
return;
|
||||
@@ -194,10 +206,12 @@ export class SessionController {
|
||||
selectedSession: session,
|
||||
...cached,
|
||||
isLoadingEarlierMessages: false,
|
||||
...(options?.preserveTreeDialog === true ? {} : { treeDialog: undefined }),
|
||||
status: session.archived === true ? undefined : this.getState().sessionStatuses[session.id],
|
||||
activity: session.archived === true ? undefined : this.getState().sessionActivities[session.id],
|
||||
availableThinkingLevels: [],
|
||||
});
|
||||
let buffered: SessionUiEvent[] | undefined;
|
||||
try {
|
||||
if (session.archived === true) {
|
||||
const page = await this.api.messages(session, { limit: MESSAGE_PAGE_SIZE }, selectedMachineId(this.getState()));
|
||||
@@ -207,10 +221,11 @@ export class SessionController {
|
||||
if (options?.updateUrl !== false) this.updateUrl();
|
||||
return;
|
||||
}
|
||||
const buffered: SessionUiEvent[] = [];
|
||||
const socketBuffer: SessionUiEvent[] = [];
|
||||
buffered = socketBuffer;
|
||||
this.socket.connect(
|
||||
session,
|
||||
(event) => buffered.push(event),
|
||||
(event) => socketBuffer.push(event),
|
||||
() => { void this.refreshSelectedSession(session.id); },
|
||||
machineId,
|
||||
() => { void this.notifications?.refreshSelectedSession(session, machineId); },
|
||||
@@ -218,16 +233,29 @@ export class SessionController {
|
||||
await this.requestSelectedSessionRefresh({ session, machineId, selectionSeq: seq });
|
||||
if (!this.isCurrentRefreshTarget({ session, machineId, selectionSeq: seq })) return;
|
||||
void this.refreshAvailableThinkingLevels();
|
||||
for (const event of buffered) this.applyEvent(event);
|
||||
for (const event of socketBuffer) this.applyEvent(event);
|
||||
this.socket.setHandler((event) => { this.applyEvent(event); });
|
||||
if (options?.updateUrl !== false) this.updateUrl();
|
||||
} catch (error) {
|
||||
if (seq !== this.selectionSeq || this.getState().selectedSession?.id !== session.id) return;
|
||||
if (seq !== this.selectionSeq || this.getState().selectedSession?.id !== session.id) {
|
||||
// Tree navigation still needs to know when a same-session reselection's
|
||||
// shared trailing refresh failed, even though this selection is stale.
|
||||
if (options?.propagateRefreshError === true && this.isSelectedSessionIdentity(session.id, machineId)) throw error;
|
||||
return;
|
||||
}
|
||||
if (isCachedNewSessionInfo(session) && isSessionNotFoundError(error)) {
|
||||
await this.recreateCachedNewSession(session, options);
|
||||
return;
|
||||
}
|
||||
// A failed join refresh must not strand the socket on its temporary
|
||||
// buffering callback. Apply what arrived and keep live events flowing so
|
||||
// reconnect/trailing refresh can recover authoritatively.
|
||||
if (buffered !== undefined) {
|
||||
for (const event of buffered) this.applyEvent(event);
|
||||
this.socket.setHandler((event) => { this.applyEvent(event); });
|
||||
}
|
||||
this.setState({ error: String(error) });
|
||||
if (options?.propagateRefreshError === true) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -378,8 +406,8 @@ export class SessionController {
|
||||
this.markSendingPrompt(session.id, true);
|
||||
try {
|
||||
const result = await this.api.runCommand(session, text, machineId);
|
||||
if (options.applyResult && this.getState().selectedSession?.id === session.id) this.applyCommandResult(result);
|
||||
else if (result.type === "select") this.setState({ error: `Queued command “${text}” needs input; open the session and run it again.` });
|
||||
if (options.applyResult && this.isSelectedSessionIdentity(session.id, machineId)) this.applyCommandResult(result);
|
||||
else if (result.type === "select" || result.type === "tree") this.setState({ error: `Queued command “${text}” needs input; open the session and run it again.` });
|
||||
this.markCachedNewSessionPersisted(session);
|
||||
return true;
|
||||
} catch (error) {
|
||||
@@ -414,6 +442,76 @@ export class SessionController {
|
||||
this.setState({ commandDialog: undefined });
|
||||
}
|
||||
|
||||
async navigateTree(targetId: string, summary: SessionTreeSummaryChoice): Promise<SessionTreeNavigateResult> {
|
||||
const state = this.getState();
|
||||
const session = state.selectedSession;
|
||||
const tree = state.treeDialog;
|
||||
if (session === undefined || tree === undefined || session.archived === true || isClientPendingStartSessionInfo(session)) {
|
||||
throw new Error("The session tree navigator is no longer available");
|
||||
}
|
||||
|
||||
const machineId = selectedMachineId(state);
|
||||
const selectionSeq = this.selectionSeq;
|
||||
const cacheKey = machineSessionKey(machineId, session.id);
|
||||
let result: SessionTreeNavigateResult;
|
||||
try {
|
||||
result = await this.api.navigateTree(session, { targetId, expectedLeafId: tree.activeLeafId, summary }, machineId);
|
||||
} catch (error) {
|
||||
if (this.isCurrentSessionSelection(session.id, machineId, selectionSeq)) this.setState({ error: String(error) });
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (result.cancelled) return result;
|
||||
|
||||
const editorText = result.editorText ?? "";
|
||||
saveDraft(cacheKey, editorText);
|
||||
this.transcripts.discard(cacheKey);
|
||||
|
||||
// A user can reselect the same session while the request is in flight. Its
|
||||
// sequence changes, but the server mutation still belongs to the selected
|
||||
// identity and requires a fresh authoritative branch read.
|
||||
if (!this.isSelectedSessionIdentity(session.id, machineId)) return result;
|
||||
this.clearPendingUpdates();
|
||||
let authoritativeRefreshFailure: { error: unknown } | undefined;
|
||||
try {
|
||||
await this.selectSession(session, { updateUrl: false, preserveTreeDialog: true, propagateRefreshError: true });
|
||||
} catch (error) {
|
||||
authoritativeRefreshFailure = { error };
|
||||
}
|
||||
if (!this.isSelectedSessionIdentity(session.id, machineId)) return result;
|
||||
|
||||
try {
|
||||
await this.replacePromptEditorText?.({ machineId, sessionId: session.id, text: editorText });
|
||||
} catch (error) {
|
||||
if (this.isSelectedSessionIdentity(session.id, machineId)) this.setState({ error: String(error) });
|
||||
throw error;
|
||||
}
|
||||
if (authoritativeRefreshFailure !== undefined) {
|
||||
if (this.isSelectedSessionIdentity(session.id, machineId)) this.setState({ error: String(authoritativeRefreshFailure.error) });
|
||||
throw authoritativeRefreshFailure.error;
|
||||
}
|
||||
if (this.isSelectedSessionIdentity(session.id, machineId) && this.getState().treeDialog === tree) this.setState({ treeDialog: undefined });
|
||||
return result;
|
||||
}
|
||||
|
||||
async abortTreeNavigation(): Promise<void> {
|
||||
const state = this.getState();
|
||||
const session = state.selectedSession;
|
||||
if (session === undefined || state.treeDialog === undefined || isClientPendingStartSessionInfo(session)) return;
|
||||
const machineId = selectedMachineId(state);
|
||||
const selectionSeq = this.selectionSeq;
|
||||
try {
|
||||
await this.api.abort(session, machineId);
|
||||
} catch (error) {
|
||||
if (this.isCurrentSessionSelection(session.id, machineId, selectionSeq)) this.setState({ error: String(error) });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
closeTreeDialog(): void {
|
||||
this.setState({ treeDialog: undefined });
|
||||
}
|
||||
|
||||
applySessionStatus(status: SessionStatus): void {
|
||||
this.applyStatus(status);
|
||||
}
|
||||
@@ -840,10 +938,14 @@ export class SessionController {
|
||||
}
|
||||
|
||||
private isCurrentSessionSelection(sessionId: string, machineId: string, selectionSeq: number): boolean {
|
||||
return selectionSeq === this.selectionSeq && this.isSelectedSessionIdentity(sessionId, machineId);
|
||||
}
|
||||
|
||||
private isSelectedSessionIdentity(sessionId: string, machineId: string): boolean {
|
||||
if (this.disposed) return false;
|
||||
const state = this.getState();
|
||||
const selected = state.selectedSession;
|
||||
return selectionSeq === this.selectionSeq
|
||||
&& selectedMachineId(state) === machineId
|
||||
return selectedMachineId(state) === machineId
|
||||
&& selected?.id === sessionId
|
||||
&& selected.archived !== true
|
||||
&& !isClientPendingStartSessionInfo(selected);
|
||||
@@ -930,6 +1032,7 @@ export class SessionController {
|
||||
status: undefined,
|
||||
activity,
|
||||
availableThinkingLevels: [],
|
||||
treeDialog: undefined,
|
||||
...(activity === undefined ? {} : { sessionActivities: { ...state.sessionActivities, [session.id]: activity } }),
|
||||
error: "",
|
||||
});
|
||||
@@ -1072,6 +1175,10 @@ export class SessionController {
|
||||
this.setState({ commandDialog: result });
|
||||
return;
|
||||
}
|
||||
if (result.type === "tree") {
|
||||
this.setState({ treeDialog: result.tree });
|
||||
return;
|
||||
}
|
||||
const message = result.type === "unsupported" ? result.message : result.message;
|
||||
if (message !== undefined && message !== "") this.setState({ messages: [...this.getState().messages, textMessage(result.type === "unsupported" ? "system" : "tool", message)] });
|
||||
if (result.type === "done" && result.session) {
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH, type SessionTreeNode, type SessionTreeSnapshot } from "../../shared/apiTypes";
|
||||
import { buildSessionTreeModel, initialSessionTreeSelection, toggleSessionTreeFold, transitionSessionTreeKey, validateSessionTreeSummaryChoice, visibleSessionTreeRows } from "./sessionTreeModel";
|
||||
|
||||
describe("session tree hierarchy model", () => {
|
||||
it("builds a complete forest while normalizing orphans, cycles, self-links, and duplicate IDs", () => {
|
||||
const model = buildSessionTreeModel({
|
||||
nodes: [
|
||||
node("root", null),
|
||||
node("child", "root"),
|
||||
node("orphan", "missing"),
|
||||
node("cycle-a", "cycle-b"),
|
||||
node("cycle-b", "cycle-a"),
|
||||
node("cycle-child", "cycle-a"),
|
||||
node("self", "self"),
|
||||
{ ...node("root", null), summary: "duplicate is ignored" },
|
||||
],
|
||||
activeLeafId: "cycle-child",
|
||||
activePathIds: ["cycle-b", "cycle-a", "cycle-child", "missing"],
|
||||
});
|
||||
|
||||
expect(model.orderedIds).toEqual(["root", "child", "orphan", "cycle-a", "cycle-b", "cycle-child", "self"]);
|
||||
expect(model.rootIds).toEqual(["root", "orphan", "cycle-b", "self"]);
|
||||
expect(model.parentById.get("cycle-b")).toBeNull();
|
||||
expect(model.parentById.get("cycle-a")).toBe("cycle-b");
|
||||
expect(model.childrenById.get("cycle-a")).toEqual(["cycle-child"]);
|
||||
|
||||
const rows = visibleSessionTreeRows(model, new Set());
|
||||
expect(rows.map((row) => [row.node.id, row.depth])).toEqual([
|
||||
["root", 0],
|
||||
["child", 1],
|
||||
["orphan", 0],
|
||||
["cycle-b", 0],
|
||||
["cycle-a", 1],
|
||||
["cycle-child", 2],
|
||||
["self", 0],
|
||||
]);
|
||||
expect(rows.filter((row) => row.activePath).map((row) => row.node.id)).toEqual(["cycle-b", "cycle-a", "cycle-child"]);
|
||||
expect(rows.filter((row) => row.activeLeaf).map((row) => row.node.id)).toEqual(["cycle-child"]);
|
||||
expect(initialSessionTreeSelection(model)).toBe("cycle-child");
|
||||
});
|
||||
|
||||
it("starts from the final retained entry when the active leaf is absent and hides only folded descendants", () => {
|
||||
const model = buildSessionTreeModel(snapshot());
|
||||
|
||||
expect(initialSessionTreeSelection(model)).toBe("branch-2");
|
||||
expect(visibleSessionTreeRows(model, new Set(["branch-1"])).map((row) => row.node.id)).toEqual(["root", "branch-1", "branch-2"]);
|
||||
expect(visibleSessionTreeRows(model, new Set(["root"])).map((row) => row.node.id)).toEqual(["root"]);
|
||||
});
|
||||
|
||||
it("derives one coherent active path from the normalized leaf instead of trusting malformed badges", () => {
|
||||
const model = buildSessionTreeModel({
|
||||
nodes: [node("root", null), node("active", "root"), node("unrelated", "root")],
|
||||
activeLeafId: "active",
|
||||
activePathIds: ["unrelated", "missing"],
|
||||
});
|
||||
|
||||
const rows = visibleSessionTreeRows(model, new Set());
|
||||
expect(rows.filter((row) => row.activePath).map((row) => row.node.id)).toEqual(["root", "active"]);
|
||||
expect(rows.filter((row) => row.activeLeaf).map((row) => row.node.id)).toEqual(["active"]);
|
||||
});
|
||||
|
||||
it("normalizes and renders a large deep tree without recursive or quadratic parent walks", () => {
|
||||
const count = 20_000;
|
||||
const nodes = Array.from({ length: count }, (_, index) => node(
|
||||
`node-${String(index)}`,
|
||||
index === 0 ? null : `node-${String(index - 1)}`,
|
||||
));
|
||||
const model = buildSessionTreeModel({
|
||||
nodes,
|
||||
activeLeafId: `node-${String(count - 1)}`,
|
||||
activePathIds: [],
|
||||
});
|
||||
|
||||
expect(model.orderedIds).toHaveLength(count);
|
||||
expect(model.depthById.get(`node-${String(count - 1)}`)).toBe(count - 1);
|
||||
expect(model.activePathIds.size).toBe(count);
|
||||
expect(visibleSessionTreeRows(model, new Set())).toHaveLength(count);
|
||||
});
|
||||
|
||||
it("keeps an empty snapshot inert", () => {
|
||||
const model = buildSessionTreeModel({ nodes: [], activeLeafId: null, activePathIds: [] });
|
||||
|
||||
expect(initialSessionTreeSelection(model)).toBeUndefined();
|
||||
expect(visibleSessionTreeRows(model, new Set())).toEqual([]);
|
||||
const transition = transitionSessionTreeKey(model, { selectedId: undefined, foldedIds: new Set() }, "Enter");
|
||||
expect(transition).toMatchObject({ selectedId: undefined, handled: true });
|
||||
expect(transition.action).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("session tree keyboard state", () => {
|
||||
const model = buildSessionTreeModel(snapshot());
|
||||
const expanded = { selectedId: "branch-1", foldedIds: new Set<string>() };
|
||||
|
||||
it("moves over visible rows with arrows, Home, and End", () => {
|
||||
expect(transitionSessionTreeKey(model, expanded, "ArrowUp").selectedId).toBe("root");
|
||||
expect(transitionSessionTreeKey(model, expanded, "ArrowDown").selectedId).toBe("leaf-1");
|
||||
expect(transitionSessionTreeKey(model, expanded, "Home").selectedId).toBe("root");
|
||||
expect(transitionSessionTreeKey(model, expanded, "End").selectedId).toBe("branch-2");
|
||||
expect(transitionSessionTreeKey(model, { ...expanded, selectedId: "root" }, "ArrowUp").selectedId).toBe("root");
|
||||
expect(transitionSessionTreeKey(model, { ...expanded, selectedId: "branch-2" }, "ArrowDown").selectedId).toBe("branch-2");
|
||||
});
|
||||
|
||||
it("folds or moves to a parent with Left and unfolds or moves to the first child with Right", () => {
|
||||
const folded = transitionSessionTreeKey(model, expanded, "ArrowLeft");
|
||||
expect([...folded.foldedIds]).toEqual(["branch-1"]);
|
||||
expect(folded.selectedId).toBe("branch-1");
|
||||
|
||||
const parent = transitionSessionTreeKey(model, folded, "ArrowLeft");
|
||||
expect(parent.selectedId).toBe("root");
|
||||
|
||||
const unfolded = transitionSessionTreeKey(model, folded, "ArrowRight");
|
||||
expect([...unfolded.foldedIds]).toEqual([]);
|
||||
expect(unfolded.selectedId).toBe("branch-1");
|
||||
|
||||
expect(transitionSessionTreeKey(model, expanded, "ArrowRight").selectedId).toBe("leaf-1");
|
||||
});
|
||||
|
||||
it("reports confirmation and cancellation actions and leaves unrelated keys alone", () => {
|
||||
expect(transitionSessionTreeKey(model, expanded, "Enter")).toMatchObject({ handled: true, action: "confirm", selectedId: "branch-1" });
|
||||
expect(transitionSessionTreeKey(model, expanded, "Escape")).toMatchObject({ handled: true, action: "cancel" });
|
||||
expect(transitionSessionTreeKey(model, expanded, "Tab")).toMatchObject({ handled: false, selectedId: "branch-1" });
|
||||
});
|
||||
|
||||
it("selects a pointer-toggled branch and keeps folding immutable", () => {
|
||||
const originalFolded = new Set<string>();
|
||||
const folded = toggleSessionTreeFold(model, { selectedId: "leaf-1", foldedIds: originalFolded }, "root");
|
||||
|
||||
expect(folded.selectedId).toBe("root");
|
||||
expect([...folded.foldedIds]).toEqual(["root"]);
|
||||
expect([...originalFolded]).toEqual([]);
|
||||
expect([...toggleSessionTreeFold(model, folded, "root").foldedIds]).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("session tree summary validation", () => {
|
||||
it("maps the three summary modes and trims custom focus", () => {
|
||||
expect(validateSessionTreeSummaryChoice("none", "ignored")).toEqual({ ok: true, choice: { mode: "none" } });
|
||||
expect(validateSessionTreeSummaryChoice("default", "ignored")).toEqual({ ok: true, choice: { mode: "default" } });
|
||||
expect(validateSessionTreeSummaryChoice("custom", " focus on test failures\n ")).toEqual({
|
||||
ok: true,
|
||||
choice: { mode: "custom", instructions: "focus on test failures" },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects blank and oversized custom focus", () => {
|
||||
expect(validateSessionTreeSummaryChoice("custom", " ")).toEqual({ ok: false, error: "Enter custom summary focus instructions." });
|
||||
expect(validateSessionTreeSummaryChoice("custom", "x".repeat(SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH + 1))).toEqual({
|
||||
ok: false,
|
||||
error: `Custom summary focus must be ${String(SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH)} characters or fewer.`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function snapshot(): SessionTreeSnapshot {
|
||||
return {
|
||||
nodes: [
|
||||
node("root", null),
|
||||
node("branch-1", "root"),
|
||||
node("leaf-1", "branch-1"),
|
||||
node("branch-2", "root"),
|
||||
],
|
||||
activeLeafId: null,
|
||||
activePathIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
function node(id: string, parentId: string | null): SessionTreeNode {
|
||||
return { id, parentId, kind: "assistant", summary: id };
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import type { SessionTreeNode, SessionTreeSnapshot, SessionTreeSummaryChoice } from "./api";
|
||||
import { SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH } from "../../shared/apiTypes";
|
||||
|
||||
export interface SessionTreeModel {
|
||||
readonly nodesById: ReadonlyMap<string, SessionTreeNode>;
|
||||
readonly orderedIds: readonly string[];
|
||||
readonly rootIds: readonly string[];
|
||||
readonly parentById: ReadonlyMap<string, string | null>;
|
||||
readonly childrenById: ReadonlyMap<string, readonly string[]>;
|
||||
readonly depthById: ReadonlyMap<string, number>;
|
||||
readonly activePathIds: ReadonlySet<string>;
|
||||
readonly activeLeafId: string | null;
|
||||
}
|
||||
|
||||
export interface SessionTreeRow {
|
||||
readonly node: SessionTreeNode;
|
||||
readonly depth: number;
|
||||
readonly parentId: string | null;
|
||||
readonly childIds: readonly string[];
|
||||
readonly activePath: boolean;
|
||||
readonly activeLeaf: boolean;
|
||||
}
|
||||
|
||||
export interface SessionTreeKeyState {
|
||||
readonly selectedId: string | undefined;
|
||||
readonly foldedIds: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
export interface SessionTreeKeyTransition extends SessionTreeKeyState {
|
||||
readonly handled: boolean;
|
||||
readonly action?: "confirm" | "cancel";
|
||||
}
|
||||
|
||||
export type SessionTreeSummaryValidation =
|
||||
| { readonly ok: true; readonly choice: SessionTreeSummaryChoice }
|
||||
| { readonly ok: false; readonly error: string };
|
||||
|
||||
/**
|
||||
* Turn the strict flat transport projection into a safe forest. Parent links to
|
||||
* missing nodes become roots, and the edge that would close a cycle is detached.
|
||||
*/
|
||||
export function buildSessionTreeModel(snapshot: SessionTreeSnapshot): SessionTreeModel {
|
||||
const nodesById = new Map<string, SessionTreeNode>();
|
||||
const orderedIds: string[] = [];
|
||||
for (const node of snapshot.nodes) {
|
||||
// Runtime projections use unique IDs. Keeping the first occurrence makes a
|
||||
// malformed duplicate deterministic without creating duplicate treeitems.
|
||||
if (nodesById.has(node.id)) continue;
|
||||
nodesById.set(node.id, node);
|
||||
orderedIds.push(node.id);
|
||||
}
|
||||
|
||||
const parentById = normalizedSessionTreeParents(nodesById, orderedIds);
|
||||
|
||||
const mutableChildren = new Map<string, string[]>();
|
||||
for (const id of orderedIds) mutableChildren.set(id, []);
|
||||
const rootIds: string[] = [];
|
||||
for (const id of orderedIds) {
|
||||
const parentId = parentById.get(id) ?? null;
|
||||
if (parentId === null) {
|
||||
rootIds.push(id);
|
||||
continue;
|
||||
}
|
||||
mutableChildren.get(parentId)?.push(id);
|
||||
}
|
||||
|
||||
const depthById = new Map<string, number>();
|
||||
const visited = new Set<string>();
|
||||
const stack = [...rootIds].reverse().map((id) => ({ id, depth: 0 }));
|
||||
while (stack.length > 0) {
|
||||
const next = stack.pop();
|
||||
if (next === undefined || visited.has(next.id)) continue;
|
||||
visited.add(next.id);
|
||||
depthById.set(next.id, next.depth);
|
||||
const children = mutableChildren.get(next.id) ?? [];
|
||||
for (let index = children.length - 1; index >= 0; index -= 1) {
|
||||
const childId = children[index];
|
||||
if (childId !== undefined) stack.push({ id: childId, depth: next.depth + 1 });
|
||||
}
|
||||
}
|
||||
|
||||
// The normalized parent map should already make every node reachable. This
|
||||
// fallback keeps the UI complete if future input violates that invariant.
|
||||
for (const id of orderedIds) {
|
||||
if (visited.has(id)) continue;
|
||||
rootIds.push(id);
|
||||
parentById.set(id, null);
|
||||
depthById.set(id, 0);
|
||||
}
|
||||
|
||||
const childrenById = new Map<string, readonly string[]>();
|
||||
for (const [id, children] of mutableChildren) childrenById.set(id, children);
|
||||
const activeLeafId = snapshot.activeLeafId !== null && nodesById.has(snapshot.activeLeafId) ? snapshot.activeLeafId : null;
|
||||
// Re-derive the path from the normalized forest. A malformed remote snapshot
|
||||
// cannot badge an unrelated branch or keep a cycle-closing edge active.
|
||||
const activePathIds = activeLeafId === null ? new Set<string>() : sessionTreeAncestorIds(activeLeafId, parentById);
|
||||
|
||||
return { nodesById, orderedIds, rootIds, parentById, childrenById, depthById, activePathIds, activeLeafId };
|
||||
}
|
||||
|
||||
export function visibleSessionTreeRows(model: SessionTreeModel, foldedIds: ReadonlySet<string>): SessionTreeRow[] {
|
||||
const rows: SessionTreeRow[] = [];
|
||||
const visited = new Set<string>();
|
||||
const stack = [...model.rootIds].reverse();
|
||||
|
||||
while (stack.length > 0) {
|
||||
const id = stack.pop();
|
||||
if (id === undefined || visited.has(id)) continue;
|
||||
const node = model.nodesById.get(id);
|
||||
if (node === undefined) continue;
|
||||
visited.add(id);
|
||||
const childIds = model.childrenById.get(id) ?? [];
|
||||
rows.push({
|
||||
node,
|
||||
depth: model.depthById.get(id) ?? 0,
|
||||
parentId: model.parentById.get(id) ?? null,
|
||||
childIds,
|
||||
activePath: model.activePathIds.has(id),
|
||||
activeLeaf: model.activeLeafId === id,
|
||||
});
|
||||
if (foldedIds.has(id)) continue;
|
||||
for (let index = childIds.length - 1; index >= 0; index -= 1) {
|
||||
const childId = childIds[index];
|
||||
if (childId !== undefined) stack.push(childId);
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function initialSessionTreeSelection(model: SessionTreeModel): string | undefined {
|
||||
if (model.activeLeafId !== null) return model.activeLeafId;
|
||||
return model.orderedIds.at(-1);
|
||||
}
|
||||
|
||||
export function transitionSessionTreeKey(model: SessionTreeModel, state: SessionTreeKeyState, key: string): SessionTreeKeyTransition {
|
||||
const rows = visibleSessionTreeRows(model, state.foldedIds);
|
||||
const visibleIds = rows.map((row) => row.node.id);
|
||||
const selectedId = normalizedVisibleSelection(visibleIds, state.selectedId);
|
||||
const selectedIndex = selectedId === undefined ? -1 : visibleIds.indexOf(selectedId);
|
||||
const unchanged = (): SessionTreeKeyTransition => ({ ...state, selectedId, handled: false });
|
||||
const select = (nextSelectedId: string | undefined): SessionTreeKeyTransition => ({ ...state, selectedId: nextSelectedId, handled: true });
|
||||
|
||||
switch (key) {
|
||||
case "ArrowUp":
|
||||
return select(selectedIndex > 0 ? visibleIds[selectedIndex - 1] : selectedId);
|
||||
case "ArrowDown":
|
||||
return select(selectedIndex >= 0 && selectedIndex < visibleIds.length - 1 ? visibleIds[selectedIndex + 1] : selectedId);
|
||||
case "Home":
|
||||
return select(visibleIds[0]);
|
||||
case "End":
|
||||
return select(visibleIds.at(-1));
|
||||
case "ArrowLeft": {
|
||||
if (selectedId === undefined) return select(undefined);
|
||||
const children = model.childrenById.get(selectedId) ?? [];
|
||||
if (children.length > 0 && !state.foldedIds.has(selectedId)) {
|
||||
const foldedIds = new Set(state.foldedIds);
|
||||
foldedIds.add(selectedId);
|
||||
return { selectedId, foldedIds, handled: true };
|
||||
}
|
||||
return select(model.parentById.get(selectedId) ?? selectedId);
|
||||
}
|
||||
case "ArrowRight": {
|
||||
if (selectedId === undefined) return select(undefined);
|
||||
const children = model.childrenById.get(selectedId) ?? [];
|
||||
if (children.length === 0) return select(selectedId);
|
||||
if (state.foldedIds.has(selectedId)) {
|
||||
const foldedIds = new Set(state.foldedIds);
|
||||
foldedIds.delete(selectedId);
|
||||
return { selectedId, foldedIds, handled: true };
|
||||
}
|
||||
return select(children[0]);
|
||||
}
|
||||
case "Enter":
|
||||
return { ...state, selectedId, handled: true, ...(selectedId === undefined ? {} : { action: "confirm" }) };
|
||||
case "Escape":
|
||||
return { ...state, selectedId, handled: true, action: "cancel" };
|
||||
default:
|
||||
return unchanged();
|
||||
}
|
||||
}
|
||||
|
||||
export function toggleSessionTreeFold(model: SessionTreeModel, state: SessionTreeKeyState, id: string): SessionTreeKeyState {
|
||||
const children = model.childrenById.get(id) ?? [];
|
||||
if (children.length === 0) return { ...state, selectedId: id };
|
||||
const foldedIds = new Set(state.foldedIds);
|
||||
if (foldedIds.has(id)) foldedIds.delete(id);
|
||||
else foldedIds.add(id);
|
||||
return { selectedId: id, foldedIds };
|
||||
}
|
||||
|
||||
export function validateSessionTreeSummaryChoice(mode: SessionTreeSummaryChoice["mode"], customInstructions: string): SessionTreeSummaryValidation {
|
||||
if (mode === "none" || mode === "default") return { ok: true, choice: { mode } };
|
||||
if (customInstructions.trim() === "") return { ok: false, error: "Enter custom summary focus instructions." };
|
||||
if (customInstructions.length > SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH) {
|
||||
return { ok: false, error: `Custom summary focus must be ${String(SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH)} characters or fewer.` };
|
||||
}
|
||||
return { ok: true, choice: { mode: "custom", instructions: customInstructions.trim() } };
|
||||
}
|
||||
|
||||
function normalizedSessionTreeParents(
|
||||
nodesById: ReadonlyMap<string, SessionTreeNode>,
|
||||
orderedIds: readonly string[],
|
||||
): Map<string, string | null> {
|
||||
const parentById = new Map<string, string | null>();
|
||||
for (const id of orderedIds) {
|
||||
const candidate = nodesById.get(id)?.parentId ?? null;
|
||||
parentById.set(id, candidate !== null && candidate !== id && nodesById.has(candidate) ? candidate : null);
|
||||
}
|
||||
|
||||
// Parent links form a functional graph. Resolve each chain once and detach
|
||||
// the edge that first closes a cycle, keeping normalization linear for large,
|
||||
// deeply nested histories.
|
||||
const stateById = new Map<string, "visiting" | "visited">();
|
||||
for (const startId of orderedIds) {
|
||||
if (stateById.has(startId)) continue;
|
||||
const path: string[] = [];
|
||||
let currentId: string | null = startId;
|
||||
while (currentId !== null && !stateById.has(currentId)) {
|
||||
stateById.set(currentId, "visiting");
|
||||
path.push(currentId);
|
||||
currentId = parentById.get(currentId) ?? null;
|
||||
}
|
||||
if (currentId !== null && stateById.get(currentId) === "visiting") {
|
||||
const cycleClosingId = path.at(-1);
|
||||
if (cycleClosingId !== undefined) parentById.set(cycleClosingId, null);
|
||||
}
|
||||
for (const id of path) stateById.set(id, "visited");
|
||||
}
|
||||
return parentById;
|
||||
}
|
||||
|
||||
function sessionTreeAncestorIds(activeLeafId: string, parentById: ReadonlyMap<string, string | null>): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
let currentId: string | null = activeLeafId;
|
||||
while (currentId !== null && !ids.has(currentId)) {
|
||||
ids.add(currentId);
|
||||
currentId = parentById.get(currentId) ?? null;
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function normalizedVisibleSelection(visibleIds: readonly string[], selectedId: string | undefined): string | undefined {
|
||||
if (selectedId !== undefined && visibleIds.includes(selectedId)) return selectedId;
|
||||
return visibleIds.at(-1);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Readable } from "node:stream";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { RemoteMachineRequestError, type MachineClient } from "./machines/machineClient.js";
|
||||
import { PI_PACKAGE_MUTATION_PROXY_TIMEOUT_MS } from "../shared/federatedRoutes.js";
|
||||
import { PI_PACKAGE_MUTATION_PROXY_TIMEOUT_MS, SESSION_TREE_NAVIGATION_PROXY_TIMEOUT_MS } from "../shared/federatedRoutes.js";
|
||||
import { appTestContext, fakeRemoteClient, registerAppTestHooks } from "./app.testSupport.js";
|
||||
|
||||
registerAppTestHooks();
|
||||
@@ -64,6 +64,28 @@ describe("buildApp remote machine proxy routes", () => {
|
||||
expect(request).toHaveBeenNthCalledWith(2, "POST", "/api/pi-packages/install", installBody, { timeoutMs: PI_PACKAGE_MUTATION_PROXY_TIMEOUT_MS });
|
||||
});
|
||||
|
||||
it("forwards remote session tree navigation with the model-operation timeout", async () => {
|
||||
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||
const remote = addResponse.json<{ id: string }>();
|
||||
const request = vi.fn<MachineClient["request"]>((method, path, body) => Promise.resolve({
|
||||
statusCode: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: Readable.from([JSON.stringify({ method, path, body })]),
|
||||
}));
|
||||
appTestContext.remoteClient = fakeRemoteClient({ request });
|
||||
const navigationBody = { cwd: "/repo", targetId: "entry-1", expectedLeafId: "leaf-1", summary: { mode: "default" } };
|
||||
|
||||
const response = await appTestContext.app.inject({
|
||||
method: "POST",
|
||||
url: `/api/machines/${remote.id}/sessions/s1/tree/navigate`,
|
||||
payload: navigationBody,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ method: "POST", path: "/api/sessions/s1/tree/navigate", body: navigationBody });
|
||||
expect(request).toHaveBeenCalledWith("POST", "/api/sessions/s1/tree/navigate", navigationBody, { timeoutMs: SESSION_TREE_NAVIGATION_PROXY_TIMEOUT_MS });
|
||||
});
|
||||
|
||||
it("proxies remote workspace effective upload config through the existing federated workspace route", async () => {
|
||||
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||
const remote = addResponse.json<{ id: string }>();
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { SessionTreeNavigateRequest, SessionTreeSummaryChoice } from "../../shared/apiTypes.js";
|
||||
import { PiSessionService, type PiAgentSession, type PiSessionManager, type PiSessionServiceDependencies } from "./piSessionService.js";
|
||||
import { CapturingSessionEventHub, emptyArchiveStore, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef, testModel, testModelRuntime, type TestSession } from "./piSessionService.testSupport.js";
|
||||
import type { ProjectableSessionTreeNode } from "./sessionTreeProjection.js";
|
||||
|
||||
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
|
||||
const SESSION_ID = "tree-session";
|
||||
|
||||
type NavigateTree = NonNullable<PiAgentSession["navigateTree"]>;
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
||||
resolve = promiseResolve;
|
||||
reject = promiseReject;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function treeNode(entry: Record<string, unknown>, children: ProjectableSessionTreeNode[] = []): ProjectableSessionTreeNode {
|
||||
return { entry, children };
|
||||
}
|
||||
|
||||
function navigationRequest(
|
||||
summary: SessionTreeSummaryChoice = { mode: "none" },
|
||||
expectedLeafId: string | null = "leaf-1",
|
||||
): SessionTreeNavigateRequest {
|
||||
return { targetId: "target-1", expectedLeafId, summary };
|
||||
}
|
||||
|
||||
function treeHarness(
|
||||
managerPatch: Partial<PiSessionManager> = {},
|
||||
sessionPatch: Partial<TestSession> = {},
|
||||
dependenciesPatch: Partial<PiSessionServiceDependencies> = {},
|
||||
) {
|
||||
const hub = new CapturingSessionEventHub();
|
||||
const manager = fakeSessionManager("/workspace", {
|
||||
getSessionId: () => SESSION_ID,
|
||||
getLeafId: () => "leaf-1",
|
||||
...managerPatch,
|
||||
});
|
||||
const fake = fakeRuntime(SESSION_ID, { sessionManager: manager, ...sessionPatch });
|
||||
const service = new PiSessionService(hub, {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
archiveStore: emptyArchiveStore(),
|
||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||
sessionManager: sessionGateway([sessionRecord(SESSION_ID)]),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
...dependenciesPatch,
|
||||
});
|
||||
return { service, fake, hub };
|
||||
}
|
||||
|
||||
describe("PiSessionService session-tree behavior", () => {
|
||||
it("opens /tree from the live manager through the safe projection boundary", async () => {
|
||||
const navigateTree = vi.fn<NavigateTree>(() => Promise.resolve({ cancelled: false }));
|
||||
const roots = [treeNode({
|
||||
type: "message",
|
||||
id: "leaf-1",
|
||||
parentId: null,
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "safe answer" },
|
||||
{ type: "thinking", thinking: "private reasoning", thinkingSignature: "private signature" },
|
||||
],
|
||||
usage: { private: true },
|
||||
},
|
||||
})];
|
||||
const { service } = treeHarness({ getTree: () => roots }, { navigateTree });
|
||||
|
||||
await expect(service.runCommand(sessionRef(SESSION_ID), "/tree")).resolves.toEqual({
|
||||
type: "tree",
|
||||
tree: {
|
||||
nodes: [{
|
||||
id: "leaf-1",
|
||||
parentId: null,
|
||||
kind: "assistant",
|
||||
summary: "safe answer",
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
}],
|
||||
activeLeafId: "leaf-1",
|
||||
activePathIds: ["leaf-1"],
|
||||
},
|
||||
});
|
||||
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("maps none, default, and trimmed custom summary choices exactly and returns only editor text", async () => {
|
||||
const navigateTree = vi.fn<NavigateTree>();
|
||||
navigateTree
|
||||
.mockResolvedValueOnce({ cancelled: false })
|
||||
.mockResolvedValueOnce({ cancelled: false })
|
||||
.mockResolvedValueOnce({ cancelled: false, editorText: "exact user text", summaryEntry: { details: "must not escape" } });
|
||||
const { service, fake } = treeHarness({}, { navigateTree });
|
||||
|
||||
await expect(service.navigateTree(sessionRef(SESSION_ID), navigationRequest({ mode: "none" }))).resolves.toEqual({ cancelled: false });
|
||||
await expect(service.navigateTree(sessionRef(SESSION_ID), navigationRequest({ mode: "default" }))).resolves.toEqual({ cancelled: false });
|
||||
await expect(service.navigateTree(sessionRef(SESSION_ID), navigationRequest({ mode: "custom", instructions: " focus on tests\nwithout losing context " }))).resolves.toEqual({
|
||||
cancelled: false,
|
||||
editorText: "exact user text",
|
||||
});
|
||||
|
||||
expect(navigateTree).toHaveBeenNthCalledWith(1, "target-1", { summarize: false });
|
||||
expect(navigateTree).toHaveBeenNthCalledWith(2, "target-1", { summarize: true });
|
||||
expect(navigateTree).toHaveBeenNthCalledWith(3, "target-1", { summarize: true, customInstructions: "focus on tests\nwithout losing context" });
|
||||
expect(service.activeCount()).toBe(1);
|
||||
expect(fake.calls.dispose).toBe(0);
|
||||
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("validates stale leaves, active work, unavailable runtimes, and custom instruction bounds", async () => {
|
||||
const navigateTree = vi.fn<NavigateTree>(() => Promise.resolve({ cancelled: false }));
|
||||
const { service, fake } = treeHarness({ getLeafId: () => "new-leaf" }, { navigateTree });
|
||||
|
||||
await expect(service.navigateTree(sessionRef(SESSION_ID), navigationRequest({ mode: "none" }, "old-leaf"))).rejects.toThrow(
|
||||
"The session changed since /tree was opened. Reopen /tree and try again.",
|
||||
);
|
||||
expect(navigateTree).not.toHaveBeenCalled();
|
||||
|
||||
fake.session.isStreaming = true;
|
||||
await expect(service.navigateTree(sessionRef(SESSION_ID), navigationRequest({ mode: "none" }, "new-leaf"))).rejects.toThrow(
|
||||
"Stop current session activity before navigating the session tree",
|
||||
);
|
||||
fake.session.isStreaming = false;
|
||||
|
||||
await expect(service.navigateTree(sessionRef(SESSION_ID), navigationRequest({ mode: "custom", instructions: " " }, "new-leaf"))).rejects.toThrow(
|
||||
"Custom branch-summary instructions are required",
|
||||
);
|
||||
await expect(service.navigateTree(sessionRef(SESSION_ID), navigationRequest({ mode: "custom", instructions: "x".repeat(10_001) }, "new-leaf"))).rejects.toThrow(
|
||||
"Custom branch-summary instructions must be at most 10000 characters",
|
||||
);
|
||||
expect(navigateTree).not.toHaveBeenCalled();
|
||||
await service.dispose();
|
||||
|
||||
const unavailable = treeHarness();
|
||||
await expect(unavailable.service.navigateTree(sessionRef(SESSION_ID), navigationRequest())).rejects.toThrow(
|
||||
"Session tree navigation is not supported by this Pi runtime",
|
||||
);
|
||||
await unavailable.service.dispose();
|
||||
});
|
||||
|
||||
it("holds a per-runtime gate that rejects concurrent navigation and leaf-producing work", async () => {
|
||||
const navigation = deferred<Awaited<ReturnType<NavigateTree>>>();
|
||||
const navigateTree = vi.fn<NavigateTree>(() => navigation.promise);
|
||||
const { service, fake } = treeHarness({}, { navigateTree });
|
||||
|
||||
const firstNavigation = service.navigateTree(sessionRef(SESSION_ID), navigationRequest());
|
||||
await vi.waitFor(() => { expect(navigateTree).toHaveBeenCalledOnce(); });
|
||||
|
||||
await expect(service.navigateTree(sessionRef(SESSION_ID), navigationRequest())).rejects.toThrow(
|
||||
"Stop current session activity before navigating the session tree",
|
||||
);
|
||||
await expect(service.prompt(sessionRef(SESSION_ID), "do not append yet")).rejects.toThrow(
|
||||
"Cannot send a prompt while session tree navigation is active",
|
||||
);
|
||||
await expect(service.shell(sessionRef(SESSION_ID), "!pwd")).rejects.toThrow(
|
||||
"Cannot run a shell command while session tree navigation is active",
|
||||
);
|
||||
await expect(service.setThinkingLevel(sessionRef(SESSION_ID), "off")).rejects.toThrow(
|
||||
"Cannot change the thinking level while session tree navigation is active",
|
||||
);
|
||||
const model = testModel();
|
||||
await expect(service.setModel(sessionRef(SESSION_ID), model.provider, model.id)).rejects.toThrow(
|
||||
"Cannot change models while session tree navigation is active",
|
||||
);
|
||||
await expect(service.cycleModel(sessionRef(SESSION_ID), "forward")).rejects.toThrow(
|
||||
"Cannot change models while session tree navigation is active",
|
||||
);
|
||||
await expect(service.runCommand(sessionRef(SESSION_ID), "/name blocked")).resolves.toEqual({
|
||||
type: "unsupported",
|
||||
message: "Cannot run commands while session tree navigation is active. Stop or finish the navigation first.",
|
||||
});
|
||||
await expect(service.archive(sessionRef(SESSION_ID))).rejects.toThrow("Stop current session activity before archiving");
|
||||
expect(fake.calls.prompt).toEqual([]);
|
||||
|
||||
navigation.resolve({ cancelled: false });
|
||||
await expect(firstNavigation).resolves.toEqual({ cancelled: false });
|
||||
await service.prompt(sessionRef(SESSION_ID), "now append");
|
||||
expect(fake.calls.prompt).toEqual([{ text: "now append", options: undefined }]);
|
||||
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("blocks navigation while prompt preflight can await before Pi reports streaming", async () => {
|
||||
const promptOperation = deferred<undefined>();
|
||||
const prompt = vi.fn(() => promptOperation.promise);
|
||||
const navigateTree = vi.fn<NavigateTree>(() => Promise.resolve({ cancelled: false }));
|
||||
const { service } = treeHarness({}, { prompt, navigateTree });
|
||||
|
||||
await service.prompt(sessionRef(SESSION_ID), "preflight is still running");
|
||||
await vi.waitFor(() => { expect(prompt).toHaveBeenCalledOnce(); });
|
||||
await expect(service.navigateTree(sessionRef(SESSION_ID), navigationRequest())).rejects.toThrow(
|
||||
"Stop current session activity before navigating the session tree",
|
||||
);
|
||||
|
||||
promptOperation.resolve(undefined);
|
||||
await expect(service.navigateTree(sessionRef(SESSION_ID), navigationRequest())).resolves.toEqual({ cancelled: false });
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("blocks navigation while an asynchronous model change can still append an entry", async () => {
|
||||
const modelChange = deferred<undefined>();
|
||||
const setModel = vi.fn(() => modelChange.promise);
|
||||
const navigateTree = vi.fn<NavigateTree>(() => Promise.resolve({ cancelled: false }));
|
||||
const { service } = treeHarness({}, { setModel, navigateTree });
|
||||
const model = testModel();
|
||||
|
||||
const changingModel = service.setModel(sessionRef(SESSION_ID), model.provider, model.id);
|
||||
await vi.waitFor(() => { expect(setModel).toHaveBeenCalledOnce(); });
|
||||
await expect(service.navigateTree(sessionRef(SESSION_ID), navigationRequest())).rejects.toThrow(
|
||||
"Stop current session activity before navigating the session tree",
|
||||
);
|
||||
|
||||
modelChange.resolve(undefined);
|
||||
await changingModel;
|
||||
await expect(service.navigateTree(sessionRef(SESSION_ID), navigationRequest())).resolves.toEqual({ cancelled: false });
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("blocks tree navigation while clone replaces and rebinds the runtime", async () => {
|
||||
const replacement = deferred<{ cancelled: boolean; selectedText?: string }>();
|
||||
const rebound = deferred<undefined>();
|
||||
const navigateTree = vi.fn<NavigateTree>(() => Promise.resolve({ cancelled: false }));
|
||||
const { service, fake } = treeHarness({}, { navigateTree });
|
||||
const replacementSessionId = "tree-session-replacement";
|
||||
const replacementFake = fakeRuntime(replacementSessionId, {
|
||||
sessionManager: fakeSessionManager("/workspace", {
|
||||
getSessionId: () => replacementSessionId,
|
||||
getLeafId: () => "replacement-leaf",
|
||||
}),
|
||||
navigateTree,
|
||||
});
|
||||
let rebindSession: ((session: PiAgentSession) => Promise<void>) | undefined;
|
||||
fake.runtime.setRebindSession = (callback) => { rebindSession = callback; };
|
||||
const fork = vi.fn(async () => {
|
||||
if (!Reflect.set(fake.runtime, "session", replacementFake.session)) throw new Error("Could not replace fake runtime session");
|
||||
await rebindSession?.(replacementFake.session);
|
||||
rebound.resolve(undefined);
|
||||
return replacement.promise;
|
||||
});
|
||||
fake.runtime.fork = fork;
|
||||
|
||||
const cloning = service.runCommand(sessionRef(SESSION_ID), "/clone");
|
||||
await rebound.promise;
|
||||
await expect(service.navigateTree(sessionRef(replacementSessionId), navigationRequest({ mode: "none" }, "replacement-leaf"))).rejects.toThrow(
|
||||
"Stop current session activity before navigating the session tree",
|
||||
);
|
||||
|
||||
replacement.resolve({ cancelled: false });
|
||||
await expect(cloning).resolves.toMatchObject({ type: "done", message: "Session cloned" });
|
||||
await expect(service.navigateTree(sessionRef(replacementSessionId), navigationRequest({ mode: "none" }, "replacement-leaf"))).resolves.toEqual({ cancelled: false });
|
||||
expect(fork).toHaveBeenCalledOnce();
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("blocks tree navigation while session resources reload", async () => {
|
||||
const reloadOperation = deferred<undefined>();
|
||||
const reload = vi.fn(() => reloadOperation.promise);
|
||||
const navigateTree = vi.fn<NavigateTree>(() => Promise.resolve({ cancelled: false }));
|
||||
const { service } = treeHarness({}, { navigateTree, reload });
|
||||
|
||||
const reloading = service.runCommand(sessionRef(SESSION_ID), "/reload");
|
||||
await vi.waitFor(() => { expect(reload).toHaveBeenCalledOnce(); });
|
||||
await expect(service.navigateTree(sessionRef(SESSION_ID), navigationRequest())).rejects.toThrow(
|
||||
"Stop current session activity before navigating the session tree",
|
||||
);
|
||||
|
||||
reloadOperation.resolve(undefined);
|
||||
await expect(reloading).resolves.toMatchObject({ type: "done" });
|
||||
await expect(service.navigateTree(sessionRef(SESSION_ID), navigationRequest())).resolves.toEqual({ cancelled: false });
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("blocks tree navigation while the route-level runtime reload disposes and reopens the session", async () => {
|
||||
const disposal = deferred<undefined>();
|
||||
const navigateTree = vi.fn<NavigateTree>(() => Promise.resolve({ cancelled: false }));
|
||||
const { service, fake } = treeHarness({}, { navigateTree });
|
||||
const dispose = vi.fn(() => disposal.promise);
|
||||
fake.runtime.dispose = dispose;
|
||||
|
||||
const reloading = service.reload(sessionRef(SESSION_ID));
|
||||
await vi.waitFor(() => { expect(dispose).toHaveBeenCalledOnce(); });
|
||||
await expect(service.navigateTree(sessionRef(SESSION_ID), navigationRequest())).rejects.toThrow(
|
||||
"Stop current session activity before navigating the session tree",
|
||||
);
|
||||
|
||||
disposal.resolve(undefined);
|
||||
await reloading;
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("blocks tree navigation throughout an asynchronous archive operation", async () => {
|
||||
const archiveOperation = deferred<{ sessionId: string; cwd: string; archivedAt: string }>();
|
||||
const archive = vi.fn(() => archiveOperation.promise);
|
||||
const archiveStore = { ...emptyArchiveStore(), archive };
|
||||
const navigateTree = vi.fn<NavigateTree>(() => Promise.resolve({ cancelled: false }));
|
||||
const { service } = treeHarness({}, { navigateTree }, { archiveStore });
|
||||
|
||||
const archiving = service.archive(sessionRef(SESSION_ID));
|
||||
await vi.waitFor(() => { expect(archive).toHaveBeenCalledOnce(); });
|
||||
await expect(service.navigateTree(sessionRef(SESSION_ID), navigationRequest())).rejects.toThrow(
|
||||
"Stop current session activity before navigating the session tree",
|
||||
);
|
||||
|
||||
archiveOperation.resolve({ sessionId: SESSION_ID, cwd: "/workspace", archivedAt: "2026-01-01T00:00:00.000Z" });
|
||||
await archiving;
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("aborts branch summarization through the existing abort path and reports cancellation", async () => {
|
||||
const navigation = deferred<Awaited<ReturnType<NavigateTree>>>();
|
||||
const navigateTree = vi.fn<NavigateTree>(() => navigation.promise);
|
||||
const abortBranchSummary = vi.fn(() => { navigation.resolve({ cancelled: true, aborted: true }); });
|
||||
const abort = vi.fn(() => Promise.resolve());
|
||||
const { service, hub } = treeHarness({}, { navigateTree, abortBranchSummary, abort });
|
||||
|
||||
const navigationResult = service.navigateTree(sessionRef(SESSION_ID), navigationRequest({ mode: "default" }));
|
||||
await vi.waitFor(() => { expect(navigateTree).toHaveBeenCalledOnce(); });
|
||||
await service.abort(sessionRef(SESSION_ID));
|
||||
|
||||
await expect(navigationResult).resolves.toEqual({ cancelled: true, aborted: true });
|
||||
expect(abortBranchSummary).toHaveBeenCalledOnce();
|
||||
expect(abort).toHaveBeenCalledOnce();
|
||||
expect(abortBranchSummary.mock.invocationCallOrder[0]).toBeLessThan(abort.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY);
|
||||
expect(hub.sessionEvents.some(({ event }) => event.type === "activity.update" && event.activity.label === "branch summary aborted")).toBe(true);
|
||||
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("does not republish stale navigation state after stopping and disposing its runtime", async () => {
|
||||
const navigation = deferred<Awaited<ReturnType<NavigateTree>>>();
|
||||
const navigateTree = vi.fn<NavigateTree>(() => navigation.promise);
|
||||
const abortBranchSummary = vi.fn(() => { navigation.resolve({ cancelled: true, aborted: true }); });
|
||||
const { service, hub, fake } = treeHarness({}, { navigateTree, abortBranchSummary });
|
||||
|
||||
const navigationResult = service.navigateTree(sessionRef(SESSION_ID), navigationRequest({ mode: "default" }));
|
||||
await vi.waitFor(() => { expect(navigateTree).toHaveBeenCalledOnce(); });
|
||||
hub.sessionEvents.length = 0;
|
||||
await service.stop(sessionRef(SESSION_ID));
|
||||
await expect(navigationResult).resolves.toEqual({ cancelled: true, aborted: true });
|
||||
|
||||
expect(service.activeCount()).toBe(0);
|
||||
expect(fake.calls.dispose).toBe(1);
|
||||
expect(hub.sessionEvents).toEqual([]);
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("still runs the normal abort and releases the gate when the branch-summary abort hook fails", async () => {
|
||||
const navigation = deferred<Awaited<ReturnType<NavigateTree>>>();
|
||||
const navigateTree = vi.fn<NavigateTree>(() => navigation.promise);
|
||||
const branchAbortFailure = new Error("branch abort hook failed");
|
||||
const abortBranchSummary = vi.fn<NonNullable<PiAgentSession["abortBranchSummary"]>>(() => { throw branchAbortFailure; });
|
||||
const abort = vi.fn(() => {
|
||||
navigation.resolve({ cancelled: true, aborted: true });
|
||||
return Promise.resolve();
|
||||
});
|
||||
const { service } = treeHarness({}, { navigateTree, abortBranchSummary, abort });
|
||||
|
||||
const navigationResult = service.navigateTree(sessionRef(SESSION_ID), navigationRequest({ mode: "default" }));
|
||||
await vi.waitFor(() => { expect(navigateTree).toHaveBeenCalledOnce(); });
|
||||
await expect(service.abort(sessionRef(SESSION_ID))).rejects.toBe(branchAbortFailure);
|
||||
await expect(navigationResult).resolves.toEqual({ cancelled: true, aborted: true });
|
||||
|
||||
expect(abortBranchSummary).toHaveBeenCalledOnce();
|
||||
expect(abort).toHaveBeenCalledOnce();
|
||||
await expect(service.prompt(sessionRef(SESSION_ID), "gate released after abort failure")).resolves.toBeUndefined();
|
||||
abortBranchSummary.mockImplementation(() => undefined);
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("releases the gate and publishes final status after navigation failure", async () => {
|
||||
const failure = new Error("summary provider failed");
|
||||
const navigateTree = vi.fn<NavigateTree>();
|
||||
navigateTree.mockRejectedValueOnce(failure).mockResolvedValueOnce({ cancelled: false });
|
||||
const { service, hub } = treeHarness({}, { navigateTree });
|
||||
|
||||
await expect(service.navigateTree(sessionRef(SESSION_ID), navigationRequest({ mode: "default" }))).rejects.toBe(failure);
|
||||
await expect(service.navigateTree(sessionRef(SESSION_ID), navigationRequest({ mode: "none" }))).resolves.toEqual({ cancelled: false });
|
||||
|
||||
expect(navigateTree).toHaveBeenCalledTimes(2);
|
||||
expect(hub.sessionEvents.some(({ event }) => event.type === "activity.update"
|
||||
&& event.activity.phase === "error"
|
||||
&& event.activity.detail === "summary provider failed")).toBe(true);
|
||||
expect(hub.sessionEvents.filter(({ event }) => event.type === "status.update").length).toBeGreaterThanOrEqual(4);
|
||||
|
||||
await service.dispose();
|
||||
});
|
||||
});
|
||||
@@ -19,12 +19,13 @@ import {
|
||||
type ModelRuntime,
|
||||
type ResourceDiagnostic,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import type { ClientArchiveSessionsResponse, ClientCommand, ClientCommandResult, ClientMessagePage, ClientSession, ClientSessionCleanupExecuteResponse, ClientSessionCleanupPreviewResponse, ClientSessionModel, ClientSessionStatus, ClientThinkingLevel, SessionStreamSnapshot, SessionUiEvent } from "../types.js";
|
||||
import type { ClientArchiveSessionsResponse, ClientCommand, ClientCommandResult, ClientMessagePage, ClientSession, ClientSessionCleanupExecuteResponse, ClientSessionCleanupPreviewResponse, ClientSessionModel, ClientSessionStatus, ClientSessionTreeNavigateRequest, ClientSessionTreeNavigateResult, ClientThinkingLevel, SessionStreamSnapshot, SessionUiEvent } from "../types.js";
|
||||
import { projectBrowserMessage } from "../browserMessageProjection.js";
|
||||
import { pageMessagesAtSafeBoundary } from "./messagePaging.js";
|
||||
import type { SessionEventHub } from "../realtime/sessionEventHub.js";
|
||||
import { BUILTIN_COMMANDS } from "./builtinCommands.js";
|
||||
import { SessionCommandService } from "./sessionCommandService.js";
|
||||
import { projectSessionTree, type ProjectableSessionTreeNode } from "./sessionTreeProjection.js";
|
||||
import { SessionArchiveStore, type ArchivedSessionRecord, type ArchiveSessionInput } from "./sessionArchiveStore.js";
|
||||
import { findArchiveCandidateByIdOrPrefix, planSessionArchiveTree, type SessionArchiveTreeCandidate } from "./sessionArchiveTree.js";
|
||||
import type { ActiveSession } from "./sessionRuntimeStore.js";
|
||||
@@ -32,6 +33,7 @@ import { deterministicSessionName, fallbackSessionName, generateShortSessionName
|
||||
import { computeEditPreview, type EditPreviewResult } from "./editPreview.js";
|
||||
import { attachmentsToInlineImages, saveAttachmentsToWorkspace } from "./attachmentService.js";
|
||||
import { parsePromptAttachments } from "../../shared/promptAttachments.js";
|
||||
import { SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH } from "../../shared/apiTypes.js";
|
||||
import type {
|
||||
SavedPromptAttachment,
|
||||
SessionBulkArchiveResponse,
|
||||
@@ -106,6 +108,51 @@ interface QueuedPrompt {
|
||||
echoUserMessage?: boolean;
|
||||
}
|
||||
|
||||
interface DeferredSubsessionNotification {
|
||||
parentId: string;
|
||||
childId: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface TreeExclusiveOperationTarget {
|
||||
sessionId: string;
|
||||
session?: PiAgentSession;
|
||||
runtime?: PiSessionRuntime;
|
||||
}
|
||||
|
||||
type PiTreeNavigationOptions =
|
||||
| { summarize: false }
|
||||
| { summarize: true; customInstructions?: string };
|
||||
|
||||
function sessionTreeNavigationOptions(request: ClientSessionTreeNavigateRequest): PiTreeNavigationOptions {
|
||||
switch (request.summary.mode) {
|
||||
case "none":
|
||||
return { summarize: false };
|
||||
case "default":
|
||||
return { summarize: true };
|
||||
case "custom": {
|
||||
const customInstructions = request.summary.instructions.trim();
|
||||
if (customInstructions === "") throw new Error("Custom branch-summary instructions are required");
|
||||
if (customInstructions.length > SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH) {
|
||||
throw new Error(`Custom branch-summary instructions must be at most ${String(SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH)} characters`);
|
||||
}
|
||||
return { summarize: true, customInstructions };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function decrementWeakCount<Key extends object>(counts: WeakMap<Key, number>, key: Key): void {
|
||||
const remaining = (counts.get(key) ?? 1) - 1;
|
||||
if (remaining <= 0) counts.delete(key);
|
||||
else counts.set(key, remaining);
|
||||
}
|
||||
|
||||
function decrementMapCount<Key>(counts: Map<Key, number>, key: Key): void {
|
||||
const remaining = (counts.get(key) ?? 1) - 1;
|
||||
if (remaining <= 0) counts.delete(key);
|
||||
else counts.set(key, remaining);
|
||||
}
|
||||
|
||||
interface TrackedSubsessionLink {
|
||||
parentSessionId: string;
|
||||
childSessionId: string;
|
||||
@@ -197,6 +244,7 @@ export interface PiSessionManager {
|
||||
getSessionFile(): string | undefined;
|
||||
getBranch(): unknown[];
|
||||
getEntries?(): readonly unknown[];
|
||||
getTree?(): readonly ProjectableSessionTreeNode[];
|
||||
getLeafId(): string | null;
|
||||
getHeader?(): { parentSession?: string } | null | undefined;
|
||||
appendCustomEntry?(customType: string, data?: unknown): string;
|
||||
@@ -276,6 +324,8 @@ export interface PiAgentSession {
|
||||
prompt(text: string, options?: { streamingBehavior?: "steer" | "followUp"; images?: ImageContent[] }): Promise<void>;
|
||||
sendCustomMessage(message: { customType: string; content: string; display: boolean; details?: unknown }, options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" }): Promise<void>;
|
||||
executeBash(command: string, onChunk?: (chunk: string) => void, options?: { excludeFromContext?: boolean }): Promise<{ output: string; exitCode: number | undefined; cancelled: boolean; truncated: boolean; fullOutputPath?: string }>;
|
||||
navigateTree?(targetId: string, options?: { summarize?: boolean; customInstructions?: string }): Promise<{ editorText?: string; cancelled: boolean; aborted?: boolean; summaryEntry?: unknown }>;
|
||||
abortBranchSummary?(): void;
|
||||
abort(): Promise<void>;
|
||||
clearQueue(): { steering: string[]; followUp: string[] };
|
||||
getSteeringMessages(): readonly string[];
|
||||
@@ -605,6 +655,15 @@ export class PiSessionService implements SessionRouteService {
|
||||
private readonly activities = new Map<string, { phase: "active" | "idle" | "error"; label: string; detail?: string; at: string }>();
|
||||
private readonly heartbeat: NodeJS.Timeout;
|
||||
private readonly commandService: SessionCommandService<PiAgentSession>;
|
||||
/** Runtime-identity gate held while Pi may await abandoned-branch summarization. */
|
||||
private readonly treeNavigations = new WeakSet<PiAgentSession>();
|
||||
/** Counts async operations that may append an entry before they settle. */
|
||||
private readonly sessionEntryMutationCounts = new WeakMap<PiAgentSession, number>();
|
||||
/** Runtime/session-identity reservations for operations that must not overlap tree navigation. */
|
||||
private readonly treeExclusiveRuntimeOperationCounts = new WeakMap<PiSessionRuntime, number>();
|
||||
private readonly treeExclusiveSessionOperationCounts = new Map<string, number>();
|
||||
private readonly deferredSubsessionNotifications = new WeakMap<PiAgentSession, DeferredSubsessionNotification[]>();
|
||||
private readonly deferredGeneratedSessionNames = new WeakMap<PiAgentSession, string>();
|
||||
private readonly compactionPromptQueues = new Map<string, QueuedPrompt[]>();
|
||||
private readonly compactionDrainTimers = new Map<string, NodeJS.Timeout>();
|
||||
private readonly authLossWarnings = new Set<string>();
|
||||
@@ -667,14 +726,27 @@ export class PiSessionService implements SessionRouteService {
|
||||
events,
|
||||
{
|
||||
onCompactionStart: (session) => {
|
||||
this.beginSessionEntryMutation(session, "compact the session");
|
||||
this.publishActivity(session, "compacting", "active");
|
||||
this.publishStatus(session);
|
||||
},
|
||||
onCompactionEnd: (session, result, detail) => {
|
||||
this.endSessionEntryMutation(session);
|
||||
this.publishActivity(session, result === "success" ? "compaction complete" : "compaction failed", result === "success" ? "idle" : "error", detail);
|
||||
this.publishStatus(session);
|
||||
},
|
||||
reloadSession: (session) => this.reloadSessionRuntime(session),
|
||||
getSessionTree: (session) => {
|
||||
if (typeof session.sessionManager.getTree !== "function" || typeof session.navigateTree !== "function") return undefined;
|
||||
return projectSessionTree(session.sessionManager.getTree(), session.sessionManager.getLeafId());
|
||||
},
|
||||
hasActiveWork: (session) => this.hasActiveWork(session),
|
||||
isTreeNavigationActive: (session) => this.treeNavigations.has(session),
|
||||
runSessionReplacement: (session, operation) => this.runTreeExclusiveOperation(
|
||||
[{ sessionId: session.sessionId, session }],
|
||||
"Stop current session activity before replacing the session",
|
||||
operation,
|
||||
),
|
||||
},
|
||||
{ listSessionNames: (cwd) => this.listSessionNames(cwd) },
|
||||
);
|
||||
@@ -789,7 +861,7 @@ export class PiSessionService implements SessionRouteService {
|
||||
active.runtime.setRebindSession(undefined);
|
||||
this.workspaceActivity?.removeSession(active.runtime.session.sessionId, active.runtime.session.sessionManager.getCwd());
|
||||
try {
|
||||
await active.runtime.session.abort();
|
||||
await this.abortSessionOperations(active.runtime.session);
|
||||
} finally {
|
||||
await active.runtime.dispose();
|
||||
}
|
||||
@@ -1197,19 +1269,33 @@ export class PiSessionService implements SessionRouteService {
|
||||
private async notifyParentOfSubsession(parentId: string, childId: string, text: string): Promise<void> {
|
||||
try {
|
||||
const session = await this.getOrOpenParentForSubsession(parentId, childId);
|
||||
await session.sendCustomMessage(
|
||||
{ customType: SUBSESSION_NOTIFICATION_CUSTOM_TYPE, content: text, display: true, details: { sessionId: childId } },
|
||||
{ triggerTurn: true, deliverAs: "followUp" },
|
||||
);
|
||||
this.publishStatus(session);
|
||||
if (this.treeNavigations.has(session)) {
|
||||
const pending = this.deferredSubsessionNotifications.get(session) ?? [];
|
||||
pending.push({ parentId, childId, text });
|
||||
this.deferredSubsessionNotifications.set(session, pending);
|
||||
return;
|
||||
}
|
||||
await this.deliverSubsessionNotification(session, { parentId, childId, text });
|
||||
} catch (error: unknown) {
|
||||
this.logger.info(
|
||||
{ parentSessionId: parentId, sessionId: childId, error: error instanceof Error ? error.message : String(error) },
|
||||
"failed to notify parent of subsession completion",
|
||||
);
|
||||
this.logSubsessionNotificationFailure(parentId, childId, error);
|
||||
}
|
||||
}
|
||||
|
||||
private async deliverSubsessionNotification(session: PiAgentSession, notification: DeferredSubsessionNotification): Promise<void> {
|
||||
await this.runSessionEntryMutation(session, "deliver a subsession notification", () => session.sendCustomMessage(
|
||||
{ customType: SUBSESSION_NOTIFICATION_CUSTOM_TYPE, content: notification.text, display: true, details: { sessionId: notification.childId } },
|
||||
{ triggerTurn: true, deliverAs: "followUp" },
|
||||
));
|
||||
this.publishStatus(session);
|
||||
}
|
||||
|
||||
private logSubsessionNotificationFailure(parentId: string, childId: string, error: unknown): void {
|
||||
this.logger.info(
|
||||
{ parentSessionId: parentId, sessionId: childId, error: error instanceof Error ? error.message : String(error) },
|
||||
"failed to notify parent of subsession completion",
|
||||
);
|
||||
}
|
||||
|
||||
async messages(ref: PiSessionLookup, page?: { before?: number; limit?: number }): Promise<unknown[] | ClientMessagePage> {
|
||||
const session = await this.getOrOpen(ref);
|
||||
return pageMessagesAtSafeBoundary(historyMessages(session), page);
|
||||
@@ -1251,14 +1337,16 @@ export class PiSessionService implements SessionRouteService {
|
||||
async setModel(ref: PiSessionLookup, provider: string, modelId: string): Promise<ClientSessionStatus> {
|
||||
await this.assertWritable(ref);
|
||||
const session = await this.getOrOpen(ref);
|
||||
this.assertTreeNavigationInactive(session, "change models");
|
||||
await session.modelRuntime.reloadConfig();
|
||||
this.assertTreeNavigationInactive(session, "change models");
|
||||
const candidates = session.scopedModels.length > 0
|
||||
? session.scopedModels.map((scoped) => scoped.model)
|
||||
: session.modelRuntime.getAvailableSnapshot();
|
||||
const model = candidates.find((candidate) => candidate.provider === provider && candidate.id === modelId)
|
||||
?? session.modelRuntime.getModel(provider, modelId);
|
||||
if (model === undefined) throw new Error(`Model not found: ${provider}/${modelId}`);
|
||||
await session.setModel(model);
|
||||
await this.runSessionEntryMutation(session, "change models", () => session.setModel(model));
|
||||
this.publishActivity(session, `model: ${model.id}`, "idle", model.provider);
|
||||
this.publishStatus(session);
|
||||
return this.statusFromSession(session);
|
||||
@@ -1267,7 +1355,7 @@ export class PiSessionService implements SessionRouteService {
|
||||
async cycleModel(ref: PiSessionLookup, direction: "forward" | "backward"): Promise<ClientSessionStatus> {
|
||||
await this.assertWritable(ref);
|
||||
const session = await this.getOrOpen(ref);
|
||||
const result = await session.cycleModel(direction);
|
||||
const result = await this.runSessionEntryMutation(session, "change models", () => session.cycleModel(direction));
|
||||
if (result === undefined) throw new Error(session.scopedModels.length > 0 ? "Only one model in scope" : "Only one model available");
|
||||
this.publishActivity(session, `model: ${result.model.id}`, "idle", result.model.provider);
|
||||
this.publishStatus(session);
|
||||
@@ -1282,6 +1370,7 @@ export class PiSessionService implements SessionRouteService {
|
||||
async setThinkingLevel(ref: PiSessionLookup, level: string): Promise<ClientSessionStatus> {
|
||||
await this.assertWritable(ref);
|
||||
const session = await this.getOrOpen(ref);
|
||||
this.assertTreeNavigationInactive(session, "change the thinking level");
|
||||
// pi owns the valid set; validate against the session's live levels rather
|
||||
// than a hardcoded union so this stays correct if pi changes the set.
|
||||
const available = session.getAvailableThinkingLevels();
|
||||
@@ -1296,6 +1385,7 @@ export class PiSessionService implements SessionRouteService {
|
||||
async cycleThinkingLevel(ref: PiSessionLookup): Promise<ClientSessionStatus> {
|
||||
await this.assertWritable(ref);
|
||||
const session = await this.getOrOpen(ref);
|
||||
this.assertTreeNavigationInactive(session, "change the thinking level");
|
||||
const level = session.cycleThinkingLevel();
|
||||
if (level === undefined) throw new Error("Current model does not support thinking");
|
||||
this.publishActivity(session, `thinking: ${level}`, "idle");
|
||||
@@ -1330,6 +1420,7 @@ export class PiSessionService implements SessionRouteService {
|
||||
const images = (await attachmentsToInlineImages(parsedAttachments)).map((entry) => entry.image);
|
||||
await this.assertWritable(ref);
|
||||
const session = await this.getOrOpen(ref);
|
||||
this.assertTreeNavigationInactive(session, "send a prompt");
|
||||
this.maybeGenerateSessionName(session, promptText);
|
||||
const isQueued = session.isStreaming || session.isCompacting;
|
||||
const behavior = isQueued ? requestedBehavior ?? "followUp" : undefined;
|
||||
@@ -1349,7 +1440,7 @@ export class PiSessionService implements SessionRouteService {
|
||||
this.publishActivity(session, behavior === "steer" ? "steering queued" : behavior === "followUp" ? "message queued" : "prompt accepted", "active");
|
||||
if (behavior === undefined && echoUserMessage) this.events.publish(session.sessionId, { type: "message.append", message: userMessage(text, images) });
|
||||
const promptOptions = buildPromptOptions(behavior, images);
|
||||
const promptPromise = session.prompt(text, promptOptions).catch((error: unknown) => {
|
||||
const promptPromise = this.runSessionEntryMutation(session, "send a prompt", () => session.prompt(text, promptOptions)).catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.publishActivity(session, "error", "error", message);
|
||||
this.events.publish(session.sessionId, { type: "session.error", message });
|
||||
@@ -1378,6 +1469,7 @@ export class PiSessionService implements SessionRouteService {
|
||||
await this.assertWritable(ref);
|
||||
const active = await this.getActive(ref);
|
||||
const { session } = active.runtime;
|
||||
this.assertTreeNavigationInactive(session, "run a shell command");
|
||||
const isExcluded = text.startsWith("!!");
|
||||
const command = (isExcluded ? text.slice(2) : text.slice(1)).trim();
|
||||
if (!command) throw new Error("Usage: !<shell command>");
|
||||
@@ -1385,11 +1477,11 @@ export class PiSessionService implements SessionRouteService {
|
||||
|
||||
this.publishActivity(session, "running bash", "active", command);
|
||||
this.events.publish(session.sessionId, { type: "shell.start", command, excludeFromContext: isExcluded });
|
||||
void session.executeBash(command, (chunk) => {
|
||||
void this.runSessionEntryMutation(session, "run a shell command", () => session.executeBash(command, (chunk) => {
|
||||
this.events.publish(session.sessionId, { type: "shell.chunk", chunk });
|
||||
this.publishActivity(session, "running bash", "active", command);
|
||||
this.publishStatus(session);
|
||||
}, { excludeFromContext: isExcluded }).then((result) => {
|
||||
}, { excludeFromContext: isExcluded })).then((result) => {
|
||||
this.events.publish(session.sessionId, {
|
||||
type: "shell.end",
|
||||
output: result.output,
|
||||
@@ -1421,43 +1513,105 @@ export class PiSessionService implements SessionRouteService {
|
||||
return this.commandService.respond(active.runtime.session.sessionId, requestId, value);
|
||||
}
|
||||
|
||||
async navigateTree(ref: PiSessionLookup, request: ClientSessionTreeNavigateRequest): Promise<ClientSessionTreeNavigateResult> {
|
||||
if (request.targetId.trim() === "") throw new Error("Session tree target is required");
|
||||
if (this.isTreeExclusiveSessionIdentityActive(sessionIdFromLookup(ref))) {
|
||||
throw new Error("Stop current session activity before navigating the session tree");
|
||||
}
|
||||
await this.assertWritable(ref);
|
||||
const options = sessionTreeNavigationOptions(request);
|
||||
const session = await this.getOrOpen(ref);
|
||||
if (typeof session.navigateTree !== "function") throw new Error("Session tree navigation is not supported by this Pi runtime");
|
||||
if (this.hasActiveWork(session)) throw new Error("Stop current session activity before navigating the session tree");
|
||||
|
||||
// Acquire synchronously after the active-work check. No leaf-producing work
|
||||
// may enter this runtime until Pi's potentially asynchronous summary settles.
|
||||
this.treeNavigations.add(session);
|
||||
try {
|
||||
if (session.sessionManager.getLeafId() !== request.expectedLeafId) {
|
||||
throw new Error("The session changed since /tree was opened. Reopen /tree and try again.");
|
||||
}
|
||||
|
||||
this.publishActivity(session, options.summarize ? "summarizing branch" : "navigating session tree", "active");
|
||||
this.publishStatus(session);
|
||||
const result = await session.navigateTree(request.targetId, options);
|
||||
if (result.cancelled) {
|
||||
if (this.isCurrentActiveSession(session)) {
|
||||
this.publishActivity(session, result.aborted === true ? "branch summary aborted" : "tree navigation cancelled", "idle");
|
||||
}
|
||||
return { cancelled: true, ...(result.aborted === undefined ? {} : { aborted: result.aborted }) };
|
||||
}
|
||||
|
||||
if (this.isCurrentActiveSession(session)) this.publishActivity(session, "session tree navigated", "idle");
|
||||
return { cancelled: false, ...(result.editorText === undefined ? {} : { editorText: result.editorText }) };
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (this.isCurrentActiveSession(session)) {
|
||||
this.publishActivity(session, "tree navigation failed", "error", message);
|
||||
this.events.publish(session.sessionId, { type: "session.error", message });
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
this.treeNavigations.delete(session);
|
||||
if (this.isCurrentActiveSession(session)) {
|
||||
this.flushDeferredTreeNavigationWork(session);
|
||||
this.publishStatus(session);
|
||||
} else {
|
||||
this.deferredGeneratedSessionNames.delete(session);
|
||||
this.deferredSubsessionNotifications.delete(session);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async reloadSessionRuntime(session: PiAgentSession): Promise<void> {
|
||||
if (this.hasActiveWork(session)) throw new Error("Stop current session activity before reloading");
|
||||
this.publishActivity(session, "reloading resources", "active");
|
||||
const priorGeneration = this.notificationGenerationBySession.get(session);
|
||||
let candidateGeneration: SessionNotificationGeneration | undefined;
|
||||
try {
|
||||
await session.reload(priorGeneration === undefined ? undefined : {
|
||||
beforeSessionStart: () => {
|
||||
candidateGeneration = this.notificationStore.beginReplacement(priorGeneration, notificationIdentityForSession(session));
|
||||
this.notificationGenerationBySession.set(session, candidateGeneration);
|
||||
this.replaceSessionNotificationContext(session, candidateGeneration);
|
||||
},
|
||||
});
|
||||
if (candidateGeneration !== undefined) {
|
||||
this.publishNotificationMutations(this.notificationStore.commitReplacement(candidateGeneration));
|
||||
}
|
||||
this.publishActivity(session, "resources reloaded", "idle");
|
||||
this.publishStatus(session);
|
||||
} catch (error: unknown) {
|
||||
if (candidateGeneration !== undefined) {
|
||||
this.publishNotificationMutations(this.notificationStore.abortReplacement(candidateGeneration, "candidate"));
|
||||
this.notificationGenerationBySession.set(session, candidateGeneration);
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.publishActivity(session, "reload failed", "error", message);
|
||||
this.events.publish(session.sessionId, { type: "session.error", message });
|
||||
this.publishStatus(session);
|
||||
throw error;
|
||||
}
|
||||
await this.runTreeExclusiveOperation(
|
||||
[{ sessionId: session.sessionId, session }],
|
||||
"Stop current session activity before reloading",
|
||||
async () => {
|
||||
this.publishActivity(session, "reloading resources", "active");
|
||||
const priorGeneration = this.notificationGenerationBySession.get(session);
|
||||
let candidateGeneration: SessionNotificationGeneration | undefined;
|
||||
try {
|
||||
await session.reload(priorGeneration === undefined ? undefined : {
|
||||
beforeSessionStart: () => {
|
||||
candidateGeneration = this.notificationStore.beginReplacement(priorGeneration, notificationIdentityForSession(session));
|
||||
this.notificationGenerationBySession.set(session, candidateGeneration);
|
||||
this.replaceSessionNotificationContext(session, candidateGeneration);
|
||||
},
|
||||
});
|
||||
if (candidateGeneration !== undefined) {
|
||||
this.publishNotificationMutations(this.notificationStore.commitReplacement(candidateGeneration));
|
||||
}
|
||||
this.publishActivity(session, "resources reloaded", "idle");
|
||||
this.publishStatus(session);
|
||||
} catch (error: unknown) {
|
||||
if (candidateGeneration !== undefined) {
|
||||
this.publishNotificationMutations(this.notificationStore.abortReplacement(candidateGeneration, "candidate"));
|
||||
this.notificationGenerationBySession.set(session, candidateGeneration);
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.publishActivity(session, "reload failed", "error", message);
|
||||
this.events.publish(session.sessionId, { type: "session.error", message });
|
||||
this.publishStatus(session);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async archive(ref: PiSessionLookup): Promise<void> {
|
||||
const session = await this.getOrOpen(ref);
|
||||
if (this.hasActiveWork(session)) throw new Error("Stop current session activity before archiving");
|
||||
const archiveInput = await this.archiveInputForSession(session);
|
||||
await this.closeActive(session.sessionId, { kind: "clear", reason: "archive" });
|
||||
await this.archiveStore.archive(archiveInput);
|
||||
await this.runTreeExclusiveOperation(
|
||||
[{ sessionId: session.sessionId, session }],
|
||||
"Stop current session activity before archiving",
|
||||
async () => {
|
||||
const archiveInput = await this.archiveInputForSession(session);
|
||||
await this.closeActive(session.sessionId, { kind: "clear", reason: "archive" });
|
||||
await this.archiveStore.archive(archiveInput);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async archiveMany(refs: readonly SessionBulkMutationRef[]): Promise<SessionBulkArchiveResponse> {
|
||||
@@ -1499,23 +1653,42 @@ export class PiSessionService implements SessionRouteService {
|
||||
}
|
||||
}
|
||||
|
||||
const readyInputs: ArchiveSessionInput[] = [];
|
||||
const readyPlanItems: { input: ArchiveSessionInput; active?: ActiveSession<PiSessionRuntime> }[] = [];
|
||||
for (const item of planItems) {
|
||||
try {
|
||||
await this.closeActive(item.input.sessionId, { kind: "clear", reason: "archive" });
|
||||
readyInputs.push(item.input);
|
||||
} catch (error: unknown) {
|
||||
failures.push({ sessionId: item.input.sessionId, error: errorMessage(error) });
|
||||
const active = this.activeForLookup({ id: item.input.sessionId, cwd: item.input.cwd });
|
||||
if (active !== undefined && this.hasActiveWork(active.runtime.session)) {
|
||||
failures.push({ sessionId: item.input.sessionId, error: "Stop current session activity before archiving" });
|
||||
continue;
|
||||
}
|
||||
readyPlanItems.push(active === undefined ? item : { ...item, active });
|
||||
}
|
||||
|
||||
const readyInputs: ArchiveSessionInput[] = [];
|
||||
const archivedSessionIds = [...alreadyArchivedSessionIds];
|
||||
try {
|
||||
const archived = await this.archiveStoreArchiveMany(readyInputs);
|
||||
archivedSessionIds.push(...archived.map((record) => record.sessionId));
|
||||
} catch (error: unknown) {
|
||||
for (const input of readyInputs) failures.push({ sessionId: input.sessionId, error: errorMessage(error) });
|
||||
}
|
||||
await this.runTreeExclusiveOperation(
|
||||
readyPlanItems.map(({ input, active }) => ({
|
||||
sessionId: input.sessionId,
|
||||
...(active === undefined ? {} : { session: active.runtime.session, runtime: active.runtime }),
|
||||
})),
|
||||
"Stop current session activity before archiving",
|
||||
async () => {
|
||||
for (const item of readyPlanItems) {
|
||||
try {
|
||||
await this.closeActive(item.input.sessionId, { kind: "clear", reason: "archive" });
|
||||
readyInputs.push(item.input);
|
||||
} catch (error: unknown) {
|
||||
failures.push({ sessionId: item.input.sessionId, error: errorMessage(error) });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const archived = await this.archiveStoreArchiveMany(readyInputs);
|
||||
archivedSessionIds.push(...archived.map((record) => record.sessionId));
|
||||
} catch (error: unknown) {
|
||||
for (const input of readyInputs) failures.push({ sessionId: input.sessionId, error: errorMessage(error) });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
archived: true,
|
||||
@@ -1533,12 +1706,21 @@ export class PiSessionService implements SessionRouteService {
|
||||
const busy = plan.targets.map((target) => target.activeSession).find((target) => target !== undefined && this.hasActiveWork(target));
|
||||
if (busy !== undefined) throw new Error(`Stop current session activity before archiving ${sessionDisplayName(busy)}`);
|
||||
|
||||
for (const target of plan.targets) {
|
||||
if (target.archived) this.publishNotificationMutations(this.notificationStore.clearSession(target.id, "archive"));
|
||||
}
|
||||
const archiveInputs = plan.unarchivedTargets.map((target) => archiveInputFromCandidate(target));
|
||||
for (const input of archiveInputs) await this.closeActive(input.sessionId, { kind: "clear", reason: "archive" });
|
||||
await this.archiveStoreArchiveMany(archiveInputs);
|
||||
await this.runTreeExclusiveOperation(
|
||||
plan.unarchivedTargets.map((target) => ({
|
||||
sessionId: target.id,
|
||||
...(target.activeSession === undefined ? {} : { session: target.activeSession }),
|
||||
})),
|
||||
`Stop current session activity before archiving ${sessionDisplayName(session)}`,
|
||||
async () => {
|
||||
for (const target of plan.targets) {
|
||||
if (target.archived) this.publishNotificationMutations(this.notificationStore.clearSession(target.id, "archive"));
|
||||
}
|
||||
for (const input of archiveInputs) await this.closeActive(input.sessionId, { kind: "clear", reason: "archive" });
|
||||
await this.archiveStoreArchiveMany(archiveInputs);
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
archived: true,
|
||||
@@ -1625,28 +1807,35 @@ export class PiSessionService implements SessionRouteService {
|
||||
const session = await this.getOrOpen(ref);
|
||||
if (this.hasActiveWork(session)) throw new Error("Stop current session activity before reloading");
|
||||
|
||||
const priorGeneration = this.notificationGenerationBySession.get(session);
|
||||
const { sessionId, cwd } = notificationIdentityForSession(session);
|
||||
let candidateGeneration: SessionNotificationGeneration | undefined;
|
||||
try {
|
||||
await this.closeActive(
|
||||
sessionId,
|
||||
priorGeneration === undefined ? CLEAR_RUNTIME_NOTIFICATIONS : DEFER_RUNTIME_NOTIFICATIONS,
|
||||
);
|
||||
candidateGeneration = priorGeneration === undefined
|
||||
? undefined
|
||||
: this.notificationStore.beginReplacement(priorGeneration, { sessionId, cwd });
|
||||
const reopened = await this.getActive(ref, candidateGeneration === undefined ? {} : { notificationGeneration: candidateGeneration });
|
||||
if (candidateGeneration !== undefined) {
|
||||
this.publishNotificationMutations(this.notificationStore.commitReplacement(candidateGeneration));
|
||||
}
|
||||
this.publishStatus(reopened.runtime.session);
|
||||
} catch (error: unknown) {
|
||||
if (candidateGeneration !== undefined) {
|
||||
this.publishNotificationMutations(this.notificationStore.abortReplacement(candidateGeneration));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const reopenedSession = await this.runTreeExclusiveOperation(
|
||||
[{ sessionId: session.sessionId, session }],
|
||||
"Stop current session activity before reloading",
|
||||
async () => {
|
||||
const priorGeneration = this.notificationGenerationBySession.get(session);
|
||||
const { sessionId, cwd } = notificationIdentityForSession(session);
|
||||
let candidateGeneration: SessionNotificationGeneration | undefined;
|
||||
try {
|
||||
await this.closeActive(
|
||||
sessionId,
|
||||
priorGeneration === undefined ? CLEAR_RUNTIME_NOTIFICATIONS : DEFER_RUNTIME_NOTIFICATIONS,
|
||||
);
|
||||
candidateGeneration = priorGeneration === undefined
|
||||
? undefined
|
||||
: this.notificationStore.beginReplacement(priorGeneration, { sessionId, cwd });
|
||||
const reopened = await this.getActive(ref, candidateGeneration === undefined ? {} : { notificationGeneration: candidateGeneration });
|
||||
if (candidateGeneration !== undefined) {
|
||||
this.publishNotificationMutations(this.notificationStore.commitReplacement(candidateGeneration));
|
||||
}
|
||||
return reopened.runtime.session;
|
||||
} catch (error: unknown) {
|
||||
if (candidateGeneration !== undefined) {
|
||||
this.publishNotificationMutations(this.notificationStore.abortReplacement(candidateGeneration));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
);
|
||||
this.publishStatus(reopenedSession);
|
||||
}
|
||||
|
||||
async detachParent(ref: PiSessionLookup): Promise<void> {
|
||||
@@ -1680,9 +1869,16 @@ export class PiSessionService implements SessionRouteService {
|
||||
const sessionId = active.runtime.session.sessionId;
|
||||
this.clearCompactionPromptQueue(sessionId);
|
||||
clearSessionQueue(active.runtime.session);
|
||||
await active.runtime.session.abort();
|
||||
this.publishActivity(active.runtime.session, "stopped", "idle");
|
||||
this.publishStatus(active.runtime.session);
|
||||
try {
|
||||
await this.abortSessionOperations(active.runtime.session);
|
||||
this.publishActivity(active.runtime.session, "stopped", "idle");
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.publishActivity(active.runtime.session, "stop failed", "error", message);
|
||||
throw error;
|
||||
} finally {
|
||||
this.publishStatus(active.runtime.session);
|
||||
}
|
||||
}
|
||||
|
||||
async stop(ref: PiSessionLookup): Promise<void> {
|
||||
@@ -1906,12 +2102,33 @@ export class PiSessionService implements SessionRouteService {
|
||||
active.unsubscribe();
|
||||
active.runtime.setRebindSession(undefined);
|
||||
try {
|
||||
await active.runtime.session.abort();
|
||||
await this.abortSessionOperations(active.runtime.session);
|
||||
} finally {
|
||||
await active.runtime.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private async abortSessionOperations(session: PiAgentSession): Promise<void> {
|
||||
let branchSummaryAbortFailed = false;
|
||||
let branchSummaryAbortError: unknown;
|
||||
try {
|
||||
session.abortBranchSummary?.();
|
||||
} catch (error: unknown) {
|
||||
branchSummaryAbortFailed = true;
|
||||
branchSummaryAbortError = error;
|
||||
}
|
||||
|
||||
try {
|
||||
await session.abort();
|
||||
} catch (abortError: unknown) {
|
||||
if (branchSummaryAbortFailed) {
|
||||
throw new AggregateError([branchSummaryAbortError, abortError], "Failed to abort session operations", { cause: abortError });
|
||||
}
|
||||
throw abortError;
|
||||
}
|
||||
if (branchSummaryAbortFailed) throw branchSummaryAbortError;
|
||||
}
|
||||
|
||||
private async assertWritable(ref: PiSessionLookup): Promise<void> {
|
||||
if (await this.getArchived(ref) !== undefined) throw new Error("Archived sessions are read-only. Restore the session to continue.");
|
||||
}
|
||||
@@ -1979,6 +2196,10 @@ export class PiSessionService implements SessionRouteService {
|
||||
return archived;
|
||||
}
|
||||
|
||||
private isCurrentActiveSession(session: PiAgentSession): boolean {
|
||||
return this.active.get(session.sessionId)?.runtime.session === session;
|
||||
}
|
||||
|
||||
private activeForLookup(ref: PiSessionLookup): ActiveSession<PiSessionRuntime> | undefined {
|
||||
const sessionId = sessionIdFromLookup(ref);
|
||||
const exact = this.active.get(sessionId);
|
||||
@@ -2260,10 +2481,37 @@ export class PiSessionService implements SessionRouteService {
|
||||
|
||||
private applyGeneratedSessionName(session: PiAgentSession, name: string | undefined): void {
|
||||
if (name === undefined || session.sessionName !== undefined) return;
|
||||
if (this.treeNavigations.has(session)) {
|
||||
this.deferredGeneratedSessionNames.set(session, name);
|
||||
return;
|
||||
}
|
||||
session.setSessionName(name);
|
||||
this.publishSessionName(session);
|
||||
}
|
||||
|
||||
private flushDeferredTreeNavigationWork(session: PiAgentSession): void {
|
||||
const generatedName = this.deferredGeneratedSessionNames.get(session);
|
||||
this.deferredGeneratedSessionNames.delete(session);
|
||||
if (generatedName !== undefined) {
|
||||
try {
|
||||
this.applyGeneratedSessionName(session, generatedName);
|
||||
} catch (error: unknown) {
|
||||
this.logger.info(
|
||||
{ sessionId: session.sessionId, error: error instanceof Error ? error.message : String(error) },
|
||||
"failed to apply deferred session name",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const notifications = this.deferredSubsessionNotifications.get(session) ?? [];
|
||||
this.deferredSubsessionNotifications.delete(session);
|
||||
for (const notification of notifications) {
|
||||
void this.deliverSubsessionNotification(session, notification).catch((error: unknown) => {
|
||||
this.logSubsessionNotificationFailure(notification.parentId, notification.childId, error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
applyAuthChange(change: AuthChange = {}): void {
|
||||
// ModelRuntime.login()/logout() refresh the shared runtime before AuthService
|
||||
// emits the change, so no refresh is needed here. Keeping this synchronous
|
||||
@@ -2329,6 +2577,8 @@ export class PiSessionService implements SessionRouteService {
|
||||
}
|
||||
|
||||
private activityLabelFromStatus(session: PiAgentSession): string {
|
||||
if (this.treeNavigations.has(session)) return "navigating session tree";
|
||||
if (this.isSessionEntryMutationActive(session)) return "updating session";
|
||||
if (session.isCompacting) return "compacting";
|
||||
if (session.isBashRunning) return "running bash";
|
||||
if (session.isStreaming) return "agent running";
|
||||
@@ -2337,7 +2587,85 @@ export class PiSessionService implements SessionRouteService {
|
||||
}
|
||||
|
||||
private hasActiveWork(session: PiAgentSession): boolean {
|
||||
return sessionHasActiveWork(session, this.compactionQueuedMessages(session.sessionId).length);
|
||||
return this.treeNavigations.has(session)
|
||||
|| this.isSessionEntryMutationActive(session)
|
||||
|| this.isTreeExclusiveOperationActive(session)
|
||||
|| sessionHasActiveWork(session, this.compactionQueuedMessages(session.sessionId).length);
|
||||
}
|
||||
|
||||
private async runTreeExclusiveOperation<T>(
|
||||
targets: readonly TreeExclusiveOperationTarget[],
|
||||
activeError: string,
|
||||
operation: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const sessionIds = new Set<string>();
|
||||
const runtimes = new Set<PiSessionRuntime>();
|
||||
for (const target of targets) {
|
||||
const runtime = target.runtime ?? (target.session === undefined ? undefined : this.activeRuntimeForSession(target.session));
|
||||
const session = target.session ?? runtime?.session;
|
||||
if (session !== undefined && this.hasActiveWork(session)) throw new Error(activeError);
|
||||
sessionIds.add(target.sessionId);
|
||||
if (runtime !== undefined) runtimes.add(runtime);
|
||||
}
|
||||
|
||||
for (const sessionId of sessionIds) {
|
||||
this.treeExclusiveSessionOperationCounts.set(sessionId, (this.treeExclusiveSessionOperationCounts.get(sessionId) ?? 0) + 1);
|
||||
}
|
||||
for (const runtime of runtimes) {
|
||||
this.treeExclusiveRuntimeOperationCounts.set(runtime, (this.treeExclusiveRuntimeOperationCounts.get(runtime) ?? 0) + 1);
|
||||
}
|
||||
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
for (const runtime of runtimes) decrementWeakCount(this.treeExclusiveRuntimeOperationCounts, runtime);
|
||||
for (const sessionId of sessionIds) decrementMapCount(this.treeExclusiveSessionOperationCounts, sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
private isTreeExclusiveSessionIdentityActive(sessionId: string): boolean {
|
||||
return (this.treeExclusiveSessionOperationCounts.get(sessionId) ?? 0) > 0;
|
||||
}
|
||||
|
||||
private isTreeExclusiveOperationActive(session: PiAgentSession): boolean {
|
||||
if (this.isTreeExclusiveSessionIdentityActive(session.sessionId)) return true;
|
||||
const runtime = this.activeRuntimeForSession(session);
|
||||
return runtime !== undefined && (this.treeExclusiveRuntimeOperationCounts.get(runtime) ?? 0) > 0;
|
||||
}
|
||||
|
||||
private activeRuntimeForSession(session: PiAgentSession): PiSessionRuntime | undefined {
|
||||
for (const active of new Set(this.active.values())) {
|
||||
if (active.runtime.session === session) return active.runtime;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private assertTreeNavigationInactive(session: PiAgentSession, action: string): void {
|
||||
if (this.treeNavigations.has(session)) throw new Error(`Cannot ${action} while session tree navigation is active`);
|
||||
}
|
||||
|
||||
private async runSessionEntryMutation<T>(session: PiAgentSession, action: string, operation: () => Promise<T>): Promise<T> {
|
||||
this.beginSessionEntryMutation(session, action);
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
this.endSessionEntryMutation(session);
|
||||
}
|
||||
}
|
||||
|
||||
private beginSessionEntryMutation(session: PiAgentSession, action: string): void {
|
||||
this.assertTreeNavigationInactive(session, action);
|
||||
this.sessionEntryMutationCounts.set(session, (this.sessionEntryMutationCounts.get(session) ?? 0) + 1);
|
||||
}
|
||||
|
||||
private endSessionEntryMutation(session: PiAgentSession): void {
|
||||
const remaining = (this.sessionEntryMutationCounts.get(session) ?? 1) - 1;
|
||||
if (remaining <= 0) this.sessionEntryMutationCounts.delete(session);
|
||||
else this.sessionEntryMutationCounts.set(session, remaining);
|
||||
}
|
||||
|
||||
private isSessionEntryMutationActive(session: PiAgentSession): boolean {
|
||||
return (this.sessionEntryMutationCounts.get(session) ?? 0) > 0;
|
||||
}
|
||||
|
||||
private publishActivityForEvent(session: PiAgentSession, event: unknown): void {
|
||||
|
||||
@@ -139,6 +139,71 @@ describe("SessionCommandService", () => {
|
||||
expect(reloadSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns an injected full tree snapshot for /tree without creating a select request", async () => {
|
||||
const active = activeSession();
|
||||
const tree = {
|
||||
nodes: [{ id: "root", parentId: null, kind: "user" as const, summary: "hello" }],
|
||||
activeLeafId: "root",
|
||||
activePathIds: ["root"],
|
||||
};
|
||||
const getSessionTree = vi.fn(() => tree);
|
||||
const service = new SessionCommandService(() => getActive(active), vi.fn(), eventPublisher(), { getSessionTree });
|
||||
|
||||
await expect(service.run("s1", "/tree")).resolves.toEqual({ type: "tree", tree });
|
||||
expect(getSessionTree).toHaveBeenCalledWith(active.runtime.session);
|
||||
expect(active.runtime.fork).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects /tree for active, empty, and unavailable runtimes", async () => {
|
||||
const active = activeSession();
|
||||
let externallyActive = true;
|
||||
const getSessionTree = vi.fn(() => ({ nodes: [], activeLeafId: null, activePathIds: [] }));
|
||||
const service = new SessionCommandService(() => getActive(active), vi.fn(), eventPublisher(), {
|
||||
getSessionTree,
|
||||
hasActiveWork: () => externallyActive,
|
||||
});
|
||||
|
||||
await expect(service.run("s1", "/tree")).resolves.toEqual({
|
||||
type: "unsupported",
|
||||
message: "Cannot open the session tree while the session is active. Stop current activity and try /tree again.",
|
||||
});
|
||||
expect(getSessionTree).not.toHaveBeenCalled();
|
||||
|
||||
externallyActive = false;
|
||||
await expect(service.run("s1", "/tree")).resolves.toEqual({
|
||||
type: "unsupported",
|
||||
message: "Cannot navigate an empty session tree.",
|
||||
});
|
||||
|
||||
const unavailable = new SessionCommandService(() => getActive(active), vi.fn(), eventPublisher());
|
||||
await expect(unavailable.run("s1", "/tree")).resolves.toEqual({
|
||||
type: "unsupported",
|
||||
message: "Session tree navigation is not available with this Pi runtime.",
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks commands and pending command responses while a tree navigation owns the session gate", async () => {
|
||||
const active = activeSession();
|
||||
let navigationActive = false;
|
||||
const service = new SessionCommandService(() => getActive(active), vi.fn(), eventPublisher(), {
|
||||
isTreeNavigationActive: () => navigationActive,
|
||||
});
|
||||
const pendingFork = await service.run("s1", "/fork");
|
||||
if (pendingFork.type !== "select") throw new Error("Expected select result");
|
||||
navigationActive = true;
|
||||
|
||||
await expect(service.run("s1", "/name changed")).resolves.toEqual({
|
||||
type: "unsupported",
|
||||
message: "Cannot run commands while session tree navigation is active. Stop or finish the navigation first.",
|
||||
});
|
||||
await expect(service.respond("s1", pendingFork.requestId, "m1")).resolves.toEqual({
|
||||
type: "unsupported",
|
||||
message: "Cannot run commands while session tree navigation is active. Stop or finish the navigation first.",
|
||||
});
|
||||
expect(active.runtime.session.setSessionName).not.toHaveBeenCalled();
|
||||
expect(active.runtime.fork).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates fork selection requests from newest message to oldest and responds with selected entry", async () => {
|
||||
const active = activeSession({
|
||||
getUserMessagesForForking: vi.fn(() => [
|
||||
@@ -202,6 +267,43 @@ describe("SessionCommandService", () => {
|
||||
expect(cloned.setSessionName).toHaveBeenCalledWith("Build auth — Copy 2");
|
||||
});
|
||||
|
||||
it("does not start a clone if tree navigation takes the gate during async name lookup", async () => {
|
||||
const active = activeSession();
|
||||
const names = deferred<readonly string[]>();
|
||||
const listSessionNames = vi.fn(() => names.promise);
|
||||
let navigationActive = false;
|
||||
const service = new SessionCommandService(() => getActive(active), vi.fn(), eventPublisher(), {
|
||||
isTreeNavigationActive: () => navigationActive,
|
||||
}, { listSessionNames });
|
||||
|
||||
const clone = service.run("s1", "/clone");
|
||||
await vi.waitFor(() => { expect(listSessionNames).toHaveBeenCalledOnce(); });
|
||||
navigationActive = true;
|
||||
names.resolve([]);
|
||||
|
||||
await expect(clone).resolves.toEqual({
|
||||
type: "unsupported",
|
||||
message: "Cannot run commands while session tree navigation is active. Stop or finish the navigation first.",
|
||||
});
|
||||
expect(active.runtime.fork).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clones the leaf that is current after asynchronous name lookup", async () => {
|
||||
let leafId = "leaf-before-navigation";
|
||||
const active = activeSession({ sessionManager: { getLeafId: () => leafId } });
|
||||
const names = deferred<readonly string[]>();
|
||||
const listSessionNames = vi.fn(() => names.promise);
|
||||
const service = new SessionCommandService(() => getActive(active), vi.fn(), eventPublisher(), {}, { listSessionNames });
|
||||
|
||||
const clone = service.run("s1", "/clone");
|
||||
await vi.waitFor(() => { expect(listSessionNames).toHaveBeenCalledOnce(); });
|
||||
leafId = "leaf-after-navigation";
|
||||
names.resolve([]);
|
||||
|
||||
await expect(clone).resolves.toMatchObject({ type: "done", message: "Session cloned" });
|
||||
expect(active.runtime.fork).toHaveBeenCalledWith("leaf-after-navigation", { position: "at" });
|
||||
});
|
||||
|
||||
it("rejects fork and clone while the session has active work", async () => {
|
||||
const active = activeSession({ isStreaming: true });
|
||||
const service = new SessionCommandService(() => getActive(active), vi.fn(), eventPublisher());
|
||||
@@ -232,3 +334,11 @@ describe("SessionCommandService", () => {
|
||||
expect(active.runtime.fork).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||
const promise = new Promise<T>((promiseResolve) => {
|
||||
resolve = promiseResolve;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import crypto from "node:crypto";
|
||||
import type { SessionUiEvent } from "../../shared/apiTypes.js";
|
||||
import type { ClientCommandResult, ClientSession } from "../types.js";
|
||||
import type { ClientCommandResult, ClientSession, ClientSessionTreeSnapshot } from "../types.js";
|
||||
import { isBuiltinCommand } from "./builtinCommands.js";
|
||||
|
||||
export interface CommandSession {
|
||||
@@ -51,6 +51,10 @@ export interface SessionCommandLifecycle<TSession extends CommandSession = Comma
|
||||
onCompactionStart?: (session: TSession) => void;
|
||||
onCompactionEnd?: (session: TSession, result: "success" | "error", detail?: string) => void;
|
||||
reloadSession?: (session: TSession) => Promise<void>;
|
||||
getSessionTree?: (session: TSession) => ClientSessionTreeSnapshot | undefined;
|
||||
hasActiveWork?: (session: TSession) => boolean;
|
||||
isTreeNavigationActive?: (session: TSession) => boolean;
|
||||
runSessionReplacement?: <T>(session: TSession, operation: () => Promise<T>) => Promise<T>;
|
||||
}
|
||||
|
||||
export interface SessionCommandNaming {
|
||||
@@ -81,6 +85,8 @@ export class SessionCommandService<TSession extends CommandSession = CommandSess
|
||||
const [name = "", ...args] = text.trim().replace(/^\//, "").split(/\s+/);
|
||||
const rest = args.join(" ").trim();
|
||||
|
||||
if (this.lifecycle.isTreeNavigationActive?.(session) === true) return treeNavigationActiveUnsupported();
|
||||
|
||||
if (!isBuiltinCommand(name)) {
|
||||
if (this.isRuntimeCommand(session, name)) {
|
||||
// The command is forwarded to the agent, which expands it (e.g. /skill:*
|
||||
@@ -99,6 +105,7 @@ export class SessionCommandService<TSession extends CommandSession = CommandSess
|
||||
if (name === "reload") return this.reload(session);
|
||||
if (name === "clone") return this.clone(active);
|
||||
if (name === "fork") return this.fork(active);
|
||||
if (name === "tree") return this.tree(session);
|
||||
|
||||
return { type: "unsupported", message: `/${name} is not implemented in the web UI yet` };
|
||||
}
|
||||
@@ -109,11 +116,17 @@ export class SessionCommandService<TSession extends CommandSession = CommandSess
|
||||
this.pendingSelects.delete(requestId);
|
||||
|
||||
const active = await this.getActive(sessionId);
|
||||
if (sessionHasActiveWork(active.runtime.session)) return forkActiveUnsupported("fork");
|
||||
if (this.lifecycle.isTreeNavigationActive?.(active.runtime.session) === true) return treeNavigationActiveUnsupported();
|
||||
if (this.hasActiveWork(active.runtime.session)) return forkActiveUnsupported("fork");
|
||||
const relatedName = await this.nextRelatedSessionName(active, "fork");
|
||||
const result = await active.runtime.fork(value);
|
||||
if (this.lifecycle.isTreeNavigationActive?.(active.runtime.session) === true) return treeNavigationActiveUnsupported();
|
||||
if (this.hasActiveWork(active.runtime.session)) return forkActiveUnsupported("fork");
|
||||
const result = await this.runSessionReplacement(active.runtime, async () => {
|
||||
const forkResult = await active.runtime.fork(value);
|
||||
if (!forkResult.cancelled) this.tryNameRelatedSession(active.runtime.session, relatedName);
|
||||
return forkResult;
|
||||
});
|
||||
if (result.cancelled) return { type: "done", message: "Fork cancelled" };
|
||||
this.tryNameRelatedSession(active.runtime.session, relatedName);
|
||||
return { type: "done", message: "Session forked", session: clientSessionFromRuntime(active.runtime), ...promptDraft(result.selectedText) };
|
||||
}
|
||||
|
||||
@@ -145,7 +158,7 @@ export class SessionCommandService<TSession extends CommandSession = CommandSess
|
||||
}
|
||||
|
||||
private async reload(session: TSession): Promise<ClientCommandResult> {
|
||||
if (sessionHasActiveWork(session)) return { type: "unsupported", message: "Cannot reload while the session is active. Stop current activity before reloading." };
|
||||
if (this.hasActiveWork(session)) return { type: "unsupported", message: "Cannot reload while the session is active. Stop current activity before reloading." };
|
||||
if (this.lifecycle.reloadSession === undefined) return { type: "unsupported", message: "/reload is not available for this session runtime." };
|
||||
|
||||
try {
|
||||
@@ -158,18 +171,27 @@ export class SessionCommandService<TSession extends CommandSession = CommandSess
|
||||
}
|
||||
|
||||
private async clone(active: CommandActiveSession<TSession>): Promise<ClientCommandResult> {
|
||||
if (sessionHasActiveWork(active.runtime.session)) return forkActiveUnsupported("clone");
|
||||
if (this.hasActiveWork(active.runtime.session)) return forkActiveUnsupported("clone");
|
||||
const initialLeafId = active.runtime.session.sessionManager.getLeafId();
|
||||
if (initialLeafId === null || initialLeafId === "") return { type: "unsupported", message: "Cannot clone: no current session entry" };
|
||||
const relatedName = await this.nextRelatedSessionName(active, "copy");
|
||||
if (this.lifecycle.isTreeNavigationActive?.(active.runtime.session) === true) return treeNavigationActiveUnsupported();
|
||||
if (this.hasActiveWork(active.runtime.session)) return forkActiveUnsupported("clone");
|
||||
// The active leaf may have changed while related-session names were loaded.
|
||||
// Clone the position that is current when the serialized replacement begins.
|
||||
const leafId = active.runtime.session.sessionManager.getLeafId();
|
||||
if (leafId === null || leafId === "") return { type: "unsupported", message: "Cannot clone: no current session entry" };
|
||||
const relatedName = await this.nextRelatedSessionName(active, "copy");
|
||||
const result = await active.runtime.fork(leafId, { position: "at" });
|
||||
const result = await this.runSessionReplacement(active.runtime, async () => {
|
||||
const cloneResult = await active.runtime.fork(leafId, { position: "at" });
|
||||
if (!cloneResult.cancelled) this.tryNameRelatedSession(active.runtime.session, relatedName);
|
||||
return cloneResult;
|
||||
});
|
||||
if (result.cancelled) return { type: "done", message: "Clone cancelled" };
|
||||
this.tryNameRelatedSession(active.runtime.session, relatedName);
|
||||
return { type: "done", message: "Session cloned", session: clientSessionFromRuntime(active.runtime) };
|
||||
}
|
||||
|
||||
private fork(active: CommandActiveSession<TSession>): ClientCommandResult {
|
||||
if (sessionHasActiveWork(active.runtime.session)) return forkActiveUnsupported("fork");
|
||||
if (this.hasActiveWork(active.runtime.session)) return forkActiveUnsupported("fork");
|
||||
const messages = active.runtime.session.getUserMessagesForForking();
|
||||
if (!messages.length) return { type: "unsupported", message: "No user messages to fork from" };
|
||||
const requestId = crypto.randomUUID();
|
||||
@@ -182,6 +204,32 @@ export class SessionCommandService<TSession extends CommandSession = CommandSess
|
||||
};
|
||||
}
|
||||
|
||||
private tree(session: TSession): ClientCommandResult {
|
||||
if (this.hasActiveWork(session)) {
|
||||
return { type: "unsupported", message: "Cannot open the session tree while the session is active. Stop current activity and try /tree again." };
|
||||
}
|
||||
if (this.lifecycle.getSessionTree === undefined) return treeUnavailableUnsupported();
|
||||
|
||||
try {
|
||||
const tree = this.lifecycle.getSessionTree(session);
|
||||
if (tree === undefined) return treeUnavailableUnsupported();
|
||||
if (tree.nodes.length === 0) return { type: "unsupported", message: "Cannot navigate an empty session tree." };
|
||||
return { type: "tree", tree };
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return { type: "unsupported", message: `Unable to open the session tree: ${message}` };
|
||||
}
|
||||
}
|
||||
|
||||
private hasActiveWork(session: TSession): boolean {
|
||||
return sessionHasActiveWork(session) || this.lifecycle.hasActiveWork?.(session) === true;
|
||||
}
|
||||
|
||||
private runSessionReplacement<T>(runtime: CommandRuntime<TSession>, operation: () => Promise<T>): Promise<T> {
|
||||
const runReplacement = this.lifecycle.runSessionReplacement;
|
||||
return runReplacement === undefined ? operation() : runReplacement(runtime.session, operation);
|
||||
}
|
||||
|
||||
private async nextRelatedSessionName(active: CommandActiveSession<TSession>, kind: RelatedSessionKind): Promise<string> {
|
||||
const sourceTitle = relatedSessionSourceTitle(active.runtime.session);
|
||||
const sourceName = normalizedName(active.runtime.session.sessionName);
|
||||
@@ -291,6 +339,14 @@ function forkActiveUnsupported(command: "fork" | "clone"): ClientCommandResult {
|
||||
return { type: "unsupported", message: `Cannot ${command} while the session is active. Stop current activity before ${command === "fork" ? "forking" : "cloning"}.` };
|
||||
}
|
||||
|
||||
function treeUnavailableUnsupported(): ClientCommandResult {
|
||||
return { type: "unsupported", message: "Session tree navigation is not available with this Pi runtime." };
|
||||
}
|
||||
|
||||
function treeNavigationActiveUnsupported(): ClientCommandResult {
|
||||
return { type: "unsupported", message: "Cannot run commands while session tree navigation is active. Stop or finish the navigation first." };
|
||||
}
|
||||
|
||||
function promptDraft(text: string | undefined): Partial<Pick<Extract<ClientCommandResult, { type: "done" }>, "promptDraft">> {
|
||||
return text === undefined ? {} : { promptDraft: text };
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { resolve } from "node:path";
|
||||
import Fastify, { type FastifyInstance } from "fastify";
|
||||
import fastifyWebsocket from "@fastify/websocket";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH } from "../../shared/apiTypes.js";
|
||||
import type {
|
||||
MessagePage,
|
||||
SessionBulkArchiveResponse,
|
||||
@@ -15,6 +16,8 @@ import type {
|
||||
SessionRef,
|
||||
SessionStatus,
|
||||
SessionStreamSnapshot,
|
||||
SessionTreeNavigateRequest,
|
||||
SessionTreeNavigateResult,
|
||||
} from "../../shared/apiTypes.js";
|
||||
import { SessionEventHub } from "../realtime/sessionEventHub.js";
|
||||
import { PiSessionService, type PiSessionManagerGateway } from "./piSessionService.js";
|
||||
@@ -169,6 +172,89 @@ describe("session routes", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("strictly parses cwd-scoped session tree navigation requests", async () => {
|
||||
const routeApp = Fastify({ logger: false });
|
||||
await routeApp.register(fastifyWebsocket);
|
||||
const eventHub = new SessionEventHub();
|
||||
const routeService = new CapturingRouteSessionService();
|
||||
registerSessionRoutes(routeApp, routeService, eventHub);
|
||||
|
||||
try {
|
||||
const response = await routeApp.inject({
|
||||
method: "POST",
|
||||
url: "/sessions/session-1/tree/navigate",
|
||||
payload: {
|
||||
cwd: "/repo/./",
|
||||
targetId: "entry-2",
|
||||
expectedLeafId: null,
|
||||
summary: { mode: "custom", instructions: " focus on tests " },
|
||||
},
|
||||
});
|
||||
|
||||
const withoutSummary = await routeApp.inject({
|
||||
method: "POST",
|
||||
url: "/sessions/session-1/tree/navigate",
|
||||
payload: { cwd: "/repo", targetId: "entry-1", expectedLeafId: "leaf-1", summary: { mode: "none" } },
|
||||
});
|
||||
const withDefaultSummary = await routeApp.inject({
|
||||
method: "POST",
|
||||
url: "/sessions/session-1/tree/navigate",
|
||||
payload: { cwd: "/repo", targetId: "entry-3", expectedLeafId: "leaf-2", summary: { mode: "default" } },
|
||||
});
|
||||
|
||||
expect([response.statusCode, withoutSummary.statusCode, withDefaultSummary.statusCode]).toEqual([200, 200, 200]);
|
||||
expect(response.json()).toEqual({ cancelled: false, editorText: "edit this" });
|
||||
expect(routeService.navigateTreeCalls).toEqual([
|
||||
{
|
||||
lookup: { id: "session-1", cwd: resolve("/repo") },
|
||||
request: { targetId: "entry-2", expectedLeafId: null, summary: { mode: "custom", instructions: "focus on tests" } },
|
||||
},
|
||||
{
|
||||
lookup: { id: "session-1", cwd: resolve("/repo") },
|
||||
request: { targetId: "entry-1", expectedLeafId: "leaf-1", summary: { mode: "none" } },
|
||||
},
|
||||
{
|
||||
lookup: { id: "session-1", cwd: resolve("/repo") },
|
||||
request: { targetId: "entry-3", expectedLeafId: "leaf-2", summary: { mode: "default" } },
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
await routeService.dispose();
|
||||
await routeApp.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects malformed session tree navigation unions before calling the service", async () => {
|
||||
const routeApp = Fastify({ logger: false });
|
||||
await routeApp.register(fastifyWebsocket);
|
||||
const eventHub = new SessionEventHub();
|
||||
const routeService = new CapturingRouteSessionService();
|
||||
registerSessionRoutes(routeApp, routeService, eventHub);
|
||||
const base = { targetId: "entry-2", expectedLeafId: "leaf-1", summary: { mode: "none" } };
|
||||
const malformed: Record<string, unknown>[] = [
|
||||
{ targetId: "entry-2", summary: { mode: "none" } },
|
||||
{ ...base, expectedLeafId: 1 },
|
||||
{ ...base, summary: { mode: "future" } },
|
||||
{ ...base, summary: { mode: "none", instructions: "not allowed" } },
|
||||
{ ...base, summary: { mode: "default", instructions: "not allowed" } },
|
||||
{ ...base, summary: { mode: "custom" } },
|
||||
{ ...base, summary: { mode: "custom", instructions: " " } },
|
||||
{ ...base, summary: { mode: "custom", instructions: "x".repeat(SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH + 1) } },
|
||||
{ ...base, summary: { mode: "custom", instructions: "focus", extra: true } },
|
||||
];
|
||||
|
||||
try {
|
||||
for (const payload of malformed) {
|
||||
const response = await routeApp.inject({ method: "POST", url: "/sessions/session-1/tree/navigate", payload });
|
||||
expect(response.statusCode).toBe(400);
|
||||
}
|
||||
expect(routeService.navigateTreeCalls).toEqual([]);
|
||||
} finally {
|
||||
await routeService.dispose();
|
||||
await routeApp.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects prompt payloads that omit text without opening a session", async () => {
|
||||
const response = await app.inject({ method: "POST", url: "/sessions/session-1/prompt", payload: { body: "Build the thing" } });
|
||||
|
||||
@@ -534,6 +620,7 @@ class CapturingRouteSessionService implements SessionRouteService {
|
||||
readonly cleanupCalls: NormalizedSessionCleanupRequest[] = [];
|
||||
readonly bulkArchiveCalls: SessionBulkMutationRef[][] = [];
|
||||
readonly bulkDeleteCalls: SessionBulkMutationRef[][] = [];
|
||||
readonly navigateTreeCalls: { lookup: SessionRouteLookup; request: SessionTreeNavigateRequest }[] = [];
|
||||
reloadError: Error | undefined;
|
||||
clearQueueError: Error | undefined;
|
||||
|
||||
@@ -667,6 +754,10 @@ class CapturingRouteSessionService implements SessionRouteService {
|
||||
shell(): never { throw unusedRouteMethod("shell"); }
|
||||
runCommand(): never { throw unusedRouteMethod("runCommand"); }
|
||||
respondToCommand(): never { throw unusedRouteMethod("respondToCommand"); }
|
||||
navigateTree(lookup: SessionRouteLookup, request: SessionTreeNavigateRequest): Promise<SessionTreeNavigateResult> {
|
||||
this.navigateTreeCalls.push({ lookup, request });
|
||||
return Promise.resolve({ cancelled: false, editorText: "edit this" });
|
||||
}
|
||||
abort(): never { throw unusedRouteMethod("abort"); }
|
||||
stop(): never { throw unusedRouteMethod("stop"); }
|
||||
archive(): never { throw unusedRouteMethod("archive"); }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import type { SessionBulkMutationRequest, SessionBulkMutationRef, SessionCleanupRequest } from "../../shared/apiTypes.js";
|
||||
import { SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH, type SessionBulkMutationRequest, type SessionBulkMutationRef, type SessionCleanupRequest, type SessionTreeNavigateRequest, type SessionTreeSummaryChoice } from "../../shared/apiTypes.js";
|
||||
import { projectBrowserMessageResponse } from "../browserMessageProjection.js";
|
||||
import { normalizeRequestCwd } from "../workingDirectory.js";
|
||||
import type { SessionEventHub } from "../realtime/sessionEventHub.js";
|
||||
@@ -286,6 +286,15 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: SessionRou
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Params: { sessionId: string }; Body: unknown }>(`${prefix}/sessions/:sessionId/tree/navigate`, async (request, reply) => {
|
||||
try {
|
||||
const body = requireRecord(request.body);
|
||||
return await sessions.navigateTree(sessionLookupFromBody(request.params.sessionId, body), sessionTreeNavigateRequestFromBody(body));
|
||||
} catch (error) {
|
||||
return reply.code(mutationErrorStatus(error)).send({ error: errorMessage(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown } | undefined }>(`${prefix}/sessions/:sessionId/abort`, async (request, reply) => {
|
||||
try {
|
||||
await sessions.abort(sessionLookupFromBody(request.params.sessionId, optionalRecord(request.body)));
|
||||
@@ -423,6 +432,38 @@ function sessionLookupFromCwd(id: string, cwd: string | undefined): SessionLooku
|
||||
return cwd === undefined || cwd === "" ? id : { id, cwd: normalizeRequestCwd(cwd) };
|
||||
}
|
||||
|
||||
function sessionTreeNavigateRequestFromBody(body: Record<string, unknown>): SessionTreeNavigateRequest {
|
||||
const targetId = requireNonEmptyString(body, "targetId");
|
||||
const expectedLeafId = requireNullableString(body, "expectedLeafId");
|
||||
return { targetId, expectedLeafId, summary: sessionTreeSummaryChoice(body["summary"]) };
|
||||
}
|
||||
|
||||
function sessionTreeSummaryChoice(value: unknown): SessionTreeSummaryChoice {
|
||||
const summary = requireRecord(value);
|
||||
const mode = requireString(summary, "mode");
|
||||
if (mode === "none" || mode === "default") {
|
||||
if (Object.hasOwn(summary, "instructions")) throw new Error(`instructions field is not valid for ${mode} summary mode`);
|
||||
requireExactFields(summary, ["mode"], "summary");
|
||||
return { mode };
|
||||
}
|
||||
if (mode === "custom") {
|
||||
requireExactFields(summary, ["mode", "instructions"], "summary");
|
||||
const instructions = requireString(summary, "instructions");
|
||||
if (instructions.trim() === "") throw new Error("instructions field must not be blank");
|
||||
if (instructions.length > SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH) {
|
||||
throw new Error(`instructions field must be at most ${String(SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH)} characters`);
|
||||
}
|
||||
return { mode, instructions: instructions.trim() };
|
||||
}
|
||||
throw new Error("summary mode is invalid");
|
||||
}
|
||||
|
||||
function requireExactFields(record: Record<string, unknown>, fields: readonly string[], label: string): void {
|
||||
const allowed = new Set(fields);
|
||||
const unexpected = Object.keys(record).find((field) => !allowed.has(field));
|
||||
if (unexpected !== undefined) throw new Error(`${label} field contains unsupported property: ${unexpected}`);
|
||||
}
|
||||
|
||||
function optionalRecord(value: unknown): Record<string, unknown> {
|
||||
if (value === undefined || value === null) return {};
|
||||
return requireRecord(value);
|
||||
@@ -439,6 +480,21 @@ function requireString(record: Record<string, unknown>, field: string): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireNonEmptyString(record: Record<string, unknown>, field: string): string {
|
||||
const value = requireString(record, field);
|
||||
if (value.trim() === "") throw new Error(`${field} field must not be empty`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireNullableString(record: Record<string, unknown>, field: string): string | null {
|
||||
if (!Object.hasOwn(record, field)) throw new Error(`${field} field is required`);
|
||||
const value = record[field];
|
||||
if (value === null) return null;
|
||||
if (typeof value !== "string") throw new Error(`${field} field must be a string or null`);
|
||||
if (value.trim() === "") throw new Error(`${field} field must not be empty`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireNonEmptyBoundedString(value: unknown, field: string, maxLength: number): string {
|
||||
if (typeof value !== "string") throw new Error(`${field} field must be a string`);
|
||||
if (value === "") throw new Error(`${field} field must not be empty`);
|
||||
|
||||
@@ -19,6 +19,8 @@ import type {
|
||||
ClientSessionModel,
|
||||
ClientSessionRef,
|
||||
ClientSessionStatus,
|
||||
ClientSessionTreeNavigateRequest,
|
||||
ClientSessionTreeNavigateResult,
|
||||
ClientThinkingLevel,
|
||||
SessionStreamSnapshot,
|
||||
} from "../types.js";
|
||||
@@ -62,6 +64,7 @@ export interface SessionRouteService {
|
||||
shell(ref: SessionRouteLookup, text: string): Promise<void>;
|
||||
runCommand(ref: SessionRouteLookup, text: string): Promise<ClientCommandResult>;
|
||||
respondToCommand(ref: SessionRouteLookup, requestId: string, value: string): Promise<ClientCommandResult>;
|
||||
navigateTree(ref: SessionRouteLookup, request: ClientSessionTreeNavigateRequest): Promise<ClientSessionTreeNavigateResult>;
|
||||
abort(ref: SessionRouteLookup): Promise<void>;
|
||||
stop(ref: SessionRouteLookup): void | Promise<void>;
|
||||
archive(ref: SessionRouteLookup): Promise<void>;
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { projectSessionTree, type ProjectableSessionTreeNode } from "./sessionTreeProjection.js";
|
||||
|
||||
function treeNode(
|
||||
entry: Record<string, unknown>,
|
||||
children: ProjectableSessionTreeNode[] = [],
|
||||
label?: unknown,
|
||||
): ProjectableSessionTreeNode {
|
||||
return { entry, children, ...(label === undefined ? {} : { label }) };
|
||||
}
|
||||
|
||||
function entry(id: string, parentId: string | null, type: string, patch: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return { id, parentId, type, timestamp: "2026-01-01T00:00:00.000Z", ...patch };
|
||||
}
|
||||
|
||||
describe("projectSessionTree", () => {
|
||||
it("preserves complete pre-order structure while strictly excluding raw private fields", () => {
|
||||
const rootChildren: ProjectableSessionTreeNode[] = [];
|
||||
const assistantChildren: ProjectableSessionTreeNode[] = [];
|
||||
const root = treeNode(entry("root", null, "message", {
|
||||
message: {
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "hello\nworld", textSignature: "secret-text-signature" },
|
||||
{ type: "image", mimeType: "image/png", data: "secret-image-base64" },
|
||||
],
|
||||
providerState: "secret-user-provider-state",
|
||||
},
|
||||
}), rootChildren, " Important\nroot ");
|
||||
const assistant = treeNode(entry("assistant", "root", "message", {
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "secret chain of thought", thinkingSignature: "secret-thinking-signature" },
|
||||
{ type: "toolCall", name: "read", arguments: { path: "secret-tool-argument", thinkingSignature: "secret-argument-signature" }, thoughtSignature: "secret-thought-signature" },
|
||||
],
|
||||
usage: { privateUsage: "secret-usage" },
|
||||
responseId: "secret-response-id",
|
||||
stopReason: "toolUse",
|
||||
},
|
||||
}), assistantChildren);
|
||||
const toolResult = treeNode(entry("tool", "assistant", "message", {
|
||||
message: {
|
||||
role: "toolResult",
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: "visible tool text" }],
|
||||
details: { token: "secret-tool-details" },
|
||||
unknownProviderField: "secret-tool-provider-field",
|
||||
isError: false,
|
||||
},
|
||||
}));
|
||||
const custom = treeNode(entry("custom", "root", "custom", {
|
||||
customType: "extension-state",
|
||||
data: { apiKey: "secret-extension-data" },
|
||||
}));
|
||||
const orphan = treeNode(entry("orphan", "missing-parent", "future_entry", {
|
||||
futurePayload: "secret-unknown-payload",
|
||||
}));
|
||||
rootChildren.push(assistant, custom);
|
||||
assistantChildren.push(toolResult);
|
||||
|
||||
const snapshot = projectSessionTree([root, orphan], "tool");
|
||||
const serialized = JSON.stringify(snapshot);
|
||||
|
||||
expect(snapshot.nodes.map((node) => node.id)).toEqual(["root", "assistant", "tool", "custom", "orphan"]);
|
||||
expect(snapshot.activeLeafId).toBe("tool");
|
||||
expect(snapshot.activePathIds).toEqual(["root", "assistant", "tool"]);
|
||||
expect(snapshot.nodes[0]).toMatchObject({ kind: "user", summary: "hello world [image]", label: "Important root" });
|
||||
expect(snapshot.nodes[1]).toMatchObject({ kind: "assistant", summary: "Tool call: read" });
|
||||
expect(snapshot.nodes[2]).toMatchObject({ kind: "tool-result", summary: "Tool result (read): visible tool text" });
|
||||
expect(snapshot.nodes[3]).toMatchObject({ kind: "custom", summary: "Custom entry: extension-state" });
|
||||
expect(snapshot.nodes[4]).toMatchObject({ kind: "other", summary: "Entry: future_entry" });
|
||||
|
||||
for (const secret of [
|
||||
"secret-text-signature",
|
||||
"secret-image-base64",
|
||||
"secret-user-provider-state",
|
||||
"secret chain of thought",
|
||||
"secret-thinking-signature",
|
||||
"secret-tool-argument",
|
||||
"secret-argument-signature",
|
||||
"secret-thought-signature",
|
||||
"secret-usage",
|
||||
"secret-response-id",
|
||||
"secret-tool-details",
|
||||
"secret-tool-provider-field",
|
||||
"secret-extension-data",
|
||||
"secret-unknown-payload",
|
||||
]) expect(serialized).not.toContain(secret);
|
||||
for (const privateKey of ["thinkingSignature", "thoughtSignature", "arguments", "usage", "details", "data", "mimeType"]) {
|
||||
expect(serialized).not.toContain(privateKey);
|
||||
}
|
||||
});
|
||||
|
||||
it("projects each supported entry kind from only its safe display fields", () => {
|
||||
const roots = [
|
||||
treeNode(entry("assistant-error", null, "message", { message: { role: "assistant", content: [{ type: "thinking", thinking: "private" }], stopReason: "error", errorMessage: "private provider error" } })),
|
||||
treeNode(entry("tool-error", null, "message", { message: { role: "toolResult", toolName: "bash", content: [{ type: "image", data: "private-image", mimeType: "image/png" }], details: "private", isError: true } })),
|
||||
treeNode(entry("bash", null, "message", { message: { role: "bashExecution", command: "npm test", output: "private shell output", fullOutputPath: "/private/path" } })),
|
||||
treeNode(entry("custom-visible", null, "custom_message", { customType: "notice", content: "visible notice", display: true, details: "private" })),
|
||||
treeNode(entry("custom-hidden", null, "custom_message", { customType: "private-custom-type", content: "private hidden content", display: false })),
|
||||
treeNode(entry("compaction", null, "compaction", { summary: "compact summary", details: "private" })),
|
||||
treeNode(entry("branch", null, "branch_summary", { summary: "branch summary", details: "private" })),
|
||||
treeNode(entry("model", null, "model_change", { provider: "anthropic", modelId: "claude" })),
|
||||
treeNode(entry("thinking", null, "thinking_level_change", { thinkingLevel: "high" })),
|
||||
treeNode(entry("info", null, "session_info", { name: "Tree work" })),
|
||||
treeNode(entry("label", null, "label", { targetId: "private-target", label: "checkpoint" })),
|
||||
];
|
||||
|
||||
const snapshot = projectSessionTree(roots, null);
|
||||
const byId = new Map(snapshot.nodes.map((node) => [node.id, node]));
|
||||
|
||||
expect(byId.get("assistant-error")).toMatchObject({ kind: "assistant", summary: "Assistant error" });
|
||||
expect(byId.get("tool-error")).toMatchObject({ kind: "tool-result", summary: "Tool error (bash): [image]" });
|
||||
expect(byId.get("bash")).toMatchObject({ kind: "bash", summary: "Shell: npm test" });
|
||||
expect(byId.get("custom-visible")).toMatchObject({ kind: "custom-message", summary: "Custom message (notice): visible notice" });
|
||||
expect(byId.get("custom-hidden")).toMatchObject({ kind: "custom-message", summary: "Hidden custom message" });
|
||||
expect(byId.get("compaction")).toMatchObject({ kind: "compaction", summary: "compact summary" });
|
||||
expect(byId.get("branch")).toMatchObject({ kind: "branch-summary", summary: "branch summary" });
|
||||
expect(byId.get("model")).toMatchObject({ kind: "model-change", summary: "Model: anthropic/claude" });
|
||||
expect(byId.get("thinking")).toMatchObject({ kind: "thinking-level-change", summary: "Thinking level: high" });
|
||||
expect(byId.get("info")).toMatchObject({ kind: "session-info", summary: "Session name: Tree work" });
|
||||
expect(byId.get("label")).toMatchObject({ kind: "label", summary: "Label: checkpoint" });
|
||||
expect(JSON.stringify(snapshot)).not.toContain("private");
|
||||
});
|
||||
|
||||
it("rejects malformed SDK node wrappers and empty entry identities with clear boundary errors", () => {
|
||||
const malformedChild = {
|
||||
entry: entry("root", null, "custom", { customType: "root" }),
|
||||
children: [null],
|
||||
};
|
||||
const malformedChildren = {
|
||||
entry: entry("root", null, "custom", { customType: "root" }),
|
||||
children: "not-an-array",
|
||||
};
|
||||
|
||||
expect(() => { Reflect.apply(projectSessionTree, undefined, [[malformedChild], null]); }).toThrow("Pi returned a malformed session-tree node");
|
||||
expect(() => { Reflect.apply(projectSessionTree, undefined, [[malformedChildren], null]); }).toThrow("Pi returned a malformed session-tree node");
|
||||
expect(() => projectSessionTree([treeNode(entry("", null, "custom"))], null)).toThrow("Pi returned a malformed session-tree entry");
|
||||
expect(() => projectSessionTree([treeNode(entry(" ", null, "custom"))], null)).toThrow("Pi returned a malformed session-tree entry");
|
||||
expect(() => projectSessionTree([treeNode(entry("child", " ", "custom"))], null)).toThrow("Pi returned a malformed session-tree entry");
|
||||
expect(() => projectSessionTree([
|
||||
treeNode(entry("duplicate", null, "custom")),
|
||||
treeNode(entry("duplicate", null, "custom")),
|
||||
], null)).toThrow("Pi returned duplicate session-tree entry IDs");
|
||||
expect(() => projectSessionTree([treeNode(entry("root", null, "custom"))], "missing")).toThrow("Pi returned an invalid active session-tree leaf");
|
||||
});
|
||||
|
||||
it("keeps an existing active orphan as a one-node active path", () => {
|
||||
const snapshot = projectSessionTree([treeNode(entry("orphan", "missing-parent", "custom"))], "orphan");
|
||||
|
||||
expect(snapshot.activeLeafId).toBe("orphan");
|
||||
expect(snapshot.activePathIds).toEqual(["orphan"]);
|
||||
});
|
||||
|
||||
it("bounds summaries and traverses deep trees and malformed parent chains without recursion", () => {
|
||||
const depth = 5_000;
|
||||
let current = treeNode(entry(`node-${String(depth - 1)}`, `node-${String(depth - 2)}`, "message", {
|
||||
message: { role: "user", content: `${"word \n".repeat(200)}tail` },
|
||||
}));
|
||||
for (let index = depth - 2; index >= 0; index -= 1) {
|
||||
current = treeNode(entry(`node-${String(index)}`, index === 0 ? null : `node-${String(index - 1)}`, "message", {
|
||||
message: { role: "user", content: `message ${String(index)}` },
|
||||
}), [current]);
|
||||
}
|
||||
|
||||
const snapshot = projectSessionTree([current], `node-${String(depth - 1)}`);
|
||||
const leaf = snapshot.nodes.at(-1);
|
||||
|
||||
expect(snapshot.nodes).toHaveLength(depth);
|
||||
expect(snapshot.activePathIds).toHaveLength(depth);
|
||||
expect(leaf?.summary.length).toBeLessThanOrEqual(360);
|
||||
expect(leaf?.summary).not.toContain("\n");
|
||||
expect(leaf?.summary.endsWith("…")).toBe(true);
|
||||
|
||||
const cycleAChildren: ProjectableSessionTreeNode[] = [];
|
||||
const cycleBChildren: ProjectableSessionTreeNode[] = [];
|
||||
const cycleA = treeNode(entry("cycle-a", "cycle-b", "custom", { customType: "a" }), cycleAChildren);
|
||||
const cycleB = treeNode(entry("cycle-b", "cycle-a", "custom", { customType: "b" }), cycleBChildren);
|
||||
cycleAChildren.push(cycleB);
|
||||
cycleBChildren.push(cycleA);
|
||||
const cyclicSnapshot = projectSessionTree([cycleA], "cycle-a");
|
||||
expect(cyclicSnapshot.nodes.map((node) => node.id)).toEqual(["cycle-a", "cycle-b"]);
|
||||
expect(cyclicSnapshot.activePathIds).toEqual(["cycle-b", "cycle-a"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
import type { SessionTreeNode, SessionTreeNodeKind, SessionTreeSnapshot } from "../../shared/apiTypes.js";
|
||||
|
||||
const SUMMARY_MAX_LENGTH = 360;
|
||||
const SUMMARY_SOURCE_MAX_LENGTH = SUMMARY_MAX_LENGTH * 2;
|
||||
const LABEL_MAX_LENGTH = 160;
|
||||
const NAMED_FIELD_MAX_LENGTH = 80;
|
||||
const TIMESTAMP_MAX_LENGTH = 80;
|
||||
|
||||
/** Narrow structural view of the tree returned by Pi's SessionManager.getTree(). */
|
||||
export interface ProjectableSessionTreeNode {
|
||||
readonly entry: unknown;
|
||||
readonly children: readonly ProjectableSessionTreeNode[];
|
||||
readonly label?: unknown;
|
||||
}
|
||||
|
||||
interface ProjectableSessionEntry extends Record<string, unknown> {
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface EntryProjection {
|
||||
kind: SessionTreeNodeKind;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Project Pi's complete append-only session tree into the strict browser contract.
|
||||
* Only explicitly selected plain-text fields leave this boundary.
|
||||
*/
|
||||
export function projectSessionTree(
|
||||
roots: readonly ProjectableSessionTreeNode[],
|
||||
activeLeafId: string | null,
|
||||
): SessionTreeSnapshot {
|
||||
const nodes: SessionTreeNode[] = [];
|
||||
const projectedIds = new Set<string>();
|
||||
const stack = [...roots].reverse();
|
||||
const visitedNodes = new WeakSet<ProjectableSessionTreeNode>();
|
||||
|
||||
while (stack.length > 0) {
|
||||
const candidate = stack.pop();
|
||||
if (candidate === undefined) continue;
|
||||
if (!isProjectableSessionTreeNode(candidate)) throw new Error("Pi returned a malformed session-tree node");
|
||||
const node = candidate;
|
||||
if (visitedNodes.has(node)) continue;
|
||||
visitedNodes.add(node);
|
||||
|
||||
if (!isProjectableSessionEntry(node.entry)) {
|
||||
throw new Error("Pi returned a malformed session-tree entry");
|
||||
}
|
||||
if (projectedIds.has(node.entry.id)) throw new Error("Pi returned duplicate session-tree entry IDs");
|
||||
projectedIds.add(node.entry.id);
|
||||
nodes.push(projectNode(node, node.entry));
|
||||
|
||||
for (let index = node.children.length - 1; index >= 0; index -= 1) {
|
||||
const child = node.children[index];
|
||||
if (child !== undefined) stack.push(child);
|
||||
}
|
||||
}
|
||||
|
||||
if (activeLeafId !== null && (activeLeafId.trim() === "" || !projectedIds.has(activeLeafId))) {
|
||||
throw new Error("Pi returned an invalid active session-tree leaf");
|
||||
}
|
||||
|
||||
return {
|
||||
nodes,
|
||||
activeLeafId,
|
||||
activePathIds: buildActivePath(nodes, activeLeafId),
|
||||
};
|
||||
}
|
||||
|
||||
function projectNode(node: ProjectableSessionTreeNode, entry: ProjectableSessionEntry): SessionTreeNode {
|
||||
const projection = projectEntry(entry);
|
||||
const timestamp = optionalPlainText(entry["timestamp"], TIMESTAMP_MAX_LENGTH);
|
||||
const label = optionalPlainText(node.label, LABEL_MAX_LENGTH);
|
||||
return {
|
||||
id: entry.id,
|
||||
parentId: entry.parentId,
|
||||
kind: projection.kind,
|
||||
summary: projection.summary,
|
||||
...(timestamp === undefined ? {} : { timestamp }),
|
||||
...(label === undefined ? {} : { label }),
|
||||
};
|
||||
}
|
||||
|
||||
function projectEntry(entry: ProjectableSessionEntry): EntryProjection {
|
||||
switch (entry.type) {
|
||||
case "message":
|
||||
return projectMessage(entry["message"]);
|
||||
case "custom_message":
|
||||
return projectCustomMessage(entry);
|
||||
case "compaction":
|
||||
return { kind: "compaction", summary: summary(entry["summary"], "Compaction summary") };
|
||||
case "branch_summary":
|
||||
return { kind: "branch-summary", summary: summary(entry["summary"], "Branch summary") };
|
||||
case "model_change":
|
||||
return { kind: "model-change", summary: modelChangeSummary(entry) };
|
||||
case "thinking_level_change":
|
||||
return { kind: "thinking-level-change", summary: namedSummary("Thinking level", entry["thinkingLevel"], "Thinking level changed") };
|
||||
case "session_info":
|
||||
return { kind: "session-info", summary: namedSummary("Session name", entry["name"], "Session info changed") };
|
||||
case "label":
|
||||
return { kind: "label", summary: namedSummary("Label", entry["label"], "Label removed") };
|
||||
case "custom":
|
||||
return { kind: "custom", summary: namedSummary("Custom entry", entry["customType"], "Custom entry") };
|
||||
default:
|
||||
return { kind: "other", summary: namedSummary("Entry", entry.type, "Other session entry") };
|
||||
}
|
||||
}
|
||||
|
||||
function projectMessage(value: unknown): EntryProjection {
|
||||
if (!isRecord(value)) return { kind: "other", summary: "Message" };
|
||||
switch (value["role"]) {
|
||||
case "user":
|
||||
return { kind: "user", summary: summary(contentPreview(value["content"], true), "User message") };
|
||||
case "assistant":
|
||||
return { kind: "assistant", summary: assistantSummary(value) };
|
||||
case "toolResult":
|
||||
return { kind: "tool-result", summary: toolResultSummary(value) };
|
||||
case "bashExecution":
|
||||
return { kind: "bash", summary: namedSummary("Shell", value["command"], "Shell command") };
|
||||
case "custom":
|
||||
return projectCustomMessage(value);
|
||||
case "compactionSummary":
|
||||
return { kind: "compaction", summary: summary(value["summary"], "Compaction summary") };
|
||||
case "branchSummary":
|
||||
return { kind: "branch-summary", summary: summary(value["summary"], "Branch summary") };
|
||||
default:
|
||||
return { kind: "other", summary: "Message" };
|
||||
}
|
||||
}
|
||||
|
||||
function projectCustomMessage(value: Record<string, unknown>): EntryProjection {
|
||||
if (value["display"] !== true) return { kind: "custom-message", summary: "Hidden custom message" };
|
||||
const customType = optionalPlainText(value["customType"], NAMED_FIELD_MAX_LENGTH);
|
||||
const content = contentPreview(value["content"], true);
|
||||
const prefix = customType === undefined ? "Custom message" : `Custom message (${customType})`;
|
||||
return { kind: "custom-message", summary: summary(content === "" ? prefix : `${prefix}: ${content}`, prefix) };
|
||||
}
|
||||
|
||||
function assistantSummary(message: Record<string, unknown>): string {
|
||||
const text = contentPreview(message["content"], false);
|
||||
if (text !== "") return summary(text, "Assistant response");
|
||||
|
||||
const toolNames = assistantToolNames(message["content"]);
|
||||
if (toolNames.length > 0) return summary(`Tool call: ${toolNames.join(", ")}`, "Assistant tool call");
|
||||
if (message["stopReason"] === "error") return "Assistant error";
|
||||
if (message["stopReason"] === "aborted") return "Assistant response aborted";
|
||||
return "Assistant response";
|
||||
}
|
||||
|
||||
function assistantToolNames(content: unknown): string[] {
|
||||
if (!Array.isArray(content)) return [];
|
||||
const names: string[] = [];
|
||||
for (const part of content) {
|
||||
if (!isRecord(part) || part["type"] !== "toolCall") continue;
|
||||
const name = optionalPlainText(part["name"], NAMED_FIELD_MAX_LENGTH);
|
||||
if (name !== undefined && !names.includes(name)) names.push(name);
|
||||
if (names.length === 3) break;
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
function toolResultSummary(message: Record<string, unknown>): string {
|
||||
const isError = message["isError"] === true;
|
||||
const toolName = optionalPlainText(message["toolName"], NAMED_FIELD_MAX_LENGTH);
|
||||
const prefix = toolName === undefined
|
||||
? isError ? "Tool error" : "Tool result"
|
||||
: `${isError ? "Tool error" : "Tool result"} (${toolName})`;
|
||||
const content = contentPreview(message["content"], true);
|
||||
return summary(content === "" ? prefix : `${prefix}: ${content}`, prefix);
|
||||
}
|
||||
|
||||
function modelChangeSummary(entry: Record<string, unknown>): string {
|
||||
const provider = optionalPlainText(entry["provider"], NAMED_FIELD_MAX_LENGTH);
|
||||
const modelId = optionalPlainText(entry["modelId"], NAMED_FIELD_MAX_LENGTH);
|
||||
if (provider !== undefined && modelId !== undefined) return summary(`Model: ${provider}/${modelId}`, "Model changed");
|
||||
return namedSummary("Model", modelId ?? provider, "Model changed");
|
||||
}
|
||||
|
||||
function contentPreview(content: unknown, includeImageMarkers: boolean): string {
|
||||
const fragments: string[] = [];
|
||||
let remaining = SUMMARY_SOURCE_MAX_LENGTH;
|
||||
const append = (text: string): void => {
|
||||
if (remaining <= 0 || text === "") return;
|
||||
const fragment = text.slice(0, remaining);
|
||||
fragments.push(fragment);
|
||||
remaining -= fragment.length;
|
||||
};
|
||||
|
||||
if (typeof content === "string") {
|
||||
append(content);
|
||||
} else if (Array.isArray(content)) {
|
||||
for (const part of content) {
|
||||
if (!isRecord(part)) continue;
|
||||
if (part["type"] === "text" && typeof part["text"] === "string") append(part["text"]);
|
||||
else if (includeImageMarkers && part["type"] === "image") append("[image]");
|
||||
if (remaining <= 0) break;
|
||||
}
|
||||
}
|
||||
return plainText(fragments.join(" "));
|
||||
}
|
||||
|
||||
function namedSummary(prefix: string, value: unknown, fallback: string): string {
|
||||
const field = optionalPlainText(value, NAMED_FIELD_MAX_LENGTH);
|
||||
return field === undefined ? fallback : summary(`${prefix}: ${field}`, fallback);
|
||||
}
|
||||
|
||||
function summary(value: unknown, fallback: string): string {
|
||||
if (typeof value !== "string") return fallback;
|
||||
const normalized = plainText(value);
|
||||
return normalized === "" ? fallback : truncate(normalized, SUMMARY_MAX_LENGTH);
|
||||
}
|
||||
|
||||
function optionalPlainText(value: unknown, maxLength: number): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const normalized = plainText(value);
|
||||
return normalized === "" ? undefined : truncate(normalized, maxLength);
|
||||
}
|
||||
|
||||
function plainText(value: string): string {
|
||||
// Browser previews should not retain non-whitespace C0/C1 controls.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return value.replace(/[\u0000-\u001f\u007f-\u009f]/gu, " ").replace(/\s+/gu, " ").trim();
|
||||
}
|
||||
|
||||
function truncate(value: string, maxLength: number): string {
|
||||
if (value.length <= maxLength) return value;
|
||||
let end = maxLength - 1;
|
||||
const finalCodeUnit = value.charCodeAt(end - 1);
|
||||
if (finalCodeUnit >= 0xd800 && finalCodeUnit <= 0xdbff) end -= 1;
|
||||
return `${value.slice(0, end)}…`;
|
||||
}
|
||||
|
||||
function buildActivePath(nodes: readonly SessionTreeNode[], activeLeafId: string | null): string[] {
|
||||
if (activeLeafId === null) return [];
|
||||
const byId = new Map<string, SessionTreeNode>();
|
||||
for (const node of nodes) {
|
||||
if (!byId.has(node.id)) byId.set(node.id, node);
|
||||
}
|
||||
|
||||
const path: string[] = [];
|
||||
const visitedIds = new Set<string>();
|
||||
let currentId: string | null = activeLeafId;
|
||||
while (currentId !== null && !visitedIds.has(currentId)) {
|
||||
const node = byId.get(currentId);
|
||||
if (node === undefined) break;
|
||||
visitedIds.add(currentId);
|
||||
path.push(currentId);
|
||||
currentId = node.parentId;
|
||||
}
|
||||
path.reverse();
|
||||
return path;
|
||||
}
|
||||
|
||||
function isProjectableSessionTreeNode(value: unknown): value is ProjectableSessionTreeNode {
|
||||
return isRecord(value) && Array.isArray(value["children"]);
|
||||
}
|
||||
|
||||
function isProjectableSessionEntry(value: unknown): value is ProjectableSessionEntry {
|
||||
return isRecord(value)
|
||||
&& typeof value["id"] === "string"
|
||||
&& value["id"].trim() !== ""
|
||||
&& (value["parentId"] === null || (typeof value["parentId"] === "string" && value["parentId"].trim() !== ""))
|
||||
&& typeof value["type"] === "string";
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
@@ -17,6 +17,9 @@ export type {
|
||||
FileSuggestion as ClientFileSuggestion,
|
||||
CommandOption as ClientCommandOption,
|
||||
CommandResult as ClientCommandResult,
|
||||
SessionTreeSnapshot as ClientSessionTreeSnapshot,
|
||||
SessionTreeNavigateRequest as ClientSessionTreeNavigateRequest,
|
||||
SessionTreeNavigateResult as ClientSessionTreeNavigateResult,
|
||||
SessionActivity as ClientSessionActivity,
|
||||
SessionUiEvent,
|
||||
GlobalSessionEvent,
|
||||
|
||||
@@ -817,6 +817,56 @@ export interface CommandOption {
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export type SessionTreeNodeKind =
|
||||
| "user"
|
||||
| "assistant"
|
||||
| "tool-result"
|
||||
| "bash"
|
||||
| "custom-message"
|
||||
| "compaction"
|
||||
| "branch-summary"
|
||||
| "model-change"
|
||||
| "thinking-level-change"
|
||||
| "session-info"
|
||||
| "label"
|
||||
| "custom"
|
||||
| "other";
|
||||
|
||||
export interface SessionTreeNode {
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
kind: SessionTreeNodeKind;
|
||||
summary: string;
|
||||
timestamp?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface SessionTreeSnapshot {
|
||||
/** Pre-order, parent-linked projection of all retained roots and descendants. */
|
||||
nodes: SessionTreeNode[];
|
||||
activeLeafId: string | null;
|
||||
/** Root-to-leaf IDs for explicit, non-color-only active-path rendering. */
|
||||
activePathIds: string[];
|
||||
}
|
||||
|
||||
export const SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH = 10_000;
|
||||
|
||||
export type SessionTreeSummaryChoice =
|
||||
| { mode: "none" }
|
||||
| { mode: "default" }
|
||||
| { mode: "custom"; instructions: string };
|
||||
|
||||
export interface SessionTreeNavigateRequest {
|
||||
targetId: string;
|
||||
/** Leaf shown when the navigator opened; null is valid for an empty/root position. */
|
||||
expectedLeafId: string | null;
|
||||
summary: SessionTreeSummaryChoice;
|
||||
}
|
||||
|
||||
export type SessionTreeNavigateResult =
|
||||
| { cancelled: false; editorText?: string }
|
||||
| { cancelled: true; aborted?: boolean };
|
||||
|
||||
export interface MessagePage {
|
||||
messages: unknown[];
|
||||
start: number;
|
||||
@@ -840,6 +890,7 @@ export interface SessionStreamSnapshot {
|
||||
export type CommandResult =
|
||||
| { type: "done"; message?: string; session?: SessionInfo; promptDraft?: string }
|
||||
| { type: "select"; requestId: string; title: string; options: CommandOption[] }
|
||||
| { type: "tree"; tree: SessionTreeSnapshot }
|
||||
| { type: "unsupported"; message: string };
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export type FederatedHttpMethod = "GET" | "POST" | "PUT" | "DELETE";
|
||||
|
||||
export const PI_PACKAGE_MUTATION_PROXY_TIMEOUT_MS = 5 * 60_000;
|
||||
export const SESSION_TREE_NAVIGATION_PROXY_TIMEOUT_MS = 5 * 60_000;
|
||||
|
||||
export interface FederatedHttpRouteSpec {
|
||||
method: FederatedHttpMethod;
|
||||
@@ -70,6 +71,7 @@ export const FEDERATED_HTTP_ROUTES = [
|
||||
{ method: "POST", path: "/sessions/:sessionId/shell" },
|
||||
{ method: "POST", path: "/sessions/:sessionId/commands/run" },
|
||||
{ method: "POST", path: "/sessions/:sessionId/commands/respond" },
|
||||
{ method: "POST", path: "/sessions/:sessionId/tree/navigate", timeoutMs: SESSION_TREE_NAVIGATION_PROXY_TIMEOUT_MS },
|
||||
{ method: "POST", path: "/sessions/:sessionId/abort" },
|
||||
{ method: "POST", path: "/sessions/:sessionId/stop" },
|
||||
{ method: "POST", path: "/sessions/:sessionId/archive" },
|
||||
|
||||
Reference in New Issue
Block a user