Merge pull request #77 from jmfederico/agent/session-notification-inbox

feat(sessions): add ephemeral notification inbox
This commit is contained in:
Federico Jaramillo Martinez
2026-07-19 06:15:05 +02:00
committed by GitHub
48 changed files with 5402 additions and 120 deletions
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Keep extension notifications discoverable in a session-scoped inbox with background badges, reconnect recovery, and explicit dismissal.
+27
View File
@@ -303,6 +303,33 @@ describe("session API compatibility", () => {
expect(fetchMock).toHaveBeenCalledOnce(); expect(fetchMock).toHaveBeenCalledOnce();
expect(fetchCall(fetchMock, 0)[0]).toBe("https://pi.example.test/api/machines/remote%20a/sessions/s%201/stream-snapshot"); expect(fetchCall(fetchMock, 0)[0]).toBe("https://pi.example.test/api/machines/remote%20a/sessions/s%201/stream-snapshot");
}); });
it("uses encoded machine/session notification routes, cwd queries, and authoritative mutation cutoffs", async () => {
const notification = { id: "daemon-a:1", message: "notice", truncated: false, severity: "warning", receivedAt: "2026-07-18T00:00:00.000Z", order: 1 };
const summary = { sessionId: "s /?", cwd: "/repo with spaces", inboxRevision: 1, retainedCount: 1, discardedCount: 0, highestSeverity: "warning" };
const inbox = { daemonInstanceId: "daemon-a", catalogRevision: 1, summary, notifications: [notification], dismissThrough: { order: 1, overflowWatermark: 0 } };
const fetchMock = stubSequenceFetch([
jsonResponse({ daemonInstanceId: "daemon-a", catalogRevision: 1, sessions: [summary] }),
jsonResponse(inbox),
jsonResponse(inbox),
jsonResponse(inbox),
]);
const ref = { id: "s /?", cwd: "/repo with spaces" };
await sessionsApi.notificationCatalog("remote /?");
await sessionsApi.notificationInbox(ref, "remote /?");
await sessionsApi.dismissNotification(ref, "daemon-a", "opaque/id?", "remote /?");
await sessionsApi.dismissAllNotifications(ref, "daemon-a", { order: 1, overflowWatermark: 7 }, "remote /?");
expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([
"https://pi.example.test/api/machines/remote%20%2F%3F/sessions/notifications",
"https://pi.example.test/api/machines/remote%20%2F%3F/sessions/s%20%2F%3F/notifications?cwd=%2Frepo+with+spaces",
"https://pi.example.test/api/machines/remote%20%2F%3F/sessions/s%20%2F%3F/notifications/dismiss",
"https://pi.example.test/api/machines/remote%20%2F%3F/sessions/s%20%2F%3F/notifications/dismiss-all",
]);
expect(JSON.parse(requestBody(fetchCall(fetchMock, 2)[1]))).toEqual({ cwd: ref.cwd, daemonInstanceId: "daemon-a", notificationId: "opaque/id?" });
expect(JSON.parse(requestBody(fetchCall(fetchMock, 3)[1]))).toEqual({ cwd: ref.cwd, daemonInstanceId: "daemon-a", throughOrder: 1, throughOverflowWatermark: 7 });
});
}); });
describe("machine-scoped file suggestion API", () => { describe("machine-scoped file suggestion API", () => {
+7 -1
View File
@@ -1,4 +1,4 @@
import type { DeleteWorkspaceFileResponse, FileSuggestion, MoveWorkspaceFileOptions, PiPackageInstallRequest, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiWebConfigValues, PromptAttachment, RunTerminalCommandInput, SessionBulkMutationRef, SessionCleanupRequest, SessionRef, TerminalCommandRun, TerminalCommandRunFilter, WriteWorkspaceFileOptions } from "../../../shared/apiTypes"; import type { DeleteWorkspaceFileResponse, FileSuggestion, MoveWorkspaceFileOptions, PiPackageInstallRequest, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiWebConfigValues, PromptAttachment, RunTerminalCommandInput, SessionBulkMutationRef, SessionCleanupRequest, SessionNotificationDismissThrough, SessionRef, TerminalCommandRun, TerminalCommandRunFilter, WriteWorkspaceFileOptions } from "../../../shared/apiTypes";
import { resolveAppUrl } from "../appUrl"; import { resolveAppUrl } from "../appUrl";
import { request } from "./http"; import { request } from "./http";
import { import {
@@ -40,6 +40,8 @@ import {
parseSessionCleanupExecuteResponse, parseSessionCleanupExecuteResponse,
parseSessionCleanupPreviewResponse, parseSessionCleanupPreviewResponse,
parseSessionInfo, parseSessionInfo,
parseSessionNotificationCatalogSnapshot,
parseSessionNotificationInboxSnapshot,
parseSessionStatus, parseSessionStatus,
parseSessionStreamSnapshot, parseSessionStreamSnapshot,
parseSlashCommand, parseSlashCommand,
@@ -203,6 +205,10 @@ export const workspacesApi = {
export const sessionsApi = { export const sessionsApi = {
sessions: (cwd: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions?cwd=${encodeURIComponent(cwd)}`, arrayOf(parseSessionInfo)), sessions: (cwd: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions?cwd=${encodeURIComponent(cwd)}`, arrayOf(parseSessionInfo)),
notificationCatalog: (machineId = "local") => request(`${machinePrefix(machineId)}/sessions/notifications`, parseSessionNotificationCatalogSnapshot),
notificationInbox: (session: SessionLookup, machineId = "local") => request(sessionQueryPath(session, "notifications", machineId), parseSessionNotificationInboxSnapshot),
dismissNotification: (session: SessionLookup, daemonInstanceId: string, notificationId: string, machineId = "local") => request(sessionPath(session, "notifications/dismiss", machineId), parseSessionNotificationInboxSnapshot, { method: "POST", body: sessionBody(session, { daemonInstanceId, notificationId }) }),
dismissAllNotifications: (session: SessionLookup, daemonInstanceId: string, through: SessionNotificationDismissThrough, machineId = "local") => request(sessionPath(session, "notifications/dismiss-all", machineId), parseSessionNotificationInboxSnapshot, { method: "POST", body: sessionBody(session, { daemonInstanceId, throughOrder: through.order, throughOverflowWatermark: through.overflowWatermark }) }),
startSession: (cwd: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions`, parseSessionInfo, { method: "POST", body: JSON.stringify({ cwd }) }), startSession: (cwd: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions`, parseSessionInfo, { method: "POST", body: JSON.stringify({ cwd }) }),
cleanupPreview: (input: SessionCleanupRequest, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/cleanup/preview`, parseSessionCleanupPreviewResponse, { method: "POST", body: JSON.stringify(input) }), cleanupPreview: (input: SessionCleanupRequest, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/cleanup/preview`, parseSessionCleanupPreviewResponse, { method: "POST", body: JSON.stringify(input) }),
cleanup: (input: SessionCleanupRequest, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/cleanup`, parseSessionCleanupExecuteResponse, { method: "POST", body: JSON.stringify(input) }), cleanup: (input: SessionCleanupRequest, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/cleanup`, parseSessionCleanupExecuteResponse, { method: "POST", body: JSON.stringify(input) }),
@@ -26,6 +26,16 @@ afterEach(() => {
}); });
describe("federated route contract", () => { describe("federated route contract", () => {
it("allowlists notification HTTP routes without adding a notification WebSocket", () => {
expect(FEDERATED_HTTP_ROUTES.filter((route) => route.path.includes("notifications"))).toEqual([
{ method: "GET", path: "/sessions/notifications" },
{ method: "GET", path: "/sessions/:sessionId/notifications" },
{ method: "POST", path: "/sessions/:sessionId/notifications/dismiss" },
{ method: "POST", path: "/sessions/:sessionId/notifications/dismiss-all" },
]);
expect(FEDERATED_WEBSOCKET_ROUTES.some((path) => path.includes("notifications"))).toBe(false);
});
it("covers machine-scoped client HTTP calls with remote proxy routes", async () => { it("covers machine-scoped client HTTP calls with remote proxy routes", async () => {
const fetchMock = vi.fn<FetchLike>(() => Promise.resolve(jsonResponse({}))); const fetchMock = vi.fn<FetchLike>(() => Promise.resolve(jsonResponse({})));
vi.stubGlobal("fetch", fetchMock); vi.stubGlobal("fetch", fetchMock);
+85 -1
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities"; import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
import { parseAuthProvidersResponse, parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMachineRuntime, parseMessagePage, parseOAuthFlowState, parsePiPackageMutationResponse, parsePiPackagesResponse, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parsePiWebStatusResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionInfo, parseSessionStatus, parseSessionStreamSnapshot, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers"; 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, parseSessionNotificationCatalogSnapshot, parseSessionNotificationInboxEvent, parseSessionNotificationInboxSnapshot, parseSessionNotificationSummaryEvent, parseSessionStatus, parseSessionStreamSnapshot, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers";
describe("API parsers", () => { describe("API parsers", () => {
it("preserves additive interactive API-key flow hints and defaults legacy options", () => { it("preserves additive interactive API-key flow hints and defaults legacy options", () => {
@@ -512,4 +513,87 @@ describe("API parsers", () => {
expect(parseCommandResult({ type: "done", message: "ok", promptDraft: "resend me" })).toEqual({ type: "done", message: "ok", promptDraft: "resend me" }); 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"); expect(() => parseCommandResult({ type: "later" })).toThrow("Invalid command result type");
}); });
it("strictly parses notification snapshots and realtime events", () => {
const inbox = notificationInboxWire();
expect(parseSessionNotificationCatalogSnapshot({
daemonInstanceId: "daemon-a",
catalogRevision: 1,
sessions: [inbox.summary],
})).toMatchObject({ daemonInstanceId: "daemon-a", sessions: [{ sessionId: "session-1" }] });
expect(parseSessionNotificationInboxSnapshot(inbox)).toEqual(inbox);
expect(parseSessionNotificationInboxEvent({
type: "notifications.inbox",
daemonInstanceId: "daemon-a",
catalogRevision: 2,
summary: { ...inbox.summary, inboxRevision: 2, retainedCount: 2, highestSeverity: "warning" },
dismissThrough: { order: 2, overflowWatermark: 0 },
delta: { kind: "added", notification: notificationWire(2, "warning") },
})).toMatchObject({ type: "notifications.inbox", delta: { kind: "added", notification: { severity: "warning" } } });
expect(parseSessionNotificationSummaryEvent({
type: "notifications.summary",
daemonInstanceId: "daemon-a",
catalogRevision: 2,
summary: { ...inbox.summary, inboxRevision: 2 },
})).toMatchObject({ type: "notifications.summary", catalogRevision: 2 });
});
it("rejects malformed, unsafe, over-cap, and oversized notification payloads", () => {
const inbox = notificationInboxWire();
expect(() => parseSessionNotificationInboxSnapshot({
...inbox,
notifications: [{ ...notificationWire(1), severity: "fatal" }],
})).toThrow("Invalid notification severity");
expect(() => parseSessionNotificationCatalogSnapshot({
daemonInstanceId: "daemon-a",
catalogRevision: Number.MAX_SAFE_INTEGER + 1,
sessions: [],
})).toThrow("safe integer");
expect(() => parseSessionNotificationInboxSnapshot({
...inbox,
summary: { ...inbox.summary, retainedCount: SESSION_NOTIFICATION_LIMIT },
notifications: Array.from({ length: SESSION_NOTIFICATION_LIMIT + 1 }, (_, index) => notificationWire(SESSION_NOTIFICATION_LIMIT + 1 - index)),
})).toThrow("exceeds limit");
expect(() => parseSessionNotificationInboxSnapshot({
...inbox,
notifications: [{ ...notificationWire(1), message: "x".repeat(SESSION_NOTIFICATION_MESSAGE_BYTES + 1) }],
})).toThrow("message exceeds byte limit");
expect(() => parseSessionNotificationInboxEvent({
type: "notifications.inbox",
daemonInstanceId: "daemon-a",
catalogRevision: 2,
summary: { ...inbox.summary, inboxRevision: 2 },
dismissThrough: { order: 1, overflowWatermark: 0 },
delta: { kind: "cleared", reason: "future-reason" },
})).toThrow("Invalid notification clear reason");
});
}); });
function notificationWire(order: number, severity: "info" | "warning" | "error" = "info") {
return {
id: `daemon-a:${String(order)}`,
message: `notice ${String(order)}`,
truncated: false,
severity,
receivedAt: "2026-07-18T00:00:00.000Z",
order,
};
}
function notificationInboxWire() {
return {
daemonInstanceId: "daemon-a",
catalogRevision: 1,
summary: {
sessionId: "session-1",
cwd: "/repo",
inboxRevision: 1,
retainedCount: 1,
discardedCount: 0,
highestSeverity: "info" as const,
},
notifications: [notificationWire(1)],
dismissThrough: { order: 1, overflowWatermark: 0 },
};
}
+208 -1
View File
@@ -1,4 +1,4 @@
import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileResponse, OAuthFlowState, PiWebAgentDirEnvSource, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SavedPromptAttachment, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionStatus, SessionStreamSnapshot, SessionWarning, SessionWarningSeverity, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevelsResponse, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes"; 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 SessionNotificationCatalogSnapshot, type SessionNotificationClearReason, type SessionNotificationDismissThrough, type SessionNotificationInboxDelta, type SessionNotificationInboxEvent, type SessionNotificationInboxSnapshot, type SessionNotificationSeverity, type SessionNotificationSummary, type SessionNotificationSummaryEvent, 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 } from "../../../shared/apiTypes";
import { parseActiveAgentProfileDescriptor } from "../../../shared/activeAgentProfile"; import { parseActiveAgentProfileDescriptor } from "../../../shared/activeAgentProfile";
import { parseKnownPiWebCapabilities } from "../../../shared/capabilities"; import { parseKnownPiWebCapabilities } from "../../../shared/capabilities";
@@ -233,6 +233,213 @@ export function parseSessionStreamSnapshot(value: unknown): SessionStreamSnapsho
}; };
} }
export function parseSessionNotificationCatalogSnapshot(value: unknown): SessionNotificationCatalogSnapshot {
const record = requireRecord(value);
const sessions = arrayOf(parseSessionNotificationSummary)(record["sessions"]);
const sessionIds = new Set(sessions.map((summary) => summary.sessionId));
if (sessionIds.size !== sessions.length) throw new Error("Duplicate notification catalog session id");
if (sessions.some((summary) => summary.retainedCount === 0 && summary.discardedCount === 0)) throw new Error("Empty notification summary in catalog");
return {
daemonInstanceId: requireNonEmptyString(record, "daemonInstanceId"),
catalogRevision: requireNonNegativeSafeInteger(record, "catalogRevision"),
sessions,
};
}
export function parseSessionNotificationInboxSnapshot(value: unknown): SessionNotificationInboxSnapshot {
const record = requireRecord(value);
const summary = parseSessionNotificationSummary(record["summary"]);
const notifications = boundedArrayOf(record["notifications"], parseSessionNotification, SESSION_NOTIFICATION_LIMIT, "notifications");
assertUniqueNotifications(notifications);
assertNewestFirst(notifications);
if (summary.retainedCount !== notifications.length) throw new Error("Notification snapshot retained count mismatch");
if (summary.highestSeverity !== highestNotificationSeverity(notifications)) throw new Error("Notification snapshot severity mismatch");
const dismissThrough = parseSessionNotificationDismissThrough(record["dismissThrough"]);
const newestOrder = notifications[0]?.order ?? 0;
if (dismissThrough.order !== newestOrder) throw new Error("Notification snapshot dismiss cutoff mismatch");
if (dismissThrough.overflowWatermark < summary.discardedCount) throw new Error("Notification snapshot overflow cutoff mismatch");
return {
daemonInstanceId: requireNonEmptyString(record, "daemonInstanceId"),
catalogRevision: requireNonNegativeSafeInteger(record, "catalogRevision"),
summary,
notifications,
dismissThrough,
};
}
export function parseSessionNotificationInboxEvent(value: unknown): SessionNotificationInboxEvent {
const record = requireRecord(value);
if (record["type"] !== "notifications.inbox") throw new Error("Invalid notification inbox event type");
const summary = parseSessionNotificationSummary(record["summary"]);
const dismissThrough = parseSessionNotificationDismissThrough(record["dismissThrough"]);
if (dismissThrough.overflowWatermark < summary.discardedCount) throw new Error("Notification event overflow cutoff mismatch");
const delta = parseSessionNotificationInboxDelta(record["delta"]);
if (delta.kind === "cleared" && !notificationSummaryIsEmpty(summary)) throw new Error("Notification clear event summary mismatch");
if (delta.kind === "added" && summary.retainedCount === 0) throw new Error("Notification add event summary mismatch");
return {
type: "notifications.inbox",
daemonInstanceId: requireNonEmptyString(record, "daemonInstanceId"),
catalogRevision: requireNonNegativeSafeInteger(record, "catalogRevision"),
summary,
dismissThrough,
delta,
};
}
export function parseSessionNotificationSummaryEvent(value: unknown): SessionNotificationSummaryEvent {
const record = requireRecord(value);
if (record["type"] !== "notifications.summary") throw new Error("Invalid notification summary event type");
return {
type: "notifications.summary",
daemonInstanceId: requireNonEmptyString(record, "daemonInstanceId"),
catalogRevision: requireNonNegativeSafeInteger(record, "catalogRevision"),
summary: parseSessionNotificationSummary(record["summary"]),
};
}
export function parseSessionNotificationSummary(value: unknown): SessionNotificationSummary {
const record = requireRecord(value);
const retainedCount = requireNonNegativeSafeInteger(record, "retainedCount");
if (retainedCount > SESSION_NOTIFICATION_LIMIT) throw new Error("Notification retained count exceeds limit");
const discardedCount = requireNonNegativeSafeInteger(record, "discardedCount");
const highestSeverity = optionalSessionNotificationSeverity(record["highestSeverity"]);
if ((retainedCount === 0) !== (highestSeverity === undefined)) throw new Error("Notification summary severity mismatch");
return {
sessionId: requireNonEmptyString(record, "sessionId"),
cwd: requireNonEmptyString(record, "cwd"),
inboxRevision: requireNonNegativeSafeInteger(record, "inboxRevision"),
retainedCount,
discardedCount,
...(highestSeverity === undefined ? {} : { highestSeverity }),
};
}
function parseSessionNotification(value: unknown): SessionNotification {
const record = requireRecord(value);
const message = requireString(record, "message");
if (new TextEncoder().encode(message).byteLength > SESSION_NOTIFICATION_MESSAGE_BYTES) throw new Error("Notification message exceeds byte limit");
const receivedAt = requireString(record, "receivedAt");
if (!Number.isFinite(Date.parse(receivedAt))) throw new Error("Invalid notification receive time");
const order = requireNonNegativeSafeInteger(record, "order");
if (order === 0) throw new Error("Invalid notification order");
return {
id: requireNonEmptyString(record, "id"),
message,
truncated: requireBoolean(record, "truncated"),
severity: parseSessionNotificationSeverity(record["severity"]),
receivedAt,
order,
};
}
function parseSessionNotificationDismissThrough(value: unknown): SessionNotificationDismissThrough {
const record = requireRecord(value);
return {
order: requireNonNegativeSafeInteger(record, "order"),
overflowWatermark: requireNonNegativeSafeInteger(record, "overflowWatermark"),
};
}
function parseSessionNotificationInboxDelta(value: unknown): SessionNotificationInboxDelta {
const record = requireRecord(value);
switch (record["kind"]) {
case "added": {
const evictedNotificationId = optionalString(record, "evictedNotificationId");
return {
kind: "added",
notification: parseSessionNotification(record["notification"]),
...(evictedNotificationId === undefined ? {} : { evictedNotificationId }),
};
}
case "dismissed": {
const notificationIds = boundedArrayOf(record["notificationIds"], parseNonEmptyString, SESSION_NOTIFICATION_LIMIT, "notificationIds");
if (new Set(notificationIds).size !== notificationIds.length) throw new Error("Duplicate dismissed notification id");
return { kind: "dismissed", notificationIds };
}
case "cleared":
return { kind: "cleared", reason: parseSessionNotificationClearReason(record["reason"]) };
case "resync":
return { kind: "resync" };
default:
throw new Error("Invalid notification inbox delta");
}
}
function parseSessionNotificationSeverity(value: unknown): SessionNotificationSeverity {
if (value !== "info" && value !== "warning" && value !== "error") throw new Error("Invalid notification severity");
return value;
}
function optionalSessionNotificationSeverity(value: unknown): SessionNotificationSeverity | undefined {
return value === undefined ? undefined : parseSessionNotificationSeverity(value);
}
function parseSessionNotificationClearReason(value: unknown): SessionNotificationClearReason {
switch (value) {
case "runtime-close":
case "archive":
case "delete":
case "restore":
case "archive-reconcile":
case "replacement":
case "initialization-failed":
case "service-dispose":
return value;
default:
throw new Error("Invalid notification clear reason");
}
}
function boundedArrayOf<T>(value: unknown, parse: (item: unknown) => T, limit: number, field: string): T[] {
if (!Array.isArray(value)) throw new Error(`Expected array field: ${field}`);
if (value.length > limit) throw new Error(`Array field exceeds limit: ${field}`);
return value.map(parse);
}
function parseNonEmptyString(value: unknown): string {
if (typeof value !== "string" || value === "") throw new Error("Expected non-empty string");
return value;
}
function requireNonEmptyString(record: Record<string, unknown>, key: string): string {
const value = requireString(record, key);
if (value === "") throw new Error(`Expected non-empty string field: ${key}`);
return value;
}
function requireNonNegativeSafeInteger(record: Record<string, unknown>, key: string): number {
const value = record[key];
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) throw new Error(`Expected non-negative safe integer field: ${key}`);
return value;
}
function assertUniqueNotifications(notifications: readonly SessionNotification[]): void {
if (new Set(notifications.map((notification) => notification.id)).size !== notifications.length) throw new Error("Duplicate notification id");
if (new Set(notifications.map((notification) => notification.order)).size !== notifications.length) throw new Error("Duplicate notification order");
}
function assertNewestFirst(notifications: readonly SessionNotification[]): void {
for (let index = 1; index < notifications.length; index += 1) {
const previous = notifications[index - 1];
const current = notifications[index];
if (previous === undefined || current === undefined || previous.order <= current.order) throw new Error("Notifications are not newest-first");
}
}
function notificationSummaryIsEmpty(summary: SessionNotificationSummary): boolean {
return summary.retainedCount === 0 && summary.discardedCount === 0;
}
function highestNotificationSeverity(notifications: readonly SessionNotification[]): SessionNotificationSeverity | undefined {
let highest: SessionNotificationSeverity | undefined;
for (const notification of notifications) {
if (notification.severity === "error") return "error";
if (notification.severity === "warning") highest = "warning";
else highest ??= "info";
}
return highest;
}
export function parseSessionCleanupPreviewResponse(value: unknown): SessionCleanupPreviewResponse { export function parseSessionCleanupPreviewResponse(value: unknown): SessionCleanupPreviewResponse {
const record = requireRecord(value); const record = requireRecord(value);
const skippedBusySessionIds = record["skippedBusySessionIds"] === undefined ? undefined : arrayOfString(record["skippedBusySessionIds"], "skippedBusySessionIds"); const skippedBusySessionIds = record["skippedBusySessionIds"] === undefined ? undefined : arrayOfString(record["skippedBusySessionIds"], "skippedBusySessionIds");
+9
View File
@@ -1,6 +1,7 @@
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, TerminalCommandRun, Workspace, WorkspaceActivity } from "./api";
import type { ChatLine } from "./components/shared"; import type { ChatLine } from "./components/shared";
import type { QualifiedContributionId } from "./plugins/ids"; import type { QualifiedContributionId } from "./plugins/ids";
import type { SelectedSessionNotificationInbox, SessionNotificationCatalogProjection } from "./sessionNotifications";
import type { WorkspaceUploadBatchState } from "./workspaceUploadState"; import type { WorkspaceUploadBatchState } from "./workspaceUploadState";
export interface AppState { export interface AppState {
@@ -36,6 +37,10 @@ export interface AppState {
sessionActivities: Record<string, SessionActivity>; sessionActivities: Record<string, SessionActivity>;
workspaceActivities: Record<string, WorkspaceActivity>; workspaceActivities: Record<string, WorkspaceActivity>;
machineActivities: Record<string, Record<string, WorkspaceActivity>>; machineActivities: Record<string, Record<string, WorkspaceActivity>>;
/** Fresh/stale daemon notification catalogs, isolated by exact machine id. */
notificationCatalogsByMachine: Record<string, SessionNotificationCatalogProjection>;
/** Authoritative projection plus browser-local optimistic overlays for the selected inbox. */
selectedNotificationInbox: SelectedSessionNotificationInbox | undefined;
workspacesByProjectId: Record<string, Workspace[]>; workspacesByProjectId: Record<string, Workspace[]>;
workspaceDeletionRuns: Record<string, TerminalCommandRun>; workspaceDeletionRuns: Record<string, TerminalCommandRun>;
commandDialog: Extract<CommandResult, { type: "select" }> | undefined; commandDialog: Extract<CommandResult, { type: "select" }> | undefined;
@@ -77,6 +82,7 @@ export type WorkspaceScopedStateReset = Pick<AppState,
| "sessions" | "sessions"
| "clientQueuedSessionMessages" | "clientQueuedSessionMessages"
| "startingSessionCount" | "startingSessionCount"
| "selectedNotificationInbox"
| "fileTree" | "fileTree"
| "expandedDirs" | "expandedDirs"
| "selectedFilePath" | "selectedFilePath"
@@ -96,6 +102,7 @@ export function resetWorkspaceScopedState(): WorkspaceScopedStateReset {
sessions: [], sessions: [],
clientQueuedSessionMessages: {}, clientQueuedSessionMessages: {},
startingSessionCount: 0, startingSessionCount: 0,
selectedNotificationInbox: undefined,
fileTree: [], fileTree: [],
expandedDirs: {}, expandedDirs: {},
selectedFilePath: undefined, selectedFilePath: undefined,
@@ -141,6 +148,8 @@ export function initialAppState(): AppState {
sessionActivities: {}, sessionActivities: {},
workspaceActivities: {}, workspaceActivities: {},
machineActivities: {}, machineActivities: {},
notificationCatalogsByMachine: {},
selectedNotificationInbox: undefined,
workspacesByProjectId: {}, workspacesByProjectId: {},
workspaceDeletionRuns: {}, workspaceDeletionRuns: {},
commandDialog: undefined, commandDialog: undefined,
@@ -1,6 +1,7 @@
import type { TemplateResult } from "lit"; import type { TemplateResult } from "lit";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import type { QueuedSessionMessage, SessionStatus, SessionWarning } from "../api"; import type { QueuedSessionMessage, SessionStatus, SessionWarning } from "../api";
import type { SelectedSessionNotificationView } from "../sessionNotifications";
import type { ChatLine } from "./shared"; import type { ChatLine } from "./shared";
import { import {
ChatView, ChatView,
@@ -141,6 +142,40 @@ describe("ChatView session-warning dismiss wiring", () => {
}); });
}); });
describe("ChatView notification tray wiring", () => {
// Escape hatch: these cases verify only the tray buttons' Lit callback wiring.
// Notification content, ordering, severity, collapse, and focus decisions are
// covered through pure seams; the node test environment has no shadow-DOM
// harness, so semantic class markers keep direct handler extraction narrow.
it("wires individual and dismiss-all actions to their injected callbacks", () => {
const view = withNotificationInbox(new ChatView());
const onDismissNotification = vi.fn();
const onDismissAllNotifications = vi.fn();
view.onDismissNotification = onDismissNotification;
view.onDismissAllNotifications = onDismissAllNotifications;
const rendered = renderNotificationTray(view);
if (rendered === null) throw new Error("expected a notification tray");
templateEventHandlerAfterMarker(rendered, "notification-card-dismiss")(new Event("click"));
templateEventHandlerAfterMarker(rendered, "notification-dismiss-all")(new Event("click"));
expect(onDismissNotification).toHaveBeenCalledExactlyOnceWith("daemon-a:1");
expect(onDismissAllNotifications).toHaveBeenCalledOnce();
});
it("wires the real expand/collapse button to component-local collapse state", () => {
const view = withNotificationInbox(new ChatView());
const rendered = renderNotificationTray(view);
if (rendered === null) throw new Error("expected a notification tray");
templateEventHandlerAfterMarker(rendered, "notification-toggle")(new Event("click"));
const collapsedSessionIds: unknown = Reflect.get(view, "collapsedNotificationSessionIds");
if (!(collapsedSessionIds instanceof Set)) throw new Error("Expected collapsed notification session ids");
expect(collapsedSessionIds.has("session-1")).toBe(true);
});
});
describe("chatMessageMetadataLabel", () => { describe("chatMessageMetadataLabel", () => {
it("uses one full date and model label without a model prefix", () => { it("uses one full date and model label without a model prefix", () => {
const timestamp = "2026-07-10T19:15:30.000Z"; const timestamp = "2026-07-10T19:15:30.000Z";
@@ -232,6 +267,7 @@ type RenderQueuedMessages = (this: ChatView) => TemplateResult;
type RenderMessageGroup = (this: ChatView, messages: ChatLine[], startIndex: number, endIndex: number, defaultOpen: boolean) => TemplateResult; type RenderMessageGroup = (this: ChatView, messages: ChatLine[], startIndex: number, endIndex: number, defaultOpen: boolean) => TemplateResult;
type RenderMessageGroupBody = (this: ChatView, messages: ChatLine[], startIndex: number) => TemplateResult; type RenderMessageGroupBody = (this: ChatView, messages: ChatLine[], startIndex: number) => TemplateResult;
type RenderWarnings = (this: ChatView) => TemplateResult | null; type RenderWarnings = (this: ChatView) => TemplateResult | null;
type RenderNotificationTray = (this: ChatView) => TemplateResult | null;
type TemplateEventHandler = (event: Event) => void; type TemplateEventHandler = (event: Event) => void;
function renderQueuedMessages(view: ChatView): TemplateResult { function renderQueuedMessages(view: ChatView): TemplateResult {
@@ -252,6 +288,12 @@ function renderWarnings(view: ChatView): TemplateResult | null {
return method.call(view); return method.call(view);
} }
function renderNotificationTray(view: ChatView): TemplateResult | null {
const method: unknown = Reflect.get(view, "renderNotificationTray");
if (!isRenderNotificationTray(method)) throw new Error("ChatView.renderNotificationTray is not callable");
return method.call(view);
}
function observeGroupBodyRenders(view: ChatView): GroupBodyRenderCall[] { function observeGroupBodyRenders(view: ChatView): GroupBodyRenderCall[] {
const method: unknown = Reflect.get(view, "renderMessageGroupBody"); const method: unknown = Reflect.get(view, "renderMessageGroupBody");
if (!isRenderMessageGroupBody(method)) throw new Error("ChatView.renderMessageGroupBody is not callable"); if (!isRenderMessageGroupBody(method)) throw new Error("ChatView.renderMessageGroupBody is not callable");
@@ -280,6 +322,10 @@ function isRenderWarnings(value: unknown): value is RenderWarnings {
return typeof value === "function"; return typeof value === "function";
} }
function isRenderNotificationTray(value: unknown): value is RenderNotificationTray {
return typeof value === "function";
}
function dispatchDetailsToggle(handler: TemplateEventHandler, open: boolean): void { function dispatchDetailsToggle(handler: TemplateEventHandler, open: boolean): void {
const hadDetailsElement = Reflect.has(globalThis, "HTMLDetailsElement"); const hadDetailsElement = Reflect.has(globalThis, "HTMLDetailsElement");
const previousDetailsElement = Reflect.get(globalThis, "HTMLDetailsElement"); const previousDetailsElement = Reflect.get(globalThis, "HTMLDetailsElement");
@@ -309,6 +355,33 @@ function withStatus(view: ChatView, status: SessionStatus): ChatView {
return view; return view;
} }
function withNotificationInbox(view: ChatView): ChatView {
const notificationInbox: SelectedSessionNotificationView = {
machineId: "local",
sessionId: "session-1",
cwd: "/repo",
daemonInstanceId: "daemon-a",
notifications: [{
id: "daemon-a:1",
message: "plain <strong>text</strong>\nsecond line",
truncated: false,
severity: "warning",
receivedAt: "2026-07-18T00:00:00.000Z",
order: 1,
}],
retainedCount: 1,
discardedCount: 0,
highestSeverity: "warning",
dismissThrough: { order: 1, overflowWatermark: 0 },
pendingDismissedIds: new Set(),
dismissAllPending: false,
announcements: [],
};
view.sessionId = notificationInbox.sessionId;
view.notificationInbox = notificationInbox;
return view;
}
function warningStatus(warnings: SessionWarning[]): SessionStatus { function warningStatus(warnings: SessionWarning[]): SessionStatus {
return { return {
...queuedStatus([]), ...queuedStatus([]),
+131 -1
View File
@@ -8,6 +8,16 @@ import { capturePrependScrollAnchor, PREPEND_RESTORE_SETTLE_FRAMES, restorePrepe
import { shouldRequestEarlierMessages } from "../chatHistoryLoading"; import { shouldRequestEarlierMessages } from "../chatHistoryLoading";
import { ChatScrollController, distanceFromScrollBottom, findFirstVisibleArticle, isNearScrollBottom, type ChatAnchorScrollPosition, type ChatScrollRestoreResult } from "../chatScrollPosition"; import { ChatScrollController, distanceFromScrollBottom, findFirstVisibleArticle, isNearScrollBottom, type ChatAnchorScrollPosition, type ChatScrollRestoreResult } from "../chatScrollPosition";
import type { QueuedSessionMessage, SessionActivity, SessionStatus, SessionWarningSeverity } from "../api"; import type { QueuedSessionMessage, SessionActivity, SessionStatus, SessionWarningSeverity } from "../api";
import {
notificationFocusTargetAfterDismiss,
notificationInboxOverflowLabel,
notificationMessageTruncationLabel,
notificationSeverityIcon,
notificationSeverityLabel,
setNotificationTrayCollapsed,
type NotificationFocusTarget,
type SelectedSessionNotificationView,
} from "../sessionNotifications";
import type { ChatLine, ChatPart } from "./shared"; import type { ChatLine, ChatPart } from "./shared";
import { chatStyles } from "./shared"; import { chatStyles } from "./shared";
import "./ConversationMeter"; import "./ConversationMeter";
@@ -15,6 +25,7 @@ import "./FormattedText";
import "./ToolExecutionView"; import "./ToolExecutionView";
const messageTimestampFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "medium" }); const messageTimestampFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "medium" });
const notificationTimestampFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" });
function warningSeverityIcon(severity: SessionWarningSeverity): string { function warningSeverityIcon(severity: SessionWarningSeverity): string {
if (severity === "error") return "⛔"; if (severity === "error") return "⛔";
@@ -151,9 +162,12 @@ export class ChatView extends LitElement {
@property({ attribute: false }) clientQueuedMessages: QueuedSessionMessage[] = []; @property({ attribute: false }) clientQueuedMessages: QueuedSessionMessage[] = [];
@property({ attribute: false }) status?: SessionStatus; @property({ attribute: false }) status?: SessionStatus;
@property({ attribute: false }) activity?: SessionActivity; @property({ attribute: false }) activity?: SessionActivity;
@property({ attribute: false }) notificationInbox?: SelectedSessionNotificationView;
@property({ type: Boolean }) canClearServerQueue = false; @property({ type: Boolean }) canClearServerQueue = false;
@property({ attribute: false }) onClearServerQueue?: () => void; @property({ attribute: false }) onClearServerQueue?: () => void;
@property({ attribute: false }) onDismissWarning?: (dismissId: string) => void; @property({ attribute: false }) onDismissWarning?: (dismissId: string) => void;
@property({ attribute: false }) onDismissNotification?: (notificationId: string) => void;
@property({ attribute: false }) onDismissAllNotifications?: () => void;
@property({ attribute: false }) onLoadMore?: () => void; @property({ attribute: false }) onLoadMore?: () => void;
@query(".chat") private chat?: HTMLDivElement; @query(".chat") private chat?: HTMLDivElement;
@query("dialog.image-zoom") private imageZoomDialog?: HTMLDialogElement; @query("dialog.image-zoom") private imageZoomDialog?: HTMLDialogElement;
@@ -162,6 +176,9 @@ export class ChatView extends LitElement {
@state() private expandedMetaKey: string | undefined; @state() private expandedMetaKey: string | undefined;
@state() private copiedMessageKey: string | undefined; @state() private copiedMessageKey: string | undefined;
@state() private currentConversationIndex: number | undefined; @state() private currentConversationIndex: number | undefined;
@state() private collapsedNotificationSessionIds: ReadonlySet<string> = new Set();
@state() private retainedEmptyNotificationTraySessionId: string | undefined;
private pendingNotificationFocus: NotificationFocusTarget | undefined;
private readonly disclosures = new ChatDisclosureController(); private readonly disclosures = new ChatDisclosureController();
private readonly scrollController = new ChatScrollController(); private readonly scrollController = new ChatScrollController();
private suppressScrollSave = false; private suppressScrollSave = false;
@@ -237,6 +254,8 @@ export class ChatView extends LitElement {
private prepareSessionUiState(): void { private prepareSessionUiState(): void {
this.disclosures.syncSession(this.sessionId); this.disclosures.syncSession(this.sessionId);
this.pendingNotificationFocus = undefined;
this.retainedEmptyNotificationTraySessionId = undefined;
this.scrollController.clearScheduledSave(); this.scrollController.clearScheduledSave();
this.suppressScrollSave = false; this.suppressScrollSave = false;
this.suppressLoadMoreRequests = false; this.suppressLoadMoreRequests = false;
@@ -271,6 +290,7 @@ export class ChatView extends LitElement {
if (changed.has("messages") || changed.has("messageStart") || changed.has("messageTotal") || changed.has("hasMore") || changed.has("loadingMore")) this.scheduleConversationRailUpdate(); if (changed.has("messages") || changed.has("messageStart") || changed.has("messageTotal") || changed.has("hasMore") || changed.has("loadingMore")) this.scheduleConversationRailUpdate();
if (changed.has("messages") || changed.has("messageStart") || changed.has("hasMore") || changed.has("loadingMore")) this.continuePendingScrollRestore(); if (changed.has("messages") || changed.has("messageStart") || changed.has("hasMore") || changed.has("loadingMore")) this.continuePendingScrollRestore();
if (changed.has("messages") || changed.has("hasMore") || changed.has("loadingMore")) this.requestLoadMoreIfNeeded(); if (changed.has("messages") || changed.has("hasMore") || changed.has("loadingMore")) this.requestLoadMoreIfNeeded();
if (changed.has("notificationInbox") && this.pendingNotificationFocus !== undefined) this.focusPendingNotificationTarget();
if (changed.has("zoomedImage")) this.syncImageZoomDialog(); if (changed.has("zoomedImage")) this.syncImageZoomDialog();
} }
@@ -284,7 +304,8 @@ export class ChatView extends LitElement {
override render() { override render() {
const groups = this.groupedMessages(); const groups = this.groupedMessages();
return html` return html`
${this.renderWarnings()} ${this.renderTopNotices()}
${this.renderNotificationLiveRegions()}
<div class="chat-wrap"> <div class="chat-wrap">
${this.renderConversationRail()} ${this.renderConversationRail()}
<div class="chat" @scroll=${() => { this.onScroll(); }} @wheel=${(event: WheelEvent) => { this.onWheel(event); }} @touchstart=${(event: TouchEvent) => { this.onTouchStart(event); }} @touchmove=${(event: TouchEvent) => { this.onTouchMove(event); }}> <div class="chat" @scroll=${() => { this.onScroll(); }} @wheel=${(event: WheelEvent) => { this.onWheel(event); }} @touchstart=${(event: TouchEvent) => { this.onTouchStart(event); }} @touchmove=${(event: TouchEvent) => { this.onTouchMove(event); }}>
@@ -307,6 +328,115 @@ export class ChatView extends LitElement {
`; `;
} }
private renderTopNotices() {
const warnings = this.renderWarnings();
const notifications = this.renderNotificationTray();
if (warnings === null && notifications === null) return null;
return html`<div class="top-notices">${warnings}${notifications}</div>`;
}
private renderNotificationTray() {
const inbox = this.notificationInbox;
if (inbox?.sessionId !== this.sessionId) return null;
const hasPendingOverlay = inbox.pendingDismissedIds.size > 0 || inbox.dismissAllPending;
const retainsFocusTarget = this.retainedEmptyNotificationTraySessionId === this.sessionId;
if (inbox.retainedCount === 0 && inbox.discardedCount === 0 && !hasPendingOverlay && !retainsFocusTarget) return null;
const collapsed = this.collapsedNotificationSessionIds.has(this.sessionId);
const severity = inbox.highestSeverity ?? "info";
const severityLabel = notificationSeverityLabel(severity);
const countLabel = `${String(inbox.retainedCount)} undismissed ${inbox.retainedCount === 1 ? "notification" : "notifications"}`;
const discardedLabel = inbox.discardedCount === 0 ? "" : ` · ${String(inbox.discardedCount)} older ${inbox.discardedCount === 1 ? "notification" : "notifications"} discarded`;
return html`
<section class=${`notification-tray ${severity} ${collapsed ? "collapsed" : ""}`} role="region" aria-labelledby="session-notifications-heading" @focusout=${(event: FocusEvent) => { this.releaseEmptyNotificationTray(event); }}>
<header class="notification-header" data-notification-focus="header" tabindex="-1">
<div class="notification-heading-group">
<span class="notification-heading-icon" aria-hidden="true">${notificationSeverityIcon(severity)}</span>
<span class="notification-heading-copy">
<strong id="session-notifications-heading">Notifications</strong>
<small>${countLabel}${discardedLabel} · highest severity ${severityLabel}</small>
</span>
</div>
<div class="notification-header-actions">
<button type="button" class="notification-control notification-toggle" aria-expanded=${String(!collapsed)} aria-controls="session-notification-cards" @click=${() => { this.toggleNotificationTray(collapsed); }}>${collapsed ? "Expand" : "Collapse"}</button>
<button type="button" class="notification-control notification-dismiss-all" ?disabled=${inbox.dismissAllPending || inbox.retainedCount + inbox.discardedCount === 0} @click=${() => { this.onDismissAllNotifications?.(); }}>Dismiss all</button>
</div>
</header>
${collapsed ? null : html`
<div class="notification-cards" id="session-notification-cards">
${inbox.discardedCount === 0 ? null : html`
<p class="notification-overflow" role="status">${notificationInboxOverflowLabel(inbox.discardedCount)}</p>
`}
${inbox.notifications.map((notification) => {
const label = notificationSeverityLabel(notification.severity);
const truncationLabel = notificationMessageTruncationLabel(notification);
return html`
<article class=${`notification-card ${notification.severity}`} data-notification-id=${notification.id} tabindex="-1">
<div class="notification-card-head">
<strong class="notification-severity"><span aria-hidden="true">${notificationSeverityIcon(notification.severity)}</span> ${label}</strong>
<time datetime=${notification.receivedAt}>${notificationTimestampFormatter.format(new Date(notification.receivedAt))}</time>
</div>
<p class="notification-message" dir="auto">${notification.message}</p>
${truncationLabel === undefined ? null : html`<p class="notification-truncated">${truncationLabel}</p>`}
<button
type="button"
class="notification-card-dismiss"
aria-label=${`Dismiss ${label.toLocaleLowerCase()} notification`}
title="Dismiss notification"
?disabled=${inbox.pendingDismissedIds.has(notification.id) || inbox.dismissAllPending}
@click=${() => { this.dismissNotification(notification.id); }}
>×</button>
</article>
`;
})}
</div>
`}
</section>
`;
}
private renderNotificationLiveRegions() {
const announcements = this.notificationInbox?.sessionId === this.sessionId ? this.notificationInbox.announcements : [];
const polite = announcements.filter((announcement) => announcement.severity !== "error");
const assertive = announcements.filter((announcement) => announcement.severity === "error");
return html`
<div class="visually-hidden notification-live" aria-live="polite" aria-atomic="false">${repeat(polite, (announcement) => announcement.id, (announcement) => html`<span data-announcement-id=${announcement.id}>${notificationSeverityLabel(announcement.severity)} notification: ${announcement.message}</span>`)}</div>
<div class="visually-hidden notification-live" aria-live="assertive" aria-atomic="false">${repeat(assertive, (announcement) => announcement.id, (announcement) => html`<span data-announcement-id=${announcement.id}>Error notification: ${announcement.message}</span>`)}</div>
`;
}
private toggleNotificationTray(collapsed: boolean): void {
this.collapsedNotificationSessionIds = setNotificationTrayCollapsed(this.collapsedNotificationSessionIds, this.sessionId, !collapsed);
}
private dismissNotification(notificationId: string): void {
const inbox = this.notificationInbox;
if (inbox === undefined) return;
this.pendingNotificationFocus = notificationFocusTargetAfterDismiss(inbox.notifications, notificationId);
if (this.pendingNotificationFocus.kind === "header") this.retainedEmptyNotificationTraySessionId = this.sessionId;
this.onDismissNotification?.(notificationId);
}
private releaseEmptyNotificationTray(event: FocusEvent): void {
const tray = event.currentTarget;
const next = event.relatedTarget;
if (tray instanceof HTMLElement && next instanceof Node && tray.contains(next)) return;
const inbox = this.notificationInbox;
if (this.retainedEmptyNotificationTraySessionId === this.sessionId && inbox?.retainedCount === 0 && inbox.discardedCount === 0) this.retainedEmptyNotificationTraySessionId = undefined;
}
private focusPendingNotificationTarget(): void {
const target = this.pendingNotificationFocus;
this.pendingNotificationFocus = undefined;
if (target === undefined) return;
if (target.kind === "header") {
this.renderRoot.querySelector<HTMLElement>("[data-notification-focus='header']")?.focus();
return;
}
const card = Array.from(this.renderRoot.querySelectorAll<HTMLElement>("[data-notification-id]"))
.find((candidate) => candidate.dataset["notificationId"] === target.notificationId);
(card ?? this.renderRoot.querySelector<HTMLElement>("[data-notification-focus='header']"))?.focus();
}
private renderWarnings() { private renderWarnings() {
const rows = chatSessionWarningRows(this.status); const rows = chatSessionWarningRows(this.status);
if (rows.length === 0) return null; if (rows.length === 0) return null;
+7 -3
View File
@@ -1,12 +1,14 @@
import { LitElement, css, html, type PropertyValues } from "lit"; import { LitElement, css, html, type PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators.js"; import { customElement, property, state } from "lit/decorators.js";
import type { Machine, MachineHealth, WorkspaceActivity } from "../api"; import type { Machine, MachineHealth, WorkspaceActivity } from "../api";
import type { SessionNotificationBadgeModel } from "../sessionNotifications";
import { machineActivityIndicator } from "../workspaceActivity"; import { machineActivityIndicator } from "../workspaceActivity";
import { actionMenuPanelStyle } from "./actionMenu"; import { actionMenuPanelStyle } from "./actionMenu";
import { renderActionActivityIndicator } from "./activityBadge"; import { renderActionActivityIndicator } from "./activityBadge";
import type { KeyboardNavigableSection } from "./navigationFocus"; import type { KeyboardNavigableSection } from "./navigationFocus";
import { activateSelectableRow, focusSelectedOrFirstSelectableRow, handleSelectableRowKeyboard } from "./selectableRow"; import { activateSelectableRow, focusSelectedOrFirstSelectableRow, handleSelectableRowKeyboard } from "./selectableRow";
import { listStyles } from "./shared"; import { listStyles } from "./shared";
import "./NotificationBadge";
@customElement("machine-list") @customElement("machine-list")
export class MachineList extends LitElement implements KeyboardNavigableSection { export class MachineList extends LitElement implements KeyboardNavigableSection {
@@ -14,6 +16,8 @@ export class MachineList extends LitElement implements KeyboardNavigableSection
@property({ attribute: false }) selected?: Machine; @property({ attribute: false }) selected?: Machine;
@property({ attribute: false }) statuses: Record<string, MachineHealth> = {}; @property({ attribute: false }) statuses: Record<string, MachineHealth> = {};
@property({ attribute: false }) activities: Record<string, Record<string, WorkspaceActivity>> = {}; @property({ attribute: false }) activities: Record<string, Record<string, WorkspaceActivity>> = {};
@property({ attribute: false }) notificationBadges: Record<string, SessionNotificationBadgeModel | undefined> = {};
@property({ attribute: false }) notificationHeadingBadge?: SessionNotificationBadgeModel;
@property({ type: Boolean, reflect: true }) collapsible = false; @property({ type: Boolean, reflect: true }) collapsible = false;
@property({ type: Boolean, reflect: true }) collapsed = false; @property({ type: Boolean, reflect: true }) collapsed = false;
@property({ attribute: false }) onSelect?: (machine: Machine) => void; @property({ attribute: false }) onSelect?: (machine: Machine) => void;
@@ -75,7 +79,7 @@ export class MachineList extends LitElement implements KeyboardNavigableSection
@keydown=${(event: KeyboardEvent) => { this.handleMachineKeydown(event, machine); }} @keydown=${(event: KeyboardEvent) => { this.handleMachineKeydown(event, machine); }}
> >
<div class="action-main"> <div class="action-main">
<span class="action-name machine-primary"><span class="machine-primary-label">${machine.name}</span></span><small>${machine.kind === "local" ? "Local Pi Web" : machine.baseUrl ?? "Remote Pi Web"} · ${statusLabel}</small> <span class="action-name machine-primary"><span class="machine-primary-label">${machine.name}</span>${this.notificationBadges[machine.id] === undefined ? null : html`<notification-badge .model=${this.notificationBadges[machine.id]}></notification-badge>`}</span><small>${machine.kind === "local" ? "Local Pi Web" : machine.baseUrl ?? "Remote Pi Web"} · ${statusLabel}</small>
${this.renderActivity(machine)} ${this.renderActivity(machine)}
</div> </div>
${hasRemoveAction ? this.renderMachineMenu(machine) : null} ${hasRemoveAction ? this.renderMachineMenu(machine) : null}
@@ -113,10 +117,10 @@ export class MachineList extends LitElement implements KeyboardNavigableSection
} }
private renderHeading() { private renderHeading() {
if (!this.collapsible) return "Machines"; if (!this.collapsible) return html`<span>Machines</span>${this.notificationHeadingBadge === undefined ? null : html`<notification-badge .model=${this.notificationHeadingBadge}></notification-badge>`}`;
const selectedSummary = this.selected?.name ?? "No machine selected"; const selectedSummary = this.selected?.name ?? "No machine selected";
const selectedTitle = this.selected?.baseUrl ?? selectedSummary; const selectedTitle = this.selected?.baseUrl ?? selectedSummary;
return html`<button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span class="section-title"><span class="section-name">${this.collapsed ? "▸" : "▾"} Machines</span>${this.collapsed ? html`<small class="section-selected" title=${selectedTitle}>${selectedSummary}</small>` : null}</span><small class="section-count">${this.machines.length}</small></button>`; return html`<button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span class="section-title"><span class="section-name">${this.collapsed ? "▸" : "▾"} Machines</span>${this.collapsed ? html`<small class="section-selected" title=${selectedTitle}>${selectedSummary}</small>` : null}</span>${this.notificationHeadingBadge === undefined ? null : html`<notification-badge .model=${this.notificationHeadingBadge}></notification-badge>`}<small class="section-count">${this.machines.length}</small></button>`;
} }
private toggleMenu(machineId: string, target: EventTarget | null): void { private toggleMenu(machineId: string, target: EventTarget | null): void {
+21 -2
View File
@@ -1,11 +1,13 @@
import { LitElement, css, html, type PropertyValues, type TemplateResult } from "lit"; import { LitElement, css, html, type PropertyValues, type TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js"; import { customElement, property, state } from "lit/decorators.js";
import type { Machine, MachineHealth, MachineStatus, WorkspaceActivity } from "../api"; import type { Machine, MachineHealth, MachineStatus, WorkspaceActivity } from "../api";
import type { SessionNotificationBadgeModel } from "../sessionNotifications";
import { machineActivityIndicator } from "../workspaceActivity"; import { machineActivityIndicator } from "../workspaceActivity";
import { actionMenuPanelStyle } from "./actionMenu"; import { actionMenuPanelStyle } from "./actionMenu";
import { renderActivityIndicator } from "./activityBadge"; import { renderActivityIndicator } from "./activityBadge";
import { canRemoveMachine } from "./MachineList"; import { canRemoveMachine } from "./MachineList";
import type { KeyboardNavigableSection } from "./navigationFocus"; import type { KeyboardNavigableSection } from "./navigationFocus";
import "./NotificationBadge";
@customElement("machine-switcher") @customElement("machine-switcher")
export class MachineSwitcher extends LitElement implements KeyboardNavigableSection { export class MachineSwitcher extends LitElement implements KeyboardNavigableSection {
@@ -13,6 +15,8 @@ export class MachineSwitcher extends LitElement implements KeyboardNavigableSect
@property({ attribute: false }) selected?: Machine; @property({ attribute: false }) selected?: Machine;
@property({ attribute: false }) statuses: Record<string, MachineHealth> = {}; @property({ attribute: false }) statuses: Record<string, MachineHealth> = {};
@property({ attribute: false }) activities: Record<string, Record<string, WorkspaceActivity>> = {}; @property({ attribute: false }) activities: Record<string, Record<string, WorkspaceActivity>> = {};
@property({ attribute: false }) notificationBadges: Record<string, SessionNotificationBadgeModel | undefined> = {};
@property({ attribute: false }) notificationHeadingBadge?: SessionNotificationBadgeModel;
@property({ attribute: false }) onSelect?: (machine: Machine) => void | Promise<void>; @property({ attribute: false }) onSelect?: (machine: Machine) => void | Promise<void>;
@property({ attribute: false }) onRemove?: (machine: Machine) => void | Promise<void>; @property({ attribute: false }) onRemove?: (machine: Machine) => void | Promise<void>;
@property({ attribute: false }) onFocusNextSection?: () => void | Promise<void>; @property({ attribute: false }) onFocusNextSection?: () => void | Promise<void>;
@@ -54,13 +58,14 @@ export class MachineSwitcher extends LitElement implements KeyboardNavigableSect
if (selected === undefined) return null; if (selected === undefined) return null;
const status = machineStatus(selected, this.statuses); const status = machineStatus(selected, this.statuses);
const label = selected.name; const label = selected.name;
const notificationBadge = machineSwitcherNotificationBadge(selected.id, this.notificationBadges, this.notificationHeadingBadge);
return html` return html`
<div class="machine-switcher"> <div class="machine-switcher">
<button <button
type="button" type="button"
class="machine-switcher-button" class="machine-switcher-button"
title=${machineTitle(selected)} title=${machineTitle(selected)}
aria-label=${`Machine: ${label}. Switch machine.`} aria-label=${this.machineSwitcherAriaLabel(selected, notificationBadge)}
aria-expanded=${String(this.open)} aria-expanded=${String(this.open)}
@click=${(event: MouseEvent) => { this.toggleMenu(event.currentTarget); }} @click=${(event: MouseEvent) => { this.toggleMenu(event.currentTarget); }}
@keydown=${(event: KeyboardEvent) => { this.handleSwitcherButtonKeydown(event); }} @keydown=${(event: KeyboardEvent) => { this.handleSwitcherButtonKeydown(event); }}
@@ -71,6 +76,7 @@ export class MachineSwitcher extends LitElement implements KeyboardNavigableSect
<span class="machine-switcher-label">${label}</span> <span class="machine-switcher-label">${label}</span>
</span> </span>
<span class=${`machine-status ${status}`}>${machineStatusLabel(status)}</span> <span class=${`machine-status ${status}`}>${machineStatusLabel(status)}</span>
${notificationBadge === undefined ? null : html`<notification-badge .model=${notificationBadge}></notification-badge>`}
<span class="machine-chevron" aria-hidden="true">▾</span> <span class="machine-chevron" aria-hidden="true">▾</span>
</button> </button>
${this.open ? html` ${this.open ? html`
@@ -97,7 +103,7 @@ export class MachineSwitcher extends LitElement implements KeyboardNavigableSect
@click=${() => { this.select(machine); }} @click=${() => { this.select(machine); }}
@keydown=${(event: KeyboardEvent) => { this.handleMachineOptionKeydown(event); }} @keydown=${(event: KeyboardEvent) => { this.handleMachineOptionKeydown(event); }}
> >
<span class="machine-option-name">${this.renderActivity(machine)}<span>${machine.name}</span></span> <span class="machine-option-name">${this.renderActivity(machine)}<span>${machine.name}</span>${this.notificationBadges[machine.id] === undefined ? null : html`<notification-badge .model=${this.notificationBadges[machine.id]}></notification-badge>`}</span>
<small>${machine.kind === "local" ? "Local Pi Web" : machine.baseUrl ?? "Remote Pi Web"} · ${machineStatusLabel(status)}</small> <small>${machine.kind === "local" ? "Local Pi Web" : machine.baseUrl ?? "Remote Pi Web"} · ${machineStatusLabel(status)}</small>
</button> </button>
${hasActions ? html` ${hasActions ? html`
@@ -132,6 +138,11 @@ export class MachineSwitcher extends LitElement implements KeyboardNavigableSect
return this.selected ?? this.machines.find((machine) => machine.id === "local") ?? this.machines[0]; return this.selected ?? this.machines.find((machine) => machine.id === "local") ?? this.machines[0];
} }
private machineSwitcherAriaLabel(machine: Machine, notificationBadge: SessionNotificationBadgeModel | undefined): string {
const notificationLabel = notificationBadge?.accessibleLabel;
return `Machine: ${machine.name}.${notificationLabel === undefined ? "" : ` Notifications across machines: ${notificationLabel}.`} Switch machine.`;
}
private switcherButton(): HTMLElement | null { private switcherButton(): HTMLElement | null {
return this.renderRoot.querySelector<HTMLElement>(".machine-switcher-button"); return this.renderRoot.querySelector<HTMLElement>(".machine-switcher-button");
} }
@@ -303,6 +314,14 @@ export class MachineSwitcher extends LitElement implements KeyboardNavigableSect
`; `;
} }
export function machineSwitcherNotificationBadge(
selectedMachineId: string,
notificationBadges: Readonly<Record<string, SessionNotificationBadgeModel | undefined>>,
notificationHeadingBadge: SessionNotificationBadgeModel | undefined,
): SessionNotificationBadgeModel | undefined {
return notificationHeadingBadge ?? notificationBadges[selectedMachineId];
}
function machineStatus(machine: Machine, statuses: Record<string, MachineHealth>): MachineStatus { function machineStatus(machine: Machine, statuses: Record<string, MachineHealth>): MachineStatus {
return statuses[machine.id]?.status ?? machine.status ?? "unknown"; return statuses[machine.id]?.status ?? machine.status ?? "unknown";
} }
@@ -0,0 +1,34 @@
import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import type { SessionNotificationBadgeModel } from "../sessionNotifications";
@customElement("notification-badge")
export class NotificationBadge extends LitElement {
@property({ attribute: false }) model?: SessionNotificationBadgeModel;
override render() {
const model = this.model;
if (model === undefined) return null;
return html`
<span class=${`badge ${model.severity}`} role="img" aria-label=${model.accessibleLabel} title=${model.accessibleLabel}>
<span class="icon" aria-hidden="true">${model.icon}</span>
<span class="count" aria-hidden="true">${model.text}</span>
</span>
`;
}
static override styles = css`
:host { flex: 0 0 auto; display: inline-flex; max-width: 100%; vertical-align: middle; }
.badge { box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; gap: 3px; min-width: 24px; max-width: 100%; min-height: 20px; border: 1px solid var(--pi-accent-border); border-radius: 999px; background: var(--pi-selection-bg); color: var(--pi-accent); padding: 1px 6px; font: 650 11px/1.2 system-ui, sans-serif; text-transform: none; white-space: nowrap; }
.badge.warning { border-color: var(--pi-warning-border); background: var(--pi-warning-surface); color: var(--pi-warning); }
.badge.error { border-color: var(--pi-danger); background: color-mix(in srgb, var(--pi-danger) 12%, var(--pi-surface)); color: var(--pi-danger); }
.icon { font-size: 11px; line-height: 1; }
.count { overflow: hidden; text-overflow: ellipsis; }
`;
}
declare global {
interface HTMLElementTagNameMap {
"notification-badge": NotificationBadge;
}
}
@@ -0,0 +1,140 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { initialAppState, type AppState } from "../appState";
import type { SessionInfo } from "../api";
import type { NavigationNotificationBadges } from "./appShell/AppNavigationPanel";
import { machineSwitcherNotificationBadge } from "./MachineSwitcher";
import { PiWebApp } from "./PiWebApp";
const currentSession: SessionInfo = {
id: "session-1",
cwd: "/repo",
path: "/tmp/session-1.jsonl",
created: "2026-07-18T00:00:00.000Z",
modified: "2026-07-18T00:00:00.000Z",
messageCount: 0,
firstMessage: "",
};
const archivedSession: SessionInfo = {
...currentSession,
id: "archived-session",
path: "/tmp/archived-session.jsonl",
archived: true,
};
afterEach(() => {
vi.unstubAllGlobals();
});
describe("PiWebApp notification hierarchy models", () => {
it("projects exact machine/cwd counts through session, workspace, project, machine, heading, and mobile levels", () => {
const app = createApp();
const local = { id: "local", name: "Local", kind: "local" as const, createdAt: "now", updatedAt: "now" };
const remote = { id: "remote", name: "Remote", kind: "remote" as const, createdAt: "now", updatedAt: "now" };
const state: AppState = {
...initialAppState(),
machines: [local, remote],
selectedMachine: local,
projects: [{ id: "project-1", name: "Repo", path: "/repo", createdAt: "now" }],
selectedProject: { id: "project-1", name: "Repo", path: "/repo", createdAt: "now" },
workspaces: [
{ id: "workspace-1", projectId: "project-1", path: "/repo", label: "repo", isMain: true, isGitRepo: true, isGitWorktree: false },
{ id: "workspace-2", projectId: "project-1", path: "/repo-worktree", label: "worktree", isMain: false, isGitRepo: true, isGitWorktree: true },
],
selectedWorkspace: { id: "workspace-1", projectId: "project-1", path: "/repo", label: "repo", isMain: true, isGitRepo: true, isGitWorktree: false },
workspacesByProjectId: {
"project-1": [
{ id: "workspace-1", projectId: "project-1", path: "/repo", label: "repo", isMain: true, isGitRepo: true, isGitWorktree: false },
{ id: "workspace-2", projectId: "project-1", path: "/repo-worktree", label: "worktree", isMain: false, isGitRepo: true, isGitWorktree: true },
],
},
sessions: [currentSession, archivedSession],
selectedSession: currentSession,
notificationCatalogsByMachine: {
local: {
machineId: "local",
status: "fresh",
daemonInstanceId: "daemon-local",
catalogRevision: 4,
summariesBySessionId: {
"session-1": { sessionId: "session-1", cwd: "/repo", inboxRevision: 1, retainedCount: 2, discardedCount: 0, highestSeverity: "error" },
"session-2": { sessionId: "session-2", cwd: "/repo-worktree", inboxRevision: 2, retainedCount: 1, discardedCount: 3, highestSeverity: "warning" },
},
},
remote: {
machineId: "remote",
status: "fresh",
daemonInstanceId: "daemon-remote",
catalogRevision: 1,
summariesBySessionId: {
"session-1": { sessionId: "session-1", cwd: "/remote", inboxRevision: 1, retainedCount: 5, discardedCount: 0, highestSeverity: "info" },
},
},
},
};
Reflect.set(app, "state", state);
const badges = navigationNotificationBadges(app);
const mobile = mobileSessionsNotificationBadge(app);
expect(badges.sessions["session-1"]).toMatchObject({ text: "2", severity: "error" });
expect(badges.sessions["archived-session"]).toBeUndefined();
expect(badges.workspaces["workspace-1"]).toMatchObject({ text: "2", severity: "error" });
expect(badges.workspaces["workspace-2"]).toMatchObject({ text: "1+", severity: "warning" });
expect(badges.projects["project-1"]).toMatchObject({ text: "3+", severity: "error" });
expect(badges.machines["local"]).toMatchObject({ text: "3+", severity: "error" });
expect(badges.machines["remote"]).toMatchObject({ text: "5", severity: "info" });
expect(badges.machinesHeading).toMatchObject({ text: "8+", severity: "error" });
expect(machineSwitcherNotificationBadge("local", badges.machines, badges.machinesHeading)).toBe(badges.machinesHeading);
expect(badges.sessionsHeading).toMatchObject({ text: "2", severity: "error" });
expect(mobile).toMatchObject({ text: "8+", severity: "error" });
});
it("excludes stale remote catalogs from mobile and machine badges", () => {
const app = createApp();
const state = initialAppState();
state.notificationCatalogsByMachine = {
remote: {
machineId: "remote",
status: "stale",
daemonInstanceId: "daemon-remote",
catalogRevision: 3,
summariesBySessionId: {
"session-1": { sessionId: "session-1", cwd: "/repo", inboxRevision: 3, retainedCount: 9, discardedCount: 0, highestSeverity: "error" },
},
},
};
Reflect.set(app, "state", state);
expect(mobileSessionsNotificationBadge(app)).toBeUndefined();
});
});
function createApp(): PiWebApp {
const storage = { getItem: () => null, setItem: () => undefined, removeItem: () => undefined };
vi.stubGlobal("window", { location: { search: "" }, localStorage: storage });
return new PiWebApp();
}
type NavigationNotificationBadgesMethod = (this: PiWebApp) => NavigationNotificationBadges;
type MobileNotificationBadgeMethod = (this: PiWebApp) => NavigationNotificationBadges["machinesHeading"];
function navigationNotificationBadges(app: PiWebApp): NavigationNotificationBadges {
const method: unknown = Reflect.get(app, "navigationNotificationBadges");
if (!isNavigationNotificationBadgesMethod(method)) throw new Error("PiWebApp.navigationNotificationBadges is not callable");
return method.call(app);
}
function mobileSessionsNotificationBadge(app: PiWebApp): NavigationNotificationBadges["machinesHeading"] {
const method: unknown = Reflect.get(app, "mobileSessionsNotificationBadge");
if (!isMobileNotificationBadgeMethod(method)) throw new Error("PiWebApp.mobileSessionsNotificationBadge is not callable");
return method.call(app);
}
function isNavigationNotificationBadgesMethod(value: unknown): value is NavigationNotificationBadgesMethod {
return typeof value === "function";
}
function isMobileNotificationBadgeMethod(value: unknown): value is MobileNotificationBadgeMethod {
return typeof value === "function";
}
+95 -15
View File
@@ -14,6 +14,7 @@ import { ProjectController } from "../controllers/projectController";
import { ProjectActivityOwnershipCoordinator } from "../controllers/projectActivityOwnershipCoordinator"; import { ProjectActivityOwnershipCoordinator } from "../controllers/projectActivityOwnershipCoordinator";
import { PiWebStatusController } from "../controllers/piWebStatusController"; import { PiWebStatusController } from "../controllers/piWebStatusController";
import { SessionController } from "../controllers/sessionController"; import { SessionController } from "../controllers/sessionController";
import { SessionNotificationController } from "../controllers/sessionNotificationController";
import { WorkspaceController, canDeleteWorkspace } from "../controllers/workspaceController"; import { WorkspaceController, canDeleteWorkspace } from "../controllers/workspaceController";
import { emptyMachineNavigationSnapshot, machineNavigationSnapshotFromState, routeFromMachineNavigationSnapshot, SessionStorageMachineNavigationMemory, type MachineNavigationSnapshot, type WorkspaceRouteSurface } from "../controllers/machineNavigationMemory"; import { emptyMachineNavigationSnapshot, machineNavigationSnapshotFromState, routeFromMachineNavigationSnapshot, SessionStorageMachineNavigationMemory, type MachineNavigationSnapshot, type WorkspaceRouteSurface } from "../controllers/machineNavigationMemory";
import { SessionStorageSessionSelectionMemory } from "../controllers/sessionSelection"; import { SessionStorageSessionSelectionMemory } from "../controllers/sessionSelection";
@@ -22,6 +23,16 @@ import { SessionStorageWorkspaceSelectionMemory } from "../controllers/workspace
import { KeyboardShortcutDispatcher } from "../keyboardShortcuts"; import { KeyboardShortcutDispatcher } from "../keyboardShortcuts";
import { selectedMachineId } from "../controllers/types"; import { selectedMachineId } from "../controllers/types";
import { sessionCleanupRequestKey, sessionCleanupUnavailableMessage } from "../sessionCleanupUi"; import { sessionCleanupRequestKey, sessionCleanupUnavailableMessage } from "../sessionCleanupUi";
import {
aggregateNotificationSummaries,
effectiveNotificationSummaries,
notificationAggregateAcrossMachines,
notificationAggregateForCwd,
notificationAggregateForProject,
notificationBadgeModel,
selectedNotificationView,
type SessionNotificationBadgeModel,
} from "../sessionNotifications";
import { hasAuthoritativeSessionPersistence as runtimeHasAuthoritativeSessionPersistence } from "../sessionPersistence"; import { hasAuthoritativeSessionPersistence as runtimeHasAuthoritativeSessionPersistence } from "../sessionPersistence";
import { RealtimeSocket } from "../sessionSocket"; import { RealtimeSocket } from "../sessionSocket";
import type { PiWebPluginRegistration, PluginMachine, PluginPromptEditor, QualifiedContributionId, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspacePanelContribution, PluginRuntimeContext, TerminalCommandRunsInternalRuntime, WorkspaceFiles, WorkspaceHost, WorkspaceLabelContext, WorkspaceLabelItem, WorkspacePanelContext } from "../plugins/types"; import type { PiWebPluginRegistration, PluginMachine, PluginPromptEditor, QualifiedContributionId, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspacePanelContribution, PluginRuntimeContext, TerminalCommandRunsInternalRuntime, WorkspaceFiles, WorkspaceHost, WorkspaceLabelContext, WorkspaceLabelItem, WorkspacePanelContext } from "../plugins/types";
@@ -63,7 +74,7 @@ import type { WorkspacePanelEmptyState } from "./WorkspacePanel";
import "./appShell/AppContextBar"; import "./appShell/AppContextBar";
import "./appShell/AppMobileMainTabs"; import "./appShell/AppMobileMainTabs";
import type { AppMobileMainTab, AppMobileMainTabIcon } from "./appShell/AppMobileMainTabs"; import type { AppMobileMainTab, AppMobileMainTabIcon } from "./appShell/AppMobileMainTabs";
import { shouldShowMachinesSection, type AppNavigationPanel, type NavigationFocusTarget } from "./appShell/AppNavigationPanel"; import { shouldShowMachinesSection, type AppNavigationPanel, type NavigationFocusTarget, type NavigationNotificationBadges } from "./appShell/AppNavigationPanel";
import "./appShell/AppPanelEdgeControl"; import "./appShell/AppPanelEdgeControl";
import "./appShell/AppRefreshControl"; import "./appShell/AppRefreshControl";
import { appStyles } from "./shared"; import { appStyles } from "./shared";
@@ -101,11 +112,17 @@ export class PiWebApp extends LitElement {
@query("#navigation-panel") private navigationPanelFrame?: HTMLElement; @query("#navigation-panel") private navigationPanelFrame?: HTMLElement;
@query("#workspace-panel") private workspacePanelFrame?: HTMLElement; @query("#workspace-panel") private workspacePanelFrame?: HTMLElement;
private readonly notifications = new SessionNotificationController(
() => this.state,
(patch) => { this.setState(patch); },
{ onBackgroundError: (message, error) => { console.warn(message, error); } },
);
private readonly sessions = new SessionController( private readonly sessions = new SessionController(
() => this.state, () => this.state,
(patch) => { this.setState(patch); }, (patch) => { this.setState(patch); },
() => { this.updateUrl(); }, () => { this.updateUrl(); },
new SessionStorageSessionSelectionMemory(), new SessionStorageSessionSelectionMemory(),
{ notifications: this.notifications },
); );
private readonly projectActivityOwnership = new ProjectActivityOwnershipCoordinator( private readonly projectActivityOwnership = new ProjectActivityOwnershipCoordinator(
() => this.state, () => this.state,
@@ -163,7 +180,7 @@ export class PiWebApp extends LitElement {
); );
private readonly keyboard = new KeyboardShortcutDispatcher(); private readonly keyboard = new KeyboardShortcutDispatcher();
private readonly realtime = new RealtimeSocket(); private readonly realtime = new RealtimeSocket();
private readonly machineActivitySockets = new Map<string, RealtimeSocket>(); private readonly machineRealtimeSockets = new Map<string, RealtimeSocket>();
private readonly activeTerminalIds = new Set<string>(); private readonly activeTerminalIds = new Set<string>();
private readonly machineNavigation = new SessionStorageMachineNavigationMemory(); private readonly machineNavigation = new SessionStorageMachineNavigationMemory();
private readonly terminalSelection = new SessionStorageTerminalSelectionMemory(); private readonly terminalSelection = new SessionStorageTerminalSelectionMemory();
@@ -260,6 +277,7 @@ export class PiWebApp extends LitElement {
this.keyboard.reset(); this.keyboard.reset();
this.auth.dispose(); this.auth.dispose();
this.sessions.dispose(); this.sessions.dispose();
this.notifications.dispose();
this.realtime.close(); this.realtime.close();
this.closeMachineActivitySockets(); this.closeMachineActivitySockets();
this.git.dispose(); this.git.dispose();
@@ -280,6 +298,7 @@ export class PiWebApp extends LitElement {
this.handleWorkspaceChange(previous, this.state); this.handleWorkspaceChange(previous, this.state);
this.handleMachineChange(previous, this.state); this.handleMachineChange(previous, this.state);
if (machineActivitySubscriptionInputsChanged(previous, this.state)) this.syncMachineActivitySubscriptions(); if (machineActivitySubscriptionInputsChanged(previous, this.state)) this.syncMachineActivitySubscriptions();
this.notifications.syncEnvironment(previous, this.state);
} }
private async loadProjectsAndRestoreRoute() { private async loadProjectsAndRestoreRoute() {
@@ -308,6 +327,7 @@ export class PiWebApp extends LitElement {
private async refreshAfterBrowserResume(): Promise<void> { private async refreshAfterBrowserResume(): Promise<void> {
await Promise.all([ await Promise.all([
this.sessions.refreshSelectedSession(), this.sessions.refreshSelectedSession(),
this.notifications.refreshAfterBrowserResume(),
this.refreshMachineActivities(), this.refreshMachineActivities(),
this.refreshWorkspaceDeletionRuns(), this.refreshWorkspaceDeletionRuns(),
]); ]);
@@ -363,6 +383,7 @@ export class PiWebApp extends LitElement {
try { try {
await Promise.all([ await Promise.all([
this.sessions.refreshSelectedSession(), this.sessions.refreshSelectedSession(),
this.notifications.refreshAfterBrowserResume(),
this.refreshMachineActivities(), this.refreshMachineActivities(),
this.loadClientConfig(), this.loadClientConfig(),
this.refreshWorkspaceDeletionRuns(), this.refreshWorkspaceDeletionRuns(),
@@ -795,39 +816,44 @@ export class PiWebApp extends LitElement {
} }
private connectRealtime(): void { private connectRealtime(): void {
const machineId = selectedMachineId(this.state);
this.realtime.connect( this.realtime.connect(
(event) => { this.handleRealtimeEvent(event); }, (event) => { this.handleRealtimeEvent(machineId, event); },
() => { () => {
this.notifications.globalSocketOpened(machineId);
const workspace = this.state.selectedWorkspace; const workspace = this.state.selectedWorkspace;
if (workspace !== undefined) void this.refreshActiveTerminals(workspace); if (workspace !== undefined) void this.refreshActiveTerminals(workspace);
void this.refreshWorkspaceActivity(); void this.refreshWorkspaceActivity(machineId);
}, },
selectedMachineId(this.state), machineId,
); );
} }
private syncMachineActivitySubscriptions(): void { private syncMachineActivitySubscriptions(): void {
const desiredMachineIds = this.machineActivitySubscriptionIds(); const desiredMachineIds = this.machineActivitySubscriptionIds();
for (const [machineId, socket] of this.machineActivitySockets.entries()) { for (const [machineId, socket] of this.machineRealtimeSockets.entries()) {
if (desiredMachineIds.has(machineId)) continue; if (desiredMachineIds.has(machineId)) continue;
socket.close(); socket.close();
this.machineActivitySockets.delete(machineId); this.machineRealtimeSockets.delete(machineId);
} }
for (const machineId of desiredMachineIds) { for (const machineId of desiredMachineIds) {
if (this.machineActivitySockets.has(machineId)) continue; if (this.machineRealtimeSockets.has(machineId)) continue;
const socket = new RealtimeSocket(); const socket = new RealtimeSocket();
socket.connect( socket.connect(
(event) => { this.handleMachineActivityEvent(machineId, event); }, (event) => { this.handleMachineActivityEvent(machineId, event); },
() => { void this.refreshWorkspaceActivity(machineId); }, () => {
this.notifications.globalSocketOpened(machineId);
void this.refreshWorkspaceActivity(machineId);
},
machineId, machineId,
); );
this.machineActivitySockets.set(machineId, socket); this.machineRealtimeSockets.set(machineId, socket);
} }
} }
private closeMachineActivitySockets(): void { private closeMachineActivitySockets(): void {
for (const socket of this.machineActivitySockets.values()) socket.close(); for (const socket of this.machineRealtimeSockets.values()) socket.close();
this.machineActivitySockets.clear(); this.machineRealtimeSockets.clear();
} }
private machineActivitySubscriptionIds(): Set<string> { private machineActivitySubscriptionIds(): Set<string> {
@@ -840,10 +866,12 @@ export class PiWebApp extends LitElement {
private handleMachineActivityEvent(machineId: string, event: RealtimeEvent): void { private handleMachineActivityEvent(machineId: string, event: RealtimeEvent): void {
if (event.type === "workspace.activity") this.activity.applyWorkspaceActivity(event.activity, machineId); if (event.type === "workspace.activity") this.activity.applyWorkspaceActivity(event.activity, machineId);
else if (event.type === "notifications.summary") this.notifications.applySummaryEvent(machineId, event);
} }
private handleRealtimeEvent(event: RealtimeEvent): void { private handleRealtimeEvent(machineId: string, event: RealtimeEvent): void {
if (event.type === "workspace.activity") this.activity.applyWorkspaceActivity(event.activity); if (event.type === "workspace.activity") this.activity.applyWorkspaceActivity(event.activity);
else if (event.type === "notifications.summary") this.notifications.applySummaryEvent(machineId, event);
else if (isTerminalEvent(event)) { else if (isTerminalEvent(event)) {
this.applyTerminalEvent(event); this.applyTerminalEvent(event);
if (event.type === "terminal.exited") void this.refreshWorkspaceDeletionRuns(); if (event.type === "terminal.exited") void this.refreshWorkspaceDeletionRuns();
@@ -1118,6 +1146,49 @@ export class PiWebApp extends LitElement {
} }
} }
private navigationNotificationBadges(): NavigationNotificationBadges {
const state = this.state;
const selectedId = selectedMachineId(state);
const selectedCatalog = state.notificationCatalogsByMachine[selectedId];
const selectedSummaries = effectiveNotificationSummaries(selectedCatalog, state.selectedNotificationInbox);
const selectedSummaryBySessionId = new Map(selectedSummaries.map((summary) => [summary.sessionId, summary]));
const sessions = Object.fromEntries(state.sessions.map((session): [string, SessionNotificationBadgeModel | undefined] => {
const summary = session.archived === true ? undefined : selectedSummaryBySessionId.get(session.id);
const exactSummary = summary?.cwd === session.cwd ? summary : undefined;
return [session.id, exactSummary === undefined ? undefined : notificationBadgeModel(aggregateNotificationSummaries([exactSummary]))];
}));
const workspaces = Object.fromEntries(state.workspaces.map((workspace): [string, SessionNotificationBadgeModel | undefined] => [
workspace.id,
notificationBadgeModel(notificationAggregateForCwd(selectedSummaries, workspace.path)),
]));
const projects = Object.fromEntries(state.projects.map((project): [string, SessionNotificationBadgeModel | undefined] => {
const projectWorkspaces = state.workspacesByProjectId[project.id] ?? (state.selectedProject?.id === project.id ? state.workspaces : []);
return [project.id, notificationBadgeModel(notificationAggregateForProject(selectedSummaries, new Set(projectWorkspaces.map((workspace) => workspace.path))))];
}));
const machines = Object.fromEntries(state.machines.map((machine): [string, SessionNotificationBadgeModel | undefined] => [
machine.id,
notificationBadgeModel(aggregateNotificationSummaries(effectiveNotificationSummaries(state.notificationCatalogsByMachine[machine.id], state.selectedNotificationInbox))),
]));
const allMachines = notificationBadgeModel(notificationAggregateAcrossMachines(state.notificationCatalogsByMachine, state.selectedNotificationInbox));
const selectedWorkspacePaths = new Set(state.workspaces.map((workspace) => workspace.path));
return {
machines,
projects,
workspaces,
sessions,
machinesHeading: allMachines,
projectsHeading: notificationBadgeModel(aggregateNotificationSummaries(selectedSummaries)),
workspacesHeading: notificationBadgeModel(notificationAggregateForProject(selectedSummaries, selectedWorkspacePaths)),
sessionsHeading: state.selectedWorkspace === undefined ? undefined : notificationBadgeModel(notificationAggregateForCwd(selectedSummaries, state.selectedWorkspace.path)),
};
}
private mobileSessionsNotificationBadge(): SessionNotificationBadgeModel | undefined {
return notificationBadgeModel(notificationAggregateAcrossMachines(this.state.notificationCatalogsByMachine, this.state.selectedNotificationInbox));
}
private renderNavigationPanel() { private renderNavigationPanel() {
return html` return html`
<app-navigation-panel <app-navigation-panel
@@ -1125,6 +1196,7 @@ export class PiWebApp extends LitElement {
.selectedMachine=${this.state.selectedMachine} .selectedMachine=${this.state.selectedMachine}
.machineStatuses=${this.state.machineStatuses} .machineStatuses=${this.state.machineStatuses}
.machineActivities=${this.state.machineActivities} .machineActivities=${this.state.machineActivities}
.notificationBadges=${this.navigationNotificationBadges()}
.machinesCollapsed=${this.navigationSections.isCollapsed("machines")} .machinesCollapsed=${this.navigationSections.isCollapsed("machines")}
.onToggleMachines=${() => { this.navigationSections.toggle("machines"); }} .onToggleMachines=${() => { this.navigationSections.toggle("machines"); }}
.onSelectMachine=${(machine: Machine) => this.selectNavigationItem("machines", "projects", () => this.selectMachineWithMemory(machine))} .onSelectMachine=${(machine: Machine) => this.selectNavigationItem("machines", "projects", () => this.selectMachineWithMemory(machine))}
@@ -1892,6 +1964,14 @@ export class PiWebApp extends LitElement {
void this.sessions.dismissWarning(dismissId); void this.sessions.dismissWarning(dismissId);
}; };
private readonly handleDismissNotification = (notificationId: string): void => {
void this.notifications.dismissNotification(notificationId);
};
private readonly handleDismissAllNotifications = (): void => {
void this.notifications.dismissAll();
};
private readonly handleSelectModel = (): void => { private readonly handleSelectModel = (): void => {
void this.openModelDialog(); void this.openModelDialog();
}; };
@@ -1902,7 +1982,7 @@ export class PiWebApp extends LitElement {
private renderChatView(state: AppState, session: SessionInfo) { private renderChatView(state: AppState, session: SessionInfo) {
return html` return html`
<chat-view .sessionId=${session.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageEnd=${state.messagePageEnd} .messageTotal=${state.messagePageTotal} .hasMore=${state.messagePageStart > 0} .loadingMore=${state.isLoadingEarlierMessages} .isSendingPrompt=${state.sendingPrompts[session.id] === true} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .clientQueuedMessages=${state.clientQueuedSessionMessages[session.id] ?? []} .status=${state.status} .activity=${state.activity} .canClearServerQueue=${this.canClearServerQueue()} .onClearServerQueue=${this.handleClearServerQueue} .onDismissWarning=${this.handleDismissWarning} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}></chat-view> <chat-view .sessionId=${session.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageEnd=${state.messagePageEnd} .messageTotal=${state.messagePageTotal} .hasMore=${state.messagePageStart > 0} .loadingMore=${state.isLoadingEarlierMessages} .isSendingPrompt=${state.sendingPrompts[session.id] === true} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .clientQueuedMessages=${state.clientQueuedSessionMessages[session.id] ?? []} .status=${state.status} .activity=${state.activity} .notificationInbox=${selectedNotificationView(state.selectedNotificationInbox)} .canClearServerQueue=${this.canClearServerQueue()} .onClearServerQueue=${this.handleClearServerQueue} .onDismissWarning=${this.handleDismissWarning} .onDismissNotification=${this.handleDismissNotification} .onDismissAllNotifications=${this.handleDismissAllNotifications} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}></chat-view>
`; `;
} }
@@ -1934,7 +2014,7 @@ export class PiWebApp extends LitElement {
private mobileMainTabs(): AppMobileMainTab[] { private mobileMainTabs(): AppMobileMainTab[] {
return [ return [
{ id: "navigation", label: "Sessions", icon: "navigation", className: "navigation-tab" }, { id: "navigation", label: "Sessions", icon: "navigation", className: "navigation-tab", badge: this.mobileSessionsNotificationBadge() },
{ id: "chat", label: "Chat", icon: "chat" }, { id: "chat", label: "Chat", icon: "chat" },
...this.visibleWorkspacePanels().map((panel): AppMobileMainTab => { ...this.visibleWorkspacePanels().map((panel): AppMobileMainTab => {
const icon = panel.icon ?? this.mobilePanelIcon(panel); const icon = panel.icon ?? this.mobilePanelIcon(panel);
+7 -3
View File
@@ -1,12 +1,14 @@
import { LitElement, html, type PropertyValues } from "lit"; import { LitElement, html, type PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators.js"; import { customElement, property, state } from "lit/decorators.js";
import type { Project, Workspace, WorkspaceActivity } from "../api"; import type { Project, Workspace, WorkspaceActivity } from "../api";
import type { SessionNotificationBadgeModel } from "../sessionNotifications";
import { projectActivityIndicator } from "../workspaceActivity"; import { projectActivityIndicator } from "../workspaceActivity";
import { actionMenuPanelStyle } from "./actionMenu"; import { actionMenuPanelStyle } from "./actionMenu";
import { renderActionActivityIndicator } from "./activityBadge"; import { renderActionActivityIndicator } from "./activityBadge";
import type { KeyboardNavigableSection } from "./navigationFocus"; import type { KeyboardNavigableSection } from "./navigationFocus";
import { activateSelectableRow, focusSelectedOrFirstSelectableRow, handleSelectableRowKeyboard } from "./selectableRow"; import { activateSelectableRow, focusSelectedOrFirstSelectableRow, handleSelectableRowKeyboard } from "./selectableRow";
import { listStyles } from "./shared"; import { listStyles } from "./shared";
import "./NotificationBadge";
@customElement("project-list") @customElement("project-list")
export class ProjectList extends LitElement implements KeyboardNavigableSection { export class ProjectList extends LitElement implements KeyboardNavigableSection {
@@ -14,6 +16,8 @@ export class ProjectList extends LitElement implements KeyboardNavigableSection
@property({ attribute: false }) selected?: Project; @property({ attribute: false }) selected?: Project;
@property({ attribute: false }) activities: Record<string, WorkspaceActivity> = {}; @property({ attribute: false }) activities: Record<string, WorkspaceActivity> = {};
@property({ attribute: false }) workspacesByProjectId: Record<string, Workspace[]> = {}; @property({ attribute: false }) workspacesByProjectId: Record<string, Workspace[]> = {};
@property({ attribute: false }) notificationBadges: Record<string, SessionNotificationBadgeModel | undefined> = {};
@property({ attribute: false }) notificationHeadingBadge?: SessionNotificationBadgeModel;
@property({ type: Boolean, reflect: true }) collapsible = false; @property({ type: Boolean, reflect: true }) collapsible = false;
@property({ type: Boolean, reflect: true }) collapsed = false; @property({ type: Boolean, reflect: true }) collapsed = false;
@property({ attribute: false }) onSelect?: (project: Project) => void; @property({ attribute: false }) onSelect?: (project: Project) => void;
@@ -64,7 +68,7 @@ export class ProjectList extends LitElement implements KeyboardNavigableSection
@keydown=${(event: KeyboardEvent) => { this.handleProjectKeydown(event, project); }} @keydown=${(event: KeyboardEvent) => { this.handleProjectKeydown(event, project); }}
> >
<div class="action-main"> <div class="action-main">
<span class="action-name">${project.name}</span><small>${project.path}</small> <span class="workspace-primary"><span class="workspace-primary-label">${project.name}</span>${this.notificationBadges[project.id] === undefined ? null : html`<notification-badge .model=${this.notificationBadges[project.id]}></notification-badge>`}</span><small>${project.path}</small>
${this.renderActivity(project)} ${this.renderActivity(project)}
</div> </div>
<div class="action-menu"> <div class="action-menu">
@@ -93,10 +97,10 @@ export class ProjectList extends LitElement implements KeyboardNavigableSection
} }
private renderHeading() { private renderHeading() {
if (!this.collapsible) return "Projects"; if (!this.collapsible) return html`<span>Projects</span>${this.notificationHeadingBadge === undefined ? null : html`<notification-badge .model=${this.notificationHeadingBadge}></notification-badge>`}`;
const selectedSummary = this.selected?.name ?? "No project selected"; const selectedSummary = this.selected?.name ?? "No project selected";
const selectedTitle = this.selected?.path ?? selectedSummary; const selectedTitle = this.selected?.path ?? selectedSummary;
return html`<button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span class="section-title"><span class="section-name">${this.collapsed ? "▸" : "▾"} Projects</span>${this.collapsed ? html`<small class="section-selected" title=${selectedTitle}>${selectedSummary}</small>` : null}</span><small class="section-count">${this.projects.length}</small></button>`; return html`<button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span class="section-title"><span class="section-name">${this.collapsed ? "▸" : "▾"} Projects</span>${this.collapsed ? html`<small class="section-selected" title=${selectedTitle}>${selectedSummary}</small>` : null}</span>${this.notificationHeadingBadge === undefined ? null : html`<notification-badge .model=${this.notificationHeadingBadge}></notification-badge>`}<small class="section-count">${this.projects.length}</small></button>`;
} }
private renderActivity(project: Project) { private renderActivity(project: Project) {
+11 -2
View File
@@ -2,6 +2,7 @@ import { LitElement, css, html, type PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators.js"; import { customElement, property, state } from "lit/decorators.js";
import type { SessionActivity, SessionInfo, SessionStatus } from "../api"; import type { SessionActivity, SessionInfo, SessionStatus } from "../api";
import { isCachedNewSessionInfo } from "../cachedNewSessions"; import { isCachedNewSessionInfo } from "../cachedNewSessions";
import type { SessionNotificationBadgeModel } from "../sessionNotifications";
import { shortSessionId } from "../sessionLabels"; import { shortSessionId } from "../sessionLabels";
import { isArchivableSessionInfo, isTransientNewSessionInfo } from "../sessionPersistence"; import { isArchivableSessionInfo, isTransientNewSessionInfo } from "../sessionPersistence";
import { isSessionActive } from "../../../shared/activity"; import { isSessionActive } from "../../../shared/activity";
@@ -10,6 +11,7 @@ import { renderActionActivityIndicator, type ActivityIndicatorKind } from "./act
import type { KeyboardNavigableSection } from "./navigationFocus"; import type { KeyboardNavigableSection } from "./navigationFocus";
import { activateSelectableRow, focusSelectedOrFirstSelectableRow, handleSelectableRowKeyboard } from "./selectableRow"; import { activateSelectableRow, focusSelectedOrFirstSelectableRow, handleSelectableRowKeyboard } from "./selectableRow";
import { listStyles } from "./shared"; import { listStyles } from "./shared";
import "./NotificationBadge";
function sessionLabel(session: SessionInfo): string { function sessionLabel(session: SessionInfo): string {
if (session.name !== undefined && session.name !== "") return session.name; if (session.name !== undefined && session.name !== "") return session.name;
@@ -30,6 +32,8 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
@property({ attribute: false }) statuses: Record<string, SessionStatus> = {}; @property({ attribute: false }) statuses: Record<string, SessionStatus> = {};
@property({ attribute: false }) activities: Record<string, SessionActivity> = {}; @property({ attribute: false }) activities: Record<string, SessionActivity> = {};
@property({ attribute: false }) sending: Record<string, true> = {}; @property({ attribute: false }) sending: Record<string, true> = {};
@property({ attribute: false }) notificationBadges: Record<string, SessionNotificationBadgeModel | undefined> = {};
@property({ attribute: false }) notificationHeadingBadge?: SessionNotificationBadgeModel;
@property({ attribute: false }) selected?: SessionInfo; @property({ attribute: false }) selected?: SessionInfo;
@property({ type: Number }) startingCount = 0; @property({ type: Number }) startingCount = 0;
@property({ type: Boolean }) canStart = false; @property({ type: Boolean }) canStart = false;
@@ -132,7 +136,8 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
if (!this.collapsible) { if (!this.collapsible) {
return html` return html`
<h2> <h2>
Sessions <span class="plain-heading">Sessions</span>
${this.notificationHeadingBadge === undefined ? null : html`<notification-badge .model=${this.notificationHeadingBadge}></notification-badge>`}
${this.renderCurrentSelectionButton(currentSessions)} ${this.renderCurrentSelectionButton(currentSessions)}
${this.renderCleanupButton()} ${this.renderCleanupButton()}
${this.renderStartButton()} ${this.renderStartButton()}
@@ -144,6 +149,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
return html` return html`
<h2> <h2>
<button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span class="section-title"><span class="section-name">${this.collapsed ? "▸" : "▾"} Sessions</span>${this.collapsed ? html`<small class="section-selected" dir="auto" title=${selectedTitle}>${selectedSummary}</small>` : null}</span></button> <button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span class="section-title"><span class="section-name">${this.collapsed ? "▸" : "▾"} Sessions</span>${this.collapsed ? html`<small class="section-selected" dir="auto" title=${selectedTitle}>${selectedSummary}</small>` : null}</span></button>
${this.notificationHeadingBadge === undefined ? null : html`<notification-badge .model=${this.notificationHeadingBadge}></notification-badge>`}
${this.renderCurrentSelectionButton(currentSessions)} ${this.renderCurrentSelectionButton(currentSessions)}
<small class="section-count">${sessionCount}</small> <small class="section-count">${sessionCount}</small>
${this.renderCleanupButton()} ${this.renderCleanupButton()}
@@ -250,7 +256,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
> >
<div class="action-main ${selectionActive ? "selecting" : ""}"> <div class="action-main ${selectionActive ? "selecting" : ""}">
${showsCheckbox ? html`<input class="session-checkbox" type="checkbox" aria-label=${`Select ${sessionLabel(session)}`} .checked=${bulkSelected} @click=${(event: MouseEvent) => { event.stopPropagation(); }} @change=${() => { this.toggleSelected(session.id); }}>` : null} ${showsCheckbox ? html`<input class="session-checkbox" type="checkbox" aria-label=${`Select ${sessionLabel(session)}`} .checked=${bulkSelected} @click=${(event: MouseEvent) => { event.stopPropagation(); }} @change=${() => { this.toggleSelected(session.id); }}>` : null}
<span class="action-name" dir="auto">${row.depth > 0 ? html`<span class="tree-marker">↳</span>` : null}${sessionLabel(session)}${row.depth > 2 ? html` <span class="badge">depth ${row.depth}</span>` : null}${row.hasMissingParent ? html` <span class="badge">parent unavailable</span>` : null}</span><small>${this.renderSessionMetaPrefix(session, status, activity)}${String(session.messageCount)} messages</small> <span class="action-name-line"><span class="action-name" dir="auto">${row.depth > 0 ? html`<span class="tree-marker">↳</span>` : null}${sessionLabel(session)}${row.depth > 2 ? html` <span class="badge">depth ${row.depth}</span>` : null}${row.hasMissingParent ? html` <span class="badge">parent unavailable</span>` : null}</span>${this.notificationBadges[session.id] === undefined ? null : html`<notification-badge .model=${this.notificationBadges[session.id]}></notification-badge>`}</span><small>${this.renderSessionMetaPrefix(session, status, activity)}${String(session.messageCount)} messages</small>
${this.renderActivity(session)} ${this.renderActivity(session)}
</div> </div>
<div class="action-menu"> <div class="action-menu">
@@ -425,6 +431,9 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
.bulk-row button { padding: 5px 7px; font-size: 12px; } .bulk-row button { padding: 5px 7px; font-size: 12px; }
.bulk-row small { display: inline; min-width: 0; color: var(--pi-muted); } .bulk-row small { display: inline; min-width: 0; color: var(--pi-muted); }
.action-name, .section-selected { text-align: start; unicode-bidi: plaintext; } .action-name, .section-selected { text-align: start; unicode-bidi: plaintext; }
.plain-heading { min-width: 0; }
.action-name-line { min-width: 0; display: flex; align-items: flex-start; gap: 6px; }
.action-name-line .action-name { flex: 1 1 auto; min-width: 0; }
.bulk-row .capability-hint { flex: 1 0 100%; color: var(--pi-warning); } .bulk-row .capability-hint { flex: 1 0 100%; color: var(--pi-warning); }
.bulk-row.selecting { padding: 6px; border: 1px solid var(--pi-border-muted); border-radius: 8px; background: color-mix(in srgb, var(--pi-surface) 65%, transparent); } .bulk-row.selecting { padding: 6px; border: 1px solid var(--pi-border-muted); border-radius: 8px; background: color-mix(in srgb, var(--pi-surface) 65%, transparent); }
button.danger, .action-menu-panel button.danger { color: var(--pi-danger); } button.danger, .action-menu-panel button.danger { color: var(--pi-danger); }
+7 -2
View File
@@ -2,6 +2,7 @@ import { LitElement, html, type PropertyValues, type TemplateResult } from "lit"
import { customElement, property, state } from "lit/decorators.js"; import { customElement, property, state } from "lit/decorators.js";
import type { Workspace, WorkspaceActivity } from "../api"; import type { Workspace, WorkspaceActivity } from "../api";
import type { WorkspaceLabelItem } from "../plugins/types"; import type { WorkspaceLabelItem } from "../plugins/types";
import type { SessionNotificationBadgeModel } from "../sessionNotifications";
import { workspaceActivityFor, workspaceActivityIndicator } from "../workspaceActivity"; import { workspaceActivityFor, workspaceActivityIndicator } from "../workspaceActivity";
import { actionMenuPanelStyle } from "./actionMenu"; import { actionMenuPanelStyle } from "./actionMenu";
import { renderActionActivityIndicator } from "./activityBadge"; import { renderActionActivityIndicator } from "./activityBadge";
@@ -9,6 +10,7 @@ import type { KeyboardNavigableSection } from "./navigationFocus";
import { activateSelectableRow, focusSelectedOrFirstSelectableRow, handleSelectableRowKeyboard } from "./selectableRow"; import { activateSelectableRow, focusSelectedOrFirstSelectableRow, handleSelectableRowKeyboard } from "./selectableRow";
import { listStyles } from "./shared"; import { listStyles } from "./shared";
import { renderWorkspaceLabelInlineItems } from "./workspaceLabel"; import { renderWorkspaceLabelInlineItems } from "./workspaceLabel";
import "./NotificationBadge";
@customElement("workspace-list") @customElement("workspace-list")
export class WorkspaceList extends LitElement implements KeyboardNavigableSection { export class WorkspaceList extends LitElement implements KeyboardNavigableSection {
@@ -19,6 +21,8 @@ export class WorkspaceList extends LitElement implements KeyboardNavigableSectio
@property({ attribute: false }) workspaceLabelItems: (workspace: Workspace) => WorkspaceLabelItem[] = () => []; @property({ attribute: false }) workspaceLabelItems: (workspace: Workspace) => WorkspaceLabelItem[] = () => [];
@property({ attribute: false }) activities: Record<string, WorkspaceActivity> = {}; @property({ attribute: false }) activities: Record<string, WorkspaceActivity> = {};
@property({ attribute: false }) deletingWorkspaceIds: string[] = []; @property({ attribute: false }) deletingWorkspaceIds: string[] = [];
@property({ attribute: false }) notificationBadges: Record<string, SessionNotificationBadgeModel | undefined> = {};
@property({ attribute: false }) notificationHeadingBadge?: SessionNotificationBadgeModel;
@property({ attribute: false }) onSelect?: (workspace: Workspace) => void; @property({ attribute: false }) onSelect?: (workspace: Workspace) => void;
@property({ attribute: false }) onDelete?: (workspace: Workspace) => void; @property({ attribute: false }) onDelete?: (workspace: Workspace) => void;
@property({ attribute: false }) onToggleCollapsed?: () => void; @property({ attribute: false }) onToggleCollapsed?: () => void;
@@ -85,10 +89,10 @@ export class WorkspaceList extends LitElement implements KeyboardNavigableSectio
} }
private renderHeading() { private renderHeading() {
if (!this.collapsible) return "Workspaces"; if (!this.collapsible) return html`<span>Workspaces</span>${this.notificationHeadingBadge === undefined ? null : html`<notification-badge .model=${this.notificationHeadingBadge}></notification-badge>`}`;
const selectedSummary = this.selected === undefined ? "No workspace selected" : `${this.selected.label}${this.selected.isMain ? " · main" : ""} · ${this.selected.path}`; const selectedSummary = this.selected === undefined ? "No workspace selected" : `${this.selected.label}${this.selected.isMain ? " · main" : ""} · ${this.selected.path}`;
const selectedTitle = this.selected?.path ?? selectedSummary; const selectedTitle = this.selected?.path ?? selectedSummary;
return html`<button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span class="section-title"><span class="section-name">${this.collapsed ? "▸" : "▾"} Workspaces</span>${this.collapsed ? html`<small class="section-selected" title=${selectedTitle}>${selectedSummary}</small>` : null}</span><small class="section-count">${this.workspaces.length}</small></button>`; return html`<button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span class="section-title"><span class="section-name">${this.collapsed ? "▸" : "▾"} Workspaces</span>${this.collapsed ? html`<small class="section-selected" title=${selectedTitle}>${selectedSummary}</small>` : null}</span>${this.notificationHeadingBadge === undefined ? null : html`<notification-badge .model=${this.notificationHeadingBadge}></notification-badge>`}<small class="section-count">${this.workspaces.length}</small></button>`;
} }
private renderActivity(workspace: Workspace): TemplateResult | undefined { private renderActivity(workspace: Workspace): TemplateResult | undefined {
@@ -101,6 +105,7 @@ export class WorkspaceList extends LitElement implements KeyboardNavigableSectio
<span class="workspace-primary"> <span class="workspace-primary">
<span class="workspace-primary-label">${label}</span> <span class="workspace-primary-label">${label}</span>
${this.isDeleting(workspace) ? html`<span class="workspace-status">Deleting…</span>` : null} ${this.isDeleting(workspace) ? html`<span class="workspace-status">Deleting…</span>` : null}
${this.notificationBadges[workspace.id] === undefined ? null : html`<notification-badge .model=${this.notificationBadges[workspace.id]}></notification-badge>`}
</span> </span>
${items.length === 0 ? null : html` ${items.length === 0 ? null : html`
<small class="workspace-secondary"> <small class="workspace-secondary">
@@ -1,7 +1,9 @@
import { LitElement, css, html, type TemplateResult } from "lit"; import { LitElement, css, html, type TemplateResult } from "lit";
import { customElement, property, query, state } from "lit/decorators.js"; import { customElement, property, query, state } from "lit/decorators.js";
import type { AppState } from "../../appState"; import type { AppState } from "../../appState";
import type { SessionNotificationBadgeModel } from "../../sessionNotifications";
import { renderAppTabIcon, type AppTabBuiltinIcon } from "../tabIcons"; import { renderAppTabIcon, type AppTabBuiltinIcon } from "../tabIcons";
import "../NotificationBadge";
export type AppMobileMainTabBuiltinIcon = AppTabBuiltinIcon; export type AppMobileMainTabBuiltinIcon = AppTabBuiltinIcon;
export type AppMobileMainTabIcon = AppMobileMainTabBuiltinIcon | TemplateResult; export type AppMobileMainTabIcon = AppMobileMainTabBuiltinIcon | TemplateResult;
@@ -53,7 +55,7 @@ export class AppMobileMainTabs extends LitElement {
<button class=${this.tabClass(tab)} title=${tab.label} aria-label=${this.tabAriaLabel(tab)} aria-pressed=${String(selected)} @click=${() => { this.onSelect?.(tab.id); }}> <button class=${this.tabClass(tab)} title=${tab.label} aria-label=${this.tabAriaLabel(tab)} aria-pressed=${String(selected)} @click=${() => { this.onSelect?.(tab.id); }}>
${this.renderTabMark(tab, fallbackLabels)} ${this.renderTabMark(tab, fallbackLabels)}
<span class="tab-label">${tab.label}</span> <span class="tab-label">${tab.label}</span>
${this.isEmptyBadge(tab.badge) ? null : html`<span class="tab-badge">${tab.badge}</span>`} ${this.renderBadge(tab.badge)}
</button> </button>
`; `;
})} })}
@@ -74,13 +76,16 @@ export class AppMobileMainTabs extends LitElement {
} }
private tabAriaLabel(tab: AppMobileMainTab): string { private tabAriaLabel(tab: AppMobileMainTab): string {
if (isSessionNotificationBadgeModel(tab.badge)) return `${tab.label}, ${tab.badge.accessibleLabel}`;
if (typeof tab.badge !== "string" && typeof tab.badge !== "number") return tab.label; if (typeof tab.badge !== "string" && typeof tab.badge !== "number") return tab.label;
const badge = String(tab.badge).trim(); const badge = String(tab.badge).trim();
return badge === "" ? tab.label : `${tab.label}, ${badge}`; return badge === "" ? tab.label : `${tab.label}, ${badge}`;
} }
private isEmptyBadge(badge: unknown): boolean { private renderBadge(badge: unknown) {
return badge === undefined || badge === ""; if (badge === undefined || badge === "") return null;
if (isSessionNotificationBadgeModel(badge)) return html`<notification-badge .model=${badge}></notification-badge>`;
return html`<span class="tab-badge">${badge}</span>`;
} }
private renderTabMark(tab: AppMobileMainTab, fallbackLabels: Map<AppState["mainView"], string>) { private renderTabMark(tab: AppMobileMainTab, fallbackLabels: Map<AppState["mainView"], string>) {
@@ -167,7 +172,7 @@ export class AppMobileMainTabs extends LitElement {
button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; } button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; }
@media (max-width: 760px) { @media (max-width: 760px) {
.mobile-tabs { gap: 4px; padding: 6px 8px; } .mobile-tabs { gap: 4px; padding: 6px 8px; }
.mobile-tabs button { min-width: 40px; height: 36px; justify-content: center; gap: 4px; padding: 0 8px; } .mobile-tabs button { min-width: 44px; height: 44px; justify-content: center; gap: 4px; padding: 0 8px; }
.mobile-tabs .navigation-tab { display: inline-flex; } .mobile-tabs .navigation-tab { display: inline-flex; }
.tab-fallback { display: inline-block; } .tab-fallback { display: inline-block; }
.tab-label { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; border: 0; } .tab-label { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; border: 0; }
@@ -175,3 +180,10 @@ export class AppMobileMainTabs extends LitElement {
} }
`; `;
} }
function isSessionNotificationBadgeModel(value: unknown): value is SessionNotificationBadgeModel {
return typeof value === "object" && value !== null
&& "accessibleLabel" in value && typeof value.accessibleLabel === "string"
&& "severity" in value && (value.severity === "info" || value.severity === "warning" || value.severity === "error")
&& "text" in value && typeof value.text === "string";
}
@@ -2,6 +2,7 @@ import { LitElement, css, html } from "lit";
import { customElement, property, query } from "lit/decorators.js"; import { customElement, property, query } from "lit/decorators.js";
import type { Machine, MachineHealth, Project, SessionActivity, SessionInfo, SessionStatus, Workspace, WorkspaceActivity } from "../../api"; import type { Machine, MachineHealth, Project, SessionActivity, SessionInfo, SessionStatus, Workspace, WorkspaceActivity } from "../../api";
import type { WorkspaceLabelItem } from "../../plugins/types"; import type { WorkspaceLabelItem } from "../../plugins/types";
import type { SessionNotificationBadgeModel } from "../../sessionNotifications";
import type { NavigationSection } from "../../appShell/navigationState"; import type { NavigationSection } from "../../appShell/navigationState";
import { NAVIGATION_SECTION_ORDER } from "../../appShell/navigationState"; import { NAVIGATION_SECTION_ORDER } from "../../appShell/navigationState";
import type { KeyboardNavigableSection } from "../navigationFocus"; import type { KeyboardNavigableSection } from "../navigationFocus";
@@ -13,6 +14,19 @@ import "../SessionList";
export type NavigationFocusTarget = NavigationSection | "chat"; export type NavigationFocusTarget = NavigationSection | "chat";
export interface NavigationNotificationBadges {
machines: Record<string, SessionNotificationBadgeModel | undefined>;
projects: Record<string, SessionNotificationBadgeModel | undefined>;
workspaces: Record<string, SessionNotificationBadgeModel | undefined>;
sessions: Record<string, SessionNotificationBadgeModel | undefined>;
machinesHeading?: SessionNotificationBadgeModel | undefined;
projectsHeading?: SessionNotificationBadgeModel | undefined;
workspacesHeading?: SessionNotificationBadgeModel | undefined;
sessionsHeading?: SessionNotificationBadgeModel | undefined;
}
const emptyNavigationNotificationBadges = (): NavigationNotificationBadges => ({ machines: {}, projects: {}, workspaces: {}, sessions: {} });
@customElement("app-navigation-panel") @customElement("app-navigation-panel")
export class AppNavigationPanel extends LitElement { export class AppNavigationPanel extends LitElement {
@property({ attribute: false }) machines: Machine[] = []; @property({ attribute: false }) machines: Machine[] = [];
@@ -30,6 +44,7 @@ export class AppNavigationPanel extends LitElement {
@property({ attribute: false }) sessionStatuses: Record<string, SessionStatus> = {}; @property({ attribute: false }) sessionStatuses: Record<string, SessionStatus> = {};
@property({ attribute: false }) sendingPrompts: Record<string, true> = {}; @property({ attribute: false }) sendingPrompts: Record<string, true> = {};
@property({ attribute: false }) workspacesByProjectId: Record<string, Workspace[]> = {}; @property({ attribute: false }) workspacesByProjectId: Record<string, Workspace[]> = {};
@property({ attribute: false }) notificationBadges: NavigationNotificationBadges = emptyNavigationNotificationBadges();
@property({ attribute: false }) deletingWorkspaceIds: string[] = []; @property({ attribute: false }) deletingWorkspaceIds: string[] = [];
@property({ attribute: false }) workspaceLabelItems: (workspace: Workspace) => WorkspaceLabelItem[] = () => []; @property({ attribute: false }) workspaceLabelItems: (workspace: Workspace) => WorkspaceLabelItem[] = () => [];
@property({ attribute: false }) refreshControl: unknown; @property({ attribute: false }) refreshControl: unknown;
@@ -100,6 +115,8 @@ export class AppNavigationPanel extends LitElement {
.selected=${this.selectedMachine} .selected=${this.selectedMachine}
.statuses=${this.machineStatuses} .statuses=${this.machineStatuses}
.activities=${this.machineActivities} .activities=${this.machineActivities}
.notificationBadges=${this.notificationBadges.machines}
.notificationHeadingBadge=${this.notificationBadges.machinesHeading}
.onSelect=${(machine: Machine) => this.onSelectMachine?.(machine)} .onSelect=${(machine: Machine) => this.onSelectMachine?.(machine)}
.onRemove=${(machine: Machine) => this.onRemoveMachine?.(machine)} .onRemove=${(machine: Machine) => this.onRemoveMachine?.(machine)}
.onFocusNextSection=${() => { this.focusNextFrom("machines"); }} .onFocusNextSection=${() => { this.focusNextFrom("machines"); }}
@@ -117,6 +134,8 @@ export class AppNavigationPanel extends LitElement {
.selected=${this.selectedMachine} .selected=${this.selectedMachine}
.statuses=${this.machineStatuses} .statuses=${this.machineStatuses}
.activities=${this.machineActivities} .activities=${this.machineActivities}
.notificationBadges=${this.notificationBadges.machines}
.notificationHeadingBadge=${this.notificationBadges.machinesHeading}
.collapsible=${this.collapsible} .collapsible=${this.collapsible}
.collapsed=${this.machinesCollapsed} .collapsed=${this.machinesCollapsed}
.onToggleCollapsed=${() => { this.onToggleMachines?.(); }} .onToggleCollapsed=${() => { this.onToggleMachines?.(); }}
@@ -131,6 +150,8 @@ export class AppNavigationPanel extends LitElement {
.selected=${this.selectedProject} .selected=${this.selectedProject}
.activities=${this.workspaceActivities} .activities=${this.workspaceActivities}
.workspacesByProjectId=${this.workspacesByProjectId} .workspacesByProjectId=${this.workspacesByProjectId}
.notificationBadges=${this.notificationBadges.projects}
.notificationHeadingBadge=${this.notificationBadges.projectsHeading}
.collapsible=${this.collapsible} .collapsible=${this.collapsible}
.collapsed=${this.projectsCollapsed} .collapsed=${this.projectsCollapsed}
.onToggleCollapsed=${() => { this.onToggleProjects?.(); }} .onToggleCollapsed=${() => { this.onToggleProjects?.(); }}
@@ -145,6 +166,8 @@ export class AppNavigationPanel extends LitElement {
.selected=${this.selectedWorkspace} .selected=${this.selectedWorkspace}
.activities=${this.workspaceActivities} .activities=${this.workspaceActivities}
.deletingWorkspaceIds=${this.deletingWorkspaceIds} .deletingWorkspaceIds=${this.deletingWorkspaceIds}
.notificationBadges=${this.notificationBadges.workspaces}
.notificationHeadingBadge=${this.notificationBadges.workspacesHeading}
.collapsible=${this.collapsible} .collapsible=${this.collapsible}
.collapsed=${this.workspacesCollapsed} .collapsed=${this.workspacesCollapsed}
.workspaceLabelItems=${this.workspaceLabelItems} .workspaceLabelItems=${this.workspaceLabelItems}
@@ -160,6 +183,8 @@ export class AppNavigationPanel extends LitElement {
.statuses=${this.sessionStatuses} .statuses=${this.sessionStatuses}
.activities=${this.sessionActivities} .activities=${this.sessionActivities}
.sending=${this.sendingPrompts} .sending=${this.sendingPrompts}
.notificationBadges=${this.notificationBadges.sessions}
.notificationHeadingBadge=${this.notificationBadges.sessionsHeading}
.selected=${this.selectedSession} .selected=${this.selectedSession}
.startingCount=${this.startingSessionCount} .startingCount=${this.startingSessionCount}
.canStart=${this.canStartSession} .canStart=${this.canStartSession}
+46 -1
View File
@@ -273,7 +273,9 @@ export const listStyles = css`
export const chatStyles = css` export const chatStyles = css`
:host { position: relative; z-index: 0; display: flex; flex-direction: column; min-height: 0; overflow: hidden; color: var(--pi-text); font: 14px system-ui, sans-serif; } :host { position: relative; z-index: 0; display: flex; flex-direction: column; min-height: 0; overflow: hidden; color: var(--pi-text); font: 14px system-ui, sans-serif; }
.chat-wrap { position: relative; flex: 1 1 auto; min-height: 0; overflow: hidden; } .chat-wrap { position: relative; flex: 1 1 auto; min-height: 0; overflow: hidden; }
.session-warnings { flex: 0 0 auto; display: grid; gap: 8px; max-height: 40%; overflow-y: auto; box-sizing: border-box; padding: 10px 16px; border-bottom: 1px solid var(--pi-border); background: var(--pi-bg-overlay); } .top-notices { box-sizing: border-box; flex: 0 0 auto; max-height: 40%; min-height: 0; display: flex; flex-direction: column; overflow: hidden; border-bottom: 1px solid var(--pi-border); background: var(--pi-bg-overlay); }
.session-warnings { flex: 0 1 auto; display: grid; gap: 8px; max-height: 50%; min-height: 0; overflow-y: auto; box-sizing: border-box; padding: 10px 16px; border-bottom: 1px solid var(--pi-border-muted); }
.session-warnings:only-child { flex: 1 1 auto; max-height: 100%; border-bottom: 0; }
.session-warning { position: relative; display: grid; gap: 4px; box-sizing: border-box; padding: 10px 34px 10px 12px; border: 1px solid var(--pi-warning-border); border-radius: 10px; background: var(--pi-warning-surface); color: var(--pi-text); } .session-warning { position: relative; display: grid; gap: 4px; box-sizing: border-box; padding: 10px 34px 10px 12px; border: 1px solid var(--pi-warning-border); border-radius: 10px; background: var(--pi-warning-surface); color: var(--pi-text); }
.session-warning.error { border-color: var(--pi-danger); background: color-mix(in srgb, var(--pi-danger) 12%, var(--pi-surface)); } .session-warning.error { border-color: var(--pi-danger); background: color-mix(in srgb, var(--pi-danger) 12%, var(--pi-surface)); }
.session-warning.info { border-color: var(--pi-accent-border); background: var(--pi-selection-bg); } .session-warning.info { border-color: var(--pi-accent-border); background: var(--pi-selection-bg); }
@@ -286,6 +288,49 @@ export const chatStyles = css`
.session-warning-dismiss { position: absolute; top: 6px; right: 6px; display: inline-grid; place-items: center; width: 22px; height: 22px; padding: 0; border: 1px solid var(--pi-border); border-radius: 6px; background: var(--pi-surface); color: var(--pi-muted); font: 15px/1 system-ui, sans-serif; cursor: pointer; } .session-warning-dismiss { position: absolute; top: 6px; right: 6px; display: inline-grid; place-items: center; width: 22px; height: 22px; padding: 0; border: 1px solid var(--pi-border); border-radius: 6px; background: var(--pi-surface); color: var(--pi-muted); font: 15px/1 system-ui, sans-serif; cursor: pointer; }
.session-warning-dismiss:hover, .session-warning-dismiss:focus-visible { color: var(--pi-text-bright); border-color: var(--pi-accent); background: var(--pi-bg-overlay); } .session-warning-dismiss:hover, .session-warning-dismiss:focus-visible { color: var(--pi-text-bright); border-color: var(--pi-accent); background: var(--pi-bg-overlay); }
.session-warning-dismiss:focus-visible { outline: 1px solid var(--pi-border); outline-offset: 2px; } .session-warning-dismiss:focus-visible { outline: 1px solid var(--pi-border); outline-offset: 2px; }
.notification-tray { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; border-top: 2px solid var(--pi-accent-border); background: var(--pi-bg-overlay); }
.notification-tray.warning { border-top-color: var(--pi-warning-border); }
.notification-tray.error { border-top-color: var(--pi-danger); }
.notification-tray.collapsed { flex: 0 0 auto; }
.notification-header { position: sticky; top: 0; z-index: 2; flex: 0 0 auto; min-width: 0; display: flex; align-items: center; justify-content: space-between; gap: 10px; box-sizing: border-box; padding: 8px 12px; background: var(--pi-bg-overlay); }
.notification-header:focus-visible { outline: 2px solid var(--pi-accent); outline-offset: -3px; }
.notification-heading-group { min-width: 0; display: flex; align-items: center; gap: 8px; }
.notification-heading-icon { flex: 0 0 auto; font-size: 16px; }
.notification-heading-copy { min-width: 0; display: grid; gap: 2px; }
.notification-heading-copy strong { color: var(--pi-text-bright); }
.notification-heading-copy small { color: var(--pi-muted); white-space: normal; overflow-wrap: anywhere; }
.notification-header-actions { flex: 0 0 auto; display: flex; align-items: center; gap: 6px; }
.notification-control { min-height: 32px; border: 1px solid var(--pi-border); border-radius: 7px; background: var(--pi-surface); color: var(--pi-text); padding: 5px 8px; font: 12px system-ui, sans-serif; cursor: pointer; }
.notification-control:hover, .notification-control:focus-visible, .notification-card-dismiss:hover, .notification-card-dismiss:focus-visible { border-color: var(--pi-accent); color: var(--pi-text-bright); }
.notification-control:focus-visible, .notification-card-dismiss:focus-visible { outline: 2px solid var(--pi-accent); outline-offset: 2px; }
.notification-control:disabled, .notification-card-dismiss:disabled { opacity: .55; cursor: default; }
.notification-cards { flex: 1 1 auto; min-height: 0; display: grid; align-content: start; gap: 8px; overflow-y: auto; overscroll-behavior-y: contain; box-sizing: border-box; padding: 0 12px 10px; }
.notification-overflow { margin: 0; border: 1px solid var(--pi-warning-border); border-radius: 8px; background: var(--pi-warning-surface); color: var(--pi-text); padding: 8px 10px; font-size: 12px; overflow-wrap: anywhere; }
.notification-card { position: relative; min-width: 0; display: grid; gap: 7px; box-sizing: border-box; padding: 10px 44px 10px 12px; border: 1px solid var(--pi-accent-border); border-radius: 10px; background: var(--pi-selection-bg); color: var(--pi-text); }
.notification-card.warning { border-color: var(--pi-warning-border); background: var(--pi-warning-surface); }
.notification-card.error { border-color: var(--pi-danger); background: color-mix(in srgb, var(--pi-danger) 12%, var(--pi-surface)); }
.notification-card:focus-visible { outline: 2px solid var(--pi-accent); outline-offset: -3px; }
.notification-card-head { min-width: 0; display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 5px 10px; }
.notification-severity { color: var(--pi-accent); font-size: 12px; text-transform: uppercase; letter-spacing: .03em; }
.notification-card.warning .notification-severity { color: var(--pi-warning); }
.notification-card.error .notification-severity { color: var(--pi-danger); }
.notification-card time { color: var(--pi-muted); font-size: 11px; }
.notification-message { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; text-align: start; unicode-bidi: plaintext; }
.notification-truncated { margin: 0; color: var(--pi-warning); font-size: 12px; overflow-wrap: anywhere; }
.notification-card-dismiss { position: absolute; top: 8px; right: 8px; display: inline-grid; place-items: center; width: 30px; height: 30px; padding: 0; border: 1px solid var(--pi-border); border-radius: 7px; background: var(--pi-surface); color: var(--pi-muted); font: 18px/1 system-ui, sans-serif; cursor: pointer; }
.visually-hidden { position: absolute !important; width: 1px !important; height: 1px !important; padding: 0 !important; margin: -1px !important; overflow: hidden !important; clip: rect(0 0 0 0) !important; clip-path: inset(50%) !important; white-space: nowrap !important; border: 0 !important; }
.notification-live span { display: block; }
@media (pointer: coarse) {
.notification-control { min-width: 44px; min-height: 44px; }
.notification-card { padding-right: 58px; }
.notification-card-dismiss { top: 6px; right: 6px; width: 44px; height: 44px; }
}
@media (max-width: 520px) {
.notification-header { align-items: flex-start; flex-wrap: wrap; }
.notification-heading-group { flex: 1 1 180px; }
.notification-header-actions { margin-left: auto; }
.notification-control { padding-inline: 8px; }
}
.chat { height: 100%; min-height: 0; overflow: auto; overflow-anchor: none; padding: 26px 16px 64px; box-sizing: border-box; } .chat { height: 100%; min-height: 0; overflow: auto; overflow-anchor: none; padding: 26px 16px 64px; box-sizing: border-box; }
.scroll-marker { display: block; height: 0; overflow: hidden; pointer-events: none; } .scroll-marker { display: block; height: 0; overflow: hidden; pointer-events: none; }
.activity-dock { position: absolute; left: 16px; right: 16px; bottom: 12px; z-index: 20; display: flex; align-items: center; gap: 8px; min-width: 0; box-sizing: border-box; border: 1px solid var(--pi-border); border-radius: 999px; background: var(--pi-bg-overlay); color: var(--pi-muted); padding: 8px 12px; font-size: 13px; pointer-events: none; box-shadow: 0 8px 28px var(--pi-shadow); backdrop-filter: blur(6px); } .activity-dock { position: absolute; left: 16px; right: 16px; bottom: 12px; z-index: 20; display: flex; align-items: center; gap: 8px; min-width: 0; box-sizing: border-box; border: 1px solid var(--pi-border); border-radius: 999px; background: var(--pi-bg-overlay); color: var(--pi-muted); padding: 8px 12px; font-size: 13px; pointer-events: none; box-shadow: 0 8px 28px var(--pi-shadow); backdrop-filter: blur(6px); }
@@ -0,0 +1,141 @@
import { describe, expect, it, vi } from "vitest";
import { initialAppState } from "../appState";
import type { SessionNotificationInboxEvent } from "../../../shared/apiTypes";
import { SessionController, type SessionNotificationSessionBridge } from "./sessionController";
import { defaultApi, EmitSocket, emptyPage, oldSession, status, workspace, type AppState } from "./sessionController.testSupport";
function inboxEvent(): SessionNotificationInboxEvent {
return {
type: "notifications.inbox",
daemonInstanceId: "daemon-a",
catalogRevision: 1,
summary: {
sessionId: oldSession.id,
cwd: oldSession.cwd,
inboxRevision: 1,
retainedCount: 1,
discardedCount: 0,
highestSeverity: "warning",
},
dismissThrough: { order: 1, overflowWatermark: 0 },
delta: {
kind: "added",
notification: {
id: "daemon-a:1",
message: "background extension needs attention",
truncated: false,
severity: "warning",
receivedAt: "2026-07-18T00:00:00.000Z",
order: 1,
},
},
};
}
describe("SessionController notification event boundary", () => {
it("refetches the bounded notification snapshot when the selected socket first opens", async () => {
const socket = new EmitSocket();
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: oldSession, sessions: [oldSession] };
const refreshSelectedSession = vi.fn(() => Promise.resolve());
const bridge: SessionNotificationSessionBridge = {
prepareSelectedSession: vi.fn(),
clearSelectedSession: vi.fn(),
refreshSelectedSession,
applyInboxEvent: vi.fn(),
shouldFilterLegacyNotification: vi.fn(() => true),
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{
socket,
notifications: bridge,
api: {
...defaultApi,
messages: vi.fn(() => Promise.resolve(emptyPage)),
status: vi.fn(() => Promise.resolve(status(oldSession.id))),
streamSnapshot: vi.fn(() => Promise.resolve({ seq: 0, partial: null })),
},
},
);
await controller.selectSession(oldSession, { updateUrl: false });
expect(refreshSelectedSession).toHaveBeenCalledOnce();
socket.open();
expect(refreshSelectedSession).toHaveBeenCalledTimes(2);
expect(refreshSelectedSession).toHaveBeenLastCalledWith(oldSession, "local");
});
it("handles inbox events before transcript watermarking and filters only marked legacy output with support", async () => {
const socket = new EmitSocket();
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: oldSession, sessions: [oldSession] };
const applyInboxEvent = vi.fn();
const bridge: SessionNotificationSessionBridge = {
prepareSelectedSession: vi.fn(),
clearSelectedSession: vi.fn(),
refreshSelectedSession: vi.fn(() => Promise.resolve()),
applyInboxEvent,
shouldFilterLegacyNotification: vi.fn((_machineId, notificationId) => notificationId !== undefined),
};
const api: typeof defaultApi = {
...defaultApi,
messages: vi.fn(() => Promise.resolve(emptyPage)),
status: vi.fn(() => Promise.resolve(status(oldSession.id))),
streamSnapshot: vi.fn(() => Promise.resolve({ seq: 100, partial: null })),
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ api, socket, notifications: bridge },
);
await controller.selectSession(oldSession, { updateUrl: false });
socket.emit({ ...inboxEvent(), seq: 50 });
socket.emit({ type: "command.output", level: "info", message: "legacy duplicate", notificationId: "daemon-a:1", seq: 101 });
expect(applyInboxEvent).toHaveBeenCalledExactlyOnceWith("local", expect.objectContaining({ type: "notifications.inbox" }));
expect(state.messages).toEqual([]);
socket.emit({ type: "command.output", level: "info", message: "ordinary extension output", seq: 102 });
expect(state.messages).toHaveLength(1);
expect(state.messages[0]?.parts).toEqual([{ type: "text", text: "ordinary extension output" }]);
});
it("preserves marked legacy notification output when capability support is absent", async () => {
const socket = new EmitSocket();
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: oldSession, sessions: [oldSession] };
const bridge: SessionNotificationSessionBridge = {
prepareSelectedSession: vi.fn(),
clearSelectedSession: vi.fn(),
refreshSelectedSession: vi.fn(() => Promise.resolve()),
applyInboxEvent: vi.fn(),
shouldFilterLegacyNotification: vi.fn(() => false),
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{
socket,
notifications: bridge,
api: {
...defaultApi,
messages: vi.fn(() => Promise.resolve(emptyPage)),
status: vi.fn(() => Promise.resolve(status(oldSession.id))),
streamSnapshot: vi.fn(() => Promise.resolve({ seq: 0, partial: null })),
},
},
);
await controller.selectSession(oldSession, { updateUrl: false });
socket.emit({ type: "command.output", level: "info", message: "legacy notification", notificationId: "new-daemon:1", seq: 1 });
expect(state.messages[0]?.parts).toEqual([{ type: "text", text: "legacy notification" }]);
});
});
@@ -55,10 +55,18 @@ export class FakeSocket implements SessionEventSocket {
export class EmitSocket implements SessionEventSocket { export class EmitSocket implements SessionEventSocket {
readonly connectedSessionIds: string[] = []; readonly connectedSessionIds: string[] = [];
private handler: ((event: SessionUiEvent) => void) | undefined; private handler: ((event: SessionUiEvent) => void) | undefined;
private onInitialOpen: (() => void) | undefined;
connect(session: SessionRef, onEvent: (event: SessionUiEvent) => void): void { connect(
session: SessionRef,
onEvent: (event: SessionUiEvent) => void,
_onReconnect?: () => void,
_machineId?: string,
onInitialOpen?: () => void,
): void {
this.connectedSessionIds.push(session.id); this.connectedSessionIds.push(session.id);
this.handler = onEvent; this.handler = onEvent;
this.onInitialOpen = onInitialOpen;
} }
setHandler(onEvent: (event: SessionUiEvent) => void): void { setHandler(onEvent: (event: SessionUiEvent) => void): void {
@@ -69,8 +77,13 @@ export class EmitSocket implements SessionEventSocket {
this.handler?.(event); this.handler?.(event);
} }
open(): void {
this.onInitialOpen?.();
}
close(): void { close(): void {
this.handler = undefined; this.handler = undefined;
this.onInitialOpen = undefined;
} }
} }
@@ -11,7 +11,7 @@ import { SessionSocket, type GlobalSessionEvent, type SessionUiEvent } from "../
import { isArchivableSessionInfo, isTransientNewSessionInfo, sessionPersistenceOptionsForRuntime } from "../sessionPersistence"; import { isArchivableSessionInfo, isTransientNewSessionInfo, sessionPersistenceOptionsForRuntime } from "../sessionPersistence";
import { isSessionActive } from "../../../shared/activity"; import { isSessionActive } from "../../../shared/activity";
import { PI_WEB_CAPABILITIES, supportsPiWebCapability } from "../../../shared/capabilities"; import { PI_WEB_CAPABILITIES, supportsPiWebCapability } from "../../../shared/capabilities";
import type { PromptAttachmentDelivery } from "../../../shared/apiTypes"; import type { PromptAttachmentDelivery, SessionNotificationInboxEvent } from "../../../shared/apiTypes";
import { InMemorySessionSelectionMemory, markSessionArchived, markSessionsArchived, selectPreferredSession, selectionAfterArchivingSession, selectionAfterArchivingSessions, shouldDeselectAfterArchivedCollapse, type SessionSelectionMemory } from "./sessionSelection"; import { InMemorySessionSelectionMemory, markSessionArchived, markSessionsArchived, selectPreferredSession, selectionAfterArchivingSession, selectionAfterArchivingSessions, shouldDeselectAfterArchivedCollapse, type SessionSelectionMemory } from "./sessionSelection";
import { selectedMachineId, type GetState, type SetState, type UpdateUrl } from "./types"; import { selectedMachineId, type GetState, type SetState, type UpdateUrl } from "./types";
import { TrailingRefreshCoordinator } from "./trailingRefreshCoordinator"; import { TrailingRefreshCoordinator } from "./trailingRefreshCoordinator";
@@ -20,15 +20,30 @@ const MESSAGE_PAGE_SIZE = 100;
const BULK_FALLBACK_CONCURRENCY = 4; const BULK_FALLBACK_CONCURRENCY = 4;
export interface SessionEventSocket { export interface SessionEventSocket {
connect(session: SessionRef, onEvent: (event: SessionUiEvent) => void, onReconnect?: () => void, machineId?: string): void; connect(
session: SessionRef,
onEvent: (event: SessionUiEvent) => void,
onReconnect?: () => void,
machineId?: string,
onInitialOpen?: () => void,
): void;
setHandler(onEvent: (event: SessionUiEvent) => void): void; setHandler(onEvent: (event: SessionUiEvent) => void): void;
close(): void; close(): void;
} }
export interface SessionNotificationSessionBridge {
prepareSelectedSession(session: SessionInfo, machineId: string): void;
clearSelectedSession(): void;
refreshSelectedSession(session: SessionRef, machineId: string): Promise<void>;
applyInboxEvent(machineId: string, event: SessionNotificationInboxEvent): void;
shouldFilterLegacyNotification(machineId: string, notificationId: string | undefined): boolean;
}
export interface SessionControllerDependencies { export interface SessionControllerDependencies {
api?: typeof defaultApi; api?: typeof defaultApi;
socket?: SessionEventSocket; socket?: SessionEventSocket;
transcripts?: ChatTranscriptStore; transcripts?: ChatTranscriptStore;
notifications?: SessionNotificationSessionBridge;
} }
interface BulkSessionMutationResult { interface BulkSessionMutationResult {
@@ -71,6 +86,7 @@ export class SessionController {
private readonly socket: SessionEventSocket; private readonly socket: SessionEventSocket;
private readonly api: typeof defaultApi; private readonly api: typeof defaultApi;
private readonly transcripts: ChatTranscriptStore; private readonly transcripts: ChatTranscriptStore;
private readonly notifications: SessionNotificationSessionBridge | undefined;
private selectionSeq = 0; private selectionSeq = 0;
// Join-time stream watermark for the selected session. `seq` is the // Join-time stream watermark for the selected session. `seq` is the
// `SessionEventHub` sequence captured together with the seeded partial by the // `SessionEventHub` sequence captured together with the seeded partial by the
@@ -98,13 +114,14 @@ export class SessionController {
this.socket = deps.socket ?? new SessionSocket(); this.socket = deps.socket ?? new SessionSocket();
this.api = deps.api ?? defaultApi; this.api = deps.api ?? defaultApi;
this.transcripts = deps.transcripts ?? new ChatTranscriptStore(); this.transcripts = deps.transcripts ?? new ChatTranscriptStore();
this.notifications = deps.notifications;
} }
applyGlobalEvent(event: GlobalSessionEvent): void { applyGlobalEvent(event: GlobalSessionEvent): void {
if (event.type === "status.update") this.queueStatusUpdate(event.status); if (event.type === "status.update") this.queueStatusUpdate(event.status);
else if (event.type === "activity.update") this.queueActivityUpdate(event.activity); else if (event.type === "activity.update") this.queueActivityUpdate(event.activity);
else if (event.type === "session.created") this.applyCreatedSession(event.session); else if (event.type === "session.created") this.applyCreatedSession(event.session);
else this.applySessionName(event.sessionId, event.name); else if (event.type === "session.name") this.applySessionName(event.sessionId, event.name);
} }
dispose() { dispose() {
@@ -116,6 +133,7 @@ export class SessionController {
clearActiveSession() { clearActiveSession() {
this.selectionSeq += 1; this.selectionSeq += 1;
this.socket.close(); this.socket.close();
this.notifications?.clearSelectedSession();
this.streamWatermark = undefined; this.streamWatermark = undefined;
this.clearPendingUpdates(); this.clearPendingUpdates();
// Note: sendingPrompts is intentionally NOT cleared here. Deselecting a // Note: sendingPrompts is intentionally NOT cleared here. Deselecting a
@@ -168,6 +186,8 @@ export class SessionController {
this.socket.close(); this.socket.close();
this.streamWatermark = undefined; this.streamWatermark = undefined;
this.clearPendingUpdates(); this.clearPendingUpdates();
const machineId = selectedMachineId(this.getState());
this.notifications?.prepareSelectedSession(session, machineId);
const transcriptKey = this.sessionCacheKey(session.id); const transcriptKey = this.sessionCacheKey(session.id);
const cached = this.transcripts.cachedView(transcriptKey); const cached = this.transcripts.cachedView(transcriptKey);
this.setState({ this.setState({
@@ -192,9 +212,9 @@ export class SessionController {
session, session,
(event) => buffered.push(event), (event) => buffered.push(event),
() => { void this.refreshSelectedSession(session.id); }, () => { void this.refreshSelectedSession(session.id); },
selectedMachineId(this.getState()), machineId,
() => { void this.notifications?.refreshSelectedSession(session, machineId); },
); );
const machineId = selectedMachineId(this.getState());
await this.requestSelectedSessionRefresh({ session, machineId, selectionSeq: seq }); await this.requestSelectedSessionRefresh({ session, machineId, selectionSeq: seq });
if (!this.isCurrentRefreshTarget({ session, machineId, selectionSeq: seq })) return; if (!this.isCurrentRefreshTarget({ session, machineId, selectionSeq: seq })) return;
void this.refreshAvailableThinkingLevels(); void this.refreshAvailableThinkingLevels();
@@ -793,6 +813,7 @@ export class SessionController {
// at seq 1, and un-stamped events fail open), so the core transcript // at seq 1, and un-stamped events fail open), so the core transcript
// still loads and streams normally. // still loads and streams normally.
this.api.streamSnapshot(target.session, target.machineId).catch((): SessionStreamSnapshot => ({ seq: 0, partial: null })), this.api.streamSnapshot(target.session, target.machineId).catch((): SessionStreamSnapshot => ({ seq: 0, partial: null })),
this.notifications?.refreshSelectedSession(target.session, target.machineId) ?? Promise.resolve(),
]); ]);
if (!this.isCurrentRefreshTarget(target)) return; if (!this.isCurrentRefreshTarget(target)) return;
// Seed the in-flight partial assistant message on top of committed history // Seed the in-flight partial assistant message on top of committed history
@@ -892,6 +913,7 @@ export class SessionController {
this.sessionSelection.rememberSession({ ...session, cwd: this.workspaceSelectionKey(session.cwd) }); this.sessionSelection.rememberSession({ ...session, cwd: this.workspaceSelectionKey(session.cwd) });
this.selectionSeq += 1; this.selectionSeq += 1;
this.socket.close(); this.socket.close();
this.notifications?.clearSelectedSession();
this.streamWatermark = undefined; this.streamWatermark = undefined;
this.clearPendingUpdates(); this.clearPendingUpdates();
const state = this.getState(); const state = this.getState();
@@ -1111,6 +1133,15 @@ export class SessionController {
} }
private applyEvent(event: SessionUiEvent) { private applyEvent(event: SessionUiEvent) {
// Notification revisions use their own join snapshot. Handle them before the
// transcript watermark so a notification event sharing an already-seeded
// transcript sequence cannot be lost.
if (event.type === "notifications.inbox") {
this.notifications?.applyInboxEvent(selectedMachineId(this.getState()), event);
return;
}
if (event.type === "command.output" && this.notifications?.shouldFilterLegacyNotification(selectedMachineId(this.getState()), event.notificationId) === true) return;
// Drop events already reflected in the seeded join snapshot (committed // Drop events already reflected in the seeded join snapshot (committed
// history + partial). Everything past the watermark applies exactly once, // history + partial). Everything past the watermark applies exactly once,
// so live content streams directly on top of the seeded partial. // so live content streams directly on top of the seeded partial.
@@ -0,0 +1,434 @@
import { describe, expect, it, vi } from "vitest";
import { initialAppState, type AppState } from "../appState";
import { selectedNotificationView } from "../sessionNotifications";
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
import type {
Machine,
SessionInfo,
SessionNotification,
SessionNotificationCatalogSnapshot,
SessionNotificationInboxEvent,
SessionNotificationInboxSnapshot,
} from "../../../shared/apiTypes";
import { SessionNotificationController, type SessionNotificationApi } from "./sessionNotificationController";
const localMachine: Machine = {
id: "local",
name: "Local",
kind: "local",
createdAt: "2026-07-18T00:00:00.000Z",
updatedAt: "2026-07-18T00:00:00.000Z",
};
const session: SessionInfo = {
id: "session-1",
cwd: "/repo",
path: "/tmp/session-1.jsonl",
created: "2026-07-18T00:00:00.000Z",
modified: "2026-07-18T00:00:00.000Z",
messageCount: 0,
firstMessage: "",
};
function entry(order: number, severity: SessionNotification["severity"] = "info"): SessionNotification {
return {
id: `daemon-a:${String(order)}`,
message: `notice ${String(order)}`,
truncated: false,
severity,
receivedAt: `2026-07-18T00:00:${String(order).padStart(2, "0")}.000Z`,
order,
};
}
function inboxSnapshot(
notifications: SessionNotification[] = [entry(1)],
options: { inboxRevision?: number; catalogRevision?: number; discardedCount?: number; daemonInstanceId?: string } = {},
): SessionNotificationInboxSnapshot {
const highestSeverity = notifications.some((notification) => notification.severity === "error")
? "error"
: notifications.some((notification) => notification.severity === "warning") ? "warning" : notifications.length > 0 ? "info" : undefined;
return {
daemonInstanceId: options.daemonInstanceId ?? "daemon-a",
catalogRevision: options.catalogRevision ?? options.inboxRevision ?? 1,
summary: {
sessionId: session.id,
cwd: session.cwd,
inboxRevision: options.inboxRevision ?? 1,
retainedCount: notifications.length,
discardedCount: options.discardedCount ?? 0,
...(highestSeverity === undefined ? {} : { highestSeverity }),
},
notifications,
dismissThrough: { order: notifications[0]?.order ?? 0, overflowWatermark: options.discardedCount ?? 0 },
};
}
function catalogSnapshot(catalogRevision = 1): SessionNotificationCatalogSnapshot {
return {
daemonInstanceId: "daemon-a",
catalogRevision,
sessions: [inboxSnapshot([entry(1)], { inboxRevision: catalogRevision, catalogRevision }).summary],
};
}
function addedEvent(notification: SessionNotification, inboxRevision: number, retainedCount: number): SessionNotificationInboxEvent {
return {
type: "notifications.inbox",
daemonInstanceId: "daemon-a",
catalogRevision: inboxRevision,
summary: {
sessionId: session.id,
cwd: session.cwd,
inboxRevision,
retainedCount,
discardedCount: 0,
highestSeverity: notification.severity,
},
dismissThrough: { order: notification.order, overflowWatermark: 0 },
delta: { kind: "added", notification },
};
}
function capableState(): AppState {
return {
...initialAppState(),
machines: [localMachine],
selectedMachine: localMachine,
selectedSession: session,
sessions: [session],
machineRuntimes: {
local: {
machineId: "local",
ok: true,
checkedAt: "2026-07-18T00:00:00.000Z",
capabilities: [PI_WEB_CAPABILITIES.sessionsNotifications],
},
},
};
}
function createHarness(initialState = capableState(), overrides: Partial<SessionNotificationApi> = {}) {
let state = initialState;
const api: SessionNotificationApi = {
notificationCatalog: vi.fn(() => Promise.resolve(catalogSnapshot())),
notificationInbox: vi.fn(() => Promise.resolve(inboxSnapshot())),
dismissNotification: vi.fn(() => Promise.resolve(inboxSnapshot([], { inboxRevision: 2, catalogRevision: 2 }))),
dismissAllNotifications: vi.fn(() => Promise.resolve(inboxSnapshot([], { inboxRevision: 2, catalogRevision: 2 }))),
workspaces: vi.fn(() => Promise.resolve([])),
...overrides,
};
const controller = new SessionNotificationController(
() => state,
(patch) => { state = { ...state, ...patch }; },
{ api, onBackgroundError: vi.fn() },
);
return {
controller,
api,
get state() { return state; },
replaceState(next: AppState) { state = next; },
};
}
describe("SessionNotificationController capability and joins", () => {
it("makes no notification requests and preserves marked legacy output without effective capability support", async () => {
const state = { ...capableState(), machineRuntimes: {} };
const harness = createHarness(state);
harness.controller.prepareSelectedSession(session, "local");
await harness.controller.refreshSelectedSession(session, "local");
harness.controller.globalSocketOpened("local");
await harness.controller.refreshAfterBrowserResume();
expect(harness.api.notificationInbox).not.toHaveBeenCalled();
expect(harness.api.notificationCatalog).not.toHaveBeenCalled();
expect(harness.controller.shouldFilterLegacyNotification("local", "notification-1")).toBe(false);
expect(harness.state.selectedNotificationInbox).toBeUndefined();
});
it("treats a validated authoritative event as support during a rolling capability transition", async () => {
const state = { ...capableState(), machineRuntimes: {} };
const harness = createHarness(state);
harness.controller.prepareSelectedSession(session, "local");
harness.controller.applySummaryEvent("local", {
type: "notifications.summary",
daemonInstanceId: "daemon-a",
catalogRevision: 1,
summary: inboxSnapshot().summary,
});
await vi.waitFor(() => {
expect(harness.api.notificationCatalog).toHaveBeenCalledOnce();
expect(harness.api.notificationInbox).toHaveBeenCalledOnce();
});
expect(harness.controller.shouldFilterLegacyNotification("local", "daemon-a:1")).toBe(true);
});
it("buffers global events around catalog hydration and applies newer revisions in order", async () => {
const catalog = deferred<SessionNotificationCatalogSnapshot>();
const harness = createHarness(capableState(), { notificationCatalog: vi.fn(() => catalog.promise) });
harness.controller.globalSocketOpened("local");
harness.controller.applySummaryEvent("local", {
type: "notifications.summary",
daemonInstanceId: "daemon-a",
catalogRevision: 2,
summary: {
...inboxSnapshot([entry(2, "warning"), entry(1)], { inboxRevision: 2, catalogRevision: 2 }).summary,
},
});
expect(harness.state.notificationCatalogsByMachine["local"]).toBeUndefined();
catalog.resolve(catalogSnapshot(1));
await vi.waitFor(() => { expect(harness.state.notificationCatalogsByMachine["local"]?.catalogRevision).toBe(2); });
expect(harness.state.notificationCatalogsByMachine["local"]).toMatchObject({ status: "fresh", daemonInstanceId: "daemon-a" });
expect(harness.state.notificationCatalogsByMachine["local"]?.summariesBySessionId[session.id]).toMatchObject({ retainedCount: 2, highestSeverity: "warning" });
expect(harness.controller.shouldFilterLegacyNotification("local", "notification-1")).toBe(true);
});
it("lets the selected live event announce before its matching global summary can trigger a snapshot", async () => {
const first = inboxSnapshot([entry(1)], { inboxRevision: 1, catalogRevision: 1 });
const state = {
...capableState(),
notificationCatalogsByMachine: {
local: {
machineId: "local",
status: "fresh" as const,
daemonInstanceId: first.daemonInstanceId,
catalogRevision: first.catalogRevision,
summariesBySessionId: { [session.id]: first.summary },
},
},
};
const notificationInbox = vi.fn(() => Promise.resolve(first));
const harness = createHarness(state, { notificationInbox });
harness.controller.prepareSelectedSession(session, "local");
await harness.controller.refreshSelectedSession(session, "local");
const event = addedEvent(entry(2, "warning"), 2, 2);
harness.controller.applySummaryEvent("local", {
type: "notifications.summary",
daemonInstanceId: event.daemonInstanceId,
catalogRevision: event.catalogRevision,
summary: event.summary,
});
await Promise.resolve();
expect(notificationInbox).toHaveBeenCalledOnce();
harness.controller.applyInboxEvent("local", event);
expect(selectedNotificationView(harness.state.selectedNotificationInbox)?.announcements).toMatchObject([
{ severity: "warning", message: "notice 2" },
]);
expect(notificationInbox).toHaveBeenCalledOnce();
});
it("refetches the selected inbox when a newly opened global socket finds a newer catalog revision", async () => {
const first = inboxSnapshot([entry(1)], { inboxRevision: 1, catalogRevision: 1 });
const second = inboxSnapshot([entry(2, "error"), entry(1)], { inboxRevision: 2, catalogRevision: 2 });
const notificationInbox = vi.fn()
.mockResolvedValueOnce(first)
.mockResolvedValueOnce(second);
const notificationCatalog = vi.fn(() => Promise.resolve<SessionNotificationCatalogSnapshot>({
daemonInstanceId: second.daemonInstanceId,
catalogRevision: second.catalogRevision,
sessions: [second.summary],
}));
const harness = createHarness(capableState(), { notificationInbox, notificationCatalog });
harness.controller.prepareSelectedSession(session, "local");
await harness.controller.refreshSelectedSession(session, "local");
harness.controller.globalSocketOpened("local");
await vi.waitFor(() => { expect(notificationInbox).toHaveBeenCalledTimes(2); });
expect(selectedNotificationView(harness.state.selectedNotificationInbox)?.notifications.map((notification) => notification.id)).toEqual([
"daemon-a:2",
"daemon-a:1",
]);
});
it("hydrates missing selected-machine project workspaces without changing selection", async () => {
const project = { id: "project-1", name: "Repo", path: "/repo", createdAt: "now" };
const next = {
...capableState(),
projects: [project],
selectedProject: project,
notificationCatalogsByMachine: {
local: {
machineId: "local",
status: "fresh" as const,
daemonInstanceId: "daemon-a",
catalogRevision: 1,
summariesBySessionId: { [session.id]: inboxSnapshot().summary },
},
},
};
const workspace = { id: "workspace-1", projectId: project.id, path: "/repo", label: "repo", isMain: true, isGitRepo: true, isGitWorktree: false };
const workspaces = vi.fn(() => Promise.resolve([workspace]));
const harness = createHarness(next, { workspaces });
harness.controller.syncEnvironment(initialAppState(), next);
await vi.waitFor(() => { expect(harness.state.workspacesByProjectId[project.id]).toEqual([workspace]); });
expect(workspaces).toHaveBeenCalledExactlyOnceWith(project.id, "local");
expect(harness.state.selectedProject).toBe(project);
});
it("ignores an old selected-inbox response after selection changes", async () => {
const oldInbox = deferred<SessionNotificationInboxSnapshot>();
const harness = createHarness(capableState(), { notificationInbox: vi.fn(() => oldInbox.promise) });
const otherSession = { ...session, id: "session-2", path: "/tmp/session-2.jsonl" };
harness.controller.prepareSelectedSession(session, "local");
const refresh = harness.controller.refreshSelectedSession(session, "local");
harness.controller.prepareSelectedSession(otherSession, "local");
oldInbox.resolve(inboxSnapshot());
await refresh;
expect(harness.state.selectedNotificationInbox).toMatchObject({ sessionId: "session-2", status: "loading", notifications: [] });
});
it("hides a selected remote inbox when the machine becomes unreachable and ignores an in-flight snapshot", async () => {
const remoteMachine: Machine = { ...localMachine, id: "remote-a", name: "Remote", kind: "remote", baseUrl: "https://remote.example.test/" };
const runtime = capableState().machineRuntimes["local"];
if (runtime === undefined) throw new Error("expected capable runtime fixture");
const initial = {
...capableState(),
machines: [remoteMachine],
selectedMachine: remoteMachine,
machineRuntimes: { [remoteMachine.id]: { ...runtime, machineId: remoteMachine.id } },
};
const pendingInbox = deferred<SessionNotificationInboxSnapshot>();
const notificationInbox = vi.fn()
.mockResolvedValueOnce(inboxSnapshot())
.mockImplementationOnce(() => pendingInbox.promise);
const harness = createHarness(initial, { notificationInbox });
harness.controller.prepareSelectedSession(session, remoteMachine.id);
await harness.controller.refreshSelectedSession(session, remoteMachine.id);
expect(selectedNotificationView(harness.state.selectedNotificationInbox)?.notifications).toHaveLength(1);
const refresh = harness.controller.refreshSelectedSession(session, remoteMachine.id);
const previous = harness.state;
const offline: AppState = {
...previous,
machineStatuses: {
[remoteMachine.id]: {
machineId: remoteMachine.id,
ok: false,
checkedAt: "2026-07-18T00:01:00.000Z",
status: "offline",
},
},
};
harness.replaceState(offline);
harness.controller.syncEnvironment(previous, offline);
expect(harness.state.selectedNotificationInbox?.status).toBe("stale");
expect(selectedNotificationView(harness.state.selectedNotificationInbox)).toBeUndefined();
pendingInbox.resolve(inboxSnapshot([entry(2, "error")], { inboxRevision: 2, catalogRevision: 2 }));
await refresh;
expect(harness.state.selectedNotificationInbox?.status).toBe("stale");
expect(selectedNotificationView(harness.state.selectedNotificationInbox)).toBeUndefined();
});
});
describe("SessionNotificationController optimistic mutations", () => {
it("does not let a delayed refresh snapshot roll back a newer dismissal response", async () => {
const initial = inboxSnapshot([entry(1)], { inboxRevision: 1, catalogRevision: 1 });
const delayedRefresh = deferred<SessionNotificationInboxSnapshot>();
const notificationInbox = vi.fn()
.mockResolvedValueOnce(initial)
.mockImplementationOnce(() => delayedRefresh.promise);
const dismissed = inboxSnapshot([], { inboxRevision: 2, catalogRevision: 2 });
const harness = createHarness(capableState(), {
notificationInbox,
dismissNotification: vi.fn(() => Promise.resolve(dismissed)),
});
harness.controller.prepareSelectedSession(session, "local");
await harness.controller.refreshSelectedSession(session, "local");
const refresh = harness.controller.refreshSelectedSession(session, "local");
await harness.controller.dismissNotification("daemon-a:1");
expect(selectedNotificationView(harness.state.selectedNotificationInbox)?.notifications).toEqual([]);
delayedRefresh.resolve(initial);
await refresh;
expect(harness.state.selectedNotificationInbox?.summary?.inboxRevision).toBe(2);
expect(selectedNotificationView(harness.state.selectedNotificationInbox)?.notifications).toEqual([]);
});
it("optimistically dismisses one card, reconciles the response, and rolls back/refetches on failure", async () => {
const dismiss = deferred<SessionNotificationInboxSnapshot>();
const refreshAfterFailure = deferred<SessionNotificationInboxSnapshot>();
const initialInbox = inboxSnapshot([entry(2, "warning"), entry(1)]);
const notificationInbox = vi.fn()
.mockResolvedValueOnce(initialInbox)
.mockImplementationOnce(() => refreshAfterFailure.promise);
const dismissNotification = vi.fn()
.mockImplementationOnce(() => dismiss.promise)
.mockRejectedValueOnce(new Error("offline"));
const notificationCatalog = vi.fn(() => Promise.resolve<SessionNotificationCatalogSnapshot>({
daemonInstanceId: initialInbox.daemonInstanceId,
catalogRevision: initialInbox.catalogRevision,
sessions: [initialInbox.summary],
}));
const harness = createHarness(capableState(), { notificationInbox, dismissNotification, notificationCatalog });
harness.controller.prepareSelectedSession(session, "local");
await harness.controller.refreshSelectedSession(session, "local");
const firstDismissal = harness.controller.dismissNotification("daemon-a:2");
expect(selectedNotificationView(harness.state.selectedNotificationInbox)?.notifications.map((notification) => notification.id)).toEqual(["daemon-a:1"]);
dismiss.resolve(inboxSnapshot([entry(1)], { inboxRevision: 2, catalogRevision: 2 }));
await firstDismissal;
expect(selectedNotificationView(harness.state.selectedNotificationInbox)?.notifications.map((notification) => notification.id)).toEqual(["daemon-a:1"]);
const failedDismissal = harness.controller.dismissNotification("daemon-a:1");
await vi.waitFor(() => { expect(harness.state.error).toContain("offline"); });
refreshAfterFailure.resolve(inboxSnapshot([entry(1)], { inboxRevision: 2, catalogRevision: 2 }));
await failedDismissal;
expect(selectedNotificationView(harness.state.selectedNotificationInbox)?.notifications.map((notification) => notification.id)).toEqual(["daemon-a:1"]);
expect(notificationInbox).toHaveBeenCalledTimes(2);
});
it("uses the server cutoff for dismiss-all and leaves a concurrently arriving newer card visible", async () => {
const dismissAll = deferred<SessionNotificationInboxSnapshot>();
const dismissAllNotifications = vi.fn(() => dismissAll.promise);
const harness = createHarness(capableState(), {
notificationInbox: vi.fn(() => Promise.resolve(inboxSnapshot([entry(2), entry(1)]))),
dismissAllNotifications,
});
harness.controller.prepareSelectedSession(session, "local");
await harness.controller.refreshSelectedSession(session, "local");
const dismissal = harness.controller.dismissAll();
expect(selectedNotificationView(harness.state.selectedNotificationInbox)?.notifications).toEqual([]);
expect(dismissAllNotifications).toHaveBeenCalledWith({ id: session.id, cwd: session.cwd }, "daemon-a", { order: 2, overflowWatermark: 0 }, "local");
harness.controller.applyInboxEvent("local", addedEvent(entry(3), 2, 3));
expect(selectedNotificationView(harness.state.selectedNotificationInbox)?.notifications.map((notification) => notification.id)).toEqual(["daemon-a:3"]);
dismissAll.resolve(inboxSnapshot([entry(3)], { inboxRevision: 3, catalogRevision: 3 }));
await dismissal;
expect(selectedNotificationView(harness.state.selectedNotificationInbox)?.notifications.map((notification) => notification.id)).toEqual(["daemon-a:3"]);
});
});
interface Deferred<T> {
promise: Promise<T>;
resolve: (value: T) => void;
}
function deferred<T>(): Deferred<T> {
let resolveDeferred: ((value: T) => void) | undefined;
const promise = new Promise<T>((resolve) => { resolveDeferred = resolve; });
if (resolveDeferred === undefined) throw new Error("Deferred promise was not initialized");
return { promise, resolve: resolveDeferred };
}
@@ -0,0 +1,624 @@
import { api as defaultApi, type Machine, type SessionInfo } from "../api";
import type { AppState } from "../appState";
import {
applyNotificationCatalogEvent,
applySelectedNotificationEvent,
freshNotificationCatalog,
installSelectedNotificationSnapshot,
loadingSelectedNotificationInbox,
notificationSummaryIsEmpty,
notificationTargetsEqual,
selectedNotificationView,
type SelectedSessionNotificationInbox,
type SessionNotificationCatalogProjection,
type SessionNotificationTarget,
} from "../sessionNotifications";
import { PI_WEB_CAPABILITIES, supportsPiWebCapability } from "../../../shared/capabilities";
import type {
SessionNotificationInboxEvent,
SessionNotificationInboxSnapshot,
SessionNotificationSummaryEvent,
SessionRef,
} from "../../../shared/apiTypes";
import { selectedMachineId, type GetState, type SetState } from "./types";
const WORKSPACE_HYDRATION_CONCURRENCY = 3;
export interface SessionNotificationApi {
notificationCatalog: typeof defaultApi.notificationCatalog;
notificationInbox: typeof defaultApi.notificationInbox;
dismissNotification: typeof defaultApi.dismissNotification;
dismissAllNotifications: typeof defaultApi.dismissAllNotifications;
workspaces: typeof defaultApi.workspaces;
}
export interface SessionNotificationControllerDependencies {
api?: SessionNotificationApi;
onBackgroundError?: (message: string, error: unknown) => void;
}
interface CatalogJoin {
events: SessionNotificationSummaryEvent[];
}
interface CatalogRefreshOperation {
promise: Promise<void>;
trailing: boolean;
}
interface SelectedJoin {
generation: number;
events: SessionNotificationInboxEvent[];
}
interface SelectedRefreshOperation {
generation: number;
promise: Promise<void>;
trailing: boolean;
}
/**
* Owns browser projections of daemon notification state.
*
* Network and socket inputs enter through explicit methods; all transcript state
* remains owned by SessionController/ChatTranscriptStore and is never touched.
*/
export class SessionNotificationController {
private readonly api: SessionNotificationApi;
private readonly onBackgroundError: (message: string, error: unknown) => void;
private readonly acceptedSupportByMachine = new Set<string>();
private readonly catalogJoins = new Map<string, CatalogJoin>();
private readonly catalogRefreshes = new Map<string, CatalogRefreshOperation>();
private selectedTarget: SessionNotificationTarget | undefined;
private selectedGeneration = 0;
private selectedJoin: SelectedJoin | undefined;
private selectedRefresh: SelectedRefreshOperation | undefined;
private readonly dismissingNotificationIds = new Set<string>();
private dismissAllPending = false;
private workspaceHydrationKey = "";
private readonly workspaceHydrationsInFlight = new Set<string>();
private disposed = false;
constructor(
private readonly getState: GetState,
private readonly setState: SetState,
dependencies: SessionNotificationControllerDependencies = {},
) {
this.api = dependencies.api ?? defaultApi;
this.onBackgroundError = dependencies.onBackgroundError ?? ((message, error) => { console.warn(message, error); });
}
dispose(): void {
this.disposed = true;
this.selectedGeneration += 1;
this.selectedTarget = undefined;
this.selectedJoin = undefined;
this.catalogJoins.clear();
this.catalogRefreshes.clear();
this.dismissingNotificationIds.clear();
this.dismissAllPending = false;
this.workspaceHydrationsInFlight.clear();
}
prepareSelectedSession(session: SessionInfo, machineId: string): void {
this.selectedGeneration += 1;
this.selectedTarget = session.archived === true ? undefined : { machineId, sessionId: session.id, cwd: session.cwd };
this.selectedJoin = undefined;
this.dismissingNotificationIds.clear();
this.dismissAllPending = false;
if (this.selectedTarget === undefined || !this.machineSupportsNotifications(machineId)) {
this.setState({ selectedNotificationInbox: undefined });
return;
}
this.setState({ selectedNotificationInbox: loadingSelectedNotificationInbox(this.selectedTarget) });
}
clearSelectedSession(): void {
this.selectedGeneration += 1;
this.selectedTarget = undefined;
this.selectedJoin = undefined;
this.dismissingNotificationIds.clear();
this.dismissAllPending = false;
this.setState({ selectedNotificationInbox: undefined });
}
refreshSelectedSession(session: SessionRef, machineId: string): Promise<void> {
const target = this.selectedTarget;
if (target?.machineId !== machineId || target.sessionId !== session.id || target.cwd !== session.cwd) return Promise.resolve();
if (!this.machineSupportsNotifications(machineId) || !this.machineIsReachable(machineId)) return Promise.resolve();
const generation = this.selectedGeneration;
const existing = this.selectedRefresh;
if (existing?.generation === generation) {
existing.trailing = true;
return existing.promise;
}
this.ensureSelectedProjection(target);
const operation: SelectedRefreshOperation = { generation, promise: Promise.resolve(), trailing: false };
operation.promise = this.runSelectedRefresh(operation, target).finally(() => {
if (this.selectedRefresh === operation) this.selectedRefresh = undefined;
});
this.selectedRefresh = operation;
return operation.promise;
}
applyInboxEvent(machineId: string, event: SessionNotificationInboxEvent): void {
this.acceptedSupportByMachine.add(machineId);
const target = this.selectedTarget;
if (target?.machineId !== machineId || target.sessionId !== event.summary.sessionId || target.cwd !== event.summary.cwd) {
this.applyCatalogSummary(machineId, inboxSummaryEvent(event));
return;
}
const join = this.selectedJoin;
if (join?.generation === this.selectedGeneration) {
join.events.push(event);
return;
}
const result = applySelectedNotificationEvent(this.getState().selectedNotificationInbox, target, event);
if (result.changed) this.setState({ selectedNotificationInbox: result.value });
this.applyCatalogSummary(machineId, inboxSummaryEvent(event));
if (result.needsRefresh) this.scheduleSelectedRefresh(target);
}
applySummaryEvent(machineId: string, event: SessionNotificationSummaryEvent): void {
this.acceptedSupportByMachine.add(machineId);
const join = this.catalogJoins.get(machineId);
if (join !== undefined) {
join.events.push(event);
return;
}
// The matching per-session event carries the notification text and must win
// the live-announcement race. Initial-open and reconnect snapshots still
// reconcile selected state through the catalog join.
this.applyCatalogSummary(machineId, event, false);
this.ensureSelectedSupport(machineId);
}
globalSocketOpened(machineId: string): void {
if (machineId === selectedMachineId(this.getState())) this.workspaceHydrationKey = "";
if (this.machineSupportsNotifications(machineId) && this.machineIsReachable(machineId)) void this.refreshCatalog(machineId);
}
async refreshAfterBrowserResume(): Promise<void> {
this.workspaceHydrationKey = "";
const machineIds = this.notificationMachineIds().filter((machineId) => this.machineSupportsNotifications(machineId) && this.machineIsReachable(machineId));
await Promise.all(machineIds.map((machineId) => this.refreshCatalog(machineId)));
}
syncEnvironment(previous: AppState, next: AppState): void {
if (this.disposed) return;
if (previous.machines !== next.machines) this.pruneRemovedMachines(next.machines);
const environmentChanged = previous.machines !== next.machines
|| previous.machineStatuses !== next.machineStatuses
|| previous.machineRuntimes !== next.machineRuntimes;
if (environmentChanged) {
for (const machineId of this.notificationMachineIds()) {
const wasEligible = this.machineSupportsNotificationsInState(previous, machineId) && this.machineIsReachableInState(previous, machineId);
const isEligible = this.machineSupportsNotificationsInState(next, machineId) && this.machineIsReachableInState(next, machineId);
if (!isEligible) this.markCatalogStale(machineId);
else if (!wasEligible || next.notificationCatalogsByMachine[machineId]?.status !== "fresh") void this.refreshCatalog(machineId);
}
const selected = this.selectedTarget;
if (selected !== undefined) {
if (!this.machineSupportsNotifications(selected.machineId) || !this.machineIsReachable(selected.machineId)) {
this.markSelectedStale(selected);
} else {
this.ensureSelectedProjection(selected);
if (!this.machineSupportsNotificationsInState(previous, selected.machineId) || next.selectedNotificationInbox?.status !== "fresh") {
void this.refreshSelectedSession({ id: selected.sessionId, cwd: selected.cwd }, selected.machineId);
}
}
}
}
if (environmentChanged
|| previous.projects !== next.projects
|| previous.workspacesByProjectId !== next.workspacesByProjectId
|| previous.selectedMachine !== next.selectedMachine
|| previous.notificationCatalogsByMachine !== next.notificationCatalogsByMachine) {
this.scheduleWorkspaceHydration();
}
}
shouldFilterLegacyNotification(machineId: string, notificationId: string | undefined): boolean {
return notificationId !== undefined && notificationId !== "" && this.machineSupportsNotifications(machineId);
}
async dismissNotification(notificationId: string): Promise<void> {
const inbox = this.getState().selectedNotificationInbox;
const view = selectedNotificationView(inbox);
if (inbox === undefined || view === undefined || this.dismissAllPending || this.dismissingNotificationIds.has(notificationId)) return;
if (!view.notifications.some((notification) => notification.id === notificationId)) return;
const target = targetFromInbox(inbox);
const generation = this.selectedGeneration;
this.dismissingNotificationIds.add(notificationId);
this.patchSelectedOverlay(target, (current) => ({
...current,
optimisticDismissedIds: [...new Set([...current.optimisticDismissedIds, notificationId])],
}));
try {
const snapshot = await this.api.dismissNotification({ id: target.sessionId, cwd: target.cwd }, view.daemonInstanceId, notificationId, target.machineId);
if (this.isCurrentTarget(target, generation)) this.applyMutationSnapshot(target, snapshot, (current) => ({
...current,
optimisticDismissedIds: current.optimisticDismissedIds.filter((id) => id !== notificationId),
}));
} catch (error) {
if (this.isCurrentTarget(target, generation)) {
this.patchSelectedOverlay(target, (current) => ({
...current,
optimisticDismissedIds: current.optimisticDismissedIds.filter((id) => id !== notificationId),
}));
this.setState({ error: `Failed to dismiss notification: ${errorMessage(error)}` });
await this.refreshSelectedSession({ id: target.sessionId, cwd: target.cwd }, target.machineId);
}
} finally {
this.dismissingNotificationIds.delete(notificationId);
}
}
async dismissAll(): Promise<void> {
const inbox = this.getState().selectedNotificationInbox;
const view = selectedNotificationView(inbox);
if (inbox === undefined || view === undefined || this.dismissAllPending || view.retainedCount + view.discardedCount === 0) return;
const target = targetFromInbox(inbox);
const generation = this.selectedGeneration;
const through = { ...inbox.dismissThrough };
this.dismissAllPending = true;
this.patchSelectedOverlay(target, (current) => ({ ...current, optimisticDismissAllThrough: through }));
try {
const snapshot = await this.api.dismissAllNotifications({ id: target.sessionId, cwd: target.cwd }, view.daemonInstanceId, through, target.machineId);
if (this.isCurrentTarget(target, generation)) this.applyMutationSnapshot(target, snapshot, (current) => {
const next = { ...current };
delete next.optimisticDismissAllThrough;
return next;
});
} catch (error) {
if (this.isCurrentTarget(target, generation)) {
this.patchSelectedOverlay(target, (current) => {
const next = { ...current };
delete next.optimisticDismissAllThrough;
return next;
});
this.setState({ error: `Failed to dismiss session notifications: ${errorMessage(error)}` });
await this.refreshSelectedSession({ id: target.sessionId, cwd: target.cwd }, target.machineId);
}
} finally {
if (generation === this.selectedGeneration) this.dismissAllPending = false;
}
}
private async runSelectedRefresh(operation: SelectedRefreshOperation, target: SessionNotificationTarget): Promise<void> {
do {
operation.trailing = false;
const join: SelectedJoin = { generation: operation.generation, events: [] };
this.selectedJoin = join;
try {
const snapshot = await this.api.notificationInbox({ id: target.sessionId, cwd: target.cwd }, target.machineId);
if (!this.isCurrentTarget(target, operation.generation)) return;
if (!this.machineSupportsNotifications(target.machineId) || !this.machineIsReachable(target.machineId)) {
this.markSelectedStale(target);
return;
}
this.acceptedSupportByMachine.add(target.machineId);
const current = this.getState().selectedNotificationInbox;
let inbox = current === undefined || shouldInstallSelectedSnapshot(current, target, snapshot)
? installSelectedNotificationSnapshot(current, target, snapshot)
: current;
const catalogEvents = [snapshotSummaryEvent(snapshot)];
for (const event of [...join.events].sort((left, right) => left.summary.inboxRevision - right.summary.inboxRevision)) {
const result = applySelectedNotificationEvent(inbox, target, event);
inbox = result.value;
catalogEvents.push(inboxSummaryEvent(event));
if (result.needsRefresh) operation.trailing = true;
}
this.setState({ selectedNotificationInbox: inbox });
for (const event of catalogEvents) this.applyCatalogSummary(target.machineId, event);
} catch (error) {
if (this.isCurrentTarget(target, operation.generation)) {
const current = this.getState().selectedNotificationInbox;
if (current !== undefined && notificationTargetsEqual(current, target) && current.status !== "stale") this.setState({ selectedNotificationInbox: { ...current, status: "stale" } });
this.onBackgroundError(`Failed to refresh notifications for session ${target.sessionId}`, error);
}
return;
} finally {
if (this.selectedJoin === join) this.selectedJoin = undefined;
}
} while (operation.trailing && this.isCurrentTarget(target, operation.generation) && this.machineIsReachable(target.machineId));
}
private refreshCatalog(machineId: string): Promise<void> {
if (this.disposed || !this.machineIsKnown(machineId) || !this.machineSupportsNotifications(machineId) || !this.machineIsReachable(machineId)) return Promise.resolve();
const existing = this.catalogRefreshes.get(machineId);
if (existing !== undefined) {
existing.trailing = true;
return existing.promise;
}
const operation: CatalogRefreshOperation = { promise: Promise.resolve(), trailing: false };
operation.promise = this.runCatalogRefresh(machineId, operation).finally(() => {
if (this.catalogRefreshes.get(machineId) === operation) this.catalogRefreshes.delete(machineId);
});
this.catalogRefreshes.set(machineId, operation);
return operation.promise;
}
private async runCatalogRefresh(machineId: string, operation: CatalogRefreshOperation): Promise<void> {
do {
operation.trailing = false;
const join: CatalogJoin = { events: [] };
this.catalogJoins.set(machineId, join);
try {
const snapshot = await this.api.notificationCatalog(machineId);
if (this.disposed || !this.machineIsKnown(machineId) || !this.machineIsReachable(machineId)) return;
this.acceptedSupportByMachine.add(machineId);
let projection = freshNotificationCatalog(machineId, snapshot);
for (const event of [...join.events].sort((left, right) => left.catalogRevision - right.catalogRevision)) {
const result = applyNotificationCatalogEvent(projection, machineId, event);
projection = result.value;
if (result.needsRefresh) operation.trailing = true;
}
this.setCatalog(machineId, projection);
} catch (error) {
if (this.disposed) return;
this.markCatalogStale(machineId);
this.onBackgroundError(`Failed to refresh notification catalog for machine ${machineId}`, error);
return;
} finally {
if (this.catalogJoins.get(machineId) === join) this.catalogJoins.delete(machineId);
}
} while (operation.trailing && this.machineIsKnown(machineId) && this.machineIsReachable(machineId));
}
private applyCatalogSummary(machineId: string, event: SessionNotificationSummaryEvent, reconcileSelected = true): void {
const join = this.catalogJoins.get(machineId);
if (join !== undefined) {
join.events.push(event);
return;
}
const current = this.getState().notificationCatalogsByMachine[machineId];
const result = applyNotificationCatalogEvent(current, machineId, event);
if (result.changed) this.setCatalog(machineId, result.value, reconcileSelected);
if (result.needsRefresh) this.scheduleCatalogRefresh(machineId);
}
private applyMutationSnapshot(
target: SessionNotificationTarget,
snapshot: SessionNotificationInboxSnapshot,
removeOverlay: (inbox: SelectedSessionNotificationInbox) => SelectedSessionNotificationInbox,
): void {
const current = this.getState().selectedNotificationInbox;
if (current === undefined || !notificationTargetsEqual(current, target)) return;
if (!this.machineSupportsNotifications(target.machineId) || !this.machineIsReachable(target.machineId)) {
this.setState({ selectedNotificationInbox: { ...removeOverlay(current), status: "stale" } });
return;
}
const authoritative = shouldInstallSelectedSnapshot(current, target, snapshot)
? installSelectedNotificationSnapshot(current, target, snapshot)
: current;
this.setState({ selectedNotificationInbox: removeOverlay(authoritative) });
this.applyCatalogSummary(target.machineId, snapshotSummaryEvent(snapshot));
}
private patchSelectedOverlay(
target: SessionNotificationTarget,
update: (inbox: SelectedSessionNotificationInbox) => SelectedSessionNotificationInbox,
): void {
const current = this.getState().selectedNotificationInbox;
if (current === undefined || !notificationTargetsEqual(current, target)) return;
this.setState({ selectedNotificationInbox: update(current) });
}
private ensureSelectedSupport(machineId: string): void {
const target = this.selectedTarget;
if (target?.machineId !== machineId) return;
const current = this.getState().selectedNotificationInbox;
if (current !== undefined && notificationTargetsEqual(current, target) && current.status === "fresh") return;
this.ensureSelectedProjection(target);
void this.refreshSelectedSession({ id: target.sessionId, cwd: target.cwd }, machineId);
}
private ensureSelectedProjection(target: SessionNotificationTarget): void {
const current = this.getState().selectedNotificationInbox;
if (current === undefined || !notificationTargetsEqual(current, target)) this.setState({ selectedNotificationInbox: loadingSelectedNotificationInbox(target) });
}
private scheduleSelectedRefresh(target: SessionNotificationTarget): void {
queueMicrotask(() => {
if (!this.disposed && this.selectedTarget !== undefined && notificationTargetsEqual(this.selectedTarget, target)) {
void this.refreshSelectedSession({ id: target.sessionId, cwd: target.cwd }, target.machineId);
}
});
}
private scheduleCatalogRefresh(machineId: string): void {
queueMicrotask(() => {
if (!this.disposed) void this.refreshCatalog(machineId);
});
}
private setCatalog(machineId: string, projection: SessionNotificationCatalogProjection, reconcileSelected = true): void {
const current = this.getState().notificationCatalogsByMachine;
if (current[machineId] === projection) return;
this.setState({ notificationCatalogsByMachine: { ...current, [machineId]: projection } });
if (reconcileSelected) this.reconcileSelectedWithCatalog(projection);
}
private reconcileSelectedWithCatalog(catalog: SessionNotificationCatalogProjection): void {
if (catalog.status !== "fresh" || catalog.daemonInstanceId === undefined) return;
const target = this.selectedTarget;
if (target?.machineId !== catalog.machineId || !this.machineIsReachable(target.machineId)) return;
const inbox = this.getState().selectedNotificationInbox;
if (inbox === undefined || !notificationTargetsEqual(inbox, target) || inbox.status !== "fresh" || inbox.daemonInstanceId === undefined || inbox.summary === undefined) {
this.scheduleSelectedRefresh(target);
return;
}
if (inbox.daemonInstanceId !== catalog.daemonInstanceId) {
this.scheduleSelectedRefresh(target);
return;
}
const catalogSummary = catalog.summariesBySessionId[target.sessionId];
if (catalogSummary === undefined) {
if (!notificationSummaryIsEmpty(inbox.summary)) this.scheduleSelectedRefresh(target);
return;
}
if (catalogSummary.cwd !== target.cwd
|| catalogSummary.inboxRevision > inbox.summary.inboxRevision
|| (catalogSummary.inboxRevision === inbox.summary.inboxRevision && !notificationSummariesEqual(catalogSummary, inbox.summary))) {
this.scheduleSelectedRefresh(target);
}
}
private markCatalogStale(machineId: string): void {
const current = this.getState().notificationCatalogsByMachine[machineId];
if (current === undefined || current.status === "stale") return;
this.setCatalog(machineId, { ...current, status: "stale" });
}
private markSelectedStale(target: SessionNotificationTarget): void {
const current = this.getState().selectedNotificationInbox;
if (current === undefined || !notificationTargetsEqual(current, target) || current.status === "stale") return;
this.setState({ selectedNotificationInbox: { ...current, status: "stale" } });
}
private pruneRemovedMachines(machines: readonly Machine[]): void {
const machineIds = new Set(machines.map((machine) => machine.id));
if (machineIds.size === 0) machineIds.add("local");
const catalogs = Object.fromEntries(Object.entries(this.getState().notificationCatalogsByMachine).filter(([machineId]) => machineIds.has(machineId)));
if (Object.keys(catalogs).length !== Object.keys(this.getState().notificationCatalogsByMachine).length) this.setState({ notificationCatalogsByMachine: catalogs });
for (const machineId of [...this.acceptedSupportByMachine]) if (!machineIds.has(machineId)) this.acceptedSupportByMachine.delete(machineId);
}
private scheduleWorkspaceHydration(): void {
const state = this.getState();
const machineId = selectedMachineId(state);
const catalog = state.notificationCatalogsByMachine[machineId];
if (catalog?.status !== "fresh" || Object.keys(catalog.summariesBySessionId).length === 0 || state.projects.length === 0) return;
const missingProjectIds = state.projects
.map((project) => project.id)
.filter((projectId) => !Object.hasOwn(state.workspacesByProjectId, projectId))
.filter((projectId) => !this.workspaceHydrationsInFlight.has(workspaceHydrationId(machineId, projectId)));
if (missingProjectIds.length === 0) return;
const key = JSON.stringify([machineId, catalog.daemonInstanceId, missingProjectIds]);
if (key === this.workspaceHydrationKey) return;
this.workspaceHydrationKey = key;
void this.hydrateProjectWorkspaces(machineId, missingProjectIds);
}
private async hydrateProjectWorkspaces(machineId: string, projectIds: readonly string[]): Promise<void> {
for (const projectId of projectIds) this.workspaceHydrationsInFlight.add(workspaceHydrationId(machineId, projectId));
await forEachWithConcurrency(projectIds, WORKSPACE_HYDRATION_CONCURRENCY, async (projectId) => {
try {
const workspaces = await this.api.workspaces(projectId, machineId);
const state = this.getState();
if (this.disposed || selectedMachineId(state) !== machineId || !state.projects.some((project) => project.id === projectId)) return;
this.setState({ workspacesByProjectId: { ...state.workspacesByProjectId, [projectId]: workspaces } });
} catch (error) {
this.onBackgroundError(`Failed to index workspaces for notification badges on machine ${machineId}`, error);
} finally {
this.workspaceHydrationsInFlight.delete(workspaceHydrationId(machineId, projectId));
}
});
}
private isCurrentTarget(target: SessionNotificationTarget, generation: number): boolean {
return !this.disposed
&& generation === this.selectedGeneration
&& this.selectedTarget !== undefined
&& notificationTargetsEqual(this.selectedTarget, target);
}
private machineSupportsNotifications(machineId: string): boolean {
return this.machineSupportsNotificationsInState(this.getState(), machineId);
}
private machineSupportsNotificationsInState(state: AppState, machineId: string): boolean {
return this.acceptedSupportByMachine.has(machineId)
|| (state.machineRuntimes[machineId]?.ok === true && supportsPiWebCapability(state.machineRuntimes[machineId], PI_WEB_CAPABILITIES.sessionsNotifications));
}
private machineIsReachable(machineId: string): boolean {
return this.machineIsReachableInState(this.getState(), machineId);
}
private machineIsReachableInState(state: AppState, machineId: string): boolean {
const machine = state.machines.find((candidate) => candidate.id === machineId);
if (machineId === "local" || machine?.kind === "local") return true;
const status = state.machineStatuses[machineId]?.status ?? machine?.status;
return status === undefined || status === "unknown" || status === "online";
}
private machineIsKnown(machineId: string): boolean {
const machines = this.getState().machines;
return machines.length === 0 ? machineId === "local" : machines.some((machine) => machine.id === machineId);
}
private notificationMachineIds(): string[] {
const machines = this.getState().machines;
return machines.length === 0 ? ["local"] : machines.map((machine) => machine.id);
}
}
function inboxSummaryEvent(event: SessionNotificationInboxEvent): SessionNotificationSummaryEvent {
return {
type: "notifications.summary",
daemonInstanceId: event.daemonInstanceId,
catalogRevision: event.catalogRevision,
summary: event.summary,
};
}
function snapshotSummaryEvent(snapshot: SessionNotificationInboxSnapshot): SessionNotificationSummaryEvent {
return {
type: "notifications.summary",
daemonInstanceId: snapshot.daemonInstanceId,
catalogRevision: snapshot.catalogRevision,
summary: snapshot.summary,
};
}
function targetFromInbox(inbox: SelectedSessionNotificationInbox): SessionNotificationTarget {
return { machineId: inbox.machineId, sessionId: inbox.sessionId, cwd: inbox.cwd };
}
function shouldInstallSelectedSnapshot(
current: SelectedSessionNotificationInbox,
target: SessionNotificationTarget,
snapshot: SessionNotificationInboxSnapshot,
): boolean {
return !notificationTargetsEqual(current, target)
|| current.daemonInstanceId !== snapshot.daemonInstanceId
|| current.summary === undefined
|| snapshot.summary.inboxRevision >= current.summary.inboxRevision;
}
async function forEachWithConcurrency<T>(items: readonly T[], concurrency: number, worker: (item: T) => Promise<void>): Promise<void> {
let nextIndex = 0;
async function run(): Promise<void> {
while (nextIndex < items.length) {
const item = items[nextIndex];
nextIndex += 1;
if (item !== undefined) await worker(item);
}
}
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => run()));
}
function workspaceHydrationId(machineId: string, projectId: string): string {
return JSON.stringify([machineId, projectId]);
}
function notificationSummariesEqual(
left: SessionNotificationInboxSnapshot["summary"],
right: SessionNotificationInboxSnapshot["summary"],
): boolean {
return left.sessionId === right.sessionId
&& left.cwd === right.cwd
&& left.inboxRevision === right.inboxRevision
&& left.retainedCount === right.retainedCount
&& left.discardedCount === right.discardedCount
&& left.highestSeverity === right.highestSeverity;
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
+248
View File
@@ -0,0 +1,248 @@
import { describe, expect, it } from "vitest";
import type {
SessionNotification,
SessionNotificationInboxEvent,
SessionNotificationInboxSnapshot,
SessionNotificationSummary,
SessionNotificationSummaryEvent,
} from "../../shared/apiTypes";
import {
aggregateNotificationSummaries,
applyNotificationCatalogEvent,
applySelectedNotificationEvent,
effectiveNotificationSummaries,
freshNotificationCatalog,
installSelectedNotificationSnapshot,
notificationAggregateAcrossMachines,
notificationAggregateForCwd,
notificationAggregateForProject,
notificationBadgeModel,
notificationFocusTargetAfterDismiss,
notificationInboxOverflowLabel,
notificationMessageTruncationLabel,
selectedNotificationView,
setNotificationTrayCollapsed,
type SessionNotificationTarget,
} from "./sessionNotifications";
const target: SessionNotificationTarget = { machineId: "local", sessionId: "session-1", cwd: "/repo" };
function notification(order: number, severity: SessionNotification["severity"] = "info", message = `notice ${String(order)}`): SessionNotification {
return {
id: `daemon-a:${String(order)}`,
message,
truncated: false,
severity,
receivedAt: `2026-07-18T00:00:${String(order).padStart(2, "0")}.000Z`,
order,
};
}
function summary(overrides: Partial<SessionNotificationSummary> = {}): SessionNotificationSummary {
return {
sessionId: "session-1",
cwd: "/repo",
inboxRevision: 1,
retainedCount: 1,
discardedCount: 0,
highestSeverity: "info",
...overrides,
};
}
function snapshot(notifications: SessionNotification[] = [notification(1)], overrides: Partial<SessionNotificationInboxSnapshot> = {}): SessionNotificationInboxSnapshot {
return {
daemonInstanceId: "daemon-a",
catalogRevision: 1,
summary: summary({ retainedCount: notifications.length, ...optionalHighestSeverity(notifications) }),
notifications,
dismissThrough: { order: notifications[0]?.order ?? 0, overflowWatermark: 0 },
...overrides,
};
}
function addedEvent(entry: SessionNotification, inboxRevision: number, catalogRevision = inboxRevision): SessionNotificationInboxEvent {
const notifications = [entry, notification(1)].filter((item, index, all) => all.findIndex((candidate) => candidate.id === item.id) === index);
return {
type: "notifications.inbox",
daemonInstanceId: "daemon-a",
catalogRevision,
summary: summary({ inboxRevision, retainedCount: notifications.length, ...optionalHighestSeverity(notifications) }),
dismissThrough: { order: entry.order, overflowWatermark: 0 },
delta: { kind: "added", notification: entry },
};
}
function summaryEvent(catalogRevision: number, overrides: Partial<SessionNotificationSummary> = {}): SessionNotificationSummaryEvent {
return {
type: "notifications.summary",
daemonInstanceId: "daemon-a",
catalogRevision,
summary: summary({ inboxRevision: catalogRevision, ...overrides }),
};
}
describe("selected notification projection", () => {
it("joins a snapshot with newer buffered events without duplicate cards or replay announcements", () => {
let inbox = installSelectedNotificationSnapshot(undefined, target, snapshot());
expect(inbox.announcements).toEqual([]);
const added = addedEvent(notification(2, "warning"), 2);
const first = applySelectedNotificationEvent(inbox, target, added);
inbox = first.value;
const duplicate = applySelectedNotificationEvent(inbox, target, added);
expect(first.needsRefresh).toBe(false);
expect(inbox.notifications.map((entry) => entry.id)).toEqual(["daemon-a:2", "daemon-a:1"]);
expect(inbox.announcements).toEqual([{ id: "daemon-a:2:daemon-a:2", severity: "warning", message: "notice 2" }]);
expect(duplicate.changed).toBe(false);
expect(duplicate.value.announcements).toHaveLength(1);
});
it("marks gaps and resync deltas stale and clears old-daemon contents", () => {
const current = installSelectedNotificationSnapshot(undefined, target, snapshot());
const gap = applySelectedNotificationEvent(current, target, addedEvent(notification(3), 3));
expect(gap.value.status).toBe("stale");
expect(gap.needsRefresh).toBe(true);
const resyncEvent: SessionNotificationInboxEvent = {
...addedEvent(notification(2), 2),
delta: { kind: "resync" },
};
expect(applySelectedNotificationEvent(current, target, resyncEvent).needsRefresh).toBe(true);
const restarted = applySelectedNotificationEvent(current, target, {
...addedEvent(notification(1), 1),
daemonInstanceId: "daemon-b",
catalogRevision: 1,
});
expect(restarted.value).toMatchObject({ status: "stale", notifications: [] });
expect(restarted.value).not.toHaveProperty("daemonInstanceId");
});
it("drops old-daemon optimistic cutoffs and announcements when a restart snapshot arrives", () => {
const current = {
...installSelectedNotificationSnapshot(undefined, target, snapshot()),
optimisticDismissedIds: ["daemon-a:1"],
optimisticDismissAllThrough: { order: 99, overflowWatermark: 12 },
announcements: [{ id: "old-announcement", severity: "error" as const, message: "old" }],
};
const restarted = installSelectedNotificationSnapshot(current, target, snapshot([notification(1)], { daemonInstanceId: "daemon-b" }));
expect(restarted.optimisticDismissedIds).toEqual([]);
expect(restarted.optimisticDismissAllThrough).toBeUndefined();
expect(restarted.announcements).toEqual([]);
expect(selectedNotificationView(restarted)?.notifications).toHaveLength(1);
});
it("applies optimistic individual and cutoff dismissals while preserving newer arrivals", () => {
const base = installSelectedNotificationSnapshot(undefined, target, snapshot(
[notification(5, "error"), notification(4, "warning"), notification(3)],
{
summary: summary({ inboxRevision: 5, retainedCount: 3, discardedCount: 2, highestSeverity: "error" }),
dismissThrough: { order: 5, overflowWatermark: 2 },
},
));
const optimistic = {
...base,
notifications: [notification(6), ...base.notifications],
summary: summary({ inboxRevision: 6, retainedCount: 4, discardedCount: 3, highestSeverity: "error" }),
dismissThrough: { order: 6, overflowWatermark: 3 },
optimisticDismissedIds: ["daemon-a:4"],
optimisticDismissAllThrough: { order: 5, overflowWatermark: 2 },
};
const view = selectedNotificationView(optimistic);
expect(view?.notifications.map((entry) => entry.order)).toEqual([6]);
expect(view).toMatchObject({ retainedCount: 1, discardedCount: 1, highestSeverity: "info", dismissAllPending: true });
});
});
describe("notification catalog revisions and aggregates", () => {
it("applies monotonic summaries idempotently and requests one recovery on a gap or daemon change", () => {
const catalog = freshNotificationCatalog("local", {
daemonInstanceId: "daemon-a",
catalogRevision: 1,
sessions: [summary()],
});
const next = applyNotificationCatalogEvent(catalog, "local", summaryEvent(2, { retainedCount: 2, highestSeverity: "warning" }));
const duplicate = applyNotificationCatalogEvent(next.value, "local", summaryEvent(2, { retainedCount: 2, highestSeverity: "warning" }));
const gap = applyNotificationCatalogEvent(next.value, "local", summaryEvent(4));
const restarted = applyNotificationCatalogEvent(next.value, "local", { ...summaryEvent(1), daemonInstanceId: "daemon-b" });
expect(next.value.summariesBySessionId["session-1"]).toMatchObject({ retainedCount: 2, highestSeverity: "warning" });
expect(duplicate.changed).toBe(false);
expect(gap).toMatchObject({ needsRefresh: true, value: { status: "stale" } });
expect(restarted.value).toMatchObject({ status: "stale", summariesBySessionId: {} });
expect(restarted.value).not.toHaveProperty("daemonInstanceId");
});
it("keeps matching session ids isolated by machine and excludes stale catalogs", () => {
const local = freshNotificationCatalog("local", {
daemonInstanceId: "daemon-a",
catalogRevision: 1,
sessions: [summary({ retainedCount: 2, discardedCount: 1, highestSeverity: "warning" })],
});
const remote = freshNotificationCatalog("remote", {
daemonInstanceId: "daemon-r",
catalogRevision: 9,
sessions: [summary({ cwd: "/remote", retainedCount: 3, discardedCount: 0, highestSeverity: "error" })],
});
const staleRemote = { ...remote, status: "stale" as const };
expect(notificationAggregateAcrossMachines({ local, remote })).toEqual({ retainedCount: 5, discardedCount: 1, highestSeverity: "error" });
expect(notificationAggregateAcrossMachines({ local, remote: staleRemote })).toEqual({ retainedCount: 2, discardedCount: 1, highestSeverity: "warning" });
expect(effectiveNotificationSummaries(local)[0]?.cwd).toBe("/repo");
expect(effectiveNotificationSummaries(remote)[0]?.cwd).toBe("/remote");
});
it("aggregates exact cwd and project workspace matches with overflow and severity", () => {
const summaries = [
summary({ sessionId: "a", cwd: "/repo", retainedCount: 2, discardedCount: 4, highestSeverity: "info" }),
summary({ sessionId: "b", cwd: "/repo-worktree", retainedCount: 1, discardedCount: 0, highestSeverity: "error" }),
summary({ sessionId: "c", cwd: "/other", retainedCount: 5, discardedCount: 0, highestSeverity: "warning" }),
];
expect(notificationAggregateForCwd(summaries, "/repo")).toEqual({ retainedCount: 2, discardedCount: 4, highestSeverity: "info" });
expect(notificationAggregateForProject(summaries, new Set(["/repo", "/repo-worktree"]))).toEqual({ retainedCount: 3, discardedCount: 4, highestSeverity: "error" });
expect(aggregateNotificationSummaries(summaries)).toEqual({ retainedCount: 8, discardedCount: 4, highestSeverity: "error" });
expect(notificationBadgeModel(notificationAggregateForCwd(summaries, "/repo"))).toMatchObject({
text: "2+",
severity: "info",
accessibleLabel: "2 undismissed notifications, 4 older notifications discarded, highest severity info",
});
});
});
describe("notification presentation helpers", () => {
it("chooses next, previous, then header focus targets", () => {
const notifications = [notification(3), notification(2), notification(1)];
expect(notificationFocusTargetAfterDismiss(notifications, "daemon-a:2")).toEqual({ kind: "notification", notificationId: "daemon-a:1" });
expect(notificationFocusTargetAfterDismiss(notifications, "daemon-a:1")).toEqual({ kind: "notification", notificationId: "daemon-a:2" });
expect(notificationFocusTargetAfterDismiss([notification(1)], "daemon-a:1")).toEqual({ kind: "header" });
});
it("retains explicit collapse state and exposes a visible truncation label", () => {
const collapsed = setNotificationTrayCollapsed(new Set(), "session-1", true);
expect(collapsed.has("session-1")).toBe(true);
expect(setNotificationTrayCollapsed(collapsed, "session-1", false).has("session-1")).toBe(false);
expect(notificationInboxOverflowLabel(23)).toBe("23 older notifications were discarded because this inbox keeps the latest 100.");
expect(notificationMessageTruncationLabel({ truncated: true })).toContain("8 KiB");
expect(notificationMessageTruncationLabel({ truncated: false })).toBeUndefined();
});
});
function optionalHighestSeverity(notifications: readonly SessionNotification[]): { highestSeverity?: SessionNotification["severity"] } {
const severity = highestSeverity(notifications);
return severity === undefined ? {} : { highestSeverity: severity };
}
function highestSeverity(notifications: readonly SessionNotification[]): SessionNotification["severity"] | undefined {
if (notifications.some((entry) => entry.severity === "error")) return "error";
if (notifications.some((entry) => entry.severity === "warning")) return "warning";
return notifications.length === 0 ? undefined : "info";
}
+432
View File
@@ -0,0 +1,432 @@
import {
SESSION_NOTIFICATION_LIMIT,
SESSION_NOTIFICATION_MESSAGE_BYTES,
type SessionNotification,
type SessionNotificationCatalogSnapshot,
type SessionNotificationDismissThrough,
type SessionNotificationInboxEvent,
type SessionNotificationInboxSnapshot,
type SessionNotificationSeverity,
type SessionNotificationSummary,
type SessionNotificationSummaryEvent,
} from "../../shared/apiTypes";
export type SessionNotificationProjectionStatus = "loading" | "fresh" | "stale";
export interface SessionNotificationCatalogProjection {
machineId: string;
status: SessionNotificationProjectionStatus;
daemonInstanceId?: string;
catalogRevision: number;
summariesBySessionId: Record<string, SessionNotificationSummary>;
}
export interface SessionNotificationTarget {
machineId: string;
sessionId: string;
cwd: string;
}
export interface SessionNotificationAnnouncement {
id: string;
severity: SessionNotificationSeverity;
message: string;
}
export interface SelectedSessionNotificationInbox extends SessionNotificationTarget {
status: SessionNotificationProjectionStatus;
daemonInstanceId?: string;
catalogRevision: number;
summary?: SessionNotificationSummary;
notifications: SessionNotification[];
dismissThrough: SessionNotificationDismissThrough;
optimisticDismissedIds: string[];
optimisticDismissAllThrough?: SessionNotificationDismissThrough;
announcements: SessionNotificationAnnouncement[];
}
export interface SelectedSessionNotificationView extends SessionNotificationTarget {
daemonInstanceId: string;
notifications: SessionNotification[];
retainedCount: number;
discardedCount: number;
highestSeverity?: SessionNotificationSeverity;
dismissThrough: SessionNotificationDismissThrough;
pendingDismissedIds: ReadonlySet<string>;
dismissAllPending: boolean;
announcements: SessionNotificationAnnouncement[];
}
export interface SessionNotificationAggregate {
retainedCount: number;
discardedCount: number;
highestSeverity?: SessionNotificationSeverity;
}
export interface SessionNotificationBadgeModel extends SessionNotificationAggregate {
text: string;
severity: SessionNotificationSeverity;
icon: string;
accessibleLabel: string;
}
export interface NotificationReducerResult<T> {
value: T;
needsRefresh: boolean;
changed: boolean;
}
const emptyDismissThrough: SessionNotificationDismissThrough = { order: 0, overflowWatermark: 0 };
export function loadingSelectedNotificationInbox(target: SessionNotificationTarget): SelectedSessionNotificationInbox {
return {
...target,
status: "loading",
catalogRevision: 0,
notifications: [],
dismissThrough: emptyDismissThrough,
optimisticDismissedIds: [],
announcements: [],
};
}
export function freshNotificationCatalog(machineId: string, snapshot: SessionNotificationCatalogSnapshot): SessionNotificationCatalogProjection {
return {
machineId,
status: "fresh",
daemonInstanceId: snapshot.daemonInstanceId,
catalogRevision: snapshot.catalogRevision,
summariesBySessionId: Object.fromEntries(snapshot.sessions.map((summary) => [summary.sessionId, summary])),
};
}
export function applyNotificationCatalogEvent(
current: SessionNotificationCatalogProjection | undefined,
machineId: string,
event: SessionNotificationSummaryEvent,
): NotificationReducerResult<SessionNotificationCatalogProjection> {
if (current?.machineId !== machineId) {
return {
value: staleNotificationCatalog(machineId),
needsRefresh: true,
changed: true,
};
}
if (current.daemonInstanceId !== event.daemonInstanceId) {
return {
value: staleNotificationCatalog(machineId),
needsRefresh: true,
changed: current.status !== "stale" || current.daemonInstanceId !== undefined || Object.keys(current.summariesBySessionId).length > 0,
};
}
if (event.catalogRevision <= current.catalogRevision) return { value: current, needsRefresh: false, changed: false };
if (current.status !== "fresh" || event.catalogRevision !== current.catalogRevision + 1) {
const stale = { ...current, status: "stale" as const };
return { value: stale, needsRefresh: true, changed: current.status !== "stale" };
}
const summariesBySessionId = notificationSummaryIsEmpty(event.summary)
? omitRecordKey(current.summariesBySessionId, event.summary.sessionId)
: { ...current.summariesBySessionId, [event.summary.sessionId]: event.summary };
return {
value: {
...current,
catalogRevision: event.catalogRevision,
summariesBySessionId,
},
needsRefresh: false,
changed: true,
};
}
export function installSelectedNotificationSnapshot(
current: SelectedSessionNotificationInbox | undefined,
target: SessionNotificationTarget,
snapshot: SessionNotificationInboxSnapshot,
): SelectedSessionNotificationInbox {
if (snapshot.summary.sessionId !== target.sessionId || snapshot.summary.cwd !== target.cwd) throw new Error("Notification inbox snapshot does not match the selected session");
const sameDaemon = current !== undefined
&& notificationTargetsEqual(current, target)
&& current.daemonInstanceId === snapshot.daemonInstanceId;
return {
...target,
status: "fresh",
daemonInstanceId: snapshot.daemonInstanceId,
catalogRevision: snapshot.catalogRevision,
summary: snapshot.summary,
notifications: snapshot.notifications,
dismissThrough: snapshot.dismissThrough,
optimisticDismissedIds: sameDaemon ? current.optimisticDismissedIds : [],
...(sameDaemon && current.optimisticDismissAllThrough !== undefined ? { optimisticDismissAllThrough: current.optimisticDismissAllThrough } : {}),
announcements: sameDaemon ? current.announcements : [],
};
}
export function applySelectedNotificationEvent(
current: SelectedSessionNotificationInbox | undefined,
target: SessionNotificationTarget,
event: SessionNotificationInboxEvent,
): NotificationReducerResult<SelectedSessionNotificationInbox> {
if (event.summary.sessionId !== target.sessionId || event.summary.cwd !== target.cwd) {
return { value: current ?? loadingSelectedNotificationInbox(target), needsRefresh: false, changed: false };
}
if (current === undefined || !notificationTargetsEqual(current, target)) {
return { value: staleSelectedNotificationInbox(target), needsRefresh: true, changed: true };
}
if (current.daemonInstanceId !== event.daemonInstanceId) {
return {
value: staleSelectedNotificationInbox(target),
needsRefresh: true,
changed: current.status !== "stale" || current.daemonInstanceId !== undefined || current.notifications.length > 0,
};
}
const currentRevision = current.summary?.inboxRevision ?? 0;
if (event.summary.inboxRevision <= currentRevision) return { value: current, needsRefresh: false, changed: false };
if (current.status !== "fresh" || event.summary.inboxRevision !== currentRevision + 1 || event.delta.kind === "resync") {
const stale = { ...current, status: "stale" as const };
return { value: stale, needsRefresh: true, changed: current.status !== "stale" };
}
let notifications: SessionNotification[];
let announcement: SessionNotificationAnnouncement | undefined;
switch (event.delta.kind) {
case "added": {
const delta = event.delta;
if (current.notifications.some((notification) => notification.id === delta.notification.id)) {
return { value: { ...current, status: "stale" }, needsRefresh: true, changed: true };
}
notifications = [delta.notification, ...current.notifications]
.filter((notification) => notification.id !== delta.evictedNotificationId)
.sort((left, right) => right.order - left.order)
.slice(0, SESSION_NOTIFICATION_LIMIT);
announcement = {
id: `${event.daemonInstanceId}:${String(event.summary.inboxRevision)}:${delta.notification.id}`,
severity: delta.notification.severity,
message: delta.notification.message,
};
break;
}
case "dismissed": {
const dismissed = new Set(event.delta.notificationIds);
notifications = current.notifications.filter((notification) => !dismissed.has(notification.id));
break;
}
case "cleared":
notifications = [];
break;
}
const newestOrder = notifications[0]?.order ?? 0;
if (!notificationListMatchesSummary(notifications, event.summary)
|| event.dismissThrough.order !== newestOrder
|| event.dismissThrough.overflowWatermark < event.summary.discardedCount) {
return { value: { ...current, status: "stale" }, needsRefresh: true, changed: true };
}
const announcements = announcement === undefined
? current.announcements
: [...current.announcements, announcement].slice(-SESSION_NOTIFICATION_LIMIT);
return {
value: {
...current,
status: "fresh",
daemonInstanceId: event.daemonInstanceId,
catalogRevision: event.catalogRevision,
summary: event.summary,
notifications,
dismissThrough: event.dismissThrough,
announcements,
},
needsRefresh: false,
changed: true,
};
}
export function selectedNotificationView(inbox: SelectedSessionNotificationInbox | undefined): SelectedSessionNotificationView | undefined {
if (inbox?.status !== "fresh" || inbox.daemonInstanceId === undefined || inbox.summary === undefined) return undefined;
const pendingDismissedIds = new Set(inbox.optimisticDismissedIds);
const through = inbox.optimisticDismissAllThrough;
const notifications = inbox.notifications.filter((notification) => !pendingDismissedIds.has(notification.id) && (through === undefined || notification.order > through.order));
let discardedCount = effectiveDiscardedCount(inbox.summary.discardedCount, inbox.dismissThrough.overflowWatermark, through?.overflowWatermark);
if (notifications.length === 0 && pendingDismissedIds.size > 0) discardedCount = 0;
return {
machineId: inbox.machineId,
sessionId: inbox.sessionId,
cwd: inbox.cwd,
daemonInstanceId: inbox.daemonInstanceId,
notifications,
retainedCount: notifications.length,
discardedCount,
...optionalSeverity(highestNotificationSeverity(notifications)),
dismissThrough: inbox.dismissThrough,
pendingDismissedIds,
dismissAllPending: through !== undefined,
announcements: inbox.announcements,
};
}
export function notificationSummaryFromSelectedView(view: SelectedSessionNotificationView, inboxRevision: number): SessionNotificationSummary {
return {
sessionId: view.sessionId,
cwd: view.cwd,
inboxRevision,
retainedCount: view.retainedCount,
discardedCount: view.discardedCount,
...optionalSeverity(view.highestSeverity),
};
}
export function effectiveNotificationSummaries(
catalog: SessionNotificationCatalogProjection | undefined,
selectedInbox?: SelectedSessionNotificationInbox,
): SessionNotificationSummary[] {
if (catalog?.status !== "fresh") return [];
const summaries = { ...catalog.summariesBySessionId };
const selected = selectedNotificationView(selectedInbox);
if (selected?.machineId !== catalog.machineId || selectedInbox?.summary === undefined) return Object.values(summaries);
const summary = notificationSummaryFromSelectedView(selected, selectedInbox.summary.inboxRevision);
return Object.values(notificationSummaryIsEmpty(summary)
? omitRecordKey(summaries, summary.sessionId)
: { ...summaries, [summary.sessionId]: summary });
}
export function aggregateNotificationSummaries(summaries: readonly SessionNotificationSummary[]): SessionNotificationAggregate {
return summaries.reduce<SessionNotificationAggregate>((aggregate, summary) => ({
retainedCount: aggregate.retainedCount + summary.retainedCount,
discardedCount: aggregate.discardedCount + summary.discardedCount,
...optionalSeverity(higherNotificationSeverity(aggregate.highestSeverity, summary.highestSeverity)),
}), { retainedCount: 0, discardedCount: 0 });
}
export function notificationAggregateForCwd(summaries: readonly SessionNotificationSummary[], cwd: string): SessionNotificationAggregate {
return aggregateNotificationSummaries(summaries.filter((summary) => summary.cwd === cwd));
}
export function notificationAggregateForProject(
summaries: readonly SessionNotificationSummary[],
workspacePaths: ReadonlySet<string>,
): SessionNotificationAggregate {
return aggregateNotificationSummaries(summaries.filter((summary) => workspacePaths.has(summary.cwd)));
}
export function notificationAggregateAcrossMachines(
catalogsByMachine: Readonly<Record<string, SessionNotificationCatalogProjection>>,
selectedInbox?: SelectedSessionNotificationInbox,
): SessionNotificationAggregate {
return aggregateNotificationSummaries(Object.values(catalogsByMachine).flatMap((catalog) => effectiveNotificationSummaries(catalog, selectedInbox)));
}
export function notificationBadgeModel(aggregate: SessionNotificationAggregate): SessionNotificationBadgeModel | undefined {
if (aggregate.retainedCount === 0 && aggregate.discardedCount === 0) return undefined;
const severity = aggregate.highestSeverity ?? "info";
const notificationNoun = aggregate.retainedCount === 1 ? "notification" : "notifications";
const discardedNoun = aggregate.discardedCount === 1 ? "notification" : "notifications";
const accessibleParts = [`${String(aggregate.retainedCount)} undismissed ${notificationNoun}`];
if (aggregate.discardedCount > 0) accessibleParts.push(`${String(aggregate.discardedCount)} older ${discardedNoun} discarded`);
accessibleParts.push(`highest severity ${severity}`);
return {
...aggregate,
severity,
icon: notificationSeverityIcon(severity),
text: `${String(aggregate.retainedCount)}${aggregate.discardedCount > 0 ? "+" : ""}`,
accessibleLabel: accessibleParts.join(", "),
};
}
export function notificationSeverityLabel(severity: SessionNotificationSeverity): "Info" | "Warning" | "Error" {
if (severity === "error") return "Error";
if (severity === "warning") return "Warning";
return "Info";
}
export function notificationSeverityIcon(severity: SessionNotificationSeverity): string {
if (severity === "error") return "⛔";
if (severity === "warning") return "⚠";
return "";
}
export type NotificationFocusTarget = { kind: "notification"; notificationId: string } | { kind: "header" };
export function notificationFocusTargetAfterDismiss(notifications: readonly SessionNotification[], notificationId: string): NotificationFocusTarget {
const index = notifications.findIndex((notification) => notification.id === notificationId);
if (index === -1) return { kind: "header" };
const next = notifications[index + 1];
if (next !== undefined) return { kind: "notification", notificationId: next.id };
const previous = notifications[index - 1];
return previous === undefined ? { kind: "header" } : { kind: "notification", notificationId: previous.id };
}
export function setNotificationTrayCollapsed(collapsedSessionIds: ReadonlySet<string>, sessionId: string, collapsed: boolean): ReadonlySet<string> {
const next = new Set(collapsedSessionIds);
if (collapsed) next.add(sessionId);
else next.delete(sessionId);
return next;
}
export function notificationInboxOverflowLabel(discardedCount: number): string {
return `${String(discardedCount)} older ${discardedCount === 1 ? "notification was" : "notifications were"} discarded because this inbox keeps the latest ${String(SESSION_NOTIFICATION_LIMIT)}.`;
}
export function notificationMessageTruncationLabel(notification: Pick<SessionNotification, "truncated">): string | undefined {
if (!notification.truncated) return undefined;
const kibibytes = SESSION_NOTIFICATION_MESSAGE_BYTES / 1024;
return `Message truncated to the ${String(kibibytes)} KiB notification limit.`;
}
export function notificationTargetsEqual(left: SessionNotificationTarget, right: SessionNotificationTarget): boolean {
return left.machineId === right.machineId && left.sessionId === right.sessionId && left.cwd === right.cwd;
}
export function notificationSummaryIsEmpty(summary: SessionNotificationSummary): boolean {
return summary.retainedCount === 0 && summary.discardedCount === 0;
}
export function higherNotificationSeverity(
left: SessionNotificationSeverity | undefined,
right: SessionNotificationSeverity | undefined,
): SessionNotificationSeverity | undefined {
if (left === "error" || right === "error") return "error";
if (left === "warning" || right === "warning") return "warning";
if (left === "info" || right === "info") return "info";
return undefined;
}
function staleNotificationCatalog(machineId: string): SessionNotificationCatalogProjection {
return {
machineId,
status: "stale",
catalogRevision: 0,
summariesBySessionId: {},
};
}
function staleSelectedNotificationInbox(target: SessionNotificationTarget): SelectedSessionNotificationInbox {
return {
...loadingSelectedNotificationInbox(target),
status: "stale",
};
}
function notificationListMatchesSummary(notifications: readonly SessionNotification[], summary: SessionNotificationSummary): boolean {
return notifications.length === summary.retainedCount && highestNotificationSeverity(notifications) === summary.highestSeverity;
}
function highestNotificationSeverity(notifications: readonly SessionNotification[]): SessionNotificationSeverity | undefined {
let highest: SessionNotificationSeverity | undefined;
for (const notification of notifications) highest = higherNotificationSeverity(highest, notification.severity);
return highest;
}
function effectiveDiscardedCount(discardedCount: number, overflowWatermark: number, throughOverflowWatermark: number | undefined): number {
if (discardedCount === 0 || throughOverflowWatermark === undefined) return discardedCount;
const firstWatermark = overflowWatermark - discardedCount + 1;
const acknowledged = Math.max(0, Math.min(discardedCount, throughOverflowWatermark - firstWatermark + 1));
return discardedCount - acknowledged;
}
function omitRecordKey<T>(record: Readonly<Record<string, T>>, key: string): Record<string, T> {
return Object.fromEntries(Object.entries(record).filter(([candidate]) => candidate !== key));
}
function optionalSeverity(severity: SessionNotificationSeverity | undefined): { highestSeverity?: SessionNotificationSeverity } {
return severity === undefined ? {} : { highestSeverity: severity };
}
+166
View File
@@ -0,0 +1,166 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { RealtimeSocket, SessionSocket, parseRealtimeSocketEvent, parseSessionSocketEvent } from "./sessionSocket";
function notification(order = 1) {
return {
id: `daemon-a:${String(order)}`,
message: "notice",
truncated: false,
severity: "info",
receivedAt: "2026-07-18T00:00:00.000Z",
order,
};
}
function summary() {
return {
sessionId: "session-1",
cwd: "/repo",
inboxRevision: 1,
retainedCount: 1,
discardedCount: 0,
highestSeverity: "info",
};
}
function inboxEvent() {
return {
type: "notifications.inbox",
daemonInstanceId: "daemon-a",
catalogRevision: 1,
summary: summary(),
dismissThrough: { order: 1, overflowWatermark: 0 },
delta: { kind: "added", notification: notification() },
};
}
describe("notification socket guards", () => {
it("accepts validated per-session and global notification events", () => {
expect(parseSessionSocketEvent(inboxEvent())).toMatchObject({ type: "notifications.inbox", delta: { kind: "added" } });
expect(parseRealtimeSocketEvent({
type: "notifications.summary",
daemonInstanceId: "daemon-a",
catalogRevision: 1,
summary: summary(),
})).toMatchObject({ type: "notifications.summary", summary: { sessionId: "session-1" } });
});
it("ignores malformed notification events instead of widening type-only acceptance", () => {
expect(parseSessionSocketEvent({
type: "notifications.inbox",
daemonInstanceId: "daemon-a",
catalogRevision: 1,
summary: { ...summary(), highestSeverity: "fatal" },
dismissThrough: { order: 1, overflowWatermark: 0 },
delta: { kind: "added", notification: notification() },
})).toBeUndefined();
expect(parseRealtimeSocketEvent({
type: "notifications.summary",
daemonInstanceId: "daemon-a",
catalogRevision: Number.POSITIVE_INFINITY,
summary: summary(),
})).toBeUndefined();
});
it("preserves existing event acceptance without treating unknown types as realtime events", () => {
expect(parseSessionSocketEvent({ type: "command.output", level: "info", message: "legacy" })).toMatchObject({ type: "command.output" });
expect(parseRealtimeSocketEvent({ type: "future.notification", payload: {} })).toBeUndefined();
});
});
class FakeWebSocket {
static readonly CONNECTING = 0;
static readonly instances: FakeWebSocket[] = [];
readyState = 1;
onopen: (() => void) | null = null;
onmessage: ((event: { data: MessageEvent["data"] }) => void) | null = null;
onerror: (() => void) | null = null;
onclose: (() => void) | null = null;
constructor(readonly url: string) {
FakeWebSocket.instances.push(this);
}
close(): void {
this.readyState = 3;
}
}
describe("socket instance isolation", () => {
const setTimeoutSpy = vi.fn(() => 1);
beforeEach(() => {
FakeWebSocket.instances.length = 0;
setTimeoutSpy.mockClear();
vi.stubGlobal("WebSocket", FakeWebSocket);
vi.stubGlobal("document", { baseURI: "https://pi.example.test/" });
vi.stubGlobal("window", { clearTimeout: vi.fn(), setTimeout: setTimeoutSpy });
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("drops queued session frames and close callbacks from a replaced machine socket", async () => {
const socket = new SessionSocket();
const oldHandler = vi.fn();
const newHandler = vi.fn();
const onInitialOpen = vi.fn();
const target = { id: "session-1", cwd: "/repo" };
socket.connect(target, oldHandler, undefined, "machine-a");
const oldSocket = FakeWebSocket.instances[0];
if (oldSocket === undefined) throw new Error("expected old session socket");
const staleClose = oldSocket.onclose;
oldSocket.onmessage?.({ data: JSON.stringify(inboxEvent()) });
socket.connect(target, newHandler, undefined, "machine-b", onInitialOpen);
staleClose?.();
await Promise.resolve();
await Promise.resolve();
expect(oldHandler).not.toHaveBeenCalled();
expect(newHandler).not.toHaveBeenCalled();
expect(setTimeoutSpy).not.toHaveBeenCalled();
const newSocket = FakeWebSocket.instances[1];
if (newSocket === undefined) throw new Error("expected replacement session socket");
newSocket.onopen?.();
expect(onInitialOpen).toHaveBeenCalledOnce();
newSocket.onmessage?.({ data: JSON.stringify(inboxEvent()) });
await Promise.resolve();
await Promise.resolve();
expect(newHandler).toHaveBeenCalledOnce();
});
it("does not attribute a queued global frame to a replacement machine", async () => {
const socket = new RealtimeSocket();
const oldHandler = vi.fn();
const newHandler = vi.fn();
const event = {
type: "notifications.summary",
daemonInstanceId: "daemon-a",
catalogRevision: 1,
summary: summary(),
};
socket.connect(oldHandler, undefined, "machine-a");
const oldSocket = FakeWebSocket.instances[0];
if (oldSocket === undefined) throw new Error("expected old realtime socket");
oldSocket.onmessage?.({ data: JSON.stringify(event) });
socket.connect(newHandler, undefined, "machine-b");
await Promise.resolve();
await Promise.resolve();
expect(oldHandler).not.toHaveBeenCalled();
expect(newHandler).not.toHaveBeenCalled();
const newSocket = FakeWebSocket.instances[1];
if (newSocket === undefined) throw new Error("expected replacement realtime socket");
newSocket.onmessage?.({ data: JSON.stringify(event) });
await Promise.resolve();
await Promise.resolve();
expect(newHandler).toHaveBeenCalledOnce();
});
});
+58 -19
View File
@@ -1,4 +1,5 @@
import { realtimeEvents, sessionEvents } from "./api"; import { realtimeEvents, sessionEvents } from "./api";
import { parseSessionNotificationInboxEvent, parseSessionNotificationSummaryEvent } from "./api/parsers";
import type { GlobalSessionEvent, RealtimeEvent, SessionRef, SessionUiEvent } from "../../shared/apiTypes"; import type { GlobalSessionEvent, RealtimeEvent, SessionRef, SessionUiEvent } from "../../shared/apiTypes";
export type { GlobalSessionEvent, RealtimeEvent, SessionUiEvent } from "../../shared/apiTypes"; export type { GlobalSessionEvent, RealtimeEvent, SessionUiEvent } from "../../shared/apiTypes";
@@ -12,14 +13,22 @@ export class SessionSocket {
private shouldReconnect = false; private shouldReconnect = false;
private hasOpened = false; private hasOpened = false;
private onReconnect: (() => void) | undefined; private onReconnect: (() => void) | undefined;
private onInitialOpen: (() => void) | undefined;
private machineId = "local"; private machineId = "local";
connect(session: SessionRef, onEvent: (event: SessionUiEvent) => void, onReconnect?: () => void, machineId = "local"): void { connect(
session: SessionRef,
onEvent: (event: SessionUiEvent) => void,
onReconnect?: () => void,
machineId = "local",
onInitialOpen?: () => void,
): void {
this.close(); this.close();
this.machineId = machineId; this.machineId = machineId;
this.session = session; this.session = session;
this.onEvent = onEvent; this.onEvent = onEvent;
this.onReconnect = onReconnect; this.onReconnect = onReconnect;
this.onInitialOpen = onInitialOpen;
this.shouldReconnect = true; this.shouldReconnect = true;
this.open(); this.open();
} }
@@ -36,23 +45,29 @@ export class SessionSocket {
this.session = undefined; this.session = undefined;
this.onEvent = undefined; this.onEvent = undefined;
this.onReconnect = undefined; this.onReconnect = undefined;
this.onInitialOpen = undefined;
this.hasOpened = false; this.hasOpened = false;
this.machineId = "local"; this.machineId = "local";
} }
private open(): void { private open(): void {
if (this.session === undefined || this.session.id === "" || this.session.cwd === "" || !this.shouldReconnect) return; const session = this.session;
const socket = sessionEvents(this.session, this.machineId); if (session === undefined || session.id === "" || session.cwd === "" || !this.shouldReconnect) return;
const socket = sessionEvents(session, this.machineId);
this.socket = socket; this.socket = socket;
socket.onopen = () => { socket.onopen = () => {
if (this.socket !== socket) return;
this.reconnectDelay = 500; this.reconnectDelay = 500;
if (this.hasOpened) this.onReconnect?.(); const isReconnect = this.hasOpened;
this.hasOpened = true; this.hasOpened = true;
if (isReconnect) this.onReconnect?.();
else this.onInitialOpen?.();
}; };
socket.onmessage = (message) => void this.handleMessage(message.data); socket.onmessage = (message) => void this.handleMessage(message.data, socket, session);
socket.onerror = () => { socket.close(); }; socket.onerror = () => { socket.close(); };
socket.onclose = () => { socket.onclose = () => {
if (this.socket === socket) this.socket = undefined; if (this.socket !== socket) return;
this.socket = undefined;
this.scheduleReconnect(); this.scheduleReconnect();
}; };
} }
@@ -65,9 +80,11 @@ export class SessionSocket {
this.reconnectTimer = window.setTimeout(() => { this.open(); }, delay); this.reconnectTimer = window.setTimeout(() => { this.open(); }, delay);
} }
private async handleMessage(data: MessageEvent["data"]): Promise<void> { private async handleMessage(data: MessageEvent["data"], socket: WebSocket, session: SessionRef): Promise<void> {
const event = await parseSocketEvent(data); const event = parseSessionSocketEvent(await parseSocketEvent(data));
if (isSessionUiEvent(event)) this.onEvent?.(event); if (this.socket !== socket || event === undefined) return;
if (event.type === "notifications.inbox" && (session.id !== event.summary.sessionId || session.cwd !== event.summary.cwd)) return;
this.onEvent?.(event);
} }
} }
@@ -104,13 +121,15 @@ export class RealtimeSocket {
const socket = realtimeEvents(this.machineId); const socket = realtimeEvents(this.machineId);
this.socket = socket; this.socket = socket;
socket.onopen = () => { socket.onopen = () => {
if (this.socket !== socket) return;
this.reconnectDelay = 500; this.reconnectDelay = 500;
this.onOpen?.(); this.onOpen?.();
}; };
socket.onmessage = (message) => void this.handleMessage(message.data); socket.onmessage = (message) => void this.handleMessage(message.data, socket);
socket.onerror = () => { socket.close(); }; socket.onerror = () => { socket.close(); };
socket.onclose = () => { socket.onclose = () => {
if (this.socket === socket) this.socket = undefined; if (this.socket !== socket) return;
this.socket = undefined;
this.scheduleReconnect(); this.scheduleReconnect();
}; };
} }
@@ -123,25 +142,45 @@ export class RealtimeSocket {
this.reconnectTimer = window.setTimeout(() => { this.open(); }, delay); this.reconnectTimer = window.setTimeout(() => { this.open(); }, delay);
} }
private async handleMessage(data: MessageEvent["data"]): Promise<void> { private async handleMessage(data: MessageEvent["data"], socket: WebSocket): Promise<void> {
const event = await parseSocketEvent(data); const event = parseRealtimeSocketEvent(await parseSocketEvent(data));
if (isRealtimeEvent(event)) this.onEvent?.(event); if (this.socket === socket && event !== undefined) this.onEvent?.(event);
} }
} }
function isSessionUiEvent(event: unknown): event is SessionUiEvent { export function parseSessionSocketEvent(event: unknown): SessionUiEvent | undefined {
const type = eventType(event); const type = eventType(event);
return ["message.append", "assistant.delta", "assistant.thinking.delta", "tool.start", "tool.update", "tool.end", "shell.start", "shell.chunk", "shell.end", "agent.start", "agent.end", "message.end", "status.update", "activity.update", "command.output", "session.error", "session.name", "session.created", "pi.event"].includes(type); if (type === "notifications.inbox") return safelyParseNotificationEvent(() => parseSessionNotificationInboxEvent(event));
return isLegacySessionUiEvent(event) ? event : undefined;
} }
function isGlobalSessionEvent(event: unknown): event is GlobalSessionEvent { export function parseRealtimeSocketEvent(event: unknown): RealtimeEvent | undefined {
const type = eventType(event);
if (type === "notifications.summary") return safelyParseNotificationEvent(() => parseSessionNotificationSummaryEvent(event));
if (isLegacyGlobalSessionEvent(event) || isLegacyRealtimeEvent(event)) return event;
return undefined;
}
function isLegacySessionUiEvent(event: unknown): event is SessionUiEvent {
return ["message.append", "assistant.delta", "assistant.thinking.delta", "tool.start", "tool.update", "tool.end", "shell.start", "shell.chunk", "shell.end", "agent.start", "agent.end", "message.end", "status.update", "activity.update", "command.output", "session.error", "session.name", "session.created", "pi.event"].includes(eventType(event));
}
function isLegacyGlobalSessionEvent(event: unknown): event is GlobalSessionEvent {
const type = eventType(event); const type = eventType(event);
return type === "status.update" || type === "activity.update" || type === "session.name" || type === "session.created"; return type === "status.update" || type === "activity.update" || type === "session.name" || type === "session.created";
} }
function isRealtimeEvent(event: unknown): event is RealtimeEvent { function isLegacyRealtimeEvent(event: unknown): event is RealtimeEvent {
const type = eventType(event); const type = eventType(event);
return isGlobalSessionEvent(event) || type === "terminal.created" || type === "terminal.exited" || type === "terminal.closed" || type === "workspace.activity"; return type === "terminal.created" || type === "terminal.exited" || type === "terminal.closed" || type === "workspace.activity";
}
function safelyParseNotificationEvent<T>(parse: () => T): T | undefined {
try {
return parse();
} catch {
return undefined;
}
} }
function eventType(event: unknown): string { function eventType(event: unknown): string {
+27
View File
@@ -186,6 +186,33 @@ describe("buildApp remote machine proxy routes", () => {
expect(request).toHaveBeenCalledWith("POST", "/api/sessions/s1/reload", { cwd: "/repo" }); expect(request).toHaveBeenCalledWith("POST", "/api/sessions/s1/reload", { cwd: "/repo" });
}); });
it("proxies only the four allowlisted remote notification HTTP routes", async () => {
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const request = vi.fn<MachineClient["request"]>((method, path, body) => Promise.resolve({
statusCode: 200,
headers: { "content-type": "application/json" },
body: Readable.from([JSON.stringify({ method, path, body })]),
}));
appTestContext.remoteClient = fakeRemoteClient({ request });
const catalog = await appTestContext.app.inject({ method: "GET", url: `/api/machines/${remote.id}/sessions/notifications` });
const inbox = await appTestContext.app.inject({ method: "GET", url: `/api/machines/${remote.id}/sessions/${encodeURIComponent("s 1")}/notifications?cwd=${encodeURIComponent("/repo one")}` });
const dismissBody = { cwd: "/repo one", daemonInstanceId: "daemon-test", notificationId: "notice-1" };
const dismiss = await appTestContext.app.inject({ method: "POST", url: `/api/machines/${remote.id}/sessions/${encodeURIComponent("s 1")}/notifications/dismiss`, payload: dismissBody });
const dismissAllBody = { cwd: "/repo one", daemonInstanceId: "daemon-test", throughOrder: 7, throughOverflowWatermark: 2 };
const dismissAll = await appTestContext.app.inject({ method: "POST", url: `/api/machines/${remote.id}/sessions/${encodeURIComponent("s 1")}/notifications/dismiss-all`, payload: dismissAllBody });
const wrongMethod = await appTestContext.app.inject({ method: "DELETE", url: `/api/machines/${remote.id}/sessions/s1/notifications` });
expect([catalog.statusCode, inbox.statusCode, dismiss.statusCode, dismissAll.statusCode]).toEqual([200, 200, 200, 200]);
expect(wrongMethod.statusCode).toBe(404);
expect(request).toHaveBeenNthCalledWith(1, "GET", "/api/sessions/notifications", undefined);
expect(request).toHaveBeenNthCalledWith(2, "GET", "/api/sessions/s%201/notifications?cwd=%2Frepo%20one", undefined);
expect(request).toHaveBeenNthCalledWith(3, "POST", "/api/sessions/s%201/notifications/dismiss", dismissBody);
expect(request).toHaveBeenNthCalledWith(4, "POST", "/api/sessions/s%201/notifications/dismiss-all", dismissAllBody);
expect(request).toHaveBeenCalledTimes(4);
});
it("proxies remote session queue clearing through the allowlisted route", async () => { it("proxies remote session queue clearing through the allowlisted route", async () => {
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } }); const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>(); const remote = addResponse.json<{ id: string }>();
@@ -23,6 +23,36 @@ describe("SessionEventHub", () => {
expect(otherSocket.send).not.toHaveBeenCalled(); expect(otherSocket.send).not.toHaveBeenCalled();
}); });
it("keeps notification inbox events session-scoped and sequence-stamped", () => {
const hub = new SessionEventHub();
const sessionSocket = new FakeSocket();
const otherSocket = new FakeSocket();
hub.add("s1", sessionSocket);
hub.add("s2", otherSocket);
const notification = { id: "daemon-test:1", message: "notice", truncated: false, severity: "warning" as const, receivedAt: "2026-01-01T00:00:00.000Z", order: 1 };
const summary = { sessionId: "s1", cwd: "/workspace", inboxRevision: 1, retainedCount: 1, discardedCount: 0, highestSeverity: "warning" as const };
hub.publish("s1", {
type: "notifications.inbox",
daemonInstanceId: "daemon-test",
catalogRevision: 1,
summary,
dismissThrough: { order: 1, overflowWatermark: 0 },
delta: { kind: "added", notification },
});
expect(sessionSocket.send).toHaveBeenCalledWith(JSON.stringify({
type: "notifications.inbox",
daemonInstanceId: "daemon-test",
catalogRevision: 1,
summary,
dismissThrough: { order: 1, overflowWatermark: 0 },
delta: { kind: "added", notification },
seq: 1,
}));
expect(otherSocket.send).not.toHaveBeenCalled();
});
it("omits thinking signatures from final-message payloads without mutating source events", () => { it("omits thinking signatures from final-message payloads without mutating source events", () => {
const hub = new SessionEventHub(); const hub = new SessionEventHub();
const socket = new FakeSocket(); const socket = new FakeSocket();
@@ -103,6 +133,30 @@ describe("SessionEventHub", () => {
expect(sessionSocket.send).not.toHaveBeenCalled(); expect(sessionSocket.send).not.toHaveBeenCalled();
}); });
it("publishes notification summaries only to global sockets", () => {
const hub = new SessionEventHub();
const globalSocket = new FakeSocket();
const sessionSocket = new FakeSocket();
hub.addGlobal(globalSocket);
hub.add("s1", sessionSocket);
const summary = { sessionId: "s1", cwd: "/workspace", inboxRevision: 1, retainedCount: 1, discardedCount: 0, highestSeverity: "warning" as const };
hub.publishNotificationSummary({
type: "notifications.summary",
daemonInstanceId: "daemon-test",
catalogRevision: 1,
summary,
});
expect(globalSocket.send).toHaveBeenCalledWith(JSON.stringify({
type: "notifications.summary",
daemonInstanceId: "daemon-test",
catalogRevision: 1,
summary,
}));
expect(sessionSocket.send).not.toHaveBeenCalled();
});
it("contains termination failures while publishing unstamped global events", () => { it("contains termination failures while publishing unstamped global events", () => {
const hub = new SessionEventHub(); const hub = new SessionEventHub();
const failed = new FakeSocket(); const failed = new FakeSocket();
+6 -1
View File
@@ -1,4 +1,4 @@
import type { GlobalSessionEvent, RealtimeEvent, SessionUiEvent } from "../../shared/apiTypes.js"; import type { GlobalSessionEvent, RealtimeEvent, SessionNotificationSummaryEvent, SessionUiEvent } from "../../shared/apiTypes.js";
import { projectBrowserSessionEvent } from "../browserMessageProjection.js"; import { projectBrowserSessionEvent } from "../browserMessageProjection.js";
export interface RealtimeSocket { export interface RealtimeSocket {
@@ -52,6 +52,11 @@ export class SessionEventHub {
this.publishRealtime(event); this.publishRealtime(event);
} }
publishNotificationSummary(event: SessionNotificationSummaryEvent): void {
const payload = JSON.stringify(event);
this.sendToSockets(this.globalSockets, payload);
}
publishRealtime(event: RealtimeEvent): void { publishRealtime(event: RealtimeEvent): void {
const payload = JSON.stringify(event); const payload = JSON.stringify(event);
this.sendToSockets(this.globalSockets, payload); this.sendToSockets(this.globalSockets, payload);
+3
View File
@@ -11,6 +11,7 @@ import { registerAuthRoutes } from "./sessions/authRoutes.js";
import { PiSessionService } from "./sessions/piSessionService.js"; import { PiSessionService } from "./sessions/piSessionService.js";
import { createPiSessionManagerGateway } from "./sessions/piSessionManagerGateway.js"; import { createPiSessionManagerGateway } from "./sessions/piSessionManagerGateway.js";
import { registerSessionRoutes } from "./sessions/sessionRoutes.js"; import { registerSessionRoutes } from "./sessions/sessionRoutes.js";
import { SessionNotificationStore } from "./sessions/sessionNotificationStore.js";
import { ProjectScopedSpawnTargetResolver } from "./sessions/spawnTargetResolver.js"; import { ProjectScopedSpawnTargetResolver } from "./sessions/spawnTargetResolver.js";
import { ProjectService } from "./projects/projectService.js"; import { ProjectService } from "./projects/projectService.js";
import { ProjectStore } from "./storage/projectStore.js"; import { ProjectStore } from "./storage/projectStore.js";
@@ -38,6 +39,7 @@ await runSessionDaemonStartup({
logger: app.log, logger: app.log,
async createRuntime() { async createRuntime() {
const eventHub = new SessionEventHub(); const eventHub = new SessionEventHub();
const notificationStore = new SessionNotificationStore();
const workspaceActivity = new WorkspaceActivityService(eventHub); const workspaceActivity = new WorkspaceActivityService(eventHub);
const auth = await AuthService.create({ agentDir: activeAgentProfile.dir, logger: app.log }); const auth = await AuthService.create({ agentDir: activeAgentProfile.dir, logger: app.log });
const spawnTargets = config.spawnSessions const spawnTargets = config.spawnSessions
@@ -50,6 +52,7 @@ await runSessionDaemonStartup({
logger: app.log, logger: app.log,
...(spawnTargets === undefined ? {} : { spawnTargets }), ...(spawnTargets === undefined ? {} : { spawnTargets }),
subsessionsEnabled: spawnTargets !== undefined && config.subsessions, subsessionsEnabled: spawnTargets !== undefined && config.subsessions,
notificationStore,
sessionManager: createPiSessionManagerGateway({ sessionManager: createPiSessionManagerGateway({
agentDir: activeAgentProfile.dir, agentDir: activeAgentProfile.dir,
env: daemonEnvironment, env: daemonEnvironment,
@@ -39,6 +39,29 @@ describe("machine-scoped session proxy routes", () => {
expect(daemon.requests).toEqual([{ method: "POST", path: "/sessions/session-1/queue/clear", body: { cwd: "/repo" } }]); expect(daemon.requests).toEqual([{ method: "POST", path: "/sessions/session-1/queue/clear", body: { cwd: "/repo" } }]);
}); });
it("forwards notification snapshots and dismissal bodies unchanged", async () => {
const catalog = await app.inject({ method: "GET", url: "/api/machines/local/sessions/notifications" });
const inbox = await app.inject({ method: "GET", url: `/api/machines/local/sessions/session-1/notifications?cwd=${encodeURIComponent("/repo")}` });
const dismiss = await app.inject({
method: "POST",
url: "/api/machines/local/sessions/session-1/notifications/dismiss",
payload: { cwd: "/repo", daemonInstanceId: "daemon-test", notificationId: "notice-1" },
});
const dismissAll = await app.inject({
method: "POST",
url: "/api/machines/local/sessions/session-1/notifications/dismiss-all",
payload: { cwd: "/repo", daemonInstanceId: "daemon-test", throughOrder: 7, throughOverflowWatermark: 2 },
});
expect([catalog.statusCode, inbox.statusCode, dismiss.statusCode, dismissAll.statusCode]).toEqual([200, 200, 200, 200]);
expect(daemon.requests).toEqual([
{ method: "GET", path: "/sessions/notifications", body: undefined },
{ method: "GET", path: "/sessions/session-1/notifications?cwd=%2Frepo", body: undefined },
{ method: "POST", path: "/sessions/session-1/notifications/dismiss", body: { cwd: "/repo", daemonInstanceId: "daemon-test", notificationId: "notice-1" } },
{ method: "POST", path: "/sessions/session-1/notifications/dismiss-all", body: { cwd: "/repo", daemonInstanceId: "daemon-test", throughOrder: 7, throughOverflowWatermark: 2 } },
]);
});
it("strips the machine prefix before forwarding auth requests", async () => { it("strips the machine prefix before forwarding auth requests", async () => {
const response = await app.inject({ method: "POST", url: "/api/machines/local/auth/api-key", payload: { providerId: "p", key: "k" } }); const response = await app.inject({ method: "POST", url: "/api/machines/local/auth/api-key", payload: { providerId: "p", key: "k" } });
@@ -1,10 +1,48 @@
import { resolve } from "node:path";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { PiSessionService } from "./piSessionService.js"; import { PiSessionService } from "./piSessionService.js";
import { SessionNotificationStore } from "./sessionNotificationStore.js";
import { CapturingSessionEventHub, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef, testModelRuntime } from "./piSessionService.testSupport.js"; import { CapturingSessionEventHub, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef, testModelRuntime } from "./piSessionService.testSupport.js";
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent"; const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
describe("PiSessionService archive and cleanup", () => { describe("PiSessionService archive and cleanup", () => {
it("clears an active notification inbox when archiving", async () => {
const store = new SessionNotificationStore({ daemonInstanceId: "daemon-archive-test" });
const hub = new CapturingSessionEventHub();
const fake = fakeRuntime("archive-notification-session", {
sessionManager: fakeSessionManager("/workspace", { getSessionId: () => "archive-notification-session" }),
});
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
notificationStore: store,
createAgentRuntime: runtimeCreator(fake.runtime),
archiveStore: {
list: () => Promise.resolve([]),
get: () => Promise.resolve(undefined),
archive: (input) => Promise.resolve({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }),
restore: () => Promise.resolve(),
isArchived: () => Promise.resolve(false),
},
sessionManager: sessionGateway([sessionRecord("archive-notification-session")]),
heartbeatIntervalMs: 60_000,
});
await service.status(sessionRef("archive-notification-session"));
const generation = store.currentGeneration("archive-notification-session", resolve("/workspace"));
if (generation === undefined) throw new Error("expected active notification generation");
store.addNotification(generation, "archive me", "warning");
await service.archive(sessionRef("archive-notification-session"));
expect(() => store.inboxSnapshot("archive-notification-session", "/workspace")).toThrow("Session not found");
expect(hub.notificationSummaryEvents.at(-1)).toMatchObject({
summary: { sessionId: "archive-notification-session", retainedCount: 0 },
});
await service.dispose();
});
it("archives a session subtree within the root workspace", async () => { it("archives a session subtree within the root workspace", async () => {
const archivedInputs: string[] = []; const archivedInputs: string[] = [];
const root = sessionRecord("root"); const root = sessionRecord("root");
@@ -13,9 +51,13 @@ describe("PiSessionService archive and cleanup", () => {
const grandchild = { ...sessionRecord("grandchild"), path: "/sessions/grandchild.jsonl", parentSessionPath: archivedChild.path }; const grandchild = { ...sessionRecord("grandchild"), path: "/sessions/grandchild.jsonl", parentSessionPath: archivedChild.path };
const otherWorkspaceChild = { ...sessionRecord("other-child", "/other"), path: "/sessions/other-child.jsonl", parentSessionPath: root.path }; const otherWorkspaceChild = { ...sessionRecord("other-child", "/other"), path: "/sessions/other-child.jsonl", parentSessionPath: root.path };
const fake = fakeRuntime("root", { sessionFile: root.path }); const fake = fakeRuntime("root", { sessionFile: root.path });
const notificationStore = new SessionNotificationStore({ daemonInstanceId: "daemon-tree-test" });
const archivedRegistration = notificationStore.registerSession("archived-child", "/workspace");
notificationStore.addNotification(archivedRegistration.generation, "residual archived child", "warning");
const service = new PiSessionService(new CapturingSessionEventHub(), { const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR, agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime, modelRuntime: testModelRuntime,
notificationStore,
createAgentRuntime: runtimeCreator(fake.runtime), createAgentRuntime: runtimeCreator(fake.runtime),
archiveStore: { archiveStore: {
list: () => Promise.resolve([{ sessionId: "archived-child", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", originalPath: archivedChild.path, archivePath: "/archive/archived-child.jsonl", created: "2026-01-01T00:00:00.000Z", modified: "2026-01-01T00:01:00.000Z", messageCount: 1, firstMessage: "archived", parentSessionPath: root.path }]), list: () => Promise.resolve([{ sessionId: "archived-child", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", originalPath: archivedChild.path, archivePath: "/archive/archived-child.jsonl", created: "2026-01-01T00:00:00.000Z", modified: "2026-01-01T00:01:00.000Z", messageCount: 1, firstMessage: "archived", parentSessionPath: root.path }]),
@@ -42,15 +84,92 @@ describe("PiSessionService archive and cleanup", () => {
skippedAlreadyArchivedCount: 1, skippedAlreadyArchivedCount: 1,
}); });
expect(archivedInputs).toEqual(["root", "direct-child", "grandchild"]); expect(archivedInputs).toEqual(["root", "direct-child", "grandchild"]);
expect(() => notificationStore.inboxSnapshot("archived-child", "/workspace")).toThrow("Session not found");
await service.dispose(); await service.dispose();
}); });
it("defensively removes residual notification state while listing archived sessions", async () => {
const store = new SessionNotificationStore({ daemonInstanceId: "daemon-reconcile-test" });
const registration = store.registerSession("archived", "/workspace");
store.addNotification(registration.generation, "residual", "error");
const hub = new CapturingSessionEventHub();
const archivedRecord = {
sessionId: "archived",
cwd: "/workspace",
archivedAt: "2026-01-02T00:00:00.000Z",
originalPath: "/sessions/archived.jsonl",
archivePath: "/archive/archived.jsonl",
};
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
notificationStore: store,
archiveStore: {
list: () => Promise.resolve([archivedRecord]),
get: () => Promise.resolve(undefined),
archive: () => Promise.reject(new Error("archive should not be called")),
restore: () => Promise.resolve(),
isArchived: () => Promise.resolve(true),
},
sessionManager: sessionGateway([]),
heartbeatIntervalMs: 60_000,
});
await service.list("/workspace");
expect(store.catalogSnapshot().sessions).toEqual([]);
expect(() => store.inboxSnapshot("archived", "/workspace")).toThrow("Session not found");
expect(hub.notificationSummaryEvents.at(-1)).toMatchObject({ summary: { sessionId: "archived", retainedCount: 0 } });
await service.dispose();
});
it("does not register notifications while opening an archived session read-only", async () => {
const notificationStore = new SessionNotificationStore({ daemonInstanceId: "daemon-archived-open-test" });
const archivedRuntime = fakeRuntime("archived", {
bindExtensions: (bindings) => {
bindings.uiContext?.notify("archived startup", "error");
return Promise.resolve();
},
});
const archivedRecord = { sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", archivePath: "/archive/archived.jsonl" };
const hub = new CapturingSessionEventHub();
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
notificationStore,
createAgentRuntime: runtimeCreator(archivedRuntime.runtime),
archiveStore: {
list: () => Promise.resolve([archivedRecord]),
get: () => Promise.resolve(archivedRecord),
archive: () => Promise.reject(new Error("archive should not be called")),
restore: () => Promise.resolve(),
isArchived: () => Promise.resolve(true),
},
sessionManager: sessionGateway([]),
heartbeatIntervalMs: 60_000,
});
await service.status(sessionRef("archived"));
expect(notificationStore.catalogSnapshot().sessions).toEqual([]);
expect(() => notificationStore.inboxSnapshot("archived", "/workspace")).toThrow("Session not found");
expect(hub.sessionEvents).toContainEqual({
sessionId: "archived",
event: { type: "command.output", level: "error", message: "archived startup" },
});
await service.dispose();
});
it("permanently deletes archived sessions through the archive store", async () => { it("permanently deletes archived sessions through the archive store", async () => {
const deletedSessionIds: string[] = []; const deletedSessionIds: string[] = [];
const notificationStore = new SessionNotificationStore({ daemonInstanceId: "daemon-delete-test" });
const registration = notificationStore.registerSession("archived", "/workspace");
notificationStore.addNotification(registration.generation, "delete me", "info");
const service = new PiSessionService(new CapturingSessionEventHub(), { const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR, agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime, modelRuntime: testModelRuntime,
notificationStore,
archiveStore: { archiveStore: {
list: () => Promise.resolve([]), list: () => Promise.resolve([]),
get: (sessionId) => Promise.resolve(sessionId === "archived" || "archived".startsWith(sessionId) get: (sessionId) => Promise.resolve(sessionId === "archived" || "archived".startsWith(sessionId)
@@ -72,6 +191,34 @@ describe("PiSessionService archive and cleanup", () => {
await expect(service.deleteArchived("active")).rejects.toThrow("Archived session not found"); await expect(service.deleteArchived("active")).rejects.toThrow("Archived session not found");
expect(deletedSessionIds).toEqual(["archived"]); expect(deletedSessionIds).toEqual(["archived"]);
expect(notificationStore.catalogSnapshot().sessions).toEqual([]);
await service.dispose();
});
it("clears residual notifications before restoring an archived session", async () => {
const notificationStore = new SessionNotificationStore({ daemonInstanceId: "daemon-restore-test" });
const registration = notificationStore.registerSession("archived", "/workspace");
notificationStore.addNotification(registration.generation, "restore me", "info");
const restore = vi.fn(() => Promise.resolve());
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
notificationStore,
archiveStore: {
list: () => Promise.resolve([]),
get: () => Promise.resolve({ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", archivePath: "/archive/archived.jsonl" }),
archive: () => Promise.reject(new Error("archive should not be called")),
restore,
isArchived: () => Promise.resolve(true),
},
sessionManager: sessionGateway([]),
heartbeatIntervalMs: 60_000,
});
await service.restore(sessionRef("archived"));
expect(restore).toHaveBeenCalledWith("archived");
expect(notificationStore.catalogSnapshot().sessions).toEqual([]);
await service.dispose(); await service.dispose();
}); });
@@ -83,9 +230,15 @@ describe("PiSessionService archive and cleanup", () => {
const listCalls: string[] = []; const listCalls: string[] = [];
const open = vi.fn(() => { throw new Error("bulk archive should not open inactive runtimes"); }); const open = vi.fn(() => { throw new Error("bulk archive should not open inactive runtimes"); });
const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" })))); const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }))));
const notificationStore = new SessionNotificationStore({ daemonInstanceId: "daemon-bulk-archive-test" });
for (const [sessionId, cwd] of [["a", "/one"], ["b", "/one"], ["c", "/two"]] as const) {
const registration = notificationStore.registerSession(sessionId, cwd);
notificationStore.addNotification(registration.generation, `notice ${sessionId}`, "info");
}
const service = new PiSessionService(new CapturingSessionEventHub(), { const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR, agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime, modelRuntime: testModelRuntime,
notificationStore,
archiveStore: { archiveStore: {
list: () => Promise.resolve([]), list: () => Promise.resolve([]),
get: () => Promise.resolve(undefined), get: () => Promise.resolve(undefined),
@@ -112,6 +265,7 @@ describe("PiSessionService archive and cleanup", () => {
expect(open).not.toHaveBeenCalled(); expect(open).not.toHaveBeenCalled();
expect(archiveMany).toHaveBeenCalledTimes(1); expect(archiveMany).toHaveBeenCalledTimes(1);
expect(archiveMany.mock.calls[0]?.[0].map((input) => input.sessionId)).toEqual(["a", "b", "c"]); expect(archiveMany.mock.calls[0]?.[0].map((input) => input.sessionId)).toEqual(["a", "b", "c"]);
expect(notificationStore.catalogSnapshot().sessions).toEqual([]);
await service.dispose(); await service.dispose();
}); });
@@ -243,9 +397,15 @@ describe("PiSessionService archive and cleanup", () => {
let listAllCalls = 0; let listAllCalls = 0;
const archived = { sessionId: "archived-old", cwd: "/old-project", archivedAt: "2026-04-01T00:00:00.000Z", archivePath: "/archive/archived-old.jsonl" }; const archived = { sessionId: "archived-old", cwd: "/old-project", archivedAt: "2026-04-01T00:00:00.000Z", archivePath: "/archive/archived-old.jsonl" };
const otherArchived = { sessionId: "archived-other", cwd: "/other-project", archivedAt: "2026-04-01T00:00:00.000Z", archivePath: "/archive/archived-other.jsonl" }; const otherArchived = { sessionId: "archived-other", cwd: "/other-project", archivedAt: "2026-04-01T00:00:00.000Z", archivePath: "/archive/archived-other.jsonl" };
const notificationStore = new SessionNotificationStore({ daemonInstanceId: "daemon-cleanup-test" });
for (const sessionId of ["execute-only", "archived-old"]) {
const registration = notificationStore.registerSession(sessionId, "/old-project");
notificationStore.addNotification(registration.generation, `notice ${sessionId}`, "warning");
}
const service = new PiSessionService(new CapturingSessionEventHub(), { const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR, agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime, modelRuntime: testModelRuntime,
notificationStore,
now: () => new Date("2026-06-25T00:00:00.000Z"), now: () => new Date("2026-06-25T00:00:00.000Z"),
archiveStore: { archiveStore: {
list: () => Promise.resolve([archived, otherArchived]), list: () => Promise.resolve([archived, otherArchived]),
@@ -283,12 +443,14 @@ describe("PiSessionService archive and cleanup", () => {
expect(preview.projects).toEqual([{ cwd: "/old-project", archiveCount: 1, deleteCount: 1 }]); expect(preview.projects).toEqual([{ cwd: "/old-project", archiveCount: 1, deleteCount: 1 }]);
expect(archivedInputs).toEqual([]); expect(archivedInputs).toEqual([]);
expect(deletedSessionIds).toEqual([]); expect(deletedSessionIds).toEqual([]);
expect(notificationStore.catalogSnapshot().sessions).toHaveLength(2);
const result = await service.cleanup({ thresholds: { archiveIdleDays: 30, deleteArchivedDays: 30 }, projectCwds: ["/old-project"] }); const result = await service.cleanup({ thresholds: { archiveIdleDays: 30, deleteArchivedDays: 30 }, projectCwds: ["/old-project"] });
expect(result.archivedSessionIds).toEqual(["execute-only"]); expect(result.archivedSessionIds).toEqual(["execute-only"]);
expect(result.deletedSessionIds).toEqual(["archived-old"]); expect(result.deletedSessionIds).toEqual(["archived-old"]);
expect(archivedInputs).toEqual(["execute-only"]); expect(archivedInputs).toEqual(["execute-only"]);
expect(deletedSessionIds).toEqual(["archived-old"]); expect(deletedSessionIds).toEqual(["archived-old"]);
expect(notificationStore.catalogSnapshot().sessions).toEqual([]);
await service.dispose(); await service.dispose();
}); });
@@ -1,8 +1,9 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join, resolve, sep } from "node:path";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { PiSessionService, type PiAgentSession, type PiSessionRuntime } from "./piSessionService.js"; import { PiSessionService, type PiAgentSession, type PiSessionRuntime } from "./piSessionService.js";
import { SessionNotificationStore } from "./sessionNotificationStore.js";
import { CapturingSessionEventHub, emptyArchiveStore, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef, testModelRuntime, type RuntimeCreator } from "./piSessionService.testSupport.js"; import { CapturingSessionEventHub, emptyArchiveStore, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef, testModelRuntime, type RuntimeCreator } from "./piSessionService.testSupport.js";
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent"; const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
@@ -17,6 +18,32 @@ function deferred<T = void>() {
return { promise, resolve, reject }; return { promise, resolve, reject };
} }
function notificationStore() {
let tick = 0;
return new SessionNotificationStore({
daemonInstanceId: "daemon-lifecycle-test",
now: () => new Date(Date.UTC(2026, 0, 1, 0, 0, tick++)),
});
}
function boundNotify(fake: { calls: { bindExtensions: unknown[] } }, index = -1) {
const bindings = fake.calls.bindExtensions.at(index);
if (typeof bindings !== "object" || bindings === null || !("uiContext" in bindings) || !hasNotify(bindings.uiContext)) {
throw new Error("Expected bound extension UI context");
}
const uiContext = bindings.uiContext;
return (message: string, type?: "info" | "warning" | "error") => { uiContext.notify(message, type); };
}
function hasNotify(value: unknown): value is { notify(message: string, type?: "info" | "warning" | "error"): void } {
return typeof value === "object" && value !== null && "notify" in value && typeof value.notify === "function";
}
function currentNotify(fake: { session: Pick<PiAgentSession, "extensionRunner"> }) {
const uiContext = fake.session.extensionRunner.getUIContext();
return (message: string, type?: "info" | "warning" | "error") => { uiContext.notify(message, type); };
}
describe("PiSessionService lifecycle, listing, and reload", () => { describe("PiSessionService lifecycle, listing, and reload", () => {
it("starts sessions through an injected runtime creator", async () => { it("starts sessions through an injected runtime creator", async () => {
const hub = new CapturingSessionEventHub(); const hub = new CapturingSessionEventHub();
@@ -351,10 +378,238 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
await expect(service.runCommand(sessionRef("extension-command-session"), "/ctx-stats")).resolves.toEqual({ type: "done" }); await expect(service.runCommand(sessionRef("extension-command-session"), "/ctx-stats")).resolves.toEqual({ type: "done" });
expect(extensionMode).toBe("rpc"); expect(extensionMode).toBe("rpc");
expect(hub.sessionEvents).toContainEqual({ const legacyEvent = hub.sessionEvents.find(({ event }) => event.type === "command.output" && event.message === "context-mode stats");
expect(legacyEvent).toMatchObject({
sessionId: "extension-command-session", sessionId: "extension-command-session",
event: { type: "command.output", level: "info", message: "context-mode stats" }, event: { type: "command.output", level: "info", message: "context-mode stats" },
}); });
expect(legacyEvent?.event.type === "command.output" ? typeof legacyEvent.event.notificationId : undefined).toBe("string");
const inboxEvent = hub.sessionEvents.find(({ event }) => event.type === "notifications.inbox");
expect(inboxEvent).toMatchObject({
sessionId: "extension-command-session",
event: {
type: "notifications.inbox",
delta: { kind: "added", notification: { message: "context-mode stats", severity: "info" } },
},
});
expect(hub.notificationSummaryEvents.at(-1)).toMatchObject({
type: "notifications.summary",
summary: { sessionId: "extension-command-session", retainedCount: 1, highestSeverity: "info" },
});
await service.dispose();
});
it("stores every extension notification without touching Pi session history", async () => {
const hub = new CapturingSessionEventHub();
const store = notificationStore();
const branch = [{ type: "message", message: { role: "user", content: "existing" } }];
const canonicalCwd = resolve(tmpdir(), "pi-web-notification-workspace");
const rawEquivalentCwd = `${canonicalCwd}${sep}nested${sep}..`;
const fake = fakeRuntime("notification-session", {
sessionManager: fakeSessionManager(rawEquivalentCwd, {
getSessionId: () => "notification-session",
getBranch: () => branch,
}),
});
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
notificationStore: store,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([]),
heartbeatIntervalMs: 60_000,
});
await service.start(canonicalCwd);
const notify = boundNotify(fake);
notify("duplicate", "warning");
notify("duplicate", "error");
const snapshot = service.notificationInbox({ id: "notification-session", cwd: canonicalCwd });
expect(snapshot.summary.cwd).toBe(canonicalCwd);
expect(snapshot.notifications).toMatchObject([
{ id: "daemon-lifecycle-test:2", message: "duplicate", severity: "error" },
{ id: "daemon-lifecycle-test:1", message: "duplicate", severity: "warning" },
]);
expect(fake.session.sessionManager.getBranch()).toBe(branch);
expect(fake.session.messages).toEqual([]);
expect(hub.sessionEvents.filter(({ event }) => event.type === "command.output")).toHaveLength(2);
expect(hub.sessionEvents.filter(({ event }) => event.type === "notifications.inbox")).toHaveLength(2);
await service.dispose();
});
it("commits Pi /reload only after replacement session_start notifications are bound", async () => {
const hub = new CapturingSessionEventHub();
const store = notificationStore();
const fake = fakeRuntime("runtime-reload-notifications");
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
notificationStore: store,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("runtime-reload-notifications")]),
heartbeatIntervalMs: 60_000,
});
await service.status(sessionRef("runtime-reload-notifications"));
const oldNotify = boundNotify(fake);
oldNotify("old notification", "warning");
fake.session.reload = async (options) => {
oldNotify("shutdown notification", "info");
await options?.beforeSessionStart?.();
currentNotify(fake)("replacement startup", "error");
};
await expect(service.runCommand(sessionRef("runtime-reload-notifications"), "/reload")).resolves.toMatchObject({ type: "done" });
expect(service.notificationInbox(sessionRef("runtime-reload-notifications"))).toMatchObject({
summary: { retainedCount: 1, discardedCount: 0, highestSeverity: "error" },
notifications: [{ message: "replacement startup", severity: "error" }],
});
expect(fake.calls.bindExtensions).toHaveLength(1);
const revision = service.notificationInbox(sessionRef("runtime-reload-notifications")).summary.inboxRevision;
oldNotify("stale old runner", "error");
expect(service.notificationInbox(sessionRef("runtime-reload-notifications")).summary.inboxRevision).toBe(revision);
await service.dispose();
});
it("preserves prior and candidate notifications when Pi /reload fails after rotation", async () => {
const hub = new CapturingSessionEventHub();
const store = notificationStore();
const fake = fakeRuntime("failed-runtime-reload");
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
notificationStore: store,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("failed-runtime-reload")]),
heartbeatIntervalMs: 60_000,
});
await service.status(sessionRef("failed-runtime-reload"));
boundNotify(fake)("prior", "info");
fake.session.reload = async (options) => {
await options?.beforeSessionStart?.();
currentNotify(fake)("candidate before failure", "warning");
throw new Error("reload failed after rotation");
};
await expect(service.runCommand(sessionRef("failed-runtime-reload"), "/reload")).resolves.toEqual({
type: "unsupported",
message: "Reload failed: reload failed after rotation",
});
expect(service.notificationInbox(sessionRef("failed-runtime-reload")).notifications.map((notification) => notification.message)).toEqual([
"candidate before failure",
"prior",
]);
currentNotify(fake)("after failed reload", "error");
expect(service.notificationInbox(sessionRef("failed-runtime-reload")).notifications[0]).toMatchObject({
message: "after failed reload",
severity: "error",
});
await service.dispose();
});
it("leaves the prior inbox unchanged when Pi /reload fails before rotation", async () => {
const store = notificationStore();
const fake = fakeRuntime("failed-before-rotation");
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
notificationStore: store,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("failed-before-rotation")]),
heartbeatIntervalMs: 60_000,
});
await service.status(sessionRef("failed-before-rotation"));
boundNotify(fake)("prior", "warning");
const before = service.notificationInbox(sessionRef("failed-before-rotation"));
fake.session.reload = () => Promise.reject(new Error("reload failed before rotation"));
await expect(service.runCommand(sessionRef("failed-before-rotation"), "/reload")).resolves.toEqual({
type: "unsupported",
message: "Reload failed: reload failed before rotation",
});
expect(service.notificationInbox(sessionRef("failed-before-rotation"))).toEqual(before);
await service.dispose();
});
it("commits changed-id SDK rebind notifications only after binding succeeds", async () => {
const store = notificationStore();
const first = fakeRuntime("session-1");
const replacement = fakeRuntime("session-2", {
bindExtensions: (bindings) => {
bindings.uiContext?.notify("replacement startup", "error");
return Promise.resolve();
},
});
let rebindSession: ((session: PiAgentSession) => Promise<void>) | undefined;
first.runtime.setRebindSession = (callback) => { rebindSession = callback; };
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
notificationStore: store,
createAgentRuntime: runtimeCreator(first.runtime),
sessionManager: sessionGateway([]),
heartbeatIntervalMs: 60_000,
});
await service.start("/workspace");
const staleNotify = boundNotify(first);
staleNotify("old", "warning");
Object.defineProperty(first.runtime, "session", { configurable: true, value: replacement.session });
await rebindSession?.(replacement.session);
expect(() => service.notificationInbox(sessionRef("session-1"))).toThrow("Session not found");
expect(service.notificationInbox(sessionRef("session-2"))).toMatchObject({
notifications: [{ message: "replacement startup", severity: "error" }],
});
const revision = service.notificationInbox(sessionRef("session-2")).summary.inboxRevision;
staleNotify("stale", "error");
expect(service.notificationInbox(sessionRef("session-2")).summary.inboxRevision).toBe(revision);
await service.dispose();
});
it("preserves changed-id SDK rebind notifications on the applied replacement when binding fails", async () => {
const store = notificationStore();
const first = fakeRuntime("session-1");
const replacement = fakeRuntime("session-2");
replacement.session.bindExtensions = (bindings) => {
replacement.session.extensionRunner.setUIContext(bindings.uiContext, "rpc");
bindings.uiContext?.notify("candidate before bind failure", "warning");
return Promise.reject(new Error("replacement bind failed"));
};
let rebindSession: ((session: PiAgentSession) => Promise<void>) | undefined;
first.runtime.setRebindSession = (callback) => { rebindSession = callback; };
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
notificationStore: store,
createAgentRuntime: runtimeCreator(first.runtime),
sessionManager: sessionGateway([]),
heartbeatIntervalMs: 60_000,
});
await service.start("/workspace");
boundNotify(first)("prior", "info");
Object.defineProperty(first.runtime, "session", { configurable: true, value: replacement.session });
await expect(rebindSession?.(replacement.session)).rejects.toThrow("replacement bind failed");
expect(() => service.notificationInbox(sessionRef("session-1"))).toThrow("Session not found");
expect(service.notificationInbox(sessionRef("session-2")).notifications.map((notification) => notification.message)).toEqual([
"candidate before bind failure",
"prior",
]);
await expect(service.status(sessionRef("session-2"))).resolves.toMatchObject({ sessionId: "session-2" });
currentNotify(replacement)("after failed rebind", "error");
expect(service.notificationInbox(sessionRef("session-2")).notifications[0]).toMatchObject({ message: "after failed rebind", severity: "error" });
await service.dispose(); await service.dispose();
}); });
@@ -489,6 +744,34 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
}); });
it("keeps notifications on abort but clears and unregisters them on stop", async () => {
const hub = new CapturingSessionEventHub();
const store = notificationStore();
const fake = fakeRuntime("stop-notification-session");
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
notificationStore: store,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("stop-notification-session")]),
heartbeatIntervalMs: 60_000,
});
await service.status(sessionRef("stop-notification-session"));
boundNotify(fake)("keep through abort", "warning");
await service.abort(sessionRef("stop-notification-session"));
expect(service.notificationInbox(sessionRef("stop-notification-session")).summary.retainedCount).toBe(1);
await expect(service.stop(sessionRef("stop-notification-session", "/other"))).rejects.toThrow("Session cwd mismatch");
expect(service.activeCount()).toBe(1);
await service.stop(sessionRef("stop-notification-session"));
expect(() => service.notificationInbox(sessionRef("stop-notification-session"))).toThrow("Session not found");
expect(service.notificationCatalog().sessions).toEqual([]);
expect(hub.notificationSummaryEvents.at(-1)).toMatchObject({ summary: { sessionId: "stop-notification-session", retainedCount: 0 } });
await service.dispose();
});
it("runs /reload by refreshing the active runtime resources in place", async () => { it("runs /reload by refreshing the active runtime resources in place", async () => {
const hub = new CapturingSessionEventHub(); const hub = new CapturingSessionEventHub();
const fake = fakeRuntime("runtime-reload-session"); const fake = fakeRuntime("runtime-reload-session");
@@ -549,6 +832,123 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
await service.dispose(); await service.dispose();
}); });
it("reload-from-disk keeps replacement startup notifications and clears the old inbox on success", async () => {
const store = notificationStore();
const first = fakeRuntime("reload-notification-session");
const second = fakeRuntime("reload-notification-session", {
bindExtensions: (bindings) => {
bindings.uiContext?.notify("replacement startup", "error");
return Promise.resolve();
},
});
const runtimes = [first.runtime, second.runtime];
let createCalls = 0;
const hub = new CapturingSessionEventHub();
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
notificationStore: store,
createAgentRuntime: () => {
const runtime = runtimes[createCalls++];
return runtime === undefined ? Promise.reject(new Error("unexpected runtime creation")) : Promise.resolve(runtime);
},
sessionManager: sessionGateway([sessionRecord("reload-notification-session")]),
heartbeatIntervalMs: 60_000,
});
await service.status(sessionRef("reload-notification-session"));
const oldNotify = boundNotify(first);
oldNotify("old", "warning");
const disposeFirst = first.runtime.dispose.bind(first.runtime);
first.runtime.dispose = async () => {
oldNotify("old shutdown", "info");
await disposeFirst();
};
await service.reload(sessionRef("reload-notification-session"));
expect(service.notificationInbox(sessionRef("reload-notification-session"))).toMatchObject({
summary: { retainedCount: 1, highestSeverity: "error" },
notifications: [{ message: "replacement startup", severity: "error" }],
});
expect(hub.sessionEvents.some(({ event }) => event.type === "notifications.inbox" && event.delta.kind === "added" && event.delta.notification.message === "old shutdown")).toBe(true);
await service.dispose();
});
it("reload-from-disk preserves prior and candidate notifications when replacement binding fails", async () => {
const store = notificationStore();
const first = fakeRuntime("failed-disk-reload");
const failed = fakeRuntime("failed-disk-reload", {
bindExtensions: (bindings) => {
bindings.uiContext?.notify("candidate before open failure", "warning");
return Promise.reject(new Error("replacement open failed"));
},
});
const runtimes = [first.runtime, failed.runtime];
let createCalls = 0;
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
notificationStore: store,
createAgentRuntime: () => {
const runtime = runtimes[createCalls++];
return runtime === undefined ? Promise.reject(new Error("unexpected runtime creation")) : Promise.resolve(runtime);
},
sessionManager: sessionGateway([sessionRecord("failed-disk-reload")]),
heartbeatIntervalMs: 60_000,
});
await service.status(sessionRef("failed-disk-reload"));
const oldNotify = boundNotify(first);
oldNotify("prior", "info");
const disposeFirst = first.runtime.dispose.bind(first.runtime);
first.runtime.dispose = async () => {
oldNotify("old shutdown", "info");
await disposeFirst();
};
await expect(service.reload(sessionRef("failed-disk-reload"))).rejects.toThrow("replacement open failed");
expect(service.notificationInbox(sessionRef("failed-disk-reload")).notifications.map((notification) => notification.message)).toEqual([
"candidate before open failure",
"old shutdown",
"prior",
]);
expect(service.activeCount()).toBe(0);
await service.stop(sessionRef("failed-disk-reload"));
expect(() => service.notificationInbox(sessionRef("failed-disk-reload"))).toThrow("Session not found");
await service.dispose();
});
it("reload-from-disk preserves the prior inbox when deferred close fails", async () => {
const store = notificationStore();
const first = fakeRuntime("failed-close-reload");
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
notificationStore: store,
createAgentRuntime: runtimeCreator(first.runtime),
sessionManager: sessionGateway([sessionRecord("failed-close-reload")]),
heartbeatIntervalMs: 60_000,
});
await service.status(sessionRef("failed-close-reload"));
const oldNotify = boundNotify(first);
oldNotify("prior", "warning");
first.runtime.dispose = () => {
oldNotify("shutdown before close failure", "info");
return Promise.reject(new Error("close failed"));
};
await expect(service.reload(sessionRef("failed-close-reload"))).rejects.toThrow("close failed");
expect(service.notificationInbox(sessionRef("failed-close-reload")).notifications.map((notification) => notification.message)).toEqual([
"shutdown before close failure",
"prior",
]);
expect(store.currentGeneration("failed-close-reload", resolve("/workspace"))).toBeDefined();
await service.stop(sessionRef("failed-close-reload"));
await service.dispose();
});
it("refuses to reload a session that has active work in progress", async () => { it("refuses to reload a session that has active work in progress", async () => {
const fake = fakeRuntime("busy-session", { isStreaming: true }); const fake = fakeRuntime("busy-session", { isStreaming: true });
const service = new PiSessionService(new CapturingSessionEventHub(), { const service = new PiSessionService(new CapturingSessionEventHub(), {
@@ -456,7 +456,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
}); });
await service.status(sessionRef("stop-session")); await service.status(sessionRef("stop-session"));
service.stop(sessionRef("stop-session")); await service.stop(sessionRef("stop-session"));
expect(fake.calls.clearQueue).toBe(1); expect(fake.calls.clearQueue).toBe(1);
await service.dispose(); await service.dispose();
@@ -1,12 +1,13 @@
import { ModelRuntime, type ExtensionUIContext } from "@earendil-works/pi-coding-agent"; import { ModelRuntime, type ExtensionUIContext } from "@earendil-works/pi-coding-agent";
import { InMemoryCredentialStore, type Credential, type CredentialStore } from "@earendil-works/pi-ai"; import { InMemoryCredentialStore, type Credential, type CredentialStore } from "@earendil-works/pi-ai";
import type { GlobalSessionEvent, SessionUiEvent } from "../../shared/apiTypes.js"; import type { GlobalSessionEvent, SessionNotificationSummaryEvent, SessionUiEvent } from "../../shared/apiTypes.js";
import { SessionEventHub } from "../realtime/sessionEventHub.js"; import { SessionEventHub } from "../realtime/sessionEventHub.js";
import type { PiAgentSession, PiSessionManager, PiSessionRuntime, PiSessionServiceDependencies } from "./piSessionService.js"; import type { PiAgentSession, PiSessionManager, PiSessionRuntime, PiSessionServiceDependencies } from "./piSessionService.js";
export class CapturingSessionEventHub extends SessionEventHub { export class CapturingSessionEventHub extends SessionEventHub {
readonly sessionEvents: { sessionId: string; event: SessionUiEvent }[] = []; readonly sessionEvents: { sessionId: string; event: SessionUiEvent }[] = [];
readonly globalEvents: GlobalSessionEvent[] = []; readonly globalEvents: GlobalSessionEvent[] = [];
readonly notificationSummaryEvents: SessionNotificationSummaryEvent[] = [];
private readonly seqBySessionOverride = new Map<string, number>(); private readonly seqBySessionOverride = new Map<string, number>();
override publish(sessionId: string, event: SessionUiEvent): void { override publish(sessionId: string, event: SessionUiEvent): void {
@@ -17,6 +18,10 @@ export class CapturingSessionEventHub extends SessionEventHub {
this.globalEvents.push(event); this.globalEvents.push(event);
} }
override publishNotificationSummary(event: SessionNotificationSummaryEvent): void {
this.notificationSummaryEvents.push(event);
}
/** Test seam: set the per-session watermark returned by {@link currentSeq}. */ /** Test seam: set the per-session watermark returned by {@link currentSeq}. */
setSeq(sessionId: string, value: number): void { setSeq(sessionId: string, value: number): void {
this.seqBySessionOverride.set(sessionId, value); this.seqBySessionOverride.set(sessionId, value);
@@ -30,6 +35,8 @@ export class CapturingSessionEventHub extends SessionEventHub {
export type SessionGateway = NonNullable<PiSessionServiceDependencies["sessionManager"]>; export type SessionGateway = NonNullable<PiSessionServiceDependencies["sessionManager"]>;
export type RuntimeCreator = NonNullable<PiSessionServiceDependencies["createAgentRuntime"]>; export type RuntimeCreator = NonNullable<PiSessionServiceDependencies["createAgentRuntime"]>;
type TestExtensionBindings = Parameters<PiAgentSession["bindExtensions"]>[0];
export interface TestSession extends PiAgentSession { export interface TestSession extends PiAgentSession {
sessionName: string | undefined; sessionName: string | undefined;
model: PiAgentSession["model"]; model: PiAgentSession["model"];
@@ -131,6 +138,7 @@ export function fakeRuntime(sessionId = "session-1", patch: Partial<TestSession>
const customMessageCalls: { message: { customType: string; content: string; display: boolean; details?: unknown }; options: unknown }[] = []; const customMessageCalls: { message: { customType: string; content: string; display: boolean; details?: unknown }; options: unknown }[] = [];
const bindExtensionCalls: unknown[] = []; const bindExtensionCalls: unknown[] = [];
const listeners: ((event: unknown) => void)[] = []; const listeners: ((event: unknown) => void)[] = [];
let extensionUiContext = testExtensionUiContext;
const calls = { abort: 0, bindExtensions: bindExtensionCalls, clearQueue: 0, dispose: 0, prompt: promptCalls, reload: 0, sendCustomMessage: customMessageCalls }; const calls = { abort: 0, bindExtensions: bindExtensionCalls, clearQueue: 0, dispose: 0, prompt: promptCalls, reload: 0, sendCustomMessage: customMessageCalls };
const session: TestSession = { const session: TestSession = {
sessionId, sessionId,
@@ -150,7 +158,8 @@ export function fakeRuntime(sessionId = "session-1", patch: Partial<TestSession>
scopedModels: [], scopedModels: [],
extensionRunner: { extensionRunner: {
getRegisteredCommands: () => [], getRegisteredCommands: () => [],
getUIContext: () => testExtensionUiContext, getUIContext: () => extensionUiContext,
setUIContext: (uiContext) => { extensionUiContext = uiContext ?? testExtensionUiContext; },
}, },
promptTemplates: [], promptTemplates: [],
resourceLoader: { getSkills: () => ({ skills: [] }) }, resourceLoader: { getSkills: () => ({ skills: [] }) },
@@ -161,8 +170,9 @@ export function fakeRuntime(sessionId = "session-1", patch: Partial<TestSession>
if (index !== -1) listeners.splice(index, 1); if (index !== -1) listeners.splice(index, 1);
}; };
}, },
bindExtensions: (bindings: unknown) => { bindExtensions: (bindings: TestExtensionBindings) => {
calls.bindExtensions.push(bindings); calls.bindExtensions.push(bindings);
if (bindings.uiContext !== undefined) extensionUiContext = bindings.uiContext;
return Promise.resolve(); return Promise.resolve();
}, },
getSessionStats: () => ({ sessionId, totalMessages: 0, userMessages: 0, assistantMessages: 0, toolCalls: 0, tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, cost: 0 }), getSessionStats: () => ({ sessionId, totalMessages: 0, userMessages: 0, assistantMessages: 0, toolCalls: 0, tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, cost: 0 }),
+274 -47
View File
@@ -32,7 +32,19 @@ import { deterministicSessionName, fallbackSessionName, generateShortSessionName
import { computeEditPreview, type EditPreviewResult } from "./editPreview.js"; import { computeEditPreview, type EditPreviewResult } from "./editPreview.js";
import { attachmentsToInlineImages, saveAttachmentsToWorkspace } from "./attachmentService.js"; import { attachmentsToInlineImages, saveAttachmentsToWorkspace } from "./attachmentService.js";
import { parsePromptAttachments } from "../../shared/promptAttachments.js"; import { parsePromptAttachments } from "../../shared/promptAttachments.js";
import type { SavedPromptAttachment, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef, SessionWarning } from "../../shared/apiTypes.js"; import type {
SavedPromptAttachment,
SessionBulkArchiveResponse,
SessionBulkDeleteArchivedResponse,
SessionBulkFailure,
SessionBulkMutationRef,
SessionNotificationCatalogSnapshot,
SessionNotificationClearReason,
SessionNotificationDismissAllRequest,
SessionNotificationDismissRequest,
SessionNotificationInboxSnapshot,
SessionWarning,
} from "../../shared/apiTypes.js";
import type { SessionRouteLookup, SessionRouteRef, SessionRouteService } from "./sessionService.js"; import type { SessionRouteLookup, SessionRouteRef, SessionRouteService } from "./sessionService.js";
import { type AuthChange } from "./authService.js"; import { type AuthChange } from "./authService.js";
@@ -43,6 +55,11 @@ import { createSubsessionToolDefinitions, type SpawnSubsessionInvocation, type S
import { buildTranscriptView } from "./subsessionTranscript.js"; import { buildTranscriptView } from "./subsessionTranscript.js";
import { planSessionCleanup, summarizeSessionCleanupExecution, type NormalizedSessionCleanupRequest, type SessionCleanupPlan } from "./sessionCleanup.js"; import { planSessionCleanup, summarizeSessionCleanupExecution, type NormalizedSessionCleanupRequest, type SessionCleanupPlan } from "./sessionCleanup.js";
import type { SpawnTargetDecision, SpawnTargetResolver } from "./spawnTargetResolver.js"; import type { SpawnTargetDecision, SpawnTargetResolver } from "./spawnTargetResolver.js";
import {
SessionNotificationStore,
type SessionNotificationGeneration,
type SessionNotificationMutation,
} from "./sessionNotificationStore.js";
/** /**
* Minimal structured-logging seam, shaped like Fastify's logger so sessiond can * Minimal structured-logging seam, shaped like Fastify's logger so sessiond can
@@ -245,6 +262,7 @@ export interface PiAgentSession {
extensionRunner: { extensionRunner: {
getRegisteredCommands(): readonly { invocationName: string; description?: string }[]; getRegisteredCommands(): readonly { invocationName: string; description?: string }[];
getUIContext(): ExtensionUIContext; getUIContext(): ExtensionUIContext;
setUIContext(uiContext?: ExtensionUIContext, mode?: "rpc"): void;
}; };
promptTemplates: readonly { name: string; description?: string }[]; promptTemplates: readonly { name: string; description?: string }[];
resourceLoader: { getSkills(): { skills: readonly { name: string; description?: string }[] } }; resourceLoader: { getSkills(): { skills: readonly { name: string; description?: string }[] } };
@@ -253,7 +271,7 @@ export interface PiAgentSession {
compact(instructions?: string): Promise<{ summary: string; tokensBefore: number }>; compact(instructions?: string): Promise<{ summary: string; tokensBefore: number }>;
getUserMessagesForForking(): readonly { entryId: string; text: string }[]; getUserMessagesForForking(): readonly { entryId: string; text: string }[];
getSessionStats(): { sessionId: string; totalMessages: number; userMessages: number; assistantMessages: number; toolCalls: number; tokens: ClientSessionStatus["tokens"]; cost: number }; getSessionStats(): { sessionId: string; totalMessages: number; userMessages: number; assistantMessages: number; toolCalls: number; tokens: ClientSessionStatus["tokens"]; cost: number };
reload(): Promise<void>; reload(options?: { beforeSessionStart?: () => void | Promise<void> }): Promise<void>;
getContextUsage(): ClientSessionStatus["contextUsage"] | undefined; getContextUsage(): ClientSessionStatus["contextUsage"] | undefined;
prompt(text: string, options?: { streamingBehavior?: "steer" | "followUp"; images?: ImageContent[] }): Promise<void>; prompt(text: string, options?: { streamingBehavior?: "steer" | "followUp"; images?: ImageContent[] }): Promise<void>;
sendCustomMessage(message: { customType: string; content: string; display: boolean; details?: unknown }, options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" }): Promise<void>; sendCustomMessage(message: { customType: string; content: string; display: boolean; details?: unknown }, options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" }): Promise<void>;
@@ -301,6 +319,18 @@ interface PendingSessionOpen {
promise: Promise<ActiveSession<PiSessionRuntime>>; promise: Promise<ActiveSession<PiSessionRuntime>>;
} }
interface CreateSessionRuntimeOptions extends Pick<InternalStartSessionOptions, "initialModel" | "creationProvenance"> {
notificationGeneration?: SessionNotificationGeneration;
notifications?: "enabled" | "disabled";
}
type NotificationClosePolicy =
| { kind: "clear"; reason: SessionNotificationClearReason }
| { kind: "defer" };
const CLEAR_RUNTIME_NOTIFICATIONS: NotificationClosePolicy = { kind: "clear", reason: "runtime-close" };
const DEFER_RUNTIME_NOTIFICATIONS: NotificationClosePolicy = { kind: "defer" };
function resourceDiagnosticToWarning(diagnostic: ResourceDiagnostic, source: string): SessionWarning { function resourceDiagnosticToWarning(diagnostic: ResourceDiagnostic, source: string): SessionWarning {
return { return {
severity: diagnostic.type === "error" ? "error" : "warning", severity: diagnostic.type === "error" ? "error" : "warning",
@@ -565,6 +595,8 @@ export interface PiSessionServiceDependencies {
logger?: PiSessionLogger; logger?: PiSessionLogger;
/** Clock seam for cleanup planning tests. */ /** Clock seam for cleanup planning tests. */
now?: () => Date; now?: () => Date;
/** Daemon-lifetime notification state, injected by sessiond in production. */
notificationStore?: SessionNotificationStore;
} }
export class PiSessionService implements SessionRouteService { export class PiSessionService implements SessionRouteService {
@@ -600,6 +632,8 @@ export class PiSessionService implements SessionRouteService {
private readonly spawnTargets: SpawnTargetResolver | undefined; private readonly spawnTargets: SpawnTargetResolver | undefined;
private readonly logger: PiSessionLogger; private readonly logger: PiSessionLogger;
private readonly now: () => Date; private readonly now: () => Date;
private readonly notificationStore: SessionNotificationStore;
private readonly notificationGenerationBySession = new WeakMap<PiAgentSession, SessionNotificationGeneration>();
constructor(private readonly events: SessionEventHub, deps: PiSessionServiceDependencies) { constructor(private readonly events: SessionEventHub, deps: PiSessionServiceDependencies) {
this.archiveStore = deps.archiveStore ?? new SessionArchiveStore(); this.archiveStore = deps.archiveStore ?? new SessionArchiveStore();
@@ -609,6 +643,7 @@ export class PiSessionService implements SessionRouteService {
this.spawnTargets = deps.spawnTargets; this.spawnTargets = deps.spawnTargets;
this.logger = deps.logger ?? noopLogger; this.logger = deps.logger ?? noopLogger;
this.now = deps.now ?? (() => new Date()); this.now = deps.now ?? (() => new Date());
this.notificationStore = deps.notificationStore ?? new SessionNotificationStore();
// Subsessions are a beta capability gated behind their own flag, and they // Subsessions are a beta capability gated behind their own flag, and they
// also require the spawn capability (they share its project-scope resolver). // also require the spawn capability (they share its project-scope resolver).
const subsessionsActive = this.spawnTargets !== undefined && deps.subsessionsEnabled === true; const subsessionsActive = this.spawnTargets !== undefined && deps.subsessionsEnabled === true;
@@ -649,6 +684,43 @@ export class PiSessionService implements SessionRouteService {
return this.active.size; return this.active.size;
} }
notificationCatalog(): SessionNotificationCatalogSnapshot {
return this.notificationStore.catalogSnapshot();
}
notificationInbox(ref: PiSessionRef): SessionNotificationInboxSnapshot {
return this.notificationStore.inboxSnapshot(ref.id, canonicalizeStoredCwd(ref.cwd));
}
dismissNotification(
ref: PiSessionRef,
request: Omit<SessionNotificationDismissRequest, "cwd">,
): SessionNotificationInboxSnapshot {
const result = this.notificationStore.dismissNotification(
ref.id,
canonicalizeStoredCwd(ref.cwd),
request.daemonInstanceId,
request.notificationId,
);
this.publishNotificationMutations(result.mutations);
return result.snapshot;
}
dismissAllNotifications(
ref: PiSessionRef,
request: Omit<SessionNotificationDismissAllRequest, "cwd">,
): SessionNotificationInboxSnapshot {
const result = this.notificationStore.dismissAll(
ref.id,
canonicalizeStoredCwd(ref.cwd),
request.daemonInstanceId,
request.throughOrder,
request.throughOverflowWatermark,
);
this.publishNotificationMutations(result.mutations);
return result.snapshot;
}
async cleanupPreview(request: NormalizedSessionCleanupRequest): Promise<ClientSessionCleanupPreviewResponse> { async cleanupPreview(request: NormalizedSessionCleanupRequest): Promise<ClientSessionCleanupPreviewResponse> {
return previewResponseFromPlan(await this.cleanupPlan(request)); return previewResponseFromPlan(await this.cleanupPlan(request));
} }
@@ -668,7 +740,7 @@ export class PiSessionService implements SessionRouteService {
skippedBusySessionIds.add(input.sessionId); skippedBusySessionIds.add(input.sessionId);
continue; continue;
} }
await this.closeActive(input.sessionId); await this.closeActive(input.sessionId, { kind: "clear", reason: "archive" });
readyArchiveInputs.push(input); readyArchiveInputs.push(input);
} }
await this.archiveStoreArchiveMany(readyArchiveInputs); await this.archiveStoreArchiveMany(readyArchiveInputs);
@@ -679,7 +751,7 @@ export class PiSessionService implements SessionRouteService {
skippedBusySessionIds.add(record.sessionId); skippedBusySessionIds.add(record.sessionId);
continue; continue;
} }
await this.closeActive(record.sessionId); await this.closeActive(record.sessionId, { kind: "clear", reason: "delete" });
readyDeleteRecords.push(record); readyDeleteRecords.push(record);
} }
await this.ensureArchivedRecordsMoved(readyDeleteRecords); await this.ensureArchivedRecordsMoved(readyDeleteRecords);
@@ -711,8 +783,10 @@ export class PiSessionService implements SessionRouteService {
this.subsessionLinks.clear(); this.subsessionLinks.clear();
this.subsessionHydratedParents.clear(); this.subsessionHydratedParents.clear();
this.subsessionNotifyArmed.clear(); this.subsessionNotifyArmed.clear();
this.notificationStore.clearAll("service-dispose");
await Promise.all(activeSessions.map(async (active) => { await Promise.all(activeSessions.map(async (active) => {
active.unsubscribe(); active.unsubscribe();
active.runtime.setRebindSession(undefined);
this.workspaceActivity?.removeSession(active.runtime.session.sessionId, active.runtime.session.sessionManager.getCwd()); this.workspaceActivity?.removeSession(active.runtime.session.sessionId, active.runtime.session.sessionManager.getCwd());
try { try {
await active.runtime.session.abort(); await active.runtime.session.abort();
@@ -731,6 +805,9 @@ export class PiSessionService implements SessionRouteService {
.map((record) => this.ensureArchivedSessionMoved(record, sessionsById.get(record.sessionId))), .map((record) => this.ensureArchivedSessionMoved(record, sessionsById.get(record.sessionId))),
); );
const archivedById = new Map(archivedForCwd.map((record) => [record.sessionId, record])); const archivedById = new Map(archivedForCwd.map((record) => [record.sessionId, record]));
for (const record of archivedForCwd) {
this.publishNotificationMutations(this.notificationStore.clearSession(record.sessionId, "archive-reconcile"));
}
const unarchivedSessions = sessions.filter((session) => !archivedById.has(session.id)).map(clientSessionFromListEntry); const unarchivedSessions = sessions.filter((session) => !archivedById.has(session.id)).map(clientSessionFromListEntry);
this.workspaceActivity?.reconcileSessionActivity(cwd, this.reconcilableSessionIds(cwd, unarchivedSessions.map((session) => session.id), archivedById)); this.workspaceActivity?.reconcileSessionActivity(cwd, this.reconcilableSessionIds(cwd, unarchivedSessions.map((session) => session.id), archivedById));
const archivedSessions = archivedForCwd const archivedSessions = archivedForCwd
@@ -1347,11 +1424,26 @@ export class PiSessionService implements SessionRouteService {
private async reloadSessionRuntime(session: PiAgentSession): Promise<void> { private async reloadSessionRuntime(session: PiAgentSession): Promise<void> {
if (this.hasActiveWork(session)) throw new Error("Stop current session activity before reloading"); if (this.hasActiveWork(session)) throw new Error("Stop current session activity before reloading");
this.publishActivity(session, "reloading resources", "active"); this.publishActivity(session, "reloading resources", "active");
const priorGeneration = this.notificationGenerationBySession.get(session);
let candidateGeneration: SessionNotificationGeneration | undefined;
try { try {
await session.reload(); await session.reload(priorGeneration === undefined ? undefined : {
beforeSessionStart: () => {
candidateGeneration = this.notificationStore.beginReplacement(priorGeneration, notificationIdentityForSession(session));
this.notificationGenerationBySession.set(session, candidateGeneration);
this.replaceSessionNotificationContext(session, candidateGeneration);
},
});
if (candidateGeneration !== undefined) {
this.publishNotificationMutations(this.notificationStore.commitReplacement(candidateGeneration));
}
this.publishActivity(session, "resources reloaded", "idle"); this.publishActivity(session, "resources reloaded", "idle");
this.publishStatus(session); this.publishStatus(session);
} catch (error: unknown) { } catch (error: unknown) {
if (candidateGeneration !== undefined) {
this.publishNotificationMutations(this.notificationStore.abortReplacement(candidateGeneration, "candidate"));
this.notificationGenerationBySession.set(session, candidateGeneration);
}
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
this.publishActivity(session, "reload failed", "error", message); this.publishActivity(session, "reload failed", "error", message);
this.events.publish(session.sessionId, { type: "session.error", message }); this.events.publish(session.sessionId, { type: "session.error", message });
@@ -1364,7 +1456,7 @@ export class PiSessionService implements SessionRouteService {
const session = await this.getOrOpen(ref); const session = await this.getOrOpen(ref);
if (this.hasActiveWork(session)) throw new Error("Stop current session activity before archiving"); if (this.hasActiveWork(session)) throw new Error("Stop current session activity before archiving");
const archiveInput = await this.archiveInputForSession(session); const archiveInput = await this.archiveInputForSession(session);
await this.closeActive(session.sessionId); await this.closeActive(session.sessionId, { kind: "clear", reason: "archive" });
await this.archiveStore.archive(archiveInput); await this.archiveStore.archive(archiveInput);
} }
@@ -1381,6 +1473,7 @@ export class PiSessionService implements SessionRouteService {
for (const ref of uniqueRefs) { for (const ref of uniqueRefs) {
const archived = findArchivedRecordForBulkRef(archivedRecords, ref); const archived = findArchivedRecordForBulkRef(archivedRecords, ref);
if (archived !== undefined) { if (archived !== undefined) {
this.publishNotificationMutations(this.notificationStore.clearSession(archived.sessionId, "archive"));
alreadyArchivedSessionIds.push(archived.sessionId); alreadyArchivedSessionIds.push(archived.sessionId);
continue; continue;
} }
@@ -1409,7 +1502,7 @@ export class PiSessionService implements SessionRouteService {
const readyInputs: ArchiveSessionInput[] = []; const readyInputs: ArchiveSessionInput[] = [];
for (const item of planItems) { for (const item of planItems) {
try { try {
await this.closeActive(item.input.sessionId); await this.closeActive(item.input.sessionId, { kind: "clear", reason: "archive" });
readyInputs.push(item.input); readyInputs.push(item.input);
} catch (error: unknown) { } catch (error: unknown) {
failures.push({ sessionId: item.input.sessionId, error: errorMessage(error) }); failures.push({ sessionId: item.input.sessionId, error: errorMessage(error) });
@@ -1440,8 +1533,11 @@ export class PiSessionService implements SessionRouteService {
const busy = plan.targets.map((target) => target.activeSession).find((target) => target !== undefined && this.hasActiveWork(target)); const busy = plan.targets.map((target) => target.activeSession).find((target) => target !== undefined && this.hasActiveWork(target));
if (busy !== undefined) throw new Error(`Stop current session activity before archiving ${sessionDisplayName(busy)}`); if (busy !== undefined) throw new Error(`Stop current session activity before archiving ${sessionDisplayName(busy)}`);
for (const target of plan.targets) {
if (target.archived) this.publishNotificationMutations(this.notificationStore.clearSession(target.id, "archive"));
}
const archiveInputs = plan.unarchivedTargets.map((target) => archiveInputFromCandidate(target)); const archiveInputs = plan.unarchivedTargets.map((target) => archiveInputFromCandidate(target));
for (const input of archiveInputs) await this.closeActive(input.sessionId); for (const input of archiveInputs) await this.closeActive(input.sessionId, { kind: "clear", reason: "archive" });
await this.archiveStoreArchiveMany(archiveInputs); await this.archiveStoreArchiveMany(archiveInputs);
return { return {
@@ -1455,7 +1551,7 @@ export class PiSessionService implements SessionRouteService {
async restore(ref: PiSessionLookup): Promise<void> { async restore(ref: PiSessionLookup): Promise<void> {
const archived = await this.getArchived(ref); const archived = await this.getArchived(ref);
if (archived === undefined) throw new Error("Session not found"); if (archived === undefined) throw new Error("Session not found");
await this.closeActive(archived.sessionId); await this.closeActive(archived.sessionId, { kind: "clear", reason: "restore" });
await this.archiveStore.restore(archived.sessionId); await this.archiveStore.restore(archived.sessionId);
} }
@@ -1464,7 +1560,7 @@ export class PiSessionService implements SessionRouteService {
if (record === undefined) throw new Error("Archived session not found"); if (record === undefined) throw new Error("Archived session not found");
if (this.archiveStore.deleteArchived === undefined) throw new Error("Archive store does not support deletion"); if (this.archiveStore.deleteArchived === undefined) throw new Error("Archive store does not support deletion");
await this.closeActive(record.sessionId); await this.closeActive(record.sessionId, { kind: "clear", reason: "delete" });
if (record.archivePath === undefined) await this.ensureArchivedRecordMoved(record); if (record.archivePath === undefined) await this.ensureArchivedRecordMoved(record);
await this.archiveStore.deleteArchived(record.sessionId); await this.archiveStore.deleteArchived(record.sessionId);
} }
@@ -1495,7 +1591,7 @@ export class PiSessionService implements SessionRouteService {
const readyRecords: ArchivedSessionRecord[] = []; const readyRecords: ArchivedSessionRecord[] = [];
for (const item of planItems) { for (const item of planItems) {
try { try {
await this.closeActive(item.record.sessionId); await this.closeActive(item.record.sessionId, { kind: "clear", reason: "delete" });
readyRecords.push(item.record); readyRecords.push(item.record);
} catch (error: unknown) { } catch (error: unknown) {
failures.push({ sessionId: item.record.sessionId, error: errorMessage(error) }); failures.push({ sessionId: item.record.sessionId, error: errorMessage(error) });
@@ -1528,9 +1624,29 @@ export class PiSessionService implements SessionRouteService {
await this.assertWritable(ref); await this.assertWritable(ref);
const session = await this.getOrOpen(ref); const session = await this.getOrOpen(ref);
if (this.hasActiveWork(session)) throw new Error("Stop current session activity before reloading"); if (this.hasActiveWork(session)) throw new Error("Stop current session activity before reloading");
await this.closeActive(session.sessionId);
const reopened = await this.getActive(ref); const priorGeneration = this.notificationGenerationBySession.get(session);
const { sessionId, cwd } = notificationIdentityForSession(session);
let candidateGeneration: SessionNotificationGeneration | undefined;
try {
await this.closeActive(
sessionId,
priorGeneration === undefined ? CLEAR_RUNTIME_NOTIFICATIONS : DEFER_RUNTIME_NOTIFICATIONS,
);
candidateGeneration = priorGeneration === undefined
? undefined
: this.notificationStore.beginReplacement(priorGeneration, { sessionId, cwd });
const reopened = await this.getActive(ref, candidateGeneration === undefined ? {} : { notificationGeneration: candidateGeneration });
if (candidateGeneration !== undefined) {
this.publishNotificationMutations(this.notificationStore.commitReplacement(candidateGeneration));
}
this.publishStatus(reopened.runtime.session); this.publishStatus(reopened.runtime.session);
} catch (error: unknown) {
if (candidateGeneration !== undefined) {
this.publishNotificationMutations(this.notificationStore.abortReplacement(candidateGeneration));
}
throw error;
}
} }
async detachParent(ref: PiSessionLookup): Promise<void> { async detachParent(ref: PiSessionLookup): Promise<void> {
@@ -1569,12 +1685,17 @@ export class PiSessionService implements SessionRouteService {
this.publishStatus(active.runtime.session); this.publishStatus(active.runtime.session);
} }
stop(ref: PiSessionLookup): void { async stop(ref: PiSessionLookup): Promise<void> {
const active = this.activeForLookup(ref); const active = this.activeForLookup(ref);
if (active === undefined) return; if (active !== undefined) {
void this.closeActive(active.runtime.session.sessionId).catch(() => { await this.closeActive(active.runtime.session.sessionId);
// Best-effort shutdown; callers that need errors await closeActive directly. return;
}); }
if (isPiSessionRef(ref)) {
this.publishNotificationMutations(this.notificationStore.clearSessionIdentity(ref.id, canonicalizeStoredCwd(ref.cwd), "runtime-close"));
return;
}
await this.closeActive(ref);
} }
private async bulkSessionLookupContext(refs: readonly SessionBulkMutationRef[]): Promise<BulkSessionLookupContext> { private async bulkSessionLookupContext(refs: readonly SessionBulkMutationRef[]): Promise<BulkSessionLookupContext> {
@@ -1760,10 +1881,17 @@ export class PiSessionService implements SessionRouteService {
return [...names]; return [...names];
} }
private async closeActive(sessionId: string): Promise<void> { private async closeActive(sessionId: string, notificationPolicy: NotificationClosePolicy = CLEAR_RUNTIME_NOTIFICATIONS): Promise<void> {
const pendingOpens = this.pendingSessionOpenPromises(sessionId); const pendingOpens = this.pendingSessionOpenPromises(sessionId);
if (pendingOpens.length > 0) await Promise.allSettled(pendingOpens); if (pendingOpens.length > 0) await Promise.allSettled(pendingOpens);
const active = this.active.get(sessionId); const active = this.active.get(sessionId);
if (notificationPolicy.kind === "clear") {
const generation = active === undefined ? undefined : this.notificationGenerationBySession.get(active.runtime.session);
const mutations = generation === undefined
? this.notificationStore.clearSession(sessionId, notificationPolicy.reason)
: this.notificationStore.clearGeneration(generation, notificationPolicy.reason);
this.publishNotificationMutations(mutations);
}
if (!active) return; if (!active) return;
this.active.delete(sessionId); this.active.delete(sessionId);
this.activities.delete(sessionId); this.activities.delete(sessionId);
@@ -1776,6 +1904,7 @@ export class PiSessionService implements SessionRouteService {
if (this.subsessionLinkForActiveChild(active.runtime.session) !== undefined) this.subsessionNotifyArmed.delete(sessionId); if (this.subsessionLinkForActiveChild(active.runtime.session) !== undefined) this.subsessionNotifyArmed.delete(sessionId);
clearSessionQueue(active.runtime.session); clearSessionQueue(active.runtime.session);
active.unsubscribe(); active.unsubscribe();
active.runtime.setRebindSession(undefined);
try { try {
await active.runtime.session.abort(); await active.runtime.session.abort();
} finally { } finally {
@@ -1791,7 +1920,7 @@ export class PiSessionService implements SessionRouteService {
return (await this.getActive(ref)).runtime.session; return (await this.getActive(ref)).runtime.session;
} }
private async getActive(ref: PiSessionLookup): Promise<ActiveSession<PiSessionRuntime>> { private async getActive(ref: PiSessionLookup, options: Pick<CreateSessionRuntimeOptions, "notificationGeneration"> = {}): Promise<ActiveSession<PiSessionRuntime>> {
const active = this.activeForLookup(ref); const active = this.activeForLookup(ref);
if (active !== undefined) return active; if (active !== undefined) return active;
@@ -1802,6 +1931,7 @@ export class PiSessionService implements SessionRouteService {
archived.sessionId, archived.sessionId,
archived.cwd, archived.cwd,
() => this.sessionManager.open(archivePath), () => this.sessionManager.open(archivePath),
{ notifications: "disabled" },
); );
} }
@@ -1809,13 +1939,14 @@ export class PiSessionService implements SessionRouteService {
? (await this.sessionManager.list(ref.cwd)).find((s) => s.id === ref.id || s.id.startsWith(ref.id)) ? (await this.sessionManager.list(ref.cwd)).find((s) => s.id === ref.id || s.id.startsWith(ref.id))
: (await this.sessionManager.listAll?.() ?? []).find((s) => s.id === ref || s.id.startsWith(ref)); : (await this.sessionManager.listAll?.() ?? []).find((s) => s.id === ref || s.id.startsWith(ref));
if (!match) throw new Error("Session not found"); if (!match) throw new Error("Session not found");
return this.openExistingSession(match.id, match.cwd, () => this.sessionManager.open(match.path)); return this.openExistingSession(match.id, match.cwd, () => this.sessionManager.open(match.path), options);
} }
private openExistingSession( private openExistingSession(
sessionId: string, sessionId: string,
cwd: string, cwd: string,
openSessionManager: () => PiSessionManager, openSessionManager: () => PiSessionManager,
options: Pick<CreateSessionRuntimeOptions, "notificationGeneration" | "notifications"> = {},
): Promise<ActiveSession<PiSessionRuntime>> { ): Promise<ActiveSession<PiSessionRuntime>> {
const active = this.activeForLookup({ id: sessionId, cwd }); const active = this.activeForLookup({ id: sessionId, cwd });
if (active !== undefined) return Promise.resolve(active); if (active !== undefined) return Promise.resolve(active);
@@ -1826,7 +1957,7 @@ export class PiSessionService implements SessionRouteService {
const pending: PendingSessionOpen = { const pending: PendingSessionOpen = {
sessionId, sessionId,
promise: this.create(openSessionManager(), cwd), promise: this.create(openSessionManager(), cwd, options),
}; };
pending.promise = pending.promise.finally(() => { pending.promise = pending.promise.finally(() => {
if (this.pendingSessionOpens.get(key) === pending) this.pendingSessionOpens.delete(key); if (this.pendingSessionOpens.get(key) === pending) this.pendingSessionOpens.delete(key);
@@ -1861,7 +1992,7 @@ export class PiSessionService implements SessionRouteService {
private async create( private async create(
sessionManager: PiSessionManager, sessionManager: PiSessionManager,
cwd: string, cwd: string,
options: Pick<InternalStartSessionOptions, "initialModel" | "creationProvenance"> = {}, options: CreateSessionRuntimeOptions = {},
): Promise<ActiveSession<PiSessionRuntime>> { ): Promise<ActiveSession<PiSessionRuntime>> {
const delegationToolsEnabled = options.creationProvenance !== "tracked-subsession" const delegationToolsEnabled = options.creationProvenance !== "tracked-subsession"
&& await sessionAllowsDelegationTools(sessionManager, this.sessionManager); && await sessionAllowsDelegationTools(sessionManager, this.sessionManager);
@@ -1873,19 +2004,76 @@ export class PiSessionService implements SessionRouteService {
...(options.initialModel === undefined ? {} : { initialModel: options.initialModel }), ...(options.initialModel === undefined ? {} : { initialModel: options.initialModel }),
}); });
const active: ActiveSession<PiSessionRuntime> = { runtime, unsubscribe: noop }; const active: ActiveSession<PiSessionRuntime> = { runtime, unsubscribe: noop };
let notificationGeneration = options.notificationGeneration;
let notificationOwnership: "disabled" | "external" | "registered" | "replacement" = options.notifications === "disabled"
? "disabled"
: notificationGeneration === undefined
? "registered"
: "external";
if (notificationOwnership === "registered") {
const notificationIdentity = notificationIdentityForSession(runtime.session);
const existingCandidate = this.notificationStore.beginReplacementForSession(
notificationIdentity.sessionId,
notificationIdentity.cwd,
);
if (existingCandidate !== undefined) {
notificationGeneration = existingCandidate;
notificationOwnership = "replacement";
} else {
const registration = this.notificationStore.registerSession(
notificationIdentity.sessionId,
notificationIdentity.cwd,
);
notificationGeneration = registration.generation;
this.publishNotificationMutations(registration.mutations);
}
}
if (notificationGeneration !== undefined) this.notificationGenerationBySession.set(runtime.session, notificationGeneration);
try { try {
await this.bindSessionExtensions(runtime.session); await this.bindSessionExtensions(runtime.session, notificationGeneration);
this.bindRuntime(active); this.bindRuntime(active);
runtime.setRebindSession(async (session) => { runtime.setRebindSession(async (session) => {
await this.bindSessionExtensions(session); const priorGeneration = notificationGeneration;
this.bindRuntime(active); let candidateGeneration: SessionNotificationGeneration | undefined;
try {
if (priorGeneration !== undefined) {
candidateGeneration = this.notificationStore.beginReplacement(priorGeneration, notificationIdentityForSession(session));
this.notificationGenerationBySession.set(session, candidateGeneration);
}
this.bindRuntime(active, session);
await this.bindSessionExtensions(session, candidateGeneration);
await this.recoverSubsessionTrackingForOpenedSession(session); await this.recoverSubsessionTrackingForOpenedSession(session);
if (candidateGeneration !== undefined) {
this.publishNotificationMutations(this.notificationStore.commitReplacement(candidateGeneration));
notificationGeneration = candidateGeneration;
}
} catch (error: unknown) {
if (candidateGeneration !== undefined) {
this.publishNotificationMutations(this.notificationStore.abortReplacement(candidateGeneration, "candidate"));
notificationGeneration = candidateGeneration;
this.notificationGenerationBySession.set(session, candidateGeneration);
}
throw error;
}
}); });
this.active.set(runtime.session.sessionId, active); this.active.set(runtime.session.sessionId, active);
await this.recoverSubsessionTrackingForOpenedSession(runtime.session); await this.recoverSubsessionTrackingForOpenedSession(runtime.session);
if (notificationOwnership === "replacement" && notificationGeneration !== undefined) {
this.publishNotificationMutations(this.notificationStore.commitReplacement(notificationGeneration));
notificationOwnership = "external";
}
this.publishStatus(runtime.session); this.publishStatus(runtime.session);
return active; return active;
} catch (error: unknown) { } catch (error: unknown) {
if (notificationGeneration !== undefined) {
if (notificationOwnership === "registered") {
this.publishNotificationMutations(this.notificationStore.clearSession(runtime.session.sessionId, "initialization-failed"));
} else if (notificationOwnership === "replacement") {
this.publishNotificationMutations(this.notificationStore.abortReplacement(notificationGeneration));
}
}
active.unsubscribe(); active.unsubscribe();
let removedActive = false; let removedActive = false;
for (const [sessionId, candidate] of this.active.entries()) { for (const [sessionId, candidate] of this.active.entries()) {
@@ -1908,25 +2096,11 @@ export class PiSessionService implements SessionRouteService {
} }
} }
private async bindSessionExtensions(session: PiAgentSession): Promise<void> { private async bindSessionExtensions(
const baseUiContext = session.extensionRunner.getUIContext(); session: PiAgentSession,
const notify: ExtensionUIContext["notify"] = (message, type) => { generation: SessionNotificationGeneration | undefined,
this.events.publish(session.sessionId, { ): Promise<void> {
type: "command.output", const uiContext = this.sessionUiContext(session, generation);
level: type === "error" ? "error" : "info",
message,
});
};
// PI WEB is a remote UI host, but currently only extension notifications
// cross this boundary. Delegate every other UI method to Pi's headless
// defaults so unsupported dialogs cancel safely instead of hanging.
const uiContext = new Proxy(baseUiContext, {
get(target, property, receiver): unknown {
if (property === "notify") return notify;
const value: unknown = Reflect.get(target, property, receiver);
return value;
},
});
await session.bindExtensions({ await session.bindExtensions({
uiContext, uiContext,
mode: "rpc", mode: "rpc",
@@ -1938,9 +2112,55 @@ export class PiSessionService implements SessionRouteService {
}); });
} }
private bindRuntime(active: ActiveSession<PiSessionRuntime>): void { private replaceSessionNotificationContext(session: PiAgentSession, generation: SessionNotificationGeneration): void {
session.extensionRunner.setUIContext(this.sessionUiContext(session, generation), "rpc");
}
private sessionUiContext(
session: PiAgentSession,
generation: SessionNotificationGeneration | undefined,
): ExtensionUIContext {
const baseUiContext = session.extensionRunner.getUIContext();
const notify: ExtensionUIContext["notify"] = (message, type) => {
if (generation === undefined) {
this.events.publish(session.sessionId, {
type: "command.output",
level: type === "error" ? "error" : "info",
message,
});
return;
}
const added = this.notificationStore.addNotification(generation, message, type);
this.publishNotificationMutations(added.mutations);
if (added.notification === undefined) return;
this.events.publish(session.sessionId, {
type: "command.output",
level: type === "error" ? "error" : "info",
message,
notificationId: added.notification.id,
});
};
// PI WEB is a remote UI host, but currently only extension notifications
// cross this boundary. Delegate every other UI method to Pi's headless
// defaults so unsupported dialogs cancel safely instead of hanging.
return new Proxy(baseUiContext, {
get(target, property, receiver): unknown {
if (property === "notify") return notify;
const value: unknown = Reflect.get(target, property, receiver);
return value;
},
});
}
private publishNotificationMutations(mutations: readonly SessionNotificationMutation[]): void {
for (const mutation of mutations) {
this.events.publish(mutation.sessionId, mutation.inboxEvent);
this.events.publishNotificationSummary(mutation.summaryEvent);
}
}
private bindRuntime(active: ActiveSession<PiSessionRuntime>, session: PiAgentSession = active.runtime.session): void {
active.unsubscribe(); active.unsubscribe();
const { session } = active.runtime;
for (const [sessionId, candidate] of this.active.entries()) { for (const [sessionId, candidate] of this.active.entries()) {
if (candidate === active) { if (candidate === active) {
this.active.delete(sessionId); this.active.delete(sessionId);
@@ -2286,6 +2506,13 @@ function modelToClientModel(model: PiAgentSession["model"]): ClientSessionModel
}; };
} }
function notificationIdentityForSession(session: PiAgentSession): { sessionId: string; cwd: string } {
return {
sessionId: session.sessionId,
cwd: canonicalizeStoredCwd(session.sessionManager.getCwd()),
};
}
function clientSessionFromListEntry(session: PiSessionListEntry): ClientSession { function clientSessionFromListEntry(session: PiSessionListEntry): ClientSession {
return { return {
id: session.id, id: session.id,
@@ -0,0 +1,339 @@
import { describe, expect, it } from "vitest";
import {
SESSION_NOTIFICATION_LIMIT,
SESSION_NOTIFICATION_MESSAGE_BYTES,
SessionNotificationStore,
truncateSessionNotificationMessage,
} from "./sessionNotificationStore.js";
const identity = { sessionId: "session-1", cwd: "/workspace" };
function testStore() {
let tick = 0;
return new SessionNotificationStore({
daemonInstanceId: "daemon-test",
now: () => new Date(Date.UTC(2026, 0, 1, 0, 0, tick++)),
});
}
function register(store: SessionNotificationStore) {
return store.registerSession(identity.sessionId, identity.cwd).generation;
}
describe("SessionNotificationStore", () => {
it("keeps duplicate calls distinct, newest-first, and recomputes severity", () => {
const store = testStore();
const generation = register(store);
const first = store.addNotification(generation, "same", undefined).notification;
const second = store.addNotification(generation, "same", "warning").notification;
const third = store.addNotification(generation, "same", "error").notification;
const unknown = store.addNotification(generation, "unknown severity", "fatal").notification;
expect([first?.id, second?.id, third?.id, unknown?.id]).toEqual([
"daemon-test:1",
"daemon-test:2",
"daemon-test:3",
"daemon-test:4",
]);
expect(store.inboxSnapshot(identity.sessionId, identity.cwd)).toMatchObject({
summary: { retainedCount: 4, discardedCount: 0, highestSeverity: "error", inboxRevision: 4 },
notifications: [
{ id: "daemon-test:4", severity: "info" },
{ id: "daemon-test:3", severity: "error" },
{ id: "daemon-test:2", severity: "warning" },
{ id: "daemon-test:1", severity: "info" },
],
dismissThrough: { order: 4, overflowWatermark: 0 },
});
store.dismissNotification(identity.sessionId, identity.cwd, store.daemonInstanceId, third?.id ?? "");
expect(store.inboxSnapshot(identity.sessionId, identity.cwd).summary.highestSeverity).toBe("warning");
});
it("truncates UTF-8 only between code points and marks exact overflow", () => {
const exact = "a".repeat(SESSION_NOTIFICATION_MESSAGE_BYTES);
expect(truncateSessionNotificationMessage(exact)).toEqual({ message: exact, truncated: false });
const astral = `${"a".repeat(SESSION_NOTIFICATION_MESSAGE_BYTES - 1)}😀tail`;
const astralResult = truncateSessionNotificationMessage(astral);
expect(astralResult).toEqual({ message: "a".repeat(SESSION_NOTIFICATION_MESSAGE_BYTES - 1), truncated: true });
expect(new TextEncoder().encode(astralResult.message).byteLength).toBe(SESSION_NOTIFICATION_MESSAGE_BYTES - 1);
const bidi = `${"a".repeat(SESSION_NOTIFICATION_MESSAGE_BYTES - 3)}\u202etail`;
const bidiResult = truncateSessionNotificationMessage(bidi);
expect(bidiResult.message.endsWith("\u202e")).toBe(true);
expect(new TextEncoder().encode(bidiResult.message).byteLength).toBe(SESSION_NOTIFICATION_MESSAGE_BYTES);
expect(bidiResult.truncated).toBe(true);
});
it("retains exactly the newest 100 and reports exact overflow", () => {
const store = testStore();
const generation = register(store);
for (let index = 1; index <= 105; index += 1) store.addNotification(generation, `message ${String(index)}`, "info");
const snapshot = store.inboxSnapshot(identity.sessionId, identity.cwd);
expect(snapshot.notifications).toHaveLength(SESSION_NOTIFICATION_LIMIT);
expect(snapshot.notifications[0]).toMatchObject({ id: "daemon-test:105", message: "message 105" });
expect(snapshot.notifications.at(-1)).toMatchObject({ id: "daemon-test:6", message: "message 6" });
expect(snapshot.summary).toMatchObject({ retainedCount: 100, discardedCount: 5 });
expect(snapshot.dismissThrough).toEqual({ order: 105, overflowWatermark: 5 });
});
it("makes individual dismissal idempotent and clears overflow with the final retained entry", () => {
const store = testStore();
const generation = register(store);
for (let index = 0; index <= SESSION_NOTIFICATION_LIMIT; index += 1) store.addNotification(generation, String(index), "info");
const firstSnapshot = store.inboxSnapshot(identity.sessionId, identity.cwd);
const firstId = firstSnapshot.notifications[0]?.id ?? "";
const firstDismiss = store.dismissNotification(identity.sessionId, identity.cwd, store.daemonInstanceId, firstId);
const afterFirstRevision = firstDismiss.snapshot.summary.inboxRevision;
const duplicateDismiss = store.dismissNotification(identity.sessionId, identity.cwd, store.daemonInstanceId, firstId);
expect(duplicateDismiss.mutations).toEqual([]);
expect(duplicateDismiss.snapshot.summary.inboxRevision).toBe(afterFirstRevision);
expect(duplicateDismiss.snapshot.summary.discardedCount).toBe(1);
for (const notification of duplicateDismiss.snapshot.notifications) {
store.dismissNotification(identity.sessionId, identity.cwd, store.daemonInstanceId, notification.id);
}
expect(store.inboxSnapshot(identity.sessionId, identity.cwd).summary).toMatchObject({ retainedCount: 0, discardedCount: 0 });
expect(store.catalogSnapshot().sessions).toEqual([]);
});
it("dismisses only through captured order and overflow cutoffs", () => {
const store = testStore();
const generation = register(store);
for (let index = 0; index <= SESSION_NOTIFICATION_LIMIT; index += 1) store.addNotification(generation, String(index), "info");
const clicked = store.inboxSnapshot(identity.sessionId, identity.cwd);
const later = store.addNotification(generation, "later", "error").notification;
const result = store.dismissAll(
identity.sessionId,
identity.cwd,
store.daemonInstanceId,
clicked.dismissThrough.order,
clicked.dismissThrough.overflowWatermark,
);
expect(result.snapshot.notifications).toEqual([later]);
expect(result.snapshot.summary).toMatchObject({ retainedCount: 1, discardedCount: 1, highestSeverity: "error" });
const revision = result.snapshot.summary.inboxRevision;
const replay = store.dismissAll(
identity.sessionId,
identity.cwd,
store.daemonInstanceId,
clicked.dismissThrough.order,
clicked.dismissThrough.overflowWatermark,
);
expect(replay.mutations).toEqual([]);
expect(replay.snapshot.summary.inboxRevision).toBe(revision);
});
it("requests resync when dismissal reveals an entry hidden by the replacement projection cap", () => {
const store = testStore();
const oldGeneration = register(store);
for (let index = 1; index <= 100; index += 1) store.addNotification(oldGeneration, `old ${String(index)}`, "info");
const candidate = store.beginReplacement(oldGeneration, identity);
const added = store.addNotification(candidate, "candidate", "warning");
const candidateId = added.notification?.id ?? "";
expect(added.mutations[0]?.inboxEvent.delta).toMatchObject({ kind: "added", evictedNotificationId: "daemon-test:1" });
const result = store.dismissNotification(identity.sessionId, identity.cwd, store.daemonInstanceId, candidateId);
expect(result.mutations[0]?.inboxEvent.delta).toEqual({ kind: "resync" });
expect(result.snapshot.notifications).toHaveLength(100);
expect(result.snapshot.notifications.at(-1)).toMatchObject({ id: "daemon-test:1", message: "old 1" });
expect(result.snapshot.summary.discardedCount).toBe(0);
});
it("keeps dismiss-all deltas bounded to the public 100-entry projection", () => {
const store = testStore();
const oldGeneration = register(store);
for (let index = 0; index < 100; index += 1) store.addNotification(oldGeneration, `old ${String(index)}`, "info");
const candidate = store.beginReplacement(oldGeneration, identity);
for (let index = 0; index < 100; index += 1) store.addNotification(candidate, `candidate ${String(index)}`, "warning");
const clicked = store.inboxSnapshot(identity.sessionId, identity.cwd);
const result = store.dismissAll(
identity.sessionId,
identity.cwd,
store.daemonInstanceId,
clicked.dismissThrough.order,
clicked.dismissThrough.overflowWatermark,
);
const delta = result.mutations[0]?.inboxEvent.delta;
expect(delta?.kind).toBe("dismissed");
expect(delta?.kind === "dismissed" ? delta.notificationIds : []).toHaveLength(100);
expect(result.snapshot.summary.retainedCount).toBe(0);
store.abortReplacement(candidate);
});
it("treats stale daemon and unknown notification identifiers as no-ops", () => {
const store = testStore();
const generation = register(store);
store.addNotification(generation, "keep", "warning");
const before = store.inboxSnapshot(identity.sessionId, identity.cwd);
const stale = store.dismissAll(identity.sessionId, identity.cwd, "old-daemon", Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER);
const unknown = store.dismissNotification(identity.sessionId, identity.cwd, store.daemonInstanceId, "missing");
expect(stale.mutations).toEqual([]);
expect(unknown.mutations).toEqual([]);
expect(unknown.snapshot).toEqual(before);
});
it("advances catalog and inbox revisions only for visible mutations and emits zero cleanup", () => {
const store = testStore();
const generation = register(store);
expect(store.catalogSnapshot()).toMatchObject({ daemonInstanceId: "daemon-test", catalogRevision: 0, sessions: [] });
const added = store.addNotification(generation, "notice", "info").mutations[0];
expect(added).toMatchObject({
sessionId: "session-1",
inboxEvent: { type: "notifications.inbox", catalogRevision: 1, summary: { inboxRevision: 1, retainedCount: 1 }, delta: { kind: "added" } },
summaryEvent: { type: "notifications.summary", catalogRevision: 1, summary: { inboxRevision: 1, retainedCount: 1 } },
});
const cleared = store.clearSession(identity.sessionId, "archive");
expect(cleared).toHaveLength(1);
expect(cleared[0]).toMatchObject({
inboxEvent: { catalogRevision: 2, summary: { inboxRevision: 2, retainedCount: 0, discardedCount: 0 }, delta: { kind: "cleared", reason: "archive" } },
summaryEvent: { catalogRevision: 2, summary: { retainedCount: 0, discardedCount: 0 } },
});
expect(store.catalogSnapshot()).toMatchObject({ catalogRevision: 2, sessions: [] });
expect(store.addNotification(generation, "stale", "error")).toEqual({ mutations: [] });
});
it("keeps inbox revisions and overflow watermarks monotonic across same-daemon reopen", () => {
const store = testStore();
const firstGeneration = register(store);
for (let index = 0; index <= SESSION_NOTIFICATION_LIMIT; index += 1) store.addNotification(firstGeneration, `first ${String(index)}`, "info");
const oldSnapshot = store.inboxSnapshot(identity.sessionId, identity.cwd);
store.clearSession(identity.sessionId, "runtime-close");
const reopenedGeneration = register(store);
const reopenedEmpty = store.inboxSnapshot(identity.sessionId, identity.cwd);
expect(reopenedEmpty.summary.inboxRevision).toBeGreaterThan(oldSnapshot.summary.inboxRevision);
expect(reopenedEmpty.dismissThrough.overflowWatermark).toBe(oldSnapshot.dismissThrough.overflowWatermark);
for (let index = 0; index <= SESSION_NOTIFICATION_LIMIT; index += 1) store.addNotification(reopenedGeneration, `second ${String(index)}`, "warning");
const beforeReplay = store.inboxSnapshot(identity.sessionId, identity.cwd);
const replay = store.dismissAll(
identity.sessionId,
identity.cwd,
store.daemonInstanceId,
oldSnapshot.dismissThrough.order,
oldSnapshot.dismissThrough.overflowWatermark,
);
expect(replay.mutations).toEqual([]);
expect(replay.snapshot).toEqual(beforeReplay);
expect(replay.snapshot.summary.discardedCount).toBe(1);
expect(replay.snapshot.dismissThrough.overflowWatermark).toBeGreaterThan(oldSnapshot.dismissThrough.overflowWatermark);
});
it("commits replacement notifications while dropping the old generation and overflow", () => {
const store = testStore();
const oldGeneration = register(store);
for (let index = 0; index <= SESSION_NOTIFICATION_LIMIT; index += 1) store.addNotification(oldGeneration, `old ${String(index)}`, "warning");
const candidate = store.beginReplacement(oldGeneration, identity);
const replacementOneResult = store.addNotification(candidate, "replacement one", "info");
const replacementOne = replacementOneResult.notification;
const replacementTwo = store.addNotification(candidate, "replacement two", "error").notification;
expect(replacementOneResult.mutations[0]?.inboxEvent.delta).toMatchObject({
kind: "added",
evictedNotificationId: "daemon-test:2",
});
expect(store.addNotification(oldGeneration, "stale shutdown callback", "error")).toEqual({ mutations: [] });
const mutations = store.commitReplacement(candidate);
const snapshot = store.inboxSnapshot(identity.sessionId, identity.cwd);
expect(mutations.at(-1)?.inboxEvent.delta).toEqual({ kind: "resync" });
expect(snapshot.notifications.map((notification) => notification.id)).toEqual([replacementTwo?.id, replacementOne?.id]);
expect(snapshot.summary).toMatchObject({ retainedCount: 2, discardedCount: 0, highestSeverity: "error" });
expect(store.addNotification(oldGeneration, "stale", "info")).toEqual({ mutations: [] });
});
it("aborts replacement without cleanup and keeps the 100-entry bound", () => {
const store = testStore();
const oldGeneration = register(store);
for (let index = 0; index < 100; index += 1) store.addNotification(oldGeneration, `old ${String(index)}`, "info");
const candidate = store.beginReplacement(oldGeneration, identity);
const replacementIds: string[] = [];
for (let index = 0; index < 100; index += 1) {
const notification = store.addNotification(candidate, `candidate ${String(index)}`, "warning").notification;
if (notification !== undefined) replacementIds.push(notification.id);
}
const mutations = store.abortReplacement(candidate);
const snapshot = store.inboxSnapshot(identity.sessionId, identity.cwd);
expect(mutations.at(-1)?.inboxEvent.delta).toEqual({ kind: "resync" });
expect(snapshot.notifications).toHaveLength(100);
expect(snapshot.notifications.every((notification) => replacementIds.includes(notification.id))).toBe(true);
expect(snapshot.summary.discardedCount).toBe(100);
expect(store.addNotification(candidate, "stale candidate", "error")).toEqual({ mutations: [] });
const afterAbort = store.addNotification(oldGeneration, "old runtime recovered", "error");
expect(afterAbort.notification).toBeDefined();
expect(store.inboxSnapshot(identity.sessionId, identity.cwd)).toMatchObject({
summary: { retainedCount: 100, discardedCount: 101, highestSeverity: "error" },
});
});
it("can keep a rotated candidate binding active after aborting cleanup", () => {
const store = testStore();
const oldGeneration = register(store);
store.addNotification(oldGeneration, "old", "info");
const candidate = store.beginReplacement(oldGeneration, identity);
store.addNotification(candidate, "candidate", "warning");
store.abortReplacement(candidate, "candidate");
expect(store.addNotification(oldGeneration, "stale old runner", "error")).toEqual({ mutations: [] });
expect(store.addNotification(candidate, "current runner", "error").notification).toMatchObject({ message: "current runner", severity: "error" });
expect(store.inboxSnapshot(identity.sessionId, identity.cwd).notifications.map((notification) => notification.message)).toEqual([
"current runner",
"candidate",
"old",
]);
});
it("moves preserved calls to the active changed-id candidate after aborting cleanup", () => {
const store = testStore();
const oldGeneration = register(store);
store.addNotification(oldGeneration, "old", "info");
const candidate = store.beginReplacement(oldGeneration, { sessionId: "session-2", cwd: identity.cwd });
store.addNotification(candidate, "candidate", "warning");
const mutations = store.abortReplacement(candidate, "candidate");
expect(mutations.map((mutation) => [mutation.sessionId, mutation.inboxEvent.delta.kind])).toEqual([
["session-1", "cleared"],
["session-2", "resync"],
]);
expect(() => store.inboxSnapshot("session-1", identity.cwd)).toThrow("Session not found");
expect(store.inboxSnapshot("session-2", identity.cwd).notifications.map((notification) => notification.message)).toEqual(["candidate", "old"]);
expect(store.addNotification(candidate, "after failure", "error").notification).toMatchObject({ message: "after failure" });
});
it("retags a failed changed-id replacement back to the prior inbox", () => {
const store = testStore();
const oldGeneration = register(store);
store.addNotification(oldGeneration, "old", "info");
const candidate = store.beginReplacement(oldGeneration, { sessionId: "session-2", cwd: identity.cwd });
const replacement = store.addNotification(candidate, "replacement", "error").notification;
const mutations = store.abortReplacement(candidate);
expect(mutations.map((mutation) => [mutation.sessionId, mutation.inboxEvent.delta.kind])).toEqual([
["session-2", "cleared"],
["session-1", "resync"],
]);
expect(() => store.inboxSnapshot("session-2", identity.cwd)).toThrow("Session not found");
expect(store.inboxSnapshot(identity.sessionId, identity.cwd).notifications.map((notification) => notification.id)).toContain(replacement?.id);
});
});
@@ -0,0 +1,597 @@
import { randomUUID } from "node:crypto";
import {
SESSION_NOTIFICATION_LIMIT,
SESSION_NOTIFICATION_MESSAGE_BYTES,
type SessionNotification,
type SessionNotificationCatalogSnapshot,
type SessionNotificationClearReason,
type SessionNotificationInboxDelta,
type SessionNotificationInboxEvent,
type SessionNotificationInboxSnapshot,
type SessionNotificationSeverity,
type SessionNotificationSummary,
type SessionNotificationSummaryEvent,
} from "../../shared/apiTypes.js";
export { SESSION_NOTIFICATION_LIMIT, SESSION_NOTIFICATION_MESSAGE_BYTES } from "../../shared/apiTypes.js";
export type SessionNotificationGeneration = symbol;
export interface SessionNotificationMutation {
sessionId: string;
inboxEvent: SessionNotificationInboxEvent;
summaryEvent: SessionNotificationSummaryEvent;
}
export interface SessionNotificationRegistration {
generation: SessionNotificationGeneration;
mutations: SessionNotificationMutation[];
}
export interface SessionNotificationAddResult {
notification?: SessionNotification;
mutations: SessionNotificationMutation[];
}
export interface SessionNotificationSnapshotResult {
snapshot: SessionNotificationInboxSnapshot;
mutations: SessionNotificationMutation[];
}
interface NotificationBucket {
generation: SessionNotificationGeneration;
entries: SessionNotification[];
discardedCount: number;
}
interface NotificationProjection {
sessionId: string;
cwd: string;
inboxRevision: number;
overflowWatermark: number;
buckets: NotificationBucket[];
}
interface NotificationReplacement {
generation: SessionNotificationGeneration;
projection: NotificationProjection;
bucket: NotificationBucket;
}
interface NotificationRuntimeState {
activeGeneration: SessionNotificationGeneration;
activeProjection: NotificationProjection;
activeBucket: NotificationBucket;
candidate?: NotificationReplacement;
}
interface GenerationBinding {
state: NotificationRuntimeState;
role: "active" | "candidate";
}
export interface SessionNotificationStoreOptions {
daemonInstanceId?: string;
now?: () => Date;
}
/**
* Daemon-owned, bounded, in-memory notification state.
*
* The store deliberately knows nothing about Fastify, Pi session persistence,
* sockets, or browser state. Runtime generations are opaque capabilities: once
* a generation is replaced or cleared, stale extension callbacks become no-ops.
*/
export class SessionNotificationStore {
readonly daemonInstanceId: string;
private readonly now: () => Date;
private readonly statesBySessionId = new Map<string, NotificationRuntimeState>();
private readonly bindings = new Map<SessionNotificationGeneration, GenerationBinding>();
private readonly lastInboxRevisionBySessionId = new Map<string, number>();
private readonly lastOverflowWatermarkBySessionId = new Map<string, number>();
private catalogRevision = 0;
private nextOrder = 0;
constructor(options: SessionNotificationStoreOptions = {}) {
this.daemonInstanceId = options.daemonInstanceId ?? randomUUID();
this.now = options.now ?? (() => new Date());
}
registerSession(sessionId: string, cwd: string): SessionNotificationRegistration {
requireIdentity(sessionId, cwd);
const mutations = this.clearSession(sessionId, "replacement");
const generation = Symbol(`notifications:${sessionId}`);
const bucket = emptyBucket(generation);
const projection: NotificationProjection = {
sessionId,
cwd,
inboxRevision: this.lastInboxRevisionBySessionId.get(sessionId) ?? 0,
overflowWatermark: this.lastOverflowWatermarkBySessionId.get(sessionId) ?? 0,
buckets: [bucket],
};
const state: NotificationRuntimeState = {
activeGeneration: generation,
activeProjection: projection,
activeBucket: bucket,
};
this.statesBySessionId.set(sessionId, state);
this.bindings.set(generation, { state, role: "active" });
return { generation, mutations };
}
currentGeneration(sessionId: string, cwd: string): SessionNotificationGeneration | undefined {
const state = this.statesBySessionId.get(sessionId);
if (state?.activeProjection.sessionId !== sessionId || state.activeProjection.cwd !== cwd || state.candidate !== undefined) return undefined;
return state.activeGeneration;
}
beginReplacement(
activeGeneration: SessionNotificationGeneration,
target: { sessionId: string; cwd: string },
): SessionNotificationGeneration {
requireIdentity(target.sessionId, target.cwd);
const binding = this.bindings.get(activeGeneration);
if (binding?.role !== "active") throw new Error("Notification runtime generation is no longer active");
const state = binding.state;
if (state.candidate !== undefined) throw new Error("Notification runtime replacement is already in progress");
const sameIdentity = state.activeProjection.sessionId === target.sessionId && state.activeProjection.cwd === target.cwd;
let projection: NotificationProjection;
if (sameIdentity) {
projection = state.activeProjection;
} else {
const existing = this.statesBySessionId.get(target.sessionId);
if (existing !== undefined && existing !== state) throw new Error("Notification target session is already registered");
projection = {
sessionId: target.sessionId,
cwd: target.cwd,
inboxRevision: this.lastInboxRevisionBySessionId.get(target.sessionId) ?? 0,
overflowWatermark: this.lastOverflowWatermarkBySessionId.get(target.sessionId) ?? 0,
buckets: [],
};
this.statesBySessionId.set(target.sessionId, state);
}
const generation = Symbol(`notifications:${target.sessionId}:candidate`);
const bucket = emptyBucket(generation);
projection.buckets.push(bucket);
state.candidate = { generation, projection, bucket };
this.bindings.set(generation, { state, role: "candidate" });
return generation;
}
beginReplacementForSession(sessionId: string, cwd: string): SessionNotificationGeneration | undefined {
const generation = this.currentGeneration(sessionId, cwd);
return generation === undefined ? undefined : this.beginReplacement(generation, { sessionId, cwd });
}
commitReplacement(candidateGeneration: SessionNotificationGeneration): SessionNotificationMutation[] {
const binding = this.requireCandidate(candidateGeneration);
const state = binding.state;
const candidate = state.candidate;
if (candidate === undefined) return [];
const oldProjection = state.activeProjection;
const sameProjection = oldProjection === candidate.projection;
const before = sameProjection ? projectionFingerprint(oldProjection) : undefined;
const mutations: SessionNotificationMutation[] = [];
this.bindings.delete(state.activeGeneration);
if (sameProjection) {
oldProjection.buckets = [candidate.bucket];
} else {
mutations.push(...this.clearProjection(oldProjection, "replacement"));
this.statesBySessionId.delete(oldProjection.sessionId);
}
state.activeGeneration = candidate.generation;
state.activeProjection = candidate.projection;
state.activeBucket = candidate.bucket;
delete state.candidate;
this.bindings.set(candidate.generation, { state, role: "active" });
this.statesBySessionId.set(candidate.projection.sessionId, state);
if (sameProjection && before !== projectionFingerprint(candidate.projection)) {
mutations.push(this.mutation(candidate.projection, { kind: "resync" }));
}
return mutations;
}
abortReplacement(
candidateGeneration: SessionNotificationGeneration,
survivingGeneration: "prior" | "candidate" = "prior",
): SessionNotificationMutation[] {
const binding = this.requireCandidate(candidateGeneration);
const state = binding.state;
const candidate = state.candidate;
if (candidate === undefined) return [];
const oldProjection = state.activeProjection;
const oldGeneration = state.activeGeneration;
const oldBucket = state.activeBucket;
const candidateProjection = candidate.projection;
const sameProjection = oldProjection === candidateProjection;
const targetProjection = sameProjection || survivingGeneration === "prior" ? oldProjection : candidateProjection;
const before = projectionFingerprint(targetProjection);
const oldEntries = [...oldBucket.entries];
const candidateEntries = [...candidate.bucket.entries];
const oldDiscardedCount = oldBucket.discardedCount;
const candidateDiscardedCount = candidate.bucket.discardedCount;
const mutations: SessionNotificationMutation[] = [];
if (!sameProjection) {
const sourceProjection = survivingGeneration === "candidate" ? oldProjection : candidateProjection;
mutations.push(...this.clearProjection(sourceProjection, "replacement"));
this.statesBySessionId.delete(sourceProjection.sessionId);
const transferredDiscardedCount = survivingGeneration === "candidate" ? oldDiscardedCount : candidateDiscardedCount;
targetProjection.overflowWatermark = addSafe(
targetProjection.overflowWatermark,
transferredDiscardedCount,
"Notification overflow watermark exhausted",
);
}
const merged = [...oldEntries, ...candidateEntries].sort((left, right) => left.order - right.order);
const overflow = Math.max(0, merged.length - SESSION_NOTIFICATION_LIMIT);
const targetGeneration = survivingGeneration === "candidate" ? candidate.generation : oldGeneration;
const targetBucket = survivingGeneration === "candidate" ? candidate.bucket : oldBucket;
targetBucket.entries = overflow === 0 ? merged : merged.slice(overflow);
targetBucket.discardedCount = addSafe(oldDiscardedCount, candidateDiscardedCount, "Notification discarded count exhausted");
addDiscardedCount(overflow, targetProjection, targetBucket);
targetProjection.buckets = [targetBucket];
this.bindings.delete(survivingGeneration === "candidate" ? oldGeneration : candidate.generation);
state.activeGeneration = targetGeneration;
state.activeProjection = targetProjection;
state.activeBucket = targetBucket;
delete state.candidate;
this.statesBySessionId.set(targetProjection.sessionId, state);
this.bindings.set(targetGeneration, { state, role: "active" });
if (before !== projectionFingerprint(targetProjection)) {
mutations.push(this.mutation(targetProjection, { kind: "resync" }));
}
return mutations;
}
addNotification(
generation: SessionNotificationGeneration,
message: string,
severity: unknown,
): SessionNotificationAddResult {
const binding = this.bindings.get(generation);
if (binding === undefined) return { mutations: [] };
const { state } = binding;
// Once the replacement runner is bound, old callbacks are stale. Suppressing
// them also keeps generation overflow ordered as a bounded suffix.
if (binding.role === "active" && state.candidate !== undefined) return { mutations: [] };
const projection = binding.role === "candidate" ? state.candidate?.projection : state.activeProjection;
const bucket = binding.role === "candidate" ? state.candidate?.bucket : state.activeBucket;
if (projection === undefined || bucket === undefined) return { mutations: [] };
const previouslyRetained = retainedEntries(projection);
const order = incrementSafe(this.nextOrder, "Notification order exhausted");
this.nextOrder = order;
const truncatedMessage = truncateSessionNotificationMessage(message);
const notification: SessionNotification = Object.freeze({
id: `${this.daemonInstanceId}:${String(order)}`,
message: truncatedMessage.message,
truncated: truncatedMessage.truncated,
severity: normalizeSeverity(severity),
receivedAt: this.now().toISOString(),
order,
});
bucket.entries.push(notification);
const bucketEviction = bucket.entries.length > SESSION_NOTIFICATION_LIMIT ? bucket.entries.shift() : undefined;
if (bucketEviction !== undefined) addDiscardedCount(1, projection, bucket);
const retainedIds = new Set(retainedEntries(projection).map((entry) => entry.id));
const projectionEviction = previouslyRetained.find((entry) => !retainedIds.has(entry.id));
const delta: SessionNotificationInboxDelta = {
kind: "added",
notification,
...(projectionEviction === undefined ? {} : { evictedNotificationId: projectionEviction.id }),
};
return { notification, mutations: [this.mutation(projection, delta)] };
}
catalogSnapshot(): SessionNotificationCatalogSnapshot {
const sessions = uniqueProjections(this.statesBySessionId.values())
.map((projection) => this.summary(projection))
.filter((summary) => summary.retainedCount > 0 || summary.discardedCount > 0);
return {
daemonInstanceId: this.daemonInstanceId,
catalogRevision: this.catalogRevision,
sessions,
};
}
inboxSnapshot(sessionId: string, cwd: string): SessionNotificationInboxSnapshot {
return this.snapshot(this.requireProjection(sessionId, cwd));
}
dismissNotification(
sessionId: string,
cwd: string,
daemonInstanceId: string,
notificationId: string,
): SessionNotificationSnapshotResult {
const projection = this.requireProjection(sessionId, cwd);
if (daemonInstanceId !== this.daemonInstanceId) return { snapshot: this.snapshot(projection), mutations: [] };
const previouslyRetained = retainedEntries(projection);
const previouslyRetainedIds = new Set(previouslyRetained.map((entry) => entry.id));
const before = projectionFingerprint(projection);
let dismissed = false;
for (const bucket of projection.buckets) {
const index = bucket.entries.findIndex((entry) => entry.id === notificationId);
if (index === -1) continue;
bucket.entries.splice(index, 1);
dismissed = true;
break;
}
if (!dismissed) return { snapshot: this.snapshot(projection), mutations: [] };
if (projection.buckets.every((bucket) => bucket.entries.length === 0)) clearDiscardedCount(projection);
const newlyRevealed = retainedEntries(projection).some((entry) => !previouslyRetainedIds.has(entry.id));
const after = projectionFingerprint(projection);
if (!previouslyRetainedIds.has(notificationId) && before === after) {
return { snapshot: this.snapshot(projection), mutations: [] };
}
const delta: SessionNotificationInboxDelta = newlyRevealed
? { kind: "resync" }
: { kind: "dismissed", notificationIds: [notificationId] };
const mutation = this.mutation(projection, delta);
return { snapshot: this.snapshot(projection), mutations: [mutation] };
}
dismissAll(
sessionId: string,
cwd: string,
daemonInstanceId: string,
throughOrder: number,
throughOverflowWatermark: number,
): SessionNotificationSnapshotResult {
const projection = this.requireProjection(sessionId, cwd);
if (daemonInstanceId !== this.daemonInstanceId) return { snapshot: this.snapshot(projection), mutations: [] };
const visibleIds = new Set(retainedEntries(projection).map((entry) => entry.id));
const dismissedIds: string[] = [];
for (const bucket of projection.buckets) {
bucket.entries = bucket.entries.filter((entry) => {
if (entry.order > throughOrder) return true;
if (visibleIds.has(entry.id)) dismissedIds.push(entry.id);
return false;
});
}
const acknowledgedOverflow = acknowledgeDiscardedThrough(projection, throughOverflowWatermark);
if (dismissedIds.length === 0 && acknowledgedOverflow === 0) return { snapshot: this.snapshot(projection), mutations: [] };
const mutation = this.mutation(projection, { kind: "dismissed", notificationIds: dismissedIds });
return { snapshot: this.snapshot(projection), mutations: [mutation] };
}
clearGeneration(generation: SessionNotificationGeneration, reason: SessionNotificationClearReason): SessionNotificationMutation[] {
const binding = this.bindings.get(generation);
return binding === undefined ? [] : this.clearSession(binding.state.activeProjection.sessionId, reason);
}
clearSessionIdentity(sessionId: string, cwd: string, reason: SessionNotificationClearReason): SessionNotificationMutation[] {
const state = this.statesBySessionId.get(sessionId);
if (state === undefined) return [];
const projection = state.activeProjection.sessionId === sessionId ? state.activeProjection : state.candidate?.projection;
if (projection?.sessionId !== sessionId) return [];
if (projection.cwd !== cwd) throw new Error("Session cwd mismatch");
return this.clearSession(sessionId, reason);
}
clearSession(sessionId: string, reason: SessionNotificationClearReason): SessionNotificationMutation[] {
const state = this.statesBySessionId.get(sessionId);
if (state === undefined) return [];
const projections = state.candidate === undefined
? [state.activeProjection]
: uniqueProjectionList([state.activeProjection, state.candidate.projection]);
const mutations = projections.flatMap((projection) => this.clearProjection(projection, reason));
this.statesBySessionId.delete(state.activeProjection.sessionId);
if (state.candidate !== undefined) {
this.statesBySessionId.delete(state.candidate.projection.sessionId);
this.bindings.delete(state.candidate.generation);
}
this.bindings.delete(state.activeGeneration);
return mutations;
}
clearAll(reason: SessionNotificationClearReason = "service-dispose"): SessionNotificationMutation[] {
const states = new Set(this.statesBySessionId.values());
const mutations: SessionNotificationMutation[] = [];
for (const state of states) mutations.push(...this.clearSession(state.activeProjection.sessionId, reason));
this.statesBySessionId.clear();
this.bindings.clear();
this.lastInboxRevisionBySessionId.clear();
this.lastOverflowWatermarkBySessionId.clear();
return mutations;
}
private requireCandidate(candidateGeneration: SessionNotificationGeneration): GenerationBinding {
const binding = this.bindings.get(candidateGeneration);
if (binding?.role !== "candidate") throw new Error("Notification replacement generation is no longer active");
return binding;
}
private requireProjection(sessionId: string, cwd: string): NotificationProjection {
const state = this.statesBySessionId.get(sessionId);
if (state === undefined) throw new Error("Session not found");
const projection = state.activeProjection.sessionId === sessionId
? state.activeProjection
: state.candidate?.projection.sessionId === sessionId
? state.candidate.projection
: undefined;
if (projection === undefined) throw new Error("Session not found");
if (projection.cwd !== cwd) throw new Error("Session cwd mismatch");
return projection;
}
private clearProjection(projection: NotificationProjection, reason: SessionNotificationClearReason): SessionNotificationMutation[] {
const wasVisible = retainedEntries(projection).length > 0 || discardedCount(projection) > 0;
for (const bucket of projection.buckets) {
bucket.entries = [];
bucket.discardedCount = 0;
}
return wasVisible ? [this.mutation(projection, { kind: "cleared", reason })] : [];
}
private mutation(projection: NotificationProjection, delta: SessionNotificationInboxDelta): SessionNotificationMutation {
projection.inboxRevision = incrementSafe(projection.inboxRevision, "Notification inbox revision exhausted");
this.lastInboxRevisionBySessionId.set(projection.sessionId, projection.inboxRevision);
this.lastOverflowWatermarkBySessionId.set(projection.sessionId, projection.overflowWatermark);
this.catalogRevision = incrementSafe(this.catalogRevision, "Notification catalog revision exhausted");
const summary = this.summary(projection);
const common = {
daemonInstanceId: this.daemonInstanceId,
catalogRevision: this.catalogRevision,
summary,
};
return {
sessionId: projection.sessionId,
inboxEvent: { type: "notifications.inbox", ...common, dismissThrough: dismissThrough(projection), delta },
summaryEvent: { type: "notifications.summary", ...common },
};
}
private summary(projection: NotificationProjection): SessionNotificationSummary {
const notifications = retainedEntries(projection);
const highestSeverity = highestSeverityOf(notifications);
return {
sessionId: projection.sessionId,
cwd: projection.cwd,
inboxRevision: projection.inboxRevision,
retainedCount: notifications.length,
discardedCount: discardedCount(projection),
...(highestSeverity === undefined ? {} : { highestSeverity }),
};
}
private snapshot(projection: NotificationProjection): SessionNotificationInboxSnapshot {
const notifications = retainedEntries(projection).reverse();
return {
daemonInstanceId: this.daemonInstanceId,
catalogRevision: this.catalogRevision,
summary: this.summary(projection),
notifications,
dismissThrough: dismissThrough(projection),
};
}
}
export function truncateSessionNotificationMessage(
message: string,
maxBytes = SESSION_NOTIFICATION_MESSAGE_BYTES,
): { message: string; truncated: boolean } {
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) throw new Error("maxBytes must be a non-negative safe integer");
const encoder = new TextEncoder();
if (encoder.encode(message).byteLength <= maxBytes) return { message, truncated: false };
let bytes = 0;
let truncated = "";
for (const codePoint of message) {
const codePointBytes = encoder.encode(codePoint).byteLength;
if (bytes + codePointBytes > maxBytes) break;
truncated += codePoint;
bytes += codePointBytes;
}
return { message: truncated, truncated: true };
}
function emptyBucket(generation: SessionNotificationGeneration): NotificationBucket {
return { generation, entries: [], discardedCount: 0 };
}
function normalizeSeverity(value: unknown): SessionNotificationSeverity {
return value === "warning" || value === "error" ? value : "info";
}
function retainedEntries(projection: NotificationProjection): SessionNotification[] {
return projection.buckets
.flatMap((bucket) => bucket.entries)
.sort((left, right) => left.order - right.order)
.slice(-SESSION_NOTIFICATION_LIMIT);
}
function discardedCount(projection: NotificationProjection): number {
return projection.buckets.reduce((total, bucket) => total + bucket.discardedCount, 0);
}
function highestSeverityOf(notifications: readonly SessionNotification[]): SessionNotificationSeverity | undefined {
let highest: SessionNotificationSeverity | undefined;
for (const notification of notifications) {
if (notification.severity === "error") return "error";
if (notification.severity === "warning") highest = "warning";
else highest ??= "info";
}
return highest;
}
function addDiscardedCount(count: number, projection: NotificationProjection, bucket: NotificationBucket): void {
if (count === 0) return;
projection.overflowWatermark = addSafe(projection.overflowWatermark, count, "Notification overflow watermark exhausted");
bucket.discardedCount = addSafe(bucket.discardedCount, count, "Notification discarded count exhausted");
}
function clearDiscardedCount(projection: NotificationProjection): void {
for (const bucket of projection.buckets) bucket.discardedCount = 0;
}
function acknowledgeDiscardedThrough(projection: NotificationProjection, throughWatermark: number): number {
const count = discardedCount(projection);
if (count === 0) return 0;
const firstWatermark = projection.overflowWatermark - count + 1;
const acknowledged = Math.max(0, Math.min(count, throughWatermark - firstWatermark + 1));
let remaining = acknowledged;
for (const bucket of projection.buckets) {
if (remaining === 0) break;
const removed = Math.min(bucket.discardedCount, remaining);
bucket.discardedCount -= removed;
remaining -= removed;
}
return acknowledged;
}
function dismissThrough(projection: NotificationProjection): { order: number; overflowWatermark: number } {
const entries = retainedEntries(projection);
return {
order: entries.at(-1)?.order ?? 0,
overflowWatermark: projection.overflowWatermark,
};
}
function projectionFingerprint(projection: NotificationProjection): string {
return JSON.stringify({
ids: retainedEntries(projection).map((entry) => entry.id),
discardedCount: discardedCount(projection),
});
}
function uniqueProjections(states: Iterable<NotificationRuntimeState>): NotificationProjection[] {
const projections: NotificationProjection[] = [];
for (const state of new Set(states)) {
projections.push(state.activeProjection);
if (state.candidate !== undefined && state.candidate.projection !== state.activeProjection) projections.push(state.candidate.projection);
}
return projections;
}
function uniqueProjectionList(projections: NotificationProjection[]): NotificationProjection[] {
return [...new Set(projections)];
}
function requireIdentity(sessionId: string, cwd: string): void {
if (sessionId === "") throw new Error("sessionId must not be empty");
if (cwd === "") throw new Error("cwd must not be empty");
}
function incrementSafe(value: number, message: string): number {
return addSafe(value, 1, message);
}
function addSafe(value: number, increment: number, message: string): number {
const next = value + increment;
if (!Number.isSafeInteger(next)) throw new Error(message);
return next;
}
+171 -1
View File
@@ -2,10 +2,24 @@ import { resolve } from "node:path";
import Fastify, { type FastifyInstance } from "fastify"; import Fastify, { type FastifyInstance } from "fastify";
import fastifyWebsocket from "@fastify/websocket"; import fastifyWebsocket from "@fastify/websocket";
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { MessagePage, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkMutationRef, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionStatus, SessionStreamSnapshot } from "../../shared/apiTypes.js"; import type {
MessagePage,
SessionBulkArchiveResponse,
SessionBulkDeleteArchivedResponse,
SessionBulkMutationRef,
SessionCleanupExecuteResponse,
SessionCleanupPreviewResponse,
SessionNotificationDismissAllRequest,
SessionNotificationDismissRequest,
SessionNotificationInboxSnapshot,
SessionRef,
SessionStatus,
SessionStreamSnapshot,
} from "../../shared/apiTypes.js";
import { SessionEventHub } from "../realtime/sessionEventHub.js"; import { SessionEventHub } from "../realtime/sessionEventHub.js";
import { PiSessionService, type PiSessionManagerGateway } from "./piSessionService.js"; import { PiSessionService, type PiSessionManagerGateway } from "./piSessionService.js";
import { testModelRuntime } from "./piSessionService.testSupport.js"; import { testModelRuntime } from "./piSessionService.testSupport.js";
import { SessionNotificationStore } from "./sessionNotificationStore.js";
import type { SessionRouteLookup, SessionRouteService } from "./sessionService.js"; import type { SessionRouteLookup, SessionRouteService } from "./sessionService.js";
import { registerSessionRoutes } from "./sessionRoutes.js"; import { registerSessionRoutes } from "./sessionRoutes.js";
import type { NormalizedSessionCleanupRequest } from "./sessionCleanup.js"; import type { NormalizedSessionCleanupRequest } from "./sessionCleanup.js";
@@ -31,6 +45,130 @@ afterEach(async () => {
}); });
describe("session routes", () => { describe("session routes", () => {
it("returns notification catalog and selected-inbox snapshots with required cwd context", async () => {
const routeApp = Fastify({ logger: false });
await routeApp.register(fastifyWebsocket);
const eventHub = new SessionEventHub();
const routeService = new CapturingRouteSessionService();
registerSessionRoutes(routeApp, routeService, eventHub);
try {
const requestCwd = resolve("/repo");
const catalog = await routeApp.inject({ method: "GET", url: "/sessions/notifications" });
const inbox = await routeApp.inject({ method: "GET", url: `/sessions/session-1/notifications?cwd=${encodeURIComponent(requestCwd)}` });
expect(catalog.statusCode).toBe(200);
expect(catalog.json()).toEqual({ daemonInstanceId: "daemon-test", catalogRevision: 0, sessions: [] });
expect(inbox.statusCode).toBe(200);
expect(inbox.json()).toMatchObject({ daemonInstanceId: "daemon-test", summary: { sessionId: "session-1", cwd: requestCwd } });
expect(routeService.notificationInboxCalls).toEqual([{ id: "session-1", cwd: requestCwd }]);
} finally {
await routeService.dispose();
await routeApp.close();
}
});
it("validates and forwards idempotent notification dismissal cutoffs", async () => {
const routeApp = Fastify({ logger: false });
await routeApp.register(fastifyWebsocket);
const eventHub = new SessionEventHub();
const routeService = new CapturingRouteSessionService();
registerSessionRoutes(routeApp, routeService, eventHub);
try {
const requestCwd = resolve("/repo");
const dismiss = await routeApp.inject({
method: "POST",
url: "/sessions/session-1/notifications/dismiss",
payload: { cwd: requestCwd, daemonInstanceId: "daemon-test", notificationId: "notice-1" },
});
const dismissAll = await routeApp.inject({
method: "POST",
url: "/sessions/session-1/notifications/dismiss-all",
payload: { cwd: requestCwd, daemonInstanceId: "daemon-test", throughOrder: 12, throughOverflowWatermark: 3 },
});
expect(dismiss.statusCode).toBe(200);
expect(dismissAll.statusCode).toBe(200);
expect(routeService.dismissNotificationCalls).toEqual([{
ref: { id: "session-1", cwd: requestCwd },
request: { daemonInstanceId: "daemon-test", notificationId: "notice-1" },
}]);
expect(routeService.dismissAllNotificationCalls).toEqual([{
ref: { id: "session-1", cwd: requestCwd },
request: { daemonInstanceId: "daemon-test", throughOrder: 12, throughOverflowWatermark: 3 },
}]);
} finally {
await routeService.dispose();
await routeApp.close();
}
});
it("keeps stale notification mutations harmless and rejects mismatched ownership", async () => {
const routeApp = Fastify({ logger: false });
await routeApp.register(fastifyWebsocket);
const eventHub = new SessionEventHub();
const requestCwd = resolve("/repo");
const notificationStore = new SessionNotificationStore({ daemonInstanceId: "daemon-current" });
const registration = notificationStore.registerSession("session-1", requestCwd);
notificationStore.addNotification(registration.generation, "keep", "warning");
const routeService = new PiSessionService(eventHub, {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
notificationStore,
sessionManager: new RejectingSessionManager(),
heartbeatIntervalMs: 60_000,
});
registerSessionRoutes(routeApp, routeService, eventHub);
try {
const stale = await routeApp.inject({
method: "POST",
url: "/sessions/session-1/notifications/dismiss-all",
payload: { cwd: requestCwd, daemonInstanceId: "daemon-old", throughOrder: Number.MAX_SAFE_INTEGER, throughOverflowWatermark: Number.MAX_SAFE_INTEGER },
});
const mismatch = await routeApp.inject({ method: "GET", url: `/sessions/session-1/notifications?cwd=${encodeURIComponent(resolve("/other"))}` });
const missing = await routeApp.inject({ method: "GET", url: `/sessions/missing/notifications?cwd=${encodeURIComponent(requestCwd)}` });
expect(stale.statusCode).toBe(200);
expect(stale.json()).toMatchObject({ summary: { retainedCount: 1, inboxRevision: 1 } });
expect(mismatch.statusCode).toBe(400);
expect(mismatch.json()).toEqual({ error: "Session cwd mismatch" });
expect(missing.statusCode).toBe(404);
expect(missing.json()).toEqual({ error: "Session not found" });
} finally {
await routeService.dispose();
await routeApp.close();
}
});
it("rejects malformed notification requests before calling the service", async () => {
const routeApp = Fastify({ logger: false });
await routeApp.register(fastifyWebsocket);
const eventHub = new SessionEventHub();
const routeService = new CapturingRouteSessionService();
registerSessionRoutes(routeApp, routeService, eventHub);
try {
const missingCwd = await routeApp.inject({ method: "GET", url: "/sessions/session-1/notifications" });
const unsafeCutoff = await routeApp.inject({
method: "POST",
url: "/sessions/session-1/notifications/dismiss-all",
payload: { cwd: "/repo", daemonInstanceId: "daemon-test", throughOrder: Number.MAX_SAFE_INTEGER + 1, throughOverflowWatermark: 0 },
});
expect(missingCwd.statusCode).toBe(400);
expect(missingCwd.json()).toEqual({ error: "cwd field must be a string" });
expect(unsafeCutoff.statusCode).toBe(400);
expect(unsafeCutoff.json()).toEqual({ error: "throughOrder field must be a non-negative safe integer" });
expect(routeService.notificationInboxCalls).toEqual([]);
expect(routeService.dismissAllNotificationCalls).toEqual([]);
} finally {
await routeService.dispose();
await routeApp.close();
}
});
it("rejects prompt payloads that omit text without opening a session", async () => { it("rejects prompt payloads that omit text without opening a session", async () => {
const response = await app.inject({ method: "POST", url: "/sessions/session-1/prompt", payload: { body: "Build the thing" } }); const response = await app.inject({ method: "POST", url: "/sessions/session-1/prompt", payload: { body: "Build the thing" } });
@@ -385,6 +523,9 @@ class CapturingRouteSessionService implements SessionRouteService {
readonly reloadCalls: SessionRouteLookup[] = []; readonly reloadCalls: SessionRouteLookup[] = [];
readonly clearQueueCalls: SessionRouteLookup[] = []; readonly clearQueueCalls: SessionRouteLookup[] = [];
readonly dismissWarningCalls: { lookup: SessionRouteLookup; dismissId: string }[] = []; readonly dismissWarningCalls: { lookup: SessionRouteLookup; dismissId: string }[] = [];
readonly notificationInboxCalls: SessionRef[] = [];
readonly dismissNotificationCalls: { ref: SessionRef; request: Omit<SessionNotificationDismissRequest, "cwd"> }[] = [];
readonly dismissAllNotificationCalls: { ref: SessionRef; request: Omit<SessionNotificationDismissAllRequest, "cwd"> }[] = [];
dismissWarningError: Error | undefined; dismissWarningError: Error | undefined;
messagesResponse: unknown[] | MessagePage = []; messagesResponse: unknown[] | MessagePage = [];
streamSnapshotResponse: SessionStreamSnapshot = { seq: 0, partial: null }; streamSnapshotResponse: SessionStreamSnapshot = { seq: 0, partial: null };
@@ -426,6 +567,25 @@ class CapturingRouteSessionService implements SessionRouteService {
return Promise.resolve(); return Promise.resolve();
} }
notificationCatalog() {
return { daemonInstanceId: "daemon-test", catalogRevision: 0, sessions: [] };
}
notificationInbox(ref: SessionRef): SessionNotificationInboxSnapshot {
this.notificationInboxCalls.push(ref);
return notificationSnapshot(ref);
}
dismissNotification(ref: SessionRef, request: Omit<SessionNotificationDismissRequest, "cwd">): SessionNotificationInboxSnapshot {
this.dismissNotificationCalls.push({ ref, request });
return notificationSnapshot(ref);
}
dismissAllNotifications(ref: SessionRef, request: Omit<SessionNotificationDismissAllRequest, "cwd">): SessionNotificationInboxSnapshot {
this.dismissAllNotificationCalls.push({ ref, request });
return notificationSnapshot(ref);
}
list(): never { throw unusedRouteMethod("list"); } list(): never { throw unusedRouteMethod("list"); }
start(): never { throw unusedRouteMethod("start"); } start(): never { throw unusedRouteMethod("start"); }
@@ -542,6 +702,16 @@ class RejectingSessionManager implements PiSessionManagerGateway {
} }
} }
function notificationSnapshot(ref: SessionRef): SessionNotificationInboxSnapshot {
return {
daemonInstanceId: "daemon-test",
catalogRevision: 0,
summary: { sessionId: ref.id, cwd: ref.cwd, inboxRevision: 0, retainedCount: 0, discardedCount: 0 },
notifications: [],
dismissThrough: { order: 0, overflowWatermark: 0 },
};
}
function sessionIdFromLookup(lookup: SessionRouteLookup): string { function sessionIdFromLookup(lookup: SessionRouteLookup): string {
return typeof lookup === "string" ? lookup : lookup.id; return typeof lookup === "string" ? lookup : lookup.id;
} }
+83
View File
@@ -30,6 +30,11 @@ interface AttachmentsRequestBody {
folder?: unknown; folder?: unknown;
} }
const MAX_NOTIFICATION_SESSION_ID_LENGTH = 512;
const MAX_NOTIFICATION_CWD_LENGTH = 32 * 1024;
const MAX_NOTIFICATION_DAEMON_ID_LENGTH = 512;
const MAX_NOTIFICATION_ID_LENGTH = 1024;
export function registerSessionRoutes(app: FastifyInstance, sessions: SessionRouteService, eventHub: SessionEventHub, prefix = ""): void { export function registerSessionRoutes(app: FastifyInstance, sessions: SessionRouteService, eventHub: SessionEventHub, prefix = ""): void {
app.get<{ Querystring: SessionQuery }>(`${prefix}/sessions`, async (request, reply) => { app.get<{ Querystring: SessionQuery }>(`${prefix}/sessions`, async (request, reply) => {
if (request.query.cwd === undefined || request.query.cwd === "") return reply.code(400).send({ error: "cwd query parameter is required" }); if (request.query.cwd === undefined || request.query.cwd === "") return reply.code(400).send({ error: "cwd query parameter is required" });
@@ -49,6 +54,14 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: SessionRou
} }
}); });
app.get(`${prefix}/sessions/notifications`, async (_request, reply) => {
try {
return await sessions.notificationCatalog();
} catch (error) {
return reply.code(400).send({ error: errorMessage(error) });
}
});
app.post<{ Body: SessionCleanupRequest | undefined }>(`${prefix}/sessions/cleanup/preview`, async (request, reply) => { app.post<{ Body: SessionCleanupRequest | undefined }>(`${prefix}/sessions/cleanup/preview`, async (request, reply) => {
try { try {
return await sessions.cleanupPreview(normalizeSessionCleanupRequest(optionalRecord(request.body))); return await sessions.cleanupPreview(normalizeSessionCleanupRequest(optionalRecord(request.body)));
@@ -81,6 +94,41 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: SessionRou
} }
}); });
app.get<{ Params: { sessionId: string }; Querystring: SessionQuery }>(`${prefix}/sessions/:sessionId/notifications`, async (request, reply) => {
try {
return await sessions.notificationInbox(notificationRefFromQuery(request.params.sessionId, request.query));
} catch (error) {
return reply.code(notificationErrorStatus(error)).send({ error: errorMessage(error) });
}
});
app.post<{ Params: { sessionId: string }; Body: Record<string, unknown> | undefined }>(`${prefix}/sessions/:sessionId/notifications/dismiss`, async (request, reply) => {
try {
const body = requireRecord(request.body);
const ref = notificationRefFromBody(request.params.sessionId, body);
return await sessions.dismissNotification(ref, {
daemonInstanceId: requireNonEmptyBoundedString(body["daemonInstanceId"], "daemonInstanceId", MAX_NOTIFICATION_DAEMON_ID_LENGTH),
notificationId: requireNonEmptyBoundedString(body["notificationId"], "notificationId", MAX_NOTIFICATION_ID_LENGTH),
});
} catch (error) {
return reply.code(notificationErrorStatus(error)).send({ error: errorMessage(error) });
}
});
app.post<{ Params: { sessionId: string }; Body: Record<string, unknown> | undefined }>(`${prefix}/sessions/:sessionId/notifications/dismiss-all`, async (request, reply) => {
try {
const body = requireRecord(request.body);
const ref = notificationRefFromBody(request.params.sessionId, body);
return await sessions.dismissAllNotifications(ref, {
daemonInstanceId: requireNonEmptyBoundedString(body["daemonInstanceId"], "daemonInstanceId", MAX_NOTIFICATION_DAEMON_ID_LENGTH),
throughOrder: requireNonNegativeSafeInteger(body["throughOrder"], "throughOrder"),
throughOverflowWatermark: requireNonNegativeSafeInteger(body["throughOverflowWatermark"], "throughOverflowWatermark"),
});
} catch (error) {
return reply.code(notificationErrorStatus(error)).send({ error: errorMessage(error) });
}
});
app.get<{ Params: { sessionId: string }; Querystring: MessageQuery }>(`${prefix}/sessions/:sessionId/messages`, async (request, reply) => { app.get<{ Params: { sessionId: string }; Querystring: MessageQuery }>(`${prefix}/sessions/:sessionId/messages`, async (request, reply) => {
try { try {
const page = { ...optionalField("before", optionalNumber(request.query.before)), ...optionalField("limit", optionalNumber(request.query.limit)) }; const page = { ...optionalField("before", optionalNumber(request.query.before)), ...optionalField("limit", optionalNumber(request.query.limit)) };
@@ -341,6 +389,23 @@ function parseBulkMutationRef(value: unknown): SessionBulkMutationRef {
return { id, cwd: normalizeRequestCwd(cwd) }; return { id, cwd: normalizeRequestCwd(cwd) };
} }
function notificationRefFromQuery(id: string, query: SessionQuery): { id: string; cwd: string } {
const cwd = requireNonEmptyBoundedString(query.cwd, "cwd", MAX_NOTIFICATION_CWD_LENGTH);
return notificationRef(id, cwd);
}
function notificationRefFromBody(id: string, body: Record<string, unknown>): { id: string; cwd: string } {
const cwd = requireNonEmptyBoundedString(body["cwd"], "cwd", MAX_NOTIFICATION_CWD_LENGTH);
return notificationRef(id, cwd);
}
function notificationRef(id: string, cwd: string): { id: string; cwd: string } {
return {
id: requireNonEmptyBoundedString(id, "sessionId", MAX_NOTIFICATION_SESSION_ID_LENGTH),
cwd: normalizeRequestCwd(cwd),
};
}
function sessionLookupFromQuery(id: string, query: SessionQuery): SessionLookup { function sessionLookupFromQuery(id: string, query: SessionQuery): SessionLookup {
return sessionLookupFromCwd(id, query.cwd); return sessionLookupFromCwd(id, query.cwd);
} }
@@ -374,6 +439,20 @@ function requireString(record: Record<string, unknown>, field: string): string {
return value; return value;
} }
function requireNonEmptyBoundedString(value: unknown, field: string, maxLength: number): string {
if (typeof value !== "string") throw new Error(`${field} field must be a string`);
if (value === "") throw new Error(`${field} field must not be empty`);
if (value.length > maxLength) throw new Error(`${field} field is too long`);
return value;
}
function requireNonNegativeSafeInteger(value: unknown, field: string): number {
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
throw new Error(`${field} field must be a non-negative safe integer`);
}
return value;
}
function requireThinkingLevel(value: unknown): string { function requireThinkingLevel(value: unknown): string {
if (typeof value !== "string" || value === "") throw new Error("level field is invalid"); if (typeof value !== "string" || value === "") throw new Error("level field is invalid");
return value; return value;
@@ -397,6 +476,10 @@ function mutationErrorStatus(error: unknown): 400 | 404 {
return isSessionNotFoundError(error) ? 404 : 400; return isSessionNotFoundError(error) ? 404 : 400;
} }
function notificationErrorStatus(error: unknown): 400 | 404 {
return isSessionNotFoundError(error) ? 404 : 400;
}
function isSessionNotFoundError(error: unknown): boolean { function isSessionNotFoundError(error: unknown): boolean {
const message = errorMessage(error); const message = errorMessage(error);
return message === "Session not found" || message === "Archived session not found"; return message === "Session not found" || message === "Archived session not found";
+8
View File
@@ -3,6 +3,10 @@ import type {
SessionBulkArchiveResponse, SessionBulkArchiveResponse,
SessionBulkDeleteArchivedResponse, SessionBulkDeleteArchivedResponse,
SessionBulkMutationRef, SessionBulkMutationRef,
SessionNotificationCatalogSnapshot,
SessionNotificationDismissAllRequest,
SessionNotificationDismissRequest,
SessionNotificationInboxSnapshot,
} from "../../shared/apiTypes.js"; } from "../../shared/apiTypes.js";
import type { import type {
ClientArchiveSessionsResponse, ClientArchiveSessionsResponse,
@@ -36,6 +40,10 @@ export interface SessionRouteService {
messages(ref: SessionRouteLookup, page?: { before?: number; limit?: number }): Promise<unknown[] | ClientMessagePage>; messages(ref: SessionRouteLookup, page?: { before?: number; limit?: number }): Promise<unknown[] | ClientMessagePage>;
status(ref: SessionRouteLookup): Promise<ClientSessionStatus>; status(ref: SessionRouteLookup): Promise<ClientSessionStatus>;
streamSnapshot(ref: SessionRouteLookup): Promise<SessionStreamSnapshot>; streamSnapshot(ref: SessionRouteLookup): Promise<SessionStreamSnapshot>;
notificationCatalog(): SessionNotificationCatalogSnapshot | Promise<SessionNotificationCatalogSnapshot>;
notificationInbox(ref: SessionRouteRef): SessionNotificationInboxSnapshot | Promise<SessionNotificationInboxSnapshot>;
dismissNotification(ref: SessionRouteRef, request: Omit<SessionNotificationDismissRequest, "cwd">): SessionNotificationInboxSnapshot | Promise<SessionNotificationInboxSnapshot>;
dismissAllNotifications(ref: SessionRouteRef, request: Omit<SessionNotificationDismissAllRequest, "cwd">): SessionNotificationInboxSnapshot | Promise<SessionNotificationInboxSnapshot>;
clearQueue(ref: SessionRouteLookup): Promise<ClientSessionStatus>; clearQueue(ref: SessionRouteLookup): Promise<ClientSessionStatus>;
dismissWarning(ref: SessionRouteLookup, dismissId: string): Promise<ClientSessionStatus>; dismissWarning(ref: SessionRouteLookup, dismissId: string): Promise<ClientSessionStatus>;
availableModels(ref: SessionRouteLookup): Promise<ClientSessionModel[]>; availableModels(ref: SessionRouteLookup): Promise<ClientSessionModel[]>;
+93 -2
View File
@@ -8,6 +8,7 @@ export const PI_WEB_CAPABILITIES = {
sessionsReload: "sessions.reload", sessionsReload: "sessions.reload",
sessionsClearQueue: "sessions.clearQueue", sessionsClearQueue: "sessions.clearQueue",
sessionsPersistedState: "sessions.persistedState", sessionsPersistedState: "sessions.persistedState",
sessionsNotifications: "sessions.notifications",
promptAttachments: "prompt.attachments", promptAttachments: "prompt.attachments",
workspaceFileSuggestions: "workspace.fileSuggestions", workspaceFileSuggestions: "workspace.fileSuggestions",
piPackagesManage: "piPackages.manage", piPackagesManage: "piPackages.manage",
@@ -203,6 +204,93 @@ export interface SessionRef {
cwd: string; cwd: string;
} }
export const SESSION_NOTIFICATION_LIMIT = 100;
export const SESSION_NOTIFICATION_MESSAGE_BYTES = 8 * 1024;
export type SessionNotificationSeverity = "info" | "warning" | "error";
export interface SessionNotification {
id: string;
message: string;
truncated: boolean;
severity: SessionNotificationSeverity;
receivedAt: string;
order: number;
}
export interface SessionNotificationSummary {
sessionId: string;
cwd: string;
inboxRevision: number;
retainedCount: number;
discardedCount: number;
highestSeverity?: SessionNotificationSeverity;
}
export interface SessionNotificationDismissThrough {
order: number;
overflowWatermark: number;
}
export interface SessionNotificationInboxSnapshot {
daemonInstanceId: string;
catalogRevision: number;
summary: SessionNotificationSummary;
notifications: SessionNotification[];
dismissThrough: SessionNotificationDismissThrough;
}
export interface SessionNotificationCatalogSnapshot {
daemonInstanceId: string;
catalogRevision: number;
sessions: SessionNotificationSummary[];
}
export interface SessionNotificationDismissRequest {
cwd: string;
daemonInstanceId: string;
notificationId: string;
}
export interface SessionNotificationDismissAllRequest {
cwd: string;
daemonInstanceId: string;
throughOrder: number;
throughOverflowWatermark: number;
}
export type SessionNotificationClearReason =
| "runtime-close"
| "archive"
| "delete"
| "restore"
| "archive-reconcile"
| "replacement"
| "initialization-failed"
| "service-dispose";
export type SessionNotificationInboxDelta =
| { kind: "added"; notification: SessionNotification; evictedNotificationId?: string }
| { kind: "dismissed"; notificationIds: string[] }
| { kind: "cleared"; reason: SessionNotificationClearReason }
| { kind: "resync" };
export interface SessionNotificationInboxEvent {
type: "notifications.inbox";
daemonInstanceId: string;
catalogRevision: number;
summary: SessionNotificationSummary;
dismissThrough: SessionNotificationDismissThrough;
delta: SessionNotificationInboxDelta;
}
export interface SessionNotificationSummaryEvent {
type: "notifications.summary";
daemonInstanceId: string;
catalogRevision: number;
summary: SessionNotificationSummary;
}
export interface SessionInfo extends SessionRef { export interface SessionInfo extends SessionRef {
path: string; path: string;
/** True when the server has verified a backing session file exists; false when known transient. */ /** True when the server has verified a backing session file exists; false when known transient. */
@@ -777,11 +865,14 @@ type SessionUiEventBody =
| { type: "message.end"; message?: unknown } | { type: "message.end"; message?: unknown }
| { type: "status.update"; status: SessionStatus } | { type: "status.update"; status: SessionStatus }
| { type: "activity.update"; activity: SessionActivity } | { type: "activity.update"; activity: SessionActivity }
| { type: "command.output"; level: "info" | "success" | "error"; message: string } | { type: "command.output"; level: "info" | "success" | "error"; message: string; notificationId?: string }
| SessionNotificationInboxEvent
| { type: "session.error"; message: string } | { type: "session.error"; message: string }
| { type: "session.name"; sessionId: string; name?: string } | { type: "session.name"; sessionId: string; name?: string }
| { type: "session.created"; session: SessionInfo } | { type: "session.created"; session: SessionInfo }
| { type: "pi.event"; eventType: string }; | { type: "pi.event"; eventType: string };
export type GlobalSessionEvent = Extract<SessionUiEventBody, { type: "status.update" | "activity.update" | "session.name" | "session.created" }>; export type GlobalSessionEvent =
| Extract<SessionUiEventBody, { type: "status.update" | "activity.update" | "session.name" | "session.created" }>
| SessionNotificationSummaryEvent;
export type RealtimeEvent = GlobalSessionEvent | TerminalUiEvent | WorkspaceActivityUiEvent; export type RealtimeEvent = GlobalSessionEvent | TerminalUiEvent | WorkspaceActivityUiEvent;
+20
View File
@@ -50,6 +50,26 @@ describe("PI WEB capabilities", () => {
})).toContain(clearQueue); })).toContain(clearQueue);
}); });
it("requires both web and session daemon support for notification inboxes", () => {
const notifications = PI_WEB_CAPABILITIES.sessionsNotifications;
expect(WEB_RUNTIME_CAPABILITIES).toContain(notifications);
expect(SESSIOND_RUNTIME_CAPABILITIES).toContain(notifications);
expect(parseKnownPiWebCapabilities([notifications, "future.capability"])).toEqual([notifications]);
expect(effectivePiWebCapabilities({
web: { available: true, capabilities: [notifications] },
sessiond: { available: true, capabilities: [] },
})).not.toContain(notifications);
expect(effectivePiWebCapabilities({
web: { available: true, capabilities: [] },
sessiond: { available: true, capabilities: [notifications] },
})).not.toContain(notifications);
expect(effectivePiWebCapabilities({
web: { available: true, capabilities: [notifications] },
sessiond: { available: true, capabilities: [notifications] },
})).toContain(notifications);
});
it("keeps only known string capabilities when parsing runtime data", () => { it("keeps only known string capabilities when parsing runtime data", () => {
expect(parseKnownPiWebCapabilities([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, "future.capability"])).toEqual([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings]); expect(parseKnownPiWebCapabilities([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, "future.capability"])).toEqual([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings]);
expect(parseKnownPiWebCapabilities([PI_WEB_CAPABILITIES.piPackagesManage, 1])).toBeUndefined(); expect(parseKnownPiWebCapabilities([PI_WEB_CAPABILITIES.piPackagesManage, 1])).toBeUndefined();
+3
View File
@@ -13,6 +13,7 @@ export const WEB_RUNTIME_CAPABILITIES = [
PI_WEB_CAPABILITIES.sessionsReload, PI_WEB_CAPABILITIES.sessionsReload,
PI_WEB_CAPABILITIES.sessionsClearQueue, PI_WEB_CAPABILITIES.sessionsClearQueue,
PI_WEB_CAPABILITIES.sessionsPersistedState, PI_WEB_CAPABILITIES.sessionsPersistedState,
PI_WEB_CAPABILITIES.sessionsNotifications,
PI_WEB_CAPABILITIES.promptAttachments, PI_WEB_CAPABILITIES.promptAttachments,
PI_WEB_CAPABILITIES.workspaceFileSuggestions, PI_WEB_CAPABILITIES.workspaceFileSuggestions,
PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.piPackagesManage,
@@ -27,6 +28,7 @@ export const SESSIOND_RUNTIME_CAPABILITIES = [
PI_WEB_CAPABILITIES.sessionsReload, PI_WEB_CAPABILITIES.sessionsReload,
PI_WEB_CAPABILITIES.sessionsClearQueue, PI_WEB_CAPABILITIES.sessionsClearQueue,
PI_WEB_CAPABILITIES.sessionsPersistedState, PI_WEB_CAPABILITIES.sessionsPersistedState,
PI_WEB_CAPABILITIES.sessionsNotifications,
PI_WEB_CAPABILITIES.promptAttachments, PI_WEB_CAPABILITIES.promptAttachments,
] as const satisfies readonly PiWebCapability[]; ] as const satisfies readonly PiWebCapability[];
@@ -37,6 +39,7 @@ const EFFECTIVE_CAPABILITY_REQUIREMENTS = {
[PI_WEB_CAPABILITIES.sessionsReload]: ["web", "sessiond"], [PI_WEB_CAPABILITIES.sessionsReload]: ["web", "sessiond"],
[PI_WEB_CAPABILITIES.sessionsClearQueue]: ["web", "sessiond"], [PI_WEB_CAPABILITIES.sessionsClearQueue]: ["web", "sessiond"],
[PI_WEB_CAPABILITIES.sessionsPersistedState]: ["web", "sessiond"], [PI_WEB_CAPABILITIES.sessionsPersistedState]: ["web", "sessiond"],
[PI_WEB_CAPABILITIES.sessionsNotifications]: ["web", "sessiond"],
[PI_WEB_CAPABILITIES.promptAttachments]: ["web", "sessiond"], [PI_WEB_CAPABILITIES.promptAttachments]: ["web", "sessiond"],
[PI_WEB_CAPABILITIES.workspaceFileSuggestions]: ["web"], [PI_WEB_CAPABILITIES.workspaceFileSuggestions]: ["web"],
[PI_WEB_CAPABILITIES.piPackagesManage]: ["web"], [PI_WEB_CAPABILITIES.piPackagesManage]: ["web"],
+4
View File
@@ -45,11 +45,15 @@ export const FEDERATED_HTTP_ROUTES = [
{ method: "GET", path: "/activity" }, { method: "GET", path: "/activity" },
{ method: "GET", path: "/sessions" }, { method: "GET", path: "/sessions" },
{ method: "POST", path: "/sessions" }, { method: "POST", path: "/sessions" },
{ method: "GET", path: "/sessions/notifications" },
{ method: "POST", path: "/sessions/cleanup/preview" }, { method: "POST", path: "/sessions/cleanup/preview" },
{ method: "POST", path: "/sessions/cleanup" }, { method: "POST", path: "/sessions/cleanup" },
{ method: "POST", path: "/sessions/bulk/archive" }, { method: "POST", path: "/sessions/bulk/archive" },
{ method: "POST", path: "/sessions/bulk/delete-archived" }, { method: "POST", path: "/sessions/bulk/delete-archived" },
{ method: "GET", path: "/sessions/:sessionId/messages" }, { method: "GET", path: "/sessions/:sessionId/messages" },
{ method: "GET", path: "/sessions/:sessionId/notifications" },
{ method: "POST", path: "/sessions/:sessionId/notifications/dismiss" },
{ method: "POST", path: "/sessions/:sessionId/notifications/dismiss-all" },
{ method: "GET", path: "/sessions/:sessionId/status" }, { method: "GET", path: "/sessions/:sessionId/status" },
{ method: "GET", path: "/sessions/:sessionId/stream-snapshot" }, { method: "GET", path: "/sessions/:sessionId/stream-snapshot" },
{ method: "GET", path: "/sessions/:sessionId/models" }, { method: "GET", path: "/sessions/:sessionId/models" },