feat: add hierarchical session tree navigator

This commit is contained in:
Federico Jaramillo Martinez
2026-07-20 10:40:17 +02:00
parent a77c83b309
commit 4ca4a1d096
31 changed files with 4062 additions and 122 deletions
+1 -1
View File
@@ -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";
+29
View File
@@ -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" }] } });
+6 -1
View File
@@ -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)),
+62 -1
View File
@@ -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)}`,
+86 -1
View File
@@ -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")) };
+5 -1
View File
@@ -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"],
};
}
+41 -3
View File
@@ -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;
}
+22
View File
@@ -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 messages 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 };
}
+118 -11
View File
@@ -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) {
+171
View File
@@ -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 };
}
+246
View File
@@ -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);
}