feat(ui): add session notification tray and badges

This commit is contained in:
Federico Jaramillo Martinez
2026-07-19 01:28:45 +02:00
parent 6e09df8329
commit ada6f0ce1f
26 changed files with 2684 additions and 49 deletions
+27
View File
@@ -303,6 +303,33 @@ describe("session API compatibility", () => {
expect(fetchMock).toHaveBeenCalledOnce();
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", () => {
+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 { request } from "./http";
import {
@@ -40,6 +40,8 @@ import {
parseSessionCleanupExecuteResponse,
parseSessionCleanupPreviewResponse,
parseSessionInfo,
parseSessionNotificationCatalogSnapshot,
parseSessionNotificationInboxSnapshot,
parseSessionStatus,
parseSessionStreamSnapshot,
parseSlashCommand,
@@ -203,6 +205,10 @@ export const workspacesApi = {
export const sessionsApi = {
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 }) }),
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) }),
+85 -1
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
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", () => {
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: "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 { parseActiveAgentProfileDescriptor } from "../../../shared/activeAgentProfile";
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 {
const record = requireRecord(value);
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 { ChatLine } from "./components/shared";
import type { QualifiedContributionId } from "./plugins/ids";
import type { SelectedSessionNotificationInbox, SessionNotificationCatalogProjection } from "./sessionNotifications";
import type { WorkspaceUploadBatchState } from "./workspaceUploadState";
export interface AppState {
@@ -36,6 +37,10 @@ export interface AppState {
sessionActivities: Record<string, SessionActivity>;
workspaceActivities: 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[]>;
workspaceDeletionRuns: Record<string, TerminalCommandRun>;
commandDialog: Extract<CommandResult, { type: "select" }> | undefined;
@@ -77,6 +82,7 @@ export type WorkspaceScopedStateReset = Pick<AppState,
| "sessions"
| "clientQueuedSessionMessages"
| "startingSessionCount"
| "selectedNotificationInbox"
| "fileTree"
| "expandedDirs"
| "selectedFilePath"
@@ -96,6 +102,7 @@ export function resetWorkspaceScopedState(): WorkspaceScopedStateReset {
sessions: [],
clientQueuedSessionMessages: {},
startingSessionCount: 0,
selectedNotificationInbox: undefined,
fileTree: [],
expandedDirs: {},
selectedFilePath: undefined,
@@ -141,6 +148,8 @@ export function initialAppState(): AppState {
sessionActivities: {},
workspaceActivities: {},
machineActivities: {},
notificationCatalogsByMachine: {},
selectedNotificationInbox: undefined,
workspacesByProjectId: {},
workspaceDeletionRuns: {},
commandDialog: undefined,
@@ -1,6 +1,7 @@
import type { TemplateResult } from "lit";
import { describe, expect, it, vi } from "vitest";
import type { QueuedSessionMessage, SessionStatus, SessionWarning } from "../api";
import type { SelectedSessionNotificationView } from "../sessionNotifications";
import type { ChatLine } from "./shared";
import {
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", () => {
it("uses one full date and model label without a model prefix", () => {
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 RenderMessageGroupBody = (this: ChatView, messages: ChatLine[], startIndex: number) => TemplateResult;
type RenderWarnings = (this: ChatView) => TemplateResult | null;
type RenderNotificationTray = (this: ChatView) => TemplateResult | null;
type TemplateEventHandler = (event: Event) => void;
function renderQueuedMessages(view: ChatView): TemplateResult {
@@ -252,6 +288,12 @@ function renderWarnings(view: ChatView): TemplateResult | null {
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[] {
const method: unknown = Reflect.get(view, "renderMessageGroupBody");
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";
}
function isRenderNotificationTray(value: unknown): value is RenderNotificationTray {
return typeof value === "function";
}
function dispatchDetailsToggle(handler: TemplateEventHandler, open: boolean): void {
const hadDetailsElement = Reflect.has(globalThis, "HTMLDetailsElement");
const previousDetailsElement = Reflect.get(globalThis, "HTMLDetailsElement");
@@ -309,6 +355,33 @@ function withStatus(view: ChatView, status: SessionStatus): ChatView {
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 {
return {
...queuedStatus([]),
+131 -1
View File
@@ -8,6 +8,16 @@ import { capturePrependScrollAnchor, PREPEND_RESTORE_SETTLE_FRAMES, restorePrepe
import { shouldRequestEarlierMessages } from "../chatHistoryLoading";
import { ChatScrollController, distanceFromScrollBottom, findFirstVisibleArticle, isNearScrollBottom, type ChatAnchorScrollPosition, type ChatScrollRestoreResult } from "../chatScrollPosition";
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 { chatStyles } from "./shared";
import "./ConversationMeter";
@@ -15,6 +25,7 @@ import "./FormattedText";
import "./ToolExecutionView";
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 {
if (severity === "error") return "⛔";
@@ -151,9 +162,12 @@ export class ChatView extends LitElement {
@property({ attribute: false }) clientQueuedMessages: QueuedSessionMessage[] = [];
@property({ attribute: false }) status?: SessionStatus;
@property({ attribute: false }) activity?: SessionActivity;
@property({ attribute: false }) notificationInbox?: SelectedSessionNotificationView;
@property({ type: Boolean }) canClearServerQueue = false;
@property({ attribute: false }) onClearServerQueue?: () => 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;
@query(".chat") private chat?: HTMLDivElement;
@query("dialog.image-zoom") private imageZoomDialog?: HTMLDialogElement;
@@ -162,6 +176,9 @@ export class ChatView extends LitElement {
@state() private expandedMetaKey: string | undefined;
@state() private copiedMessageKey: string | 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 scrollController = new ChatScrollController();
private suppressScrollSave = false;
@@ -237,6 +254,8 @@ export class ChatView extends LitElement {
private prepareSessionUiState(): void {
this.disclosures.syncSession(this.sessionId);
this.pendingNotificationFocus = undefined;
this.retainedEmptyNotificationTraySessionId = undefined;
this.scrollController.clearScheduledSave();
this.suppressScrollSave = 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("hasMore") || changed.has("loadingMore")) this.continuePendingScrollRestore();
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();
}
@@ -284,7 +304,8 @@ export class ChatView extends LitElement {
override render() {
const groups = this.groupedMessages();
return html`
${this.renderWarnings()}
${this.renderTopNotices()}
${this.renderNotificationLiveRegions()}
<div class="chat-wrap">
${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); }}>
@@ -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">${polite.map((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">${assertive.map((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() {
const rows = chatSessionWarningRows(this.status);
if (rows.length === 0) return null;
+7 -3
View File
@@ -1,12 +1,14 @@
import { LitElement, css, html, type PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import type { Machine, MachineHealth, WorkspaceActivity } from "../api";
import type { SessionNotificationBadgeModel } from "../sessionNotifications";
import { machineActivityIndicator } from "../workspaceActivity";
import { actionMenuPanelStyle } from "./actionMenu";
import { renderActionActivityIndicator } from "./activityBadge";
import type { KeyboardNavigableSection } from "./navigationFocus";
import { activateSelectableRow, focusSelectedOrFirstSelectableRow, handleSelectableRowKeyboard } from "./selectableRow";
import { listStyles } from "./shared";
import "./NotificationBadge";
@customElement("machine-list")
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 }) statuses: Record<string, MachineHealth> = {};
@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 }) collapsed = false;
@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); }}
>
<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)}
</div>
${hasRemoveAction ? this.renderMachineMenu(machine) : null}
@@ -113,10 +117,10 @@ export class MachineList extends LitElement implements KeyboardNavigableSection
}
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 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 {
+11 -2
View File
@@ -1,11 +1,13 @@
import { LitElement, css, html, type PropertyValues, type TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import type { Machine, MachineHealth, MachineStatus, WorkspaceActivity } from "../api";
import type { SessionNotificationBadgeModel } from "../sessionNotifications";
import { machineActivityIndicator } from "../workspaceActivity";
import { actionMenuPanelStyle } from "./actionMenu";
import { renderActivityIndicator } from "./activityBadge";
import { canRemoveMachine } from "./MachineList";
import type { KeyboardNavigableSection } from "./navigationFocus";
import "./NotificationBadge";
@customElement("machine-switcher")
export class MachineSwitcher extends LitElement implements KeyboardNavigableSection {
@@ -13,6 +15,7 @@ export class MachineSwitcher extends LitElement implements KeyboardNavigableSect
@property({ attribute: false }) selected?: Machine;
@property({ attribute: false }) statuses: Record<string, MachineHealth> = {};
@property({ attribute: false }) activities: Record<string, Record<string, WorkspaceActivity>> = {};
@property({ attribute: false }) notificationBadges: Record<string, SessionNotificationBadgeModel | undefined> = {};
@property({ attribute: false }) onSelect?: (machine: Machine) => void | Promise<void>;
@property({ attribute: false }) onRemove?: (machine: Machine) => void | Promise<void>;
@property({ attribute: false }) onFocusNextSection?: () => void | Promise<void>;
@@ -60,7 +63,7 @@ export class MachineSwitcher extends LitElement implements KeyboardNavigableSect
type="button"
class="machine-switcher-button"
title=${machineTitle(selected)}
aria-label=${`Machine: ${label}. Switch machine.`}
aria-label=${this.machineSwitcherAriaLabel(selected)}
aria-expanded=${String(this.open)}
@click=${(event: MouseEvent) => { this.toggleMenu(event.currentTarget); }}
@keydown=${(event: KeyboardEvent) => { this.handleSwitcherButtonKeydown(event); }}
@@ -71,6 +74,7 @@ export class MachineSwitcher extends LitElement implements KeyboardNavigableSect
<span class="machine-switcher-label">${label}</span>
</span>
<span class=${`machine-status ${status}`}>${machineStatusLabel(status)}</span>
${this.notificationBadges[selected.id] === undefined ? null : html`<notification-badge .model=${this.notificationBadges[selected.id]}></notification-badge>`}
<span class="machine-chevron" aria-hidden="true">▾</span>
</button>
${this.open ? html`
@@ -97,7 +101,7 @@ export class MachineSwitcher extends LitElement implements KeyboardNavigableSect
@click=${() => { this.select(machine); }}
@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>
</button>
${hasActions ? html`
@@ -132,6 +136,11 @@ export class MachineSwitcher extends LitElement implements KeyboardNavigableSect
return this.selected ?? this.machines.find((machine) => machine.id === "local") ?? this.machines[0];
}
private machineSwitcherAriaLabel(machine: Machine): string {
const notificationLabel = this.notificationBadges[machine.id]?.accessibleLabel;
return `Machine: ${machine.name}.${notificationLabel === undefined ? "" : ` ${notificationLabel}.`} Switch machine.`;
}
private switcherButton(): HTMLElement | null {
return this.renderRoot.querySelector<HTMLElement>(".machine-switcher-button");
}
@@ -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,137 @@
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 { 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.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
@@ -13,6 +13,7 @@ import { MachineController } from "../controllers/machineController";
import { ProjectController } from "../controllers/projectController";
import { PiWebStatusController } from "../controllers/piWebStatusController";
import { SessionController } from "../controllers/sessionController";
import { SessionNotificationController } from "../controllers/sessionNotificationController";
import { WorkspaceController, canDeleteWorkspace } from "../controllers/workspaceController";
import { emptyMachineNavigationSnapshot, machineNavigationSnapshotFromState, routeFromMachineNavigationSnapshot, SessionStorageMachineNavigationMemory, type MachineNavigationSnapshot, type WorkspaceRouteSurface } from "../controllers/machineNavigationMemory";
import { SessionStorageSessionSelectionMemory } from "../controllers/sessionSelection";
@@ -21,6 +22,16 @@ import { SessionStorageWorkspaceSelectionMemory } from "../controllers/workspace
import { KeyboardShortcutDispatcher } from "../keyboardShortcuts";
import { selectedMachineId } from "../controllers/types";
import { sessionCleanupRequestKey, sessionCleanupUnavailableMessage } from "../sessionCleanupUi";
import {
aggregateNotificationSummaries,
effectiveNotificationSummaries,
notificationAggregateAcrossMachines,
notificationAggregateForCwd,
notificationAggregateForProject,
notificationBadgeModel,
selectedNotificationView,
type SessionNotificationBadgeModel,
} from "../sessionNotifications";
import { hasAuthoritativeSessionPersistence as runtimeHasAuthoritativeSessionPersistence } from "../sessionPersistence";
import { RealtimeSocket } from "../sessionSocket";
import type { PiWebPluginRegistration, PluginMachine, PluginPromptEditor, QualifiedContributionId, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspacePanelContribution, PluginRuntimeContext, TerminalCommandRunsInternalRuntime, WorkspaceFiles, WorkspaceHost, WorkspaceLabelContext, WorkspaceLabelItem, WorkspacePanelContext } from "../plugins/types";
@@ -62,7 +73,7 @@ import type { WorkspacePanelEmptyState } from "./WorkspacePanel";
import "./appShell/AppContextBar";
import "./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/AppRefreshControl";
import { appStyles } from "./shared";
@@ -100,11 +111,17 @@ export class PiWebApp extends LitElement {
@query("#navigation-panel") private navigationPanelFrame?: 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(
() => this.state,
(patch) => { this.setState(patch); },
() => { this.updateUrl(); },
new SessionStorageSessionSelectionMemory(),
{ notifications: this.notifications },
);
private readonly activity = new ActivityController(
() => this.state,
@@ -150,7 +167,7 @@ export class PiWebApp extends LitElement {
);
private readonly keyboard = new KeyboardShortcutDispatcher();
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 machineNavigation = new SessionStorageMachineNavigationMemory();
private readonly terminalSelection = new SessionStorageTerminalSelectionMemory();
@@ -247,6 +264,7 @@ export class PiWebApp extends LitElement {
this.keyboard.reset();
this.auth.dispose();
this.sessions.dispose();
this.notifications.dispose();
this.realtime.close();
this.closeMachineActivitySockets();
this.git.dispose();
@@ -267,6 +285,7 @@ export class PiWebApp extends LitElement {
this.handleWorkspaceChange(previous, this.state);
this.handleMachineChange(previous, this.state);
if (machineActivitySubscriptionInputsChanged(previous, this.state)) this.syncMachineActivitySubscriptions();
this.notifications.syncEnvironment(previous, this.state);
}
private async loadProjectsAndRestoreRoute() {
@@ -295,6 +314,7 @@ export class PiWebApp extends LitElement {
private async refreshAfterBrowserResume(): Promise<void> {
await Promise.all([
this.sessions.refreshSelectedSession(),
this.notifications.refreshAfterBrowserResume(),
this.refreshMachineActivities(),
this.refreshWorkspaceDeletionRuns(),
]);
@@ -350,6 +370,7 @@ export class PiWebApp extends LitElement {
try {
await Promise.all([
this.sessions.refreshSelectedSession(),
this.notifications.refreshAfterBrowserResume(),
this.refreshMachineActivities(),
this.loadClientConfig(),
this.refreshWorkspaceDeletionRuns(),
@@ -782,39 +803,44 @@ export class PiWebApp extends LitElement {
}
private connectRealtime(): void {
const machineId = selectedMachineId(this.state);
this.realtime.connect(
(event) => { this.handleRealtimeEvent(event); },
(event) => { this.handleRealtimeEvent(machineId, event); },
() => {
this.notifications.globalSocketOpened(machineId);
const workspace = this.state.selectedWorkspace;
if (workspace !== undefined) void this.refreshActiveTerminals(workspace);
void this.refreshWorkspaceActivity();
void this.refreshWorkspaceActivity(machineId);
},
selectedMachineId(this.state),
machineId,
);
}
private syncMachineActivitySubscriptions(): void {
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;
socket.close();
this.machineActivitySockets.delete(machineId);
this.machineRealtimeSockets.delete(machineId);
}
for (const machineId of desiredMachineIds) {
if (this.machineActivitySockets.has(machineId)) continue;
if (this.machineRealtimeSockets.has(machineId)) continue;
const socket = new RealtimeSocket();
socket.connect(
(event) => { this.handleMachineActivityEvent(machineId, event); },
() => { void this.refreshWorkspaceActivity(machineId); },
() => {
this.notifications.globalSocketOpened(machineId);
void this.refreshWorkspaceActivity(machineId);
},
machineId,
);
this.machineActivitySockets.set(machineId, socket);
this.machineRealtimeSockets.set(machineId, socket);
}
}
private closeMachineActivitySockets(): void {
for (const socket of this.machineActivitySockets.values()) socket.close();
this.machineActivitySockets.clear();
for (const socket of this.machineRealtimeSockets.values()) socket.close();
this.machineRealtimeSockets.clear();
}
private machineActivitySubscriptionIds(): Set<string> {
@@ -827,10 +853,12 @@ export class PiWebApp extends LitElement {
private handleMachineActivityEvent(machineId: string, event: RealtimeEvent): void {
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);
else if (event.type === "notifications.summary") this.notifications.applySummaryEvent(machineId, event);
else if (isTerminalEvent(event)) {
this.applyTerminalEvent(event);
if (event.type === "terminal.exited") void this.refreshWorkspaceDeletionRuns();
@@ -1104,6 +1132,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() {
return html`
<app-navigation-panel
@@ -1111,6 +1182,7 @@ export class PiWebApp extends LitElement {
.selectedMachine=${this.state.selectedMachine}
.machineStatuses=${this.state.machineStatuses}
.machineActivities=${this.state.machineActivities}
.notificationBadges=${this.navigationNotificationBadges()}
.machinesCollapsed=${this.navigationSections.isCollapsed("machines")}
.onToggleMachines=${() => { this.navigationSections.toggle("machines"); }}
.onSelectMachine=${(machine: Machine) => this.selectNavigationItem("machines", "projects", () => this.selectMachineWithMemory(machine))}
@@ -1878,6 +1950,14 @@ export class PiWebApp extends LitElement {
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 => {
void this.openModelDialog();
};
@@ -1888,7 +1968,7 @@ export class PiWebApp extends LitElement {
private renderChatView(state: AppState, session: SessionInfo) {
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>
`;
}
@@ -1920,7 +2000,7 @@ export class PiWebApp extends LitElement {
private mobileMainTabs(): AppMobileMainTab[] {
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" },
...this.visibleWorkspacePanels().map((panel): AppMobileMainTab => {
const icon = panel.icon ?? this.mobilePanelIcon(panel);
+7 -3
View File
@@ -1,12 +1,14 @@
import { LitElement, html, type PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import type { Project, Workspace, WorkspaceActivity } from "../api";
import type { SessionNotificationBadgeModel } from "../sessionNotifications";
import { projectActivityIndicator } from "../workspaceActivity";
import { actionMenuPanelStyle } from "./actionMenu";
import { renderActionActivityIndicator } from "./activityBadge";
import type { KeyboardNavigableSection } from "./navigationFocus";
import { activateSelectableRow, focusSelectedOrFirstSelectableRow, handleSelectableRowKeyboard } from "./selectableRow";
import { listStyles } from "./shared";
import "./NotificationBadge";
@customElement("project-list")
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 }) activities: Record<string, WorkspaceActivity> = {};
@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 }) collapsed = false;
@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); }}
>
<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)}
</div>
<div class="action-menu">
@@ -93,10 +97,10 @@ export class ProjectList extends LitElement implements KeyboardNavigableSection
}
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 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) {
+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 type { SessionActivity, SessionInfo, SessionStatus } from "../api";
import { isCachedNewSessionInfo } from "../cachedNewSessions";
import type { SessionNotificationBadgeModel } from "../sessionNotifications";
import { shortSessionId } from "../sessionLabels";
import { isArchivableSessionInfo, isTransientNewSessionInfo } from "../sessionPersistence";
import { isSessionActive } from "../../../shared/activity";
@@ -10,6 +11,7 @@ import { renderActionActivityIndicator, type ActivityIndicatorKind } from "./act
import type { KeyboardNavigableSection } from "./navigationFocus";
import { activateSelectableRow, focusSelectedOrFirstSelectableRow, handleSelectableRowKeyboard } from "./selectableRow";
import { listStyles } from "./shared";
import "./NotificationBadge";
function sessionLabel(session: SessionInfo): string {
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 }) activities: Record<string, SessionActivity> = {};
@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({ type: Number }) startingCount = 0;
@property({ type: Boolean }) canStart = false;
@@ -132,7 +136,8 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
if (!this.collapsible) {
return html`
<h2>
Sessions
<span class="plain-heading">Sessions</span>
${this.notificationHeadingBadge === undefined ? null : html`<notification-badge .model=${this.notificationHeadingBadge}></notification-badge>`}
${this.renderCurrentSelectionButton(currentSessions)}
${this.renderCleanupButton()}
${this.renderStartButton()}
@@ -144,6 +149,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
return html`
<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>
${this.notificationHeadingBadge === undefined ? null : html`<notification-badge .model=${this.notificationHeadingBadge}></notification-badge>`}
${this.renderCurrentSelectionButton(currentSessions)}
<small class="section-count">${sessionCount}</small>
${this.renderCleanupButton()}
@@ -250,7 +256,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
>
<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}
<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)}
</div>
<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 small { display: inline; min-width: 0; color: var(--pi-muted); }
.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.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); }
+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 type { Workspace, WorkspaceActivity } from "../api";
import type { WorkspaceLabelItem } from "../plugins/types";
import type { SessionNotificationBadgeModel } from "../sessionNotifications";
import { workspaceActivityFor, workspaceActivityIndicator } from "../workspaceActivity";
import { actionMenuPanelStyle } from "./actionMenu";
import { renderActionActivityIndicator } from "./activityBadge";
@@ -9,6 +10,7 @@ import type { KeyboardNavigableSection } from "./navigationFocus";
import { activateSelectableRow, focusSelectedOrFirstSelectableRow, handleSelectableRowKeyboard } from "./selectableRow";
import { listStyles } from "./shared";
import { renderWorkspaceLabelInlineItems } from "./workspaceLabel";
import "./NotificationBadge";
@customElement("workspace-list")
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 }) activities: Record<string, WorkspaceActivity> = {};
@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 }) onDelete?: (workspace: Workspace) => void;
@property({ attribute: false }) onToggleCollapsed?: () => void;
@@ -85,10 +89,10 @@ export class WorkspaceList extends LitElement implements KeyboardNavigableSectio
}
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 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 {
@@ -101,6 +105,7 @@ export class WorkspaceList extends LitElement implements KeyboardNavigableSectio
<span class="workspace-primary">
<span class="workspace-primary-label">${label}</span>
${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>
${items.length === 0 ? null : html`
<small class="workspace-secondary">
@@ -1,7 +1,9 @@
import { LitElement, css, html, type TemplateResult } from "lit";
import { customElement, property, query, state } from "lit/decorators.js";
import type { AppState } from "../../appState";
import type { SessionNotificationBadgeModel } from "../../sessionNotifications";
import { renderAppTabIcon, type AppTabBuiltinIcon } from "../tabIcons";
import "../NotificationBadge";
export type AppMobileMainTabBuiltinIcon = AppTabBuiltinIcon;
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); }}>
${this.renderTabMark(tab, fallbackLabels)}
<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>
`;
})}
@@ -74,13 +76,16 @@ export class AppMobileMainTabs extends LitElement {
}
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;
const badge = String(tab.badge).trim();
return badge === "" ? tab.label : `${tab.label}, ${badge}`;
}
private isEmptyBadge(badge: unknown): boolean {
return badge === undefined || badge === "";
private renderBadge(badge: unknown) {
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>) {
@@ -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; }
@media (max-width: 760px) {
.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; }
.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; }
@@ -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 type { Machine, MachineHealth, Project, SessionActivity, SessionInfo, SessionStatus, Workspace, WorkspaceActivity } from "../../api";
import type { WorkspaceLabelItem } from "../../plugins/types";
import type { SessionNotificationBadgeModel } from "../../sessionNotifications";
import type { NavigationSection } from "../../appShell/navigationState";
import { NAVIGATION_SECTION_ORDER } from "../../appShell/navigationState";
import type { KeyboardNavigableSection } from "../navigationFocus";
@@ -13,6 +14,19 @@ import "../SessionList";
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")
export class AppNavigationPanel extends LitElement {
@property({ attribute: false }) machines: Machine[] = [];
@@ -30,6 +44,7 @@ export class AppNavigationPanel extends LitElement {
@property({ attribute: false }) sessionStatuses: Record<string, SessionStatus> = {};
@property({ attribute: false }) sendingPrompts: Record<string, true> = {};
@property({ attribute: false }) workspacesByProjectId: Record<string, Workspace[]> = {};
@property({ attribute: false }) notificationBadges: NavigationNotificationBadges = emptyNavigationNotificationBadges();
@property({ attribute: false }) deletingWorkspaceIds: string[] = [];
@property({ attribute: false }) workspaceLabelItems: (workspace: Workspace) => WorkspaceLabelItem[] = () => [];
@property({ attribute: false }) refreshControl: unknown;
@@ -100,6 +115,7 @@ export class AppNavigationPanel extends LitElement {
.selected=${this.selectedMachine}
.statuses=${this.machineStatuses}
.activities=${this.machineActivities}
.notificationBadges=${this.notificationBadges.machines}
.onSelect=${(machine: Machine) => this.onSelectMachine?.(machine)}
.onRemove=${(machine: Machine) => this.onRemoveMachine?.(machine)}
.onFocusNextSection=${() => { this.focusNextFrom("machines"); }}
@@ -117,6 +133,8 @@ export class AppNavigationPanel extends LitElement {
.selected=${this.selectedMachine}
.statuses=${this.machineStatuses}
.activities=${this.machineActivities}
.notificationBadges=${this.notificationBadges.machines}
.notificationHeadingBadge=${this.notificationBadges.machinesHeading}
.collapsible=${this.collapsible}
.collapsed=${this.machinesCollapsed}
.onToggleCollapsed=${() => { this.onToggleMachines?.(); }}
@@ -131,6 +149,8 @@ export class AppNavigationPanel extends LitElement {
.selected=${this.selectedProject}
.activities=${this.workspaceActivities}
.workspacesByProjectId=${this.workspacesByProjectId}
.notificationBadges=${this.notificationBadges.projects}
.notificationHeadingBadge=${this.notificationBadges.projectsHeading}
.collapsible=${this.collapsible}
.collapsed=${this.projectsCollapsed}
.onToggleCollapsed=${() => { this.onToggleProjects?.(); }}
@@ -145,6 +165,8 @@ export class AppNavigationPanel extends LitElement {
.selected=${this.selectedWorkspace}
.activities=${this.workspaceActivities}
.deletingWorkspaceIds=${this.deletingWorkspaceIds}
.notificationBadges=${this.notificationBadges.workspaces}
.notificationHeadingBadge=${this.notificationBadges.workspacesHeading}
.collapsible=${this.collapsible}
.collapsed=${this.workspacesCollapsed}
.workspaceLabelItems=${this.workspaceLabelItems}
@@ -160,6 +182,8 @@ export class AppNavigationPanel extends LitElement {
.statuses=${this.sessionStatuses}
.activities=${this.sessionActivities}
.sending=${this.sendingPrompts}
.notificationBadges=${this.notificationBadges.sessions}
.notificationHeadingBadge=${this.notificationBadges.sessionsHeading}
.selected=${this.selectedSession}
.startingCount=${this.startingSessionCount}
.canStart=${this.canStartSession}
+46 -1
View File
@@ -273,7 +273,9 @@ export const listStyles = 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; }
.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.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); }
@@ -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: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; }
.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; }
.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); }
@@ -0,0 +1,105 @@
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("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" }]);
});
});
@@ -11,7 +11,7 @@ import { SessionSocket, type GlobalSessionEvent, type SessionUiEvent } from "../
import { isArchivableSessionInfo, isTransientNewSessionInfo, sessionPersistenceOptionsForRuntime } from "../sessionPersistence";
import { isSessionActive } from "../../../shared/activity";
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 { selectedMachineId, type GetState, type SetState, type UpdateUrl } from "./types";
import { TrailingRefreshCoordinator } from "./trailingRefreshCoordinator";
@@ -25,10 +25,19 @@ export interface SessionEventSocket {
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 {
api?: typeof defaultApi;
socket?: SessionEventSocket;
transcripts?: ChatTranscriptStore;
notifications?: SessionNotificationSessionBridge;
}
interface BulkSessionMutationResult {
@@ -71,6 +80,7 @@ export class SessionController {
private readonly socket: SessionEventSocket;
private readonly api: typeof defaultApi;
private readonly transcripts: ChatTranscriptStore;
private readonly notifications: SessionNotificationSessionBridge | undefined;
private selectionSeq = 0;
// Join-time stream watermark for the selected session. `seq` is the
// `SessionEventHub` sequence captured together with the seeded partial by the
@@ -98,6 +108,7 @@ export class SessionController {
this.socket = deps.socket ?? new SessionSocket();
this.api = deps.api ?? defaultApi;
this.transcripts = deps.transcripts ?? new ChatTranscriptStore();
this.notifications = deps.notifications;
}
applyGlobalEvent(event: GlobalSessionEvent): void {
@@ -116,6 +127,7 @@ export class SessionController {
clearActiveSession() {
this.selectionSeq += 1;
this.socket.close();
this.notifications?.clearSelectedSession();
this.streamWatermark = undefined;
this.clearPendingUpdates();
// Note: sendingPrompts is intentionally NOT cleared here. Deselecting a
@@ -168,6 +180,8 @@ export class SessionController {
this.socket.close();
this.streamWatermark = undefined;
this.clearPendingUpdates();
const machineId = selectedMachineId(this.getState());
this.notifications?.prepareSelectedSession(session, machineId);
const transcriptKey = this.sessionCacheKey(session.id);
const cached = this.transcripts.cachedView(transcriptKey);
this.setState({
@@ -194,7 +208,6 @@ export class SessionController {
() => { void this.refreshSelectedSession(session.id); },
selectedMachineId(this.getState()),
);
const machineId = selectedMachineId(this.getState());
await this.requestSelectedSessionRefresh({ session, machineId, selectionSeq: seq });
if (!this.isCurrentRefreshTarget({ session, machineId, selectionSeq: seq })) return;
void this.refreshAvailableThinkingLevels();
@@ -793,6 +806,7 @@ export class SessionController {
// at seq 1, and un-stamped events fail open), so the core transcript
// still loads and streams normally.
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;
// Seed the in-flight partial assistant message on top of committed history
@@ -892,6 +906,7 @@ export class SessionController {
this.sessionSelection.rememberSession({ ...session, cwd: this.workspaceSelectionKey(session.cwd) });
this.selectionSeq += 1;
this.socket.close();
this.notifications?.clearSelectedSession();
this.streamWatermark = undefined;
this.clearPendingUpdates();
const state = this.getState();
@@ -1111,6 +1126,15 @@ export class SessionController {
}
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
// history + partial). Everything past the watermark applies exactly once,
// so live content streams directly on top of the seeded partial.
@@ -0,0 +1,293 @@
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; } };
}
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("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: [] });
});
});
describe("SessionNotificationController optimistic mutations", () => {
it("optimistically dismisses one card, reconciles the response, and rolls back/refetches on failure", async () => {
const dismiss = deferred<SessionNotificationInboxSnapshot>();
const refreshAfterFailure = deferred<SessionNotificationInboxSnapshot>();
const notificationInbox = vi.fn()
.mockResolvedValueOnce(inboxSnapshot([entry(2, "warning"), entry(1)]))
.mockImplementationOnce(() => refreshAfterFailure.promise);
const dismissNotification = vi.fn()
.mockImplementationOnce(() => dismiss.promise)
.mockRejectedValueOnce(new Error("offline"));
const harness = createHarness(capableState(), { notificationInbox, dismissNotification });
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,545 @@
import { api as defaultApi, type Machine, type SessionInfo } from "../api";
import type { AppState } from "../appState";
import {
applyNotificationCatalogEvent,
applySelectedNotificationEvent,
freshNotificationCatalog,
installSelectedNotificationSnapshot,
loadingSelectedNotificationInbox,
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);
this.applyCatalogSummary(machineId, inboxSummaryEvent(event));
const target = this.selectedTarget;
if (target?.machineId !== machineId || target.sessionId !== event.summary.sessionId || target.cwd !== event.summary.cwd) 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 });
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;
}
this.applyCatalogSummary(machineId, event);
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 && this.machineSupportsNotifications(selected.machineId) && this.machineIsReachable(selected.machineId)) {
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;
this.acceptedSupportByMachine.add(target.machineId);
let inbox = installSelectedNotificationSnapshot(this.getState().selectedNotificationInbox, target, snapshot);
this.applyCatalogSummary(target.machineId, 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;
this.applyCatalogSummary(target.machineId, inboxSummaryEvent(event));
if (result.needsRefresh) operation.trailing = true;
}
this.setState({ selectedNotificationInbox: inbox });
} 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): 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);
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;
const shouldInstall = current.daemonInstanceId !== snapshot.daemonInstanceId
|| current.summary === undefined
|| snapshot.summary.inboxRevision >= current.summary.inboxRevision;
const authoritative = shouldInstall ? 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;
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): void {
const current = this.getState().notificationCatalogsByMachine;
if (current[machineId] === projection) return;
this.setState({ notificationCatalogsByMachine: { ...current, [machineId]: projection } });
}
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 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 };
}
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 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 };
}
+66
View File
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import { 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",
};
}
describe("notification socket guards", () => {
it("accepts validated per-session and global notification events", () => {
expect(parseSessionSocketEvent({
type: "notifications.inbox",
daemonInstanceId: "daemon-a",
catalogRevision: 1,
summary: summary(),
dismissThrough: { order: 1, overflowWatermark: 0 },
delta: { kind: "added", notification: notification() },
})).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();
});
});
+34 -11
View File
@@ -1,4 +1,5 @@
import { realtimeEvents, sessionEvents } from "./api";
import { parseSessionNotificationInboxEvent, parseSessionNotificationSummaryEvent } from "./api/parsers";
import type { GlobalSessionEvent, RealtimeEvent, SessionRef, SessionUiEvent } from "../../shared/apiTypes";
export type { GlobalSessionEvent, RealtimeEvent, SessionUiEvent } from "../../shared/apiTypes";
@@ -49,7 +50,7 @@ export class SessionSocket {
if (this.hasOpened) this.onReconnect?.();
this.hasOpened = true;
};
socket.onmessage = (message) => void this.handleMessage(message.data);
socket.onmessage = (message) => void this.handleMessage(message.data, this.session);
socket.onerror = () => { socket.close(); };
socket.onclose = () => {
if (this.socket === socket) this.socket = undefined;
@@ -65,9 +66,11 @@ export class SessionSocket {
this.reconnectTimer = window.setTimeout(() => { this.open(); }, delay);
}
private async handleMessage(data: MessageEvent["data"]): Promise<void> {
const event = await parseSocketEvent(data);
if (isSessionUiEvent(event)) this.onEvent?.(event);
private async handleMessage(data: MessageEvent["data"], session: SessionRef | undefined): Promise<void> {
const event = parseSessionSocketEvent(await parseSocketEvent(data));
if (event === undefined) return;
if (event.type === "notifications.inbox" && (session?.id !== event.summary.sessionId || session.cwd !== event.summary.cwd)) return;
this.onEvent?.(event);
}
}
@@ -124,24 +127,44 @@ export class RealtimeSocket {
}
private async handleMessage(data: MessageEvent["data"]): Promise<void> {
const event = await parseSocketEvent(data);
if (isRealtimeEvent(event)) this.onEvent?.(event);
const event = parseRealtimeSocketEvent(await parseSocketEvent(data));
if (event !== undefined) this.onEvent?.(event);
}
}
function isSessionUiEvent(event: unknown): event is SessionUiEvent {
export function parseSessionSocketEvent(event: unknown): SessionUiEvent | undefined {
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);
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);
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 {