fix: scope notifications to the selected chat

This commit is contained in:
Federico Jaramillo Martinez
2026-07-19 10:37:36 +02:00
parent df8cf5a250
commit d5650aef5b
25 changed files with 460 additions and 1174 deletions
@@ -2,4 +2,4 @@
"@jmfederico/pi-web": patch
---
Keep extension notifications discoverable in a session-scoped inbox with background badges, reconnect recovery, and explicit dismissal.
Show extension notifications in a compact, dismissible tray for the selected chat, with reconnect recovery and per-chat collapse state.
+3 -6
View File
@@ -304,31 +304,28 @@ describe("session API compatibility", () => {
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 () => {
it("uses encoded selected-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 });
expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ cwd: ref.cwd, daemonInstanceId: "daemon-a", notificationId: "opaque/id?" });
expect(JSON.parse(requestBody(fetchCall(fetchMock, 2)[1]))).toEqual({ cwd: ref.cwd, daemonInstanceId: "daemon-a", throughOrder: 1, throughOverflowWatermark: 7 });
});
});
-2
View File
@@ -40,7 +40,6 @@ import {
parseSessionCleanupExecuteResponse,
parseSessionCleanupPreviewResponse,
parseSessionInfo,
parseSessionNotificationCatalogSnapshot,
parseSessionNotificationInboxSnapshot,
parseSessionStatus,
parseSessionStreamSnapshot,
@@ -205,7 +204,6 @@ 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 }) }),
+4 -16
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
import { SESSION_NOTIFICATION_LIMIT, SESSION_NOTIFICATION_MESSAGE_BYTES } from "../../../shared/apiTypes";
import { parseAuthProvidersResponse, parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMachineRuntime, parseMessagePage, parseOAuthFlowState, parsePiPackageMutationResponse, parsePiPackagesResponse, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parsePiWebStatusResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionInfo, parseSessionNotificationCatalogSnapshot, parseSessionNotificationInboxEvent, parseSessionNotificationInboxSnapshot, parseSessionNotificationSummaryEvent, parseSessionStatus, parseSessionStreamSnapshot, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers";
import { parseAuthProvidersResponse, parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMachineRuntime, parseMessagePage, parseOAuthFlowState, parsePiPackageMutationResponse, parsePiPackagesResponse, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parsePiWebStatusResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionInfo, parseSessionNotificationInboxEvent, parseSessionNotificationInboxSnapshot, parseSessionStatus, parseSessionStreamSnapshot, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers";
describe("API parsers", () => {
it("preserves additive interactive API-key flow hints and defaults legacy options", () => {
@@ -514,14 +514,9 @@ describe("API parsers", () => {
expect(() => parseCommandResult({ type: "later" })).toThrow("Invalid command result type");
});
it("strictly parses notification snapshots and realtime events", () => {
it("strictly parses selected 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",
@@ -531,12 +526,6 @@ describe("API parsers", () => {
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", () => {
@@ -545,10 +534,9 @@ describe("API parsers", () => {
...inbox,
notifications: [{ ...notificationWire(1), severity: "fatal" }],
})).toThrow("Invalid notification severity");
expect(() => parseSessionNotificationCatalogSnapshot({
daemonInstanceId: "daemon-a",
expect(() => parseSessionNotificationInboxSnapshot({
...inbox,
catalogRevision: Number.MAX_SAFE_INTEGER + 1,
sessions: [],
})).toThrow("safe integer");
expect(() => parseSessionNotificationInboxSnapshot({
...inbox,
+2 -26
View File
@@ -1,4 +1,4 @@
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 { SESSION_NOTIFICATION_LIMIT, SESSION_NOTIFICATION_MESSAGE_BYTES, type ArchiveSessionsResponse, type AuthProviderOption, type AuthProviderStatus, type AuthProvidersResponse, type AuthStatusSource, type AuthType, type CommandOption, type CommandResult, type DeleteWorkspaceFileResponse, type FileContentResponse, type FileSuggestion, type FileTreeEntry, type FileTreeResponse, type GitDiffResponse, type GitFileState, type GitStatusFile, type GitStatusResponse, type Machine, type MachineHealth, type MachineKind, type MachineRuntime, type MachineStatus, type MessagePage, type ModelSelectionResponse, type MoveWorkspaceFileResponse, type OAuthFlowState, type PiWebAgentDirEnvSource, type PiWebCapability, type PiWebComponentStatus, type PiWebConfigEnvOverrides, type PiWebConfigResponse, type PiWebConfigValues, type PiWebInstallationInfo, type PiWebPluginConfigMap, type PiWebPluginInfo, type PiWebPluginsResponse, type PiWebPluginScope, type PiWebReleaseStatus, type PiWebRuntimeComponent, type PiWebRuntimeResponse, type PiWebServiceComponent, type PiWebShortcutConfig, type PiWebStatusMessage, type PiWebStatusResponse, type PiWebStatusSeverity, type Project, type QueuedSessionMessage, type SavedPromptAttachment, type SessionBulkArchiveResponse, type SessionBulkDeleteArchivedResponse, type SessionBulkFailure, type SessionCleanupExecuteResponse, type SessionCleanupPreviewResponse, type SessionCleanupProjectSummary, type SessionCleanupThresholds, type SessionCleanupTotals, type SessionInfo, type SessionModel, type SessionNotification, type SessionNotificationClearReason, type SessionNotificationDismissThrough, type SessionNotificationInboxDelta, type SessionNotificationInboxEvent, type SessionNotificationInboxSnapshot, type SessionNotificationSeverity, type SessionNotificationSummary, type SessionStatus, type SessionStreamSnapshot, type SessionWarning, type SessionWarningSeverity, type SlashCommand, type TerminalCommandRun, type TerminalCommandRunStatus, type TerminalInfo, type ThinkingLevelsResponse, type WriteWorkspaceFileResponse, type Workspace, type WorkspaceActivity, type WorkspaceActivityResponse } from "../../../shared/apiTypes";
import type { PiPackageInfo, PiPackageMutationAction, PiPackageMutationResponse, PiPackageScope, PiPackagesResponse } from "../../../shared/apiTypes";
import { parseActiveAgentProfileDescriptor } from "../../../shared/activeAgentProfile";
import { parseKnownPiWebCapabilities } from "../../../shared/capabilities";
@@ -233,19 +233,6 @@ 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"]);
@@ -286,18 +273,7 @@ export function parseSessionNotificationInboxEvent(value: unknown): SessionNotif
};
}
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 {
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");
+1 -4
View File
@@ -1,7 +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 { SelectedSessionNotificationInbox } from "./sessionNotifications";
import type { WorkspaceUploadBatchState } from "./workspaceUploadState";
export interface AppState {
@@ -37,8 +37,6 @@ 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[]>;
@@ -148,7 +146,6 @@ export function initialAppState(): AppState {
sessionActivities: {},
workspaceActivities: {},
machineActivities: {},
notificationCatalogsByMachine: {},
selectedNotificationInbox: undefined,
workspacesByProjectId: {},
workspaceDeletionRuns: {},
+100 -13
View File
@@ -1,7 +1,11 @@
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 {
notificationTargetKey,
notificationTrayIsCollapsed,
type SelectedSessionNotificationView,
} from "../sessionNotifications";
import type { ChatLine } from "./shared";
import {
ChatView,
@@ -144,35 +148,79 @@ 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", () => {
// Content and identity decisions use pure seams; Vitest has no shadow-DOM
// harness, so stable semantic class markers keep handler extraction narrow.
// A minimal render-root fake verifies the resulting focus move without
// recreating a browser DOM harness.
it("wires individual dismissal and recovers header focus after the final row", () => {
const view = withNotificationInbox(new ChatView());
const onDismissNotification = vi.fn();
const onDismissAllNotifications = vi.fn();
const headerFocus = installNotificationFocusRoot(view);
view.onDismissNotification = onDismissNotification;
const rendered = renderNotificationTray(view);
if (rendered === null) throw new Error("expected a notification tray");
templateEventHandlerAfterMarker(rendered, "notification-row-dismiss")(new Event("click"));
view.notificationInbox = emptyNotificationInbox(requireNotificationInbox(view));
expect(renderNotificationTray(view)).not.toBeNull();
focusPendingNotificationTarget(view);
expect(onDismissNotification).toHaveBeenCalledExactlyOnceWith("daemon-a:1");
expect(headerFocus).toHaveBeenCalledOnce();
});
it("wires clear-all and recovers header focus while the emptied tray is retained", () => {
const view = withNotificationInbox(new ChatView());
const onDismissAllNotifications = vi.fn();
const headerFocus = installNotificationFocusRoot(view);
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"));
templateEventHandlerAfterMarker(rendered, "notification-clear")(new Event("click"));
view.notificationInbox = emptyNotificationInbox(requireNotificationInbox(view));
expect(onDismissNotification).toHaveBeenCalledExactlyOnceWith("daemon-a:1");
expect(renderNotificationTray(view)).not.toBeNull();
focusPendingNotificationTarget(view);
expect(onDismissAllNotifications).toHaveBeenCalledOnce();
expect(headerFocus).toHaveBeenCalledOnce();
});
it("wires the real expand/collapse button to component-local collapse state", () => {
it("does not move pending dismissal focus into another exact chat", () => {
const view = withNotificationInbox(new ChatView());
const headerFocus = installNotificationFocusRoot(view);
view.onDismissAllNotifications = vi.fn();
const rendered = renderNotificationTray(view);
if (rendered === null) throw new Error("expected a notification tray");
templateEventHandlerAfterMarker(rendered, "notification-clear")(new Event("click"));
view.notificationInbox = { ...requireNotificationInbox(view), machineId: "remote" };
focusPendingNotificationTarget(view);
expect(headerFocus).not.toHaveBeenCalled();
});
it("keeps a collapsed tray closed for new arrivals and isolates matching session ids by exact chat", () => {
const view = withNotificationInbox(new ChatView());
const inbox = requireNotificationInbox(view);
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);
const collapsedTargetKeys: unknown = Reflect.get(view, "collapsedNotificationTargetKeys");
if (!(collapsedTargetKeys instanceof Set)) throw new Error("Expected collapsed notification target keys");
const firstNotification = inbox.notifications[0];
if (firstNotification === undefined) throw new Error("expected a retained notification");
const newArrival = {
...inbox,
notifications: [{ ...firstNotification, id: "daemon-a:2", order: 2 }, ...inbox.notifications],
retainedCount: 2,
};
expect(notificationTrayIsCollapsed(collapsedTargetKeys, newArrival)).toBe(true);
expect(notificationTrayIsCollapsed(collapsedTargetKeys, { ...newArrival, cwd: "/other" })).toBe(false);
expect(notificationTrayIsCollapsed(collapsedTargetKeys, { ...newArrival, machineId: "remote" })).toBe(false);
expect(collapsedTargetKeys.has(notificationTargetKey(inbox))).toBe(true);
});
});
@@ -268,6 +316,7 @@ type RenderMessageGroup = (this: ChatView, messages: ChatLine[], startIndex: num
type RenderMessageGroupBody = (this: ChatView, messages: ChatLine[], startIndex: number) => TemplateResult;
type RenderWarnings = (this: ChatView) => TemplateResult | null;
type RenderNotificationTray = (this: ChatView) => TemplateResult | null;
type FocusPendingNotificationTarget = (this: ChatView) => void;
type TemplateEventHandler = (event: Event) => void;
function renderQueuedMessages(view: ChatView): TemplateResult {
@@ -294,6 +343,12 @@ function renderNotificationTray(view: ChatView): TemplateResult | null {
return method.call(view);
}
function focusPendingNotificationTarget(view: ChatView): void {
const method: unknown = Reflect.get(view, "focusPendingNotificationTarget");
if (!isFocusPendingNotificationTarget(method)) throw new Error("ChatView.focusPendingNotificationTarget is not callable");
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");
@@ -326,6 +381,10 @@ function isRenderNotificationTray(value: unknown): value is RenderNotificationTr
return typeof value === "function";
}
function isFocusPendingNotificationTarget(value: unknown): value is FocusPendingNotificationTarget {
return typeof value === "function";
}
function dispatchDetailsToggle(handler: TemplateEventHandler, open: boolean): void {
const hadDetailsElement = Reflect.has(globalThis, "HTMLDetailsElement");
const previousDetailsElement = Reflect.get(globalThis, "HTMLDetailsElement");
@@ -382,6 +441,34 @@ function withNotificationInbox(view: ChatView): ChatView {
return view;
}
function requireNotificationInbox(view: ChatView): SelectedSessionNotificationView {
if (view.notificationInbox === undefined) throw new Error("expected a notification inbox");
return view.notificationInbox;
}
function emptyNotificationInbox(inbox: SelectedSessionNotificationView): SelectedSessionNotificationView {
const empty: SelectedSessionNotificationView = {
...inbox,
notifications: [],
retainedCount: 0,
discardedCount: 0,
pendingDismissedIds: new Set(),
dismissAllPending: false,
};
delete empty.highestSeverity;
return empty;
}
function installNotificationFocusRoot(view: ChatView): ReturnType<typeof vi.fn> {
const headerFocus = vi.fn();
const renderRoot = {
querySelector: (selector: string) => selector === "[data-notification-focus='header']" ? { focus: headerFocus } : null,
querySelectorAll: () => [],
};
if (!Reflect.set(view, "renderRoot", renderRoot)) throw new Error("Could not install notification focus root");
return headerFocus;
}
function warningStatus(warnings: SessionWarning[]): SessionStatus {
return {
...queuedStatus([]),
+121 -47
View File
@@ -9,14 +9,20 @@ import { shouldRequestEarlierMessages } from "../chatHistoryLoading";
import { ChatScrollController, distanceFromScrollBottom, findFirstVisibleArticle, isNearScrollBottom, type ChatAnchorScrollPosition, type ChatScrollRestoreResult } from "../chatScrollPosition";
import type { QueuedSessionMessage, SessionActivity, SessionStatus, SessionWarningSeverity } from "../api";
import {
notificationAnnouncementLabel,
notificationDismissLabel,
notificationFocusTargetAfterDismiss,
notificationInboxOverflowLabel,
notificationInboxTotalCount,
notificationMessageTruncationLabel,
notificationSeverityIcon,
notificationSeverityLabel,
notificationTargetKey,
notificationTrayHeading,
notificationTrayIsCollapsed,
setNotificationTrayCollapsed,
type NotificationFocusTarget,
type SelectedSessionNotificationView,
type SessionNotificationTarget,
} from "../sessionNotifications";
import type { ChatLine, ChatPart } from "./shared";
import { chatStyles } from "./shared";
@@ -25,7 +31,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" });
const notificationTimestampFormatter = new Intl.DateTimeFormat(undefined, { timeStyle: "short" });
function warningSeverityIcon(severity: SessionWarningSeverity): string {
if (severity === "error") return "⛔";
@@ -33,6 +39,31 @@ function warningSeverityIcon(severity: SessionWarningSeverity): string {
return "⚠️";
}
function renderNotificationDisclosureIcon(collapsed: boolean) {
return html`
<svg class=${`notification-icon notification-disclosure-icon${collapsed ? "" : " expanded"}`} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="m9 18 6-6-6-6"></path>
</svg>
`;
}
function renderNotificationCloseIcon() {
return html`
<svg class="notification-icon notification-close-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M6 6l12 12"></path>
<path d="M18 6 6 18"></path>
</svg>
`;
}
function isSessionNotificationTarget(value: unknown): value is SessionNotificationTarget {
return typeof value === "object"
&& value !== null
&& typeof Reflect.get(value, "machineId") === "string"
&& typeof Reflect.get(value, "cwd") === "string"
&& typeof Reflect.get(value, "sessionId") === "string";
}
function clampPercent(value: number): number {
return clampNumber(value, 0, 100);
}
@@ -42,6 +73,11 @@ function clampNumber(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
interface PendingNotificationFocus {
chatKey: string;
focusTarget: NotificationFocusTarget;
}
export interface QueuedMessageSection {
source: "client" | "server";
heading: string;
@@ -176,9 +212,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;
@state() private collapsedNotificationTargetKeys: ReadonlySet<string> = new Set();
@state() private retainedEmptyNotificationTrayTargetKey: string | undefined;
private pendingNotificationFocus: PendingNotificationFocus | undefined;
private readonly disclosures = new ChatDisclosureController();
private readonly scrollController = new ChatScrollController();
private suppressScrollSave = false;
@@ -255,7 +291,7 @@ export class ChatView extends LitElement {
private prepareSessionUiState(): void {
this.disclosures.syncSession(this.sessionId);
this.pendingNotificationFocus = undefined;
this.retainedEmptyNotificationTraySessionId = undefined;
this.retainedEmptyNotificationTrayTargetKey = undefined;
this.scrollController.clearScheduledSave();
this.suppressScrollSave = false;
this.suppressLoadMoreRequests = false;
@@ -272,6 +308,9 @@ export class ChatView extends LitElement {
if (changed.has("sessionId")) {
this.savePreviousSessionScrollPosition(changed.get("sessionId"));
this.prepareSessionUiState();
} else if (changed.has("notificationInbox") && this.notificationTargetChanged(changed.get("notificationInbox"))) {
this.pendingNotificationFocus = undefined;
this.retainedEmptyNotificationTrayTargetKey = undefined;
}
if (changed.has("messages")) this.pinnedToBottom = this.pinnedToBottom && (this.didChatHeightChange() || this.isNearBottom());
}
@@ -301,6 +340,12 @@ export class ChatView extends LitElement {
else if (this.zoomedImage === undefined && dialog.open) dialog.close();
}
private notificationTargetChanged(previous: unknown): boolean {
const currentInbox = this.notificationInbox;
if (!isSessionNotificationTarget(previous) || currentInbox === undefined) return previous !== currentInbox;
return notificationTargetKey(previous) !== notificationTargetKey(currentInbox);
}
override render() {
const groups = this.groupedMessages();
return html`
@@ -338,58 +383,65 @@ export class ChatView extends LitElement {
private renderNotificationTray() {
const inbox = this.notificationInbox;
if (inbox?.sessionId !== this.sessionId) return null;
const chatKey = notificationTargetKey(inbox);
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`;
const retainsFocusTarget = this.retainedEmptyNotificationTrayTargetKey === chatKey;
const totalCount = notificationInboxTotalCount(inbox);
if (totalCount === 0 && !hasPendingOverlay && !retainsFocusTarget) return null;
const collapsed = notificationTrayIsCollapsed(this.collapsedNotificationTargetKeys, inbox);
const toggleLabel = collapsed ? "Expand notifications" : "Collapse notifications";
return html`
<section class=${`notification-tray ${severity} ${collapsed ? "collapsed" : ""}`} role="region" aria-labelledby="session-notifications-heading" @focusout=${(event: FocusEvent) => { this.releaseEmptyNotificationTray(event); }}>
<section class=${`notification-tray${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>
<strong class="notification-heading" id="session-notifications-heading">${notificationTrayHeading(inbox)}</strong>
<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>
<button
type="button"
class="notification-control notification-clear"
aria-label="Clear all notifications"
title="Clear all notifications"
?disabled=${inbox.dismissAllPending || totalCount === 0 || this.onDismissAllNotifications === undefined}
@click=${() => { this.dismissAllNotifications(); }}
>Clear</button>
<button
type="button"
class="notification-control notification-toggle"
aria-label=${toggleLabel}
title=${toggleLabel}
aria-expanded=${String(!collapsed)}
aria-controls="session-notification-list"
@click=${() => { this.toggleNotificationTray(inbox, collapsed); }}
>${renderNotificationDisclosureIcon(collapsed)}</button>
</div>
</header>
${collapsed ? null : html`
<div class="notification-cards" id="session-notification-cards">
<div class="notification-list" id="session-notification-list" ?hidden=${collapsed}>
${inbox.discardedCount === 0 ? null : html`
<p class="notification-overflow" role="status">${notificationInboxOverflowLabel(inbox.discardedCount)}</p>
<p class="notification-overflow">${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>
<article class=${`notification-row ${notification.severity}`} data-notification-id=${notification.id} tabindex="-1">
<div class="notification-metadata">
<strong class="notification-severity">${label}</strong>
<span aria-hidden="true">·</span>
<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`}
class="notification-row-dismiss"
aria-label=${notificationDismissLabel(notification)}
title="Dismiss notification"
?disabled=${inbox.pendingDismissedIds.has(notification.id) || inbox.dismissAllPending}
?disabled=${inbox.pendingDismissedIds.has(notification.id) || inbox.dismissAllPending || this.onDismissNotification === undefined}
@click=${() => { this.dismissNotification(notification.id); }}
>×</button>
>${renderNotificationCloseIcon()}</button>
</article>
`;
})}
</div>
`}
</section>
`;
}
@@ -399,42 +451,64 @@ export class ChatView extends LitElement {
const polite = announcements.filter((announcement) => announcement.severity !== "error");
const assertive = announcements.filter((announcement) => announcement.severity === "error");
return html`
<div class="visually-hidden notification-live" aria-live="polite" aria-atomic="false">${repeat(polite, (announcement) => announcement.id, (announcement) => html`<span data-announcement-id=${announcement.id}>${notificationSeverityLabel(announcement.severity)} notification: ${announcement.message}</span>`)}</div>
<div class="visually-hidden notification-live" aria-live="assertive" aria-atomic="false">${repeat(assertive, (announcement) => announcement.id, (announcement) => html`<span data-announcement-id=${announcement.id}>Error notification: ${announcement.message}</span>`)}</div>
<div class="visually-hidden notification-live" aria-live="polite" aria-atomic="false">${repeat(polite, (announcement) => announcement.id, (announcement) => html`<span data-announcement-id=${announcement.id}>${notificationAnnouncementLabel(announcement)}</span>`)}</div>
<div class="visually-hidden notification-live" aria-live="assertive" aria-atomic="false">${repeat(assertive, (announcement) => announcement.id, (announcement) => html`<span data-announcement-id=${announcement.id}>${notificationAnnouncementLabel(announcement)}</span>`)}</div>
`;
}
private toggleNotificationTray(collapsed: boolean): void {
this.collapsedNotificationSessionIds = setNotificationTrayCollapsed(this.collapsedNotificationSessionIds, this.sessionId, !collapsed);
private toggleNotificationTray(inbox: SelectedSessionNotificationView, collapsed: boolean): void {
this.collapsedNotificationTargetKeys = setNotificationTrayCollapsed(this.collapsedNotificationTargetKeys, inbox, !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);
if (inbox === undefined || this.onDismissNotification === undefined) return;
const focusTarget = notificationFocusTargetAfterDismiss(inbox.notifications, notificationId);
const chatKey = notificationTargetKey(inbox);
this.pendingNotificationFocus = { chatKey, focusTarget };
if (focusTarget.kind === "header") this.retainedEmptyNotificationTrayTargetKey = chatKey;
this.onDismissNotification(notificationId);
}
private dismissAllNotifications(): void {
const inbox = this.notificationInbox;
if (inbox === undefined || this.onDismissAllNotifications === undefined) return;
const chatKey = notificationTargetKey(inbox);
this.pendingNotificationFocus = { chatKey, focusTarget: { kind: "header" } };
this.retainedEmptyNotificationTrayTargetKey = chatKey;
this.onDismissAllNotifications();
}
private releaseEmptyNotificationTray(event: FocusEvent): void {
const tray = event.currentTarget;
const next = event.relatedTarget;
if (tray instanceof HTMLElement && next instanceof Node && tray.contains(next)) return;
// Removing the activated row can emit focusout before updated() moves focus.
if (this.pendingNotificationFocus !== undefined) return;
const inbox = this.notificationInbox;
if (this.retainedEmptyNotificationTraySessionId === this.sessionId && inbox?.retainedCount === 0 && inbox.discardedCount === 0) this.retainedEmptyNotificationTraySessionId = undefined;
if (inbox !== undefined
&& this.retainedEmptyNotificationTrayTargetKey === notificationTargetKey(inbox)
&& notificationInboxTotalCount(inbox) === 0) this.retainedEmptyNotificationTrayTargetKey = undefined;
}
private focusPendingNotificationTarget(): void {
const target = this.pendingNotificationFocus;
const pending = this.pendingNotificationFocus;
this.pendingNotificationFocus = undefined;
if (target === undefined) return;
const inbox = this.notificationInbox;
if (pending === undefined || inbox === undefined || notificationTargetKey(inbox) !== pending.chatKey) return;
const target = pending.focusTarget;
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]"))
const row = 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();
if (row !== undefined) {
row.focus();
return;
}
if (notificationInboxTotalCount(inbox) === 0) this.retainedEmptyNotificationTrayTargetKey = pending.chatKey;
this.renderRoot.querySelector<HTMLElement>("[data-notification-focus='header']")?.focus();
}
private renderWarnings() {
+3 -7
View File
@@ -1,14 +1,12 @@
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 {
@@ -16,8 +14,6 @@ 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;
@@ -79,7 +75,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>${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>
<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>
${this.renderActivity(machine)}
</div>
${hasRemoveAction ? this.renderMachineMenu(machine) : null}
@@ -117,10 +113,10 @@ export class MachineList extends LitElement implements KeyboardNavigableSection
}
private renderHeading() {
if (!this.collapsible) return html`<span>Machines</span>${this.notificationHeadingBadge === undefined ? null : html`<notification-badge .model=${this.notificationHeadingBadge}></notification-badge>`}`;
if (!this.collapsible) return html`<span>Machines</span>`;
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>${this.notificationHeadingBadge === undefined ? null : html`<notification-badge .model=${this.notificationHeadingBadge}></notification-badge>`}<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><small class="section-count">${this.machines.length}</small></button>`;
}
private toggleMenu(machineId: string, target: EventTarget | null): void {
+4 -19
View File
@@ -1,13 +1,11 @@
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 {
@@ -15,8 +13,6 @@ 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 }) notificationHeadingBadge?: SessionNotificationBadgeModel;
@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>;
@@ -58,14 +54,13 @@ export class MachineSwitcher extends LitElement implements KeyboardNavigableSect
if (selected === undefined) return null;
const status = machineStatus(selected, this.statuses);
const label = selected.name;
const notificationBadge = machineSwitcherNotificationBadge(selected.id, this.notificationBadges, this.notificationHeadingBadge);
return html`
<div class="machine-switcher">
<button
type="button"
class="machine-switcher-button"
title=${machineTitle(selected)}
aria-label=${this.machineSwitcherAriaLabel(selected, notificationBadge)}
aria-label=${this.machineSwitcherAriaLabel(selected)}
aria-expanded=${String(this.open)}
@click=${(event: MouseEvent) => { this.toggleMenu(event.currentTarget); }}
@keydown=${(event: KeyboardEvent) => { this.handleSwitcherButtonKeydown(event); }}
@@ -76,7 +71,6 @@ export class MachineSwitcher extends LitElement implements KeyboardNavigableSect
<span class="machine-switcher-label">${label}</span>
</span>
<span class=${`machine-status ${status}`}>${machineStatusLabel(status)}</span>
${notificationBadge === undefined ? null : html`<notification-badge .model=${notificationBadge}></notification-badge>`}
<span class="machine-chevron" aria-hidden="true">▾</span>
</button>
${this.open ? html`
@@ -103,7 +97,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>${this.notificationBadges[machine.id] === undefined ? null : html`<notification-badge .model=${this.notificationBadges[machine.id]}></notification-badge>`}</span>
<span class="machine-option-name">${this.renderActivity(machine)}<span>${machine.name}</span></span>
<small>${machine.kind === "local" ? "Local Pi Web" : machine.baseUrl ?? "Remote Pi Web"} · ${machineStatusLabel(status)}</small>
</button>
${hasActions ? html`
@@ -138,9 +132,8 @@ export class MachineSwitcher extends LitElement implements KeyboardNavigableSect
return this.selected ?? this.machines.find((machine) => machine.id === "local") ?? this.machines[0];
}
private machineSwitcherAriaLabel(machine: Machine, notificationBadge: SessionNotificationBadgeModel | undefined): string {
const notificationLabel = notificationBadge?.accessibleLabel;
return `Machine: ${machine.name}.${notificationLabel === undefined ? "" : ` Notifications across machines: ${notificationLabel}.`} Switch machine.`;
private machineSwitcherAriaLabel(machine: Machine): string {
return `Machine: ${machine.name}. Switch machine.`;
}
private switcherButton(): HTMLElement | null {
@@ -314,14 +307,6 @@ export class MachineSwitcher extends LitElement implements KeyboardNavigableSect
`;
}
export function machineSwitcherNotificationBadge(
selectedMachineId: string,
notificationBadges: Readonly<Record<string, SessionNotificationBadgeModel | undefined>>,
notificationHeadingBadge: SessionNotificationBadgeModel | undefined,
): SessionNotificationBadgeModel | undefined {
return notificationHeadingBadge ?? notificationBadges[selectedMachineId];
}
function machineStatus(machine: Machine, statuses: Record<string, MachineHealth>): MachineStatus {
return statuses[machine.id]?.status ?? machine.status ?? "unknown";
}
@@ -1,34 +0,0 @@
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;
}
}
@@ -1,140 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { initialAppState, type AppState } from "../appState";
import type { SessionInfo } from "../api";
import type { NavigationNotificationBadges } from "./appShell/AppNavigationPanel";
import { machineSwitcherNotificationBadge } from "./MachineSwitcher";
import { PiWebApp } from "./PiWebApp";
const currentSession: SessionInfo = {
id: "session-1",
cwd: "/repo",
path: "/tmp/session-1.jsonl",
created: "2026-07-18T00:00:00.000Z",
modified: "2026-07-18T00:00:00.000Z",
messageCount: 0,
firstMessage: "",
};
const archivedSession: SessionInfo = {
...currentSession,
id: "archived-session",
path: "/tmp/archived-session.jsonl",
archived: true,
};
afterEach(() => {
vi.unstubAllGlobals();
});
describe("PiWebApp notification hierarchy models", () => {
it("projects exact machine/cwd counts through session, workspace, project, machine, heading, and mobile levels", () => {
const app = createApp();
const local = { id: "local", name: "Local", kind: "local" as const, createdAt: "now", updatedAt: "now" };
const remote = { id: "remote", name: "Remote", kind: "remote" as const, createdAt: "now", updatedAt: "now" };
const state: AppState = {
...initialAppState(),
machines: [local, remote],
selectedMachine: local,
projects: [{ id: "project-1", name: "Repo", path: "/repo", createdAt: "now" }],
selectedProject: { id: "project-1", name: "Repo", path: "/repo", createdAt: "now" },
workspaces: [
{ id: "workspace-1", projectId: "project-1", path: "/repo", label: "repo", isMain: true, isGitRepo: true, isGitWorktree: false },
{ id: "workspace-2", projectId: "project-1", path: "/repo-worktree", label: "worktree", isMain: false, isGitRepo: true, isGitWorktree: true },
],
selectedWorkspace: { id: "workspace-1", projectId: "project-1", path: "/repo", label: "repo", isMain: true, isGitRepo: true, isGitWorktree: false },
workspacesByProjectId: {
"project-1": [
{ id: "workspace-1", projectId: "project-1", path: "/repo", label: "repo", isMain: true, isGitRepo: true, isGitWorktree: false },
{ id: "workspace-2", projectId: "project-1", path: "/repo-worktree", label: "worktree", isMain: false, isGitRepo: true, isGitWorktree: true },
],
},
sessions: [currentSession, archivedSession],
selectedSession: currentSession,
notificationCatalogsByMachine: {
local: {
machineId: "local",
status: "fresh",
daemonInstanceId: "daemon-local",
catalogRevision: 4,
summariesBySessionId: {
"session-1": { sessionId: "session-1", cwd: "/repo", inboxRevision: 1, retainedCount: 2, discardedCount: 0, highestSeverity: "error" },
"session-2": { sessionId: "session-2", cwd: "/repo-worktree", inboxRevision: 2, retainedCount: 1, discardedCount: 3, highestSeverity: "warning" },
},
},
remote: {
machineId: "remote",
status: "fresh",
daemonInstanceId: "daemon-remote",
catalogRevision: 1,
summariesBySessionId: {
"session-1": { sessionId: "session-1", cwd: "/remote", inboxRevision: 1, retainedCount: 5, discardedCount: 0, highestSeverity: "info" },
},
},
},
};
Reflect.set(app, "state", state);
const badges = navigationNotificationBadges(app);
const mobile = mobileSessionsNotificationBadge(app);
expect(badges.sessions["session-1"]).toMatchObject({ text: "2", severity: "error" });
expect(badges.sessions["archived-session"]).toBeUndefined();
expect(badges.workspaces["workspace-1"]).toMatchObject({ text: "2", severity: "error" });
expect(badges.workspaces["workspace-2"]).toMatchObject({ text: "1+", severity: "warning" });
expect(badges.projects["project-1"]).toMatchObject({ text: "3+", severity: "error" });
expect(badges.machines["local"]).toMatchObject({ text: "3+", severity: "error" });
expect(badges.machines["remote"]).toMatchObject({ text: "5", severity: "info" });
expect(badges.machinesHeading).toMatchObject({ text: "8+", severity: "error" });
expect(machineSwitcherNotificationBadge("local", badges.machines, badges.machinesHeading)).toBe(badges.machinesHeading);
expect(badges.sessionsHeading).toMatchObject({ text: "2", severity: "error" });
expect(mobile).toMatchObject({ text: "8+", severity: "error" });
});
it("excludes stale remote catalogs from mobile and machine badges", () => {
const app = createApp();
const state = initialAppState();
state.notificationCatalogsByMachine = {
remote: {
machineId: "remote",
status: "stale",
daemonInstanceId: "daemon-remote",
catalogRevision: 3,
summariesBySessionId: {
"session-1": { sessionId: "session-1", cwd: "/repo", inboxRevision: 3, retainedCount: 9, discardedCount: 0, highestSeverity: "error" },
},
},
};
Reflect.set(app, "state", state);
expect(mobileSessionsNotificationBadge(app)).toBeUndefined();
});
});
function createApp(): PiWebApp {
const storage = { getItem: () => null, setItem: () => undefined, removeItem: () => undefined };
vi.stubGlobal("window", { location: { search: "" }, localStorage: storage });
return new PiWebApp();
}
type NavigationNotificationBadgesMethod = (this: PiWebApp) => NavigationNotificationBadges;
type MobileNotificationBadgeMethod = (this: PiWebApp) => NavigationNotificationBadges["machinesHeading"];
function navigationNotificationBadges(app: PiWebApp): NavigationNotificationBadges {
const method: unknown = Reflect.get(app, "navigationNotificationBadges");
if (!isNavigationNotificationBadgesMethod(method)) throw new Error("PiWebApp.navigationNotificationBadges is not callable");
return method.call(app);
}
function mobileSessionsNotificationBadge(app: PiWebApp): NavigationNotificationBadges["machinesHeading"] {
const method: unknown = Reflect.get(app, "mobileSessionsNotificationBadge");
if (!isMobileNotificationBadgeMethod(method)) throw new Error("PiWebApp.mobileSessionsNotificationBadge is not callable");
return method.call(app);
}
function isNavigationNotificationBadgesMethod(value: unknown): value is NavigationNotificationBadgesMethod {
return typeof value === "function";
}
function isMobileNotificationBadgeMethod(value: unknown): value is MobileNotificationBadgeMethod {
return typeof value === "function";
}
+9 -68
View File
@@ -1,6 +1,6 @@
import { LitElement, html } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import { configApi, effectiveWorkspaceUploadFolder, sessionsApi, terminalsApi, workspacesApi, workspaceEffectiveUploadFolder, type Machine, type MachineHealth, type PiWebConfigValues, type PiWebShortcutConfig, type Project, type RealtimeEvent, type SessionCleanupExecuteResponse, type SessionCleanupPreviewResponse, type SessionCleanupRequest, type SessionInfo, type TerminalCommandRun, type TerminalUiEvent, type Workspace } from "../api";
import { configApi, effectiveWorkspaceUploadFolder, sessionsApi, terminalsApi, workspacesApi, workspaceEffectiveUploadFolder, type Machine, type MachineHealth, type PiWebConfigValues, type PiWebShortcutConfig, type Project, type SessionCleanupExecuteResponse, type SessionCleanupPreviewResponse, type SessionCleanupRequest, type SessionInfo, type TerminalCommandRun, type TerminalUiEvent, type Workspace } from "../api";
import type { AppAction } from "../actions";
import { initialAppState, type AppState } from "../appState";
import { isSessionActive } from "../../../shared/activity";
@@ -23,18 +23,9 @@ 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 { selectedNotificationView } from "../sessionNotifications";
import { hasAuthoritativeSessionPersistence as runtimeHasAuthoritativeSessionPersistence } from "../sessionPersistence";
import { RealtimeSocket } from "../sessionSocket";
import { RealtimeSocket, type BrowserRealtimeEvent } from "../sessionSocket";
import type { PiWebPluginRegistration, PluginMachine, PluginPromptEditor, QualifiedContributionId, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspacePanelContribution, PluginRuntimeContext, TerminalCommandRunsInternalRuntime, WorkspaceFiles, WorkspaceHost, WorkspaceLabelContext, WorkspaceLabelItem, WorkspacePanelContext } from "../plugins/types";
import { CLASSIC_THEME_ID, DEFAULT_THEME_PREFERENCE, applyPiWebTheme, findThemePairForTheme, readStoredThemePreference, resolveThemePreference, writeStoredThemePreference, type ThemePreference, type ThemePreferenceResolution } from "../theme";
import { corePlugin } from "../plugins/core";
@@ -74,7 +65,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, type NavigationNotificationBadges } from "./appShell/AppNavigationPanel";
import { shouldShowMachinesSection, type AppNavigationPanel, type NavigationFocusTarget } from "./appShell/AppNavigationPanel";
import "./appShell/AppPanelEdgeControl";
import "./appShell/AppRefreshControl";
import { appStyles } from "./shared";
@@ -327,7 +318,6 @@ export class PiWebApp extends LitElement {
private async refreshAfterBrowserResume(): Promise<void> {
await Promise.all([
this.sessions.refreshSelectedSession(),
this.notifications.refreshAfterBrowserResume(),
this.refreshMachineActivities(),
this.refreshWorkspaceDeletionRuns(),
]);
@@ -383,7 +373,6 @@ export class PiWebApp extends LitElement {
try {
await Promise.all([
this.sessions.refreshSelectedSession(),
this.notifications.refreshAfterBrowserResume(),
this.refreshMachineActivities(),
this.loadClientConfig(),
this.refreshWorkspaceDeletionRuns(),
@@ -818,9 +807,8 @@ export class PiWebApp extends LitElement {
private connectRealtime(): void {
const machineId = selectedMachineId(this.state);
this.realtime.connect(
(event) => { this.handleRealtimeEvent(machineId, event); },
(event) => { this.handleRealtimeEvent(event); },
() => {
this.notifications.globalSocketOpened(machineId);
const workspace = this.state.selectedWorkspace;
if (workspace !== undefined) void this.refreshActiveTerminals(workspace);
void this.refreshWorkspaceActivity(machineId);
@@ -842,7 +830,6 @@ export class PiWebApp extends LitElement {
socket.connect(
(event) => { this.handleMachineActivityEvent(machineId, event); },
() => {
this.notifications.globalSocketOpened(machineId);
void this.refreshWorkspaceActivity(machineId);
},
machineId,
@@ -864,14 +851,12 @@ export class PiWebApp extends LitElement {
.map((machine) => machine.id));
}
private handleMachineActivityEvent(machineId: string, event: RealtimeEvent): void {
private handleMachineActivityEvent(machineId: string, event: BrowserRealtimeEvent): 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(machineId: string, event: RealtimeEvent): void {
private handleRealtimeEvent(event: BrowserRealtimeEvent): 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();
@@ -1146,49 +1131,6 @@ 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
@@ -1196,7 +1138,6 @@ 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))}
@@ -2014,7 +1955,7 @@ export class PiWebApp extends LitElement {
private mobileMainTabs(): AppMobileMainTab[] {
return [
{ id: "navigation", label: "Sessions", icon: "navigation", className: "navigation-tab", badge: this.mobileSessionsNotificationBadge() },
{ id: "navigation", label: "Sessions", icon: "navigation", className: "navigation-tab" },
{ id: "chat", label: "Chat", icon: "chat" },
...this.visibleWorkspacePanels().map((panel): AppMobileMainTab => {
const icon = panel.icon ?? this.mobilePanelIcon(panel);
@@ -2105,7 +2046,7 @@ function isActive(state: Pick<AppState, "status" | "activity">): boolean {
return isSessionActive(state.status, state.activity);
}
function isTerminalEvent(event: RealtimeEvent): event is TerminalUiEvent {
function isTerminalEvent(event: BrowserRealtimeEvent): event is TerminalUiEvent {
return event.type === "terminal.created" || event.type === "terminal.exited" || event.type === "terminal.closed";
}
+3 -7
View File
@@ -1,14 +1,12 @@
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 {
@@ -16,8 +14,6 @@ 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;
@@ -68,7 +64,7 @@ export class ProjectList extends LitElement implements KeyboardNavigableSection
@keydown=${(event: KeyboardEvent) => { this.handleProjectKeydown(event, project); }}
>
<div class="action-main">
<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>
<span class="workspace-primary"><span class="workspace-primary-label">${project.name}</span></span><small>${project.path}</small>
${this.renderActivity(project)}
</div>
<div class="action-menu">
@@ -97,10 +93,10 @@ export class ProjectList extends LitElement implements KeyboardNavigableSection
}
private renderHeading() {
if (!this.collapsible) return html`<span>Projects</span>${this.notificationHeadingBadge === undefined ? null : html`<notification-badge .model=${this.notificationHeadingBadge}></notification-badge>`}`;
if (!this.collapsible) return html`<span>Projects</span>`;
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>${this.notificationHeadingBadge === undefined ? null : html`<notification-badge .model=${this.notificationHeadingBadge}></notification-badge>`}<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><small class="section-count">${this.projects.length}</small></button>`;
}
private renderActivity(project: Project) {
+1 -7
View File
@@ -2,7 +2,6 @@ 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";
@@ -11,7 +10,6 @@ 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;
@@ -32,8 +30,6 @@ 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;
@@ -137,7 +133,6 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
return html`
<h2>
<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()}
@@ -149,7 +144,6 @@ 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()}
@@ -256,7 +250,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-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>
<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></span><small>${this.renderSessionMetaPrefix(session, status, activity)}${String(session.messageCount)} messages</small>
${this.renderActivity(session)}
</div>
<div class="action-menu">
+2 -7
View File
@@ -2,7 +2,6 @@ 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";
@@ -10,7 +9,6 @@ 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 {
@@ -21,8 +19,6 @@ 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;
@@ -89,10 +85,10 @@ export class WorkspaceList extends LitElement implements KeyboardNavigableSectio
}
private renderHeading() {
if (!this.collapsible) return html`<span>Workspaces</span>${this.notificationHeadingBadge === undefined ? null : html`<notification-badge .model=${this.notificationHeadingBadge}></notification-badge>`}`;
if (!this.collapsible) return html`<span>Workspaces</span>`;
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>${this.notificationHeadingBadge === undefined ? null : html`<notification-badge .model=${this.notificationHeadingBadge}></notification-badge>`}<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><small class="section-count">${this.workspaces.length}</small></button>`;
}
private renderActivity(workspace: Workspace): TemplateResult | undefined {
@@ -105,7 +101,6 @@ 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,9 +1,7 @@
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;
@@ -76,7 +74,6 @@ 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}`;
@@ -84,7 +81,6 @@ export class AppMobileMainTabs extends LitElement {
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>`;
}
@@ -180,10 +176,3 @@ 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,7 +2,6 @@ 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";
@@ -14,19 +13,6 @@ 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[] = [];
@@ -44,7 +30,6 @@ 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;
@@ -115,8 +100,6 @@ export class AppNavigationPanel extends LitElement {
.selected=${this.selectedMachine}
.statuses=${this.machineStatuses}
.activities=${this.machineActivities}
.notificationBadges=${this.notificationBadges.machines}
.notificationHeadingBadge=${this.notificationBadges.machinesHeading}
.onSelect=${(machine: Machine) => this.onSelectMachine?.(machine)}
.onRemove=${(machine: Machine) => this.onRemoveMachine?.(machine)}
.onFocusNextSection=${() => { this.focusNextFrom("machines"); }}
@@ -134,8 +117,6 @@ 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?.(); }}
@@ -150,8 +131,6 @@ 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?.(); }}
@@ -166,8 +145,6 @@ 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}
@@ -183,8 +160,6 @@ 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}
+30 -34
View File
@@ -288,48 +288,44 @@ 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 { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; background: var(--pi-bg-overlay); }
.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 { position: sticky; top: 0; z-index: 2; flex: 0 0 auto; min-width: 0; display: flex; flex-wrap: nowrap; align-items: center; justify-content: space-between; gap: 8px; box-sizing: border-box; min-height: 40px; padding: 4px 10px; border-bottom: 1px solid var(--pi-border-muted); background: var(--pi-bg-overlay); }
.notification-tray.collapsed .notification-header { border-bottom: 0; }
.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-heading { min-width: 0; flex: 1 1 auto; overflow: hidden; color: var(--pi-text-bright); font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
.notification-header-actions { flex: 0 0 auto; display: flex; align-items: center; gap: 2px; }
.notification-control, .notification-row-dismiss { box-sizing: border-box; min-height: 32px; border: 0; border-radius: 6px; background: transparent; color: var(--pi-muted); cursor: pointer; }
.notification-control { padding: 0 7px; font: 12px system-ui, sans-serif; white-space: nowrap; }
.notification-toggle { display: inline-grid; place-items: center; width: 32px; height: 32px; padding: 0; }
.notification-control:hover, .notification-control:focus-visible, .notification-row-dismiss:hover, .notification-row-dismiss:focus-visible { background: var(--pi-selection-bg); color: var(--pi-text-bright); }
.notification-control:focus-visible, .notification-row-dismiss:focus-visible { outline: 2px solid var(--pi-accent); outline-offset: 1px; }
.notification-control:disabled, .notification-row-dismiss:disabled { opacity: .5; background: transparent; cursor: default; }
.notification-icon { width: 17px; height: 17px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; }
.notification-disclosure-icon.expanded { transform: rotate(90deg); }
.notification-close-icon { width: 16px; height: 16px; }
.notification-list { flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior-y: contain; box-sizing: border-box; padding: 0 10px 5px; }
.notification-list[hidden] { display: none; }
.notification-overflow { margin: 0; padding: 7px 2px; border-bottom: 1px solid var(--pi-border-muted); color: var(--pi-muted); font-size: 11px; overflow-wrap: anywhere; }
.notification-row { position: relative; min-width: 0; display: grid; gap: 4px; box-sizing: border-box; padding: 9px 38px 9px 2px; border-bottom: 1px solid var(--pi-border-muted); color: var(--pi-text); }
.notification-row:focus-visible { outline: 2px solid var(--pi-accent); outline-offset: -2px; }
.notification-metadata { min-width: 0; display: flex; align-items: baseline; gap: 5px; color: var(--pi-muted); font-size: 11px; }
.notification-severity { color: var(--pi-muted); font-size: inherit; font-weight: 600; }
.notification-row.warning .notification-severity { color: var(--pi-warning); }
.notification-row.error .notification-severity { color: var(--pi-danger); }
.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; }
.notification-truncated { margin: 0; color: var(--pi-muted); font-size: 11px; overflow-wrap: anywhere; }
.notification-row-dismiss { position: absolute; top: 5px; right: 0; display: inline-grid; place-items: center; width: 32px; height: 32px; padding: 0; }
.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; }
.notification-control, .notification-row-dismiss { min-height: 34px; }
.notification-toggle, .notification-row-dismiss { width: 34px; height: 34px; }
.notification-row { padding-right: 40px; }
}
@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; }
.notification-header { gap: 4px; padding-inline: 8px; }
.notification-list { 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; }
@@ -6,7 +6,6 @@ import type {
Machine,
SessionInfo,
SessionNotification,
SessionNotificationCatalogSnapshot,
SessionNotificationInboxEvent,
SessionNotificationInboxSnapshot,
} from "../../../shared/apiTypes";
@@ -64,14 +63,6 @@ function inboxSnapshot(
};
}
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",
@@ -111,11 +102,9 @@ function capableState(): AppState {
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(
@@ -131,149 +120,83 @@ function createHarness(initialState = capableState(), overrides: Partial<Session
};
}
describe("SessionNotificationController capability and joins", () => {
it("makes no notification requests and preserves marked legacy output without effective capability support", async () => {
describe("SessionNotificationController selected inbox ownership", () => {
it("makes no notification request 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 () => {
it("treats a validated selected-inbox 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,
});
harness.controller.applyInboxEvent("local", addedEvent(entry(1), 1, 1));
await vi.waitFor(() => {
expect(harness.api.notificationCatalog).toHaveBeenCalledOnce();
expect(harness.api.notificationInbox).toHaveBeenCalledOnce();
});
await vi.waitFor(() => { expect(harness.api.notificationInbox).toHaveBeenCalledOnce(); });
expect(selectedNotificationView(harness.state.selectedNotificationInbox)?.notifications).toHaveLength(1);
expect(harness.controller.shouldFilterLegacyNotification("local", "daemon-a:1")).toBe(true);
});
it("buffers global events around catalog hydration and applies newer revisions in order", async () => {
const catalog = deferred<SessionNotificationCatalogSnapshot>();
const harness = createHarness(capableState(), { notificationCatalog: vi.fn(() => catalog.promise) });
harness.controller.globalSocketOpened("local");
harness.controller.applySummaryEvent("local", {
type: "notifications.summary",
daemonInstanceId: "daemon-a",
catalogRevision: 2,
summary: {
...inboxSnapshot([entry(2, "warning"), entry(1)], { inboxRevision: 2, catalogRevision: 2 }).summary,
},
});
expect(harness.state.notificationCatalogsByMachine["local"]).toBeUndefined();
catalog.resolve(catalogSnapshot(1));
await vi.waitFor(() => { expect(harness.state.notificationCatalogsByMachine["local"]?.catalogRevision).toBe(2); });
expect(harness.state.notificationCatalogsByMachine["local"]).toMatchObject({ status: "fresh", daemonInstanceId: "daemon-a" });
expect(harness.state.notificationCatalogsByMachine["local"]?.summariesBySessionId[session.id]).toMatchObject({ retainedCount: 2, highestSeverity: "warning" });
expect(harness.controller.shouldFilterLegacyNotification("local", "notification-1")).toBe(true);
});
it("lets the selected live event announce before its matching global summary can trigger a snapshot", async () => {
const first = inboxSnapshot([entry(1)], { inboxRevision: 1, catalogRevision: 1 });
const state = {
...capableState(),
notificationCatalogsByMachine: {
local: {
machineId: "local",
status: "fresh" as const,
daemonInstanceId: first.daemonInstanceId,
catalogRevision: first.catalogRevision,
summariesBySessionId: { [session.id]: first.summary },
},
},
};
const notificationInbox = vi.fn(() => Promise.resolve(first));
const harness = createHarness(state, { notificationInbox });
harness.controller.prepareSelectedSession(session, "local");
await harness.controller.refreshSelectedSession(session, "local");
const event = addedEvent(entry(2, "warning"), 2, 2);
harness.controller.applySummaryEvent("local", {
type: "notifications.summary",
daemonInstanceId: event.daemonInstanceId,
catalogRevision: event.catalogRevision,
summary: event.summary,
});
await Promise.resolve();
expect(notificationInbox).toHaveBeenCalledOnce();
harness.controller.applyInboxEvent("local", event);
expect(selectedNotificationView(harness.state.selectedNotificationInbox)?.announcements).toMatchObject([
{ severity: "warning", message: "notice 2" },
]);
expect(notificationInbox).toHaveBeenCalledOnce();
});
it("refetches the selected inbox when a newly opened global socket finds a newer catalog revision", async () => {
const first = inboxSnapshot([entry(1)], { inboxRevision: 1, catalogRevision: 1 });
const second = inboxSnapshot([entry(2, "error"), entry(1)], { inboxRevision: 2, catalogRevision: 2 });
const notificationInbox = vi.fn()
.mockResolvedValueOnce(first)
.mockResolvedValueOnce(second);
const notificationCatalog = vi.fn(() => Promise.resolve<SessionNotificationCatalogSnapshot>({
daemonInstanceId: second.daemonInstanceId,
catalogRevision: second.catalogRevision,
sessions: [second.summary],
}));
const harness = createHarness(capableState(), { notificationInbox, notificationCatalog });
it("joins selected live events that arrive while the selected snapshot is loading", async () => {
const pendingInbox = deferred<SessionNotificationInboxSnapshot>();
const harness = createHarness(capableState(), { notificationInbox: vi.fn(() => pendingInbox.promise) });
harness.controller.prepareSelectedSession(session, "local");
await harness.controller.refreshSelectedSession(session, "local");
harness.controller.globalSocketOpened("local");
const refresh = harness.controller.refreshSelectedSession(session, "local");
harness.controller.applyInboxEvent("local", addedEvent(entry(2, "warning"), 2, 2));
pendingInbox.resolve(inboxSnapshot([entry(1)], { inboxRevision: 1, catalogRevision: 1 }));
await refresh;
await vi.waitFor(() => { expect(notificationInbox).toHaveBeenCalledTimes(2); });
expect(selectedNotificationView(harness.state.selectedNotificationInbox)?.notifications.map((notification) => notification.id)).toEqual([
"daemon-a:2",
"daemon-a:1",
]);
expect(selectedNotificationView(harness.state.selectedNotificationInbox)?.announcements).toMatchObject([
{ severity: "warning", message: "notice 2" },
]);
});
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 });
it("ignores notification events for an unselected chat", async () => {
const harness = createHarness();
harness.controller.prepareSelectedSession(session, "local");
await harness.controller.refreshSelectedSession(session, "local");
const selectedBefore = harness.state.selectedNotificationInbox;
harness.controller.syncEnvironment(initialAppState(), next);
harness.controller.applyInboxEvent("local", {
...addedEvent(entry(2), 2, 1),
summary: { ...addedEvent(entry(2), 2, 1).summary, sessionId: "session-2" },
});
await Promise.resolve();
await vi.waitFor(() => { expect(harness.state.workspacesByProjectId[project.id]).toEqual([workspace]); });
expect(workspaces).toHaveBeenCalledExactlyOnceWith(project.id, "local");
expect(harness.state.selectedProject).toBe(project);
expect(harness.api.notificationInbox).toHaveBeenCalledOnce();
expect(harness.state.selectedNotificationInbox).toBe(selectedBefore);
});
it("recovers a selected inbox revision gap from its bounded snapshot", async () => {
const first = inboxSnapshot([entry(1)], { inboxRevision: 1, catalogRevision: 1 });
const recovered = inboxSnapshot([entry(3), entry(1)], { inboxRevision: 3, catalogRevision: 3 });
const notificationInbox = vi.fn()
.mockResolvedValueOnce(first)
.mockResolvedValueOnce(recovered);
const harness = createHarness(capableState(), { notificationInbox });
harness.controller.prepareSelectedSession(session, "local");
await harness.controller.refreshSelectedSession(session, "local");
harness.controller.applyInboxEvent("local", addedEvent(entry(3), 3, 2));
await vi.waitFor(() => { expect(notificationInbox).toHaveBeenCalledTimes(2); });
expect(selectedNotificationView(harness.state.selectedNotificationInbox)?.notifications.map((notification) => notification.id)).toEqual([
"daemon-a:3",
"daemon-a:1",
]);
});
it("ignores an old selected-inbox response after selection changes", async () => {
@@ -372,12 +295,7 @@ describe("SessionNotificationController optimistic mutations", () => {
const dismissNotification = vi.fn()
.mockImplementationOnce(() => dismiss.promise)
.mockRejectedValueOnce(new Error("offline"));
const notificationCatalog = vi.fn(() => Promise.resolve<SessionNotificationCatalogSnapshot>({
daemonInstanceId: initialInbox.daemonInstanceId,
catalogRevision: initialInbox.catalogRevision,
sessions: [initialInbox.summary],
}));
const harness = createHarness(capableState(), { notificationInbox, dismissNotification, notificationCatalog });
const harness = createHarness(capableState(), { notificationInbox, dismissNotification });
harness.controller.prepareSelectedSession(session, "local");
await harness.controller.refreshSelectedSession(session, "local");
@@ -1,35 +1,26 @@
import { api as defaultApi, type Machine, type SessionInfo } from "../api";
import type { AppState } from "../appState";
import {
applyNotificationCatalogEvent,
applySelectedNotificationEvent,
freshNotificationCatalog,
installSelectedNotificationSnapshot,
loadingSelectedNotificationInbox,
notificationSummaryIsEmpty,
notificationTargetsEqual,
selectedNotificationView,
type SelectedSessionNotificationInbox,
type SessionNotificationCatalogProjection,
type SessionNotificationTarget,
} from "../sessionNotifications";
import { PI_WEB_CAPABILITIES, supportsPiWebCapability } from "../../../shared/capabilities";
import type {
SessionNotificationInboxEvent,
SessionNotificationInboxSnapshot,
SessionNotificationSummaryEvent,
SessionRef,
} from "../../../shared/apiTypes";
import { selectedMachineId, type GetState, type SetState } from "./types";
const WORKSPACE_HYDRATION_CONCURRENCY = 3;
import type { GetState, SetState } from "./types";
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 {
@@ -37,15 +28,6 @@ export interface SessionNotificationControllerDependencies {
onBackgroundError?: (message: string, error: unknown) => void;
}
interface CatalogJoin {
events: SessionNotificationSummaryEvent[];
}
interface CatalogRefreshOperation {
promise: Promise<void>;
trailing: boolean;
}
interface SelectedJoin {
generation: number;
events: SessionNotificationInboxEvent[];
@@ -58,7 +40,7 @@ interface SelectedRefreshOperation {
}
/**
* Owns browser projections of daemon notification state.
* Owns the browser projection of the selected session's notification inbox.
*
* Network and socket inputs enter through explicit methods; all transcript state
* remains owned by SessionController/ChatTranscriptStore and is never touched.
@@ -67,16 +49,12 @@ 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(
@@ -93,11 +71,9 @@ export class SessionNotificationController {
this.selectedGeneration += 1;
this.selectedTarget = undefined;
this.selectedJoin = undefined;
this.catalogJoins.clear();
this.catalogRefreshes.clear();
this.acceptedSupportByMachine.clear();
this.dismissingNotificationIds.clear();
this.dismissAllPending = false;
this.workspaceHydrationsInFlight.clear();
}
prepareSelectedSession(session: SessionInfo, machineId: string): void {
@@ -124,7 +100,7 @@ export class SessionNotificationController {
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.disposed || 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;
@@ -143,12 +119,9 @@ export class SessionNotificationController {
}
applyInboxEvent(machineId: string, event: SessionNotificationInboxEvent): void {
this.acceptedSupportByMachine.add(machineId);
const target = this.selectedTarget;
if (target?.machineId !== machineId || target.sessionId !== event.summary.sessionId || target.cwd !== event.summary.cwd) {
this.applyCatalogSummary(machineId, inboxSummaryEvent(event));
return;
}
if (target?.machineId !== machineId || target.sessionId !== event.summary.sessionId || target.cwd !== event.summary.cwd) return;
this.acceptedSupportByMachine.add(machineId);
const join = this.selectedJoin;
if (join?.generation === this.selectedGeneration) {
join.events.push(event);
@@ -156,35 +129,9 @@ export class SessionNotificationController {
}
const result = applySelectedNotificationEvent(this.getState().selectedNotificationInbox, target, event);
if (result.changed) this.setState({ selectedNotificationInbox: result.value });
this.applyCatalogSummary(machineId, inboxSummaryEvent(event));
if (result.needsRefresh) this.scheduleSelectedRefresh(target);
}
applySummaryEvent(machineId: string, event: SessionNotificationSummaryEvent): void {
this.acceptedSupportByMachine.add(machineId);
const join = this.catalogJoins.get(machineId);
if (join !== undefined) {
join.events.push(event);
return;
}
// The matching per-session event carries the notification text and must win
// the live-announcement race. Initial-open and reconnect snapshots still
// reconcile selected state through the catalog join.
this.applyCatalogSummary(machineId, event, false);
this.ensureSelectedSupport(machineId);
}
globalSocketOpened(machineId: string): void {
if (machineId === selectedMachineId(this.getState())) this.workspaceHydrationKey = "";
if (this.machineSupportsNotifications(machineId) && this.machineIsReachable(machineId)) void this.refreshCatalog(machineId);
}
async refreshAfterBrowserResume(): Promise<void> {
this.workspaceHydrationKey = "";
const machineIds = this.notificationMachineIds().filter((machineId) => this.machineSupportsNotifications(machineId) && this.machineIsReachable(machineId));
await Promise.all(machineIds.map((machineId) => this.refreshCatalog(machineId)));
}
syncEnvironment(previous: AppState, next: AppState): void {
if (this.disposed) return;
if (previous.machines !== next.machines) this.pruneRemovedMachines(next.machines);
@@ -192,32 +139,19 @@ export class SessionNotificationController {
const environmentChanged = previous.machines !== next.machines
|| previous.machineStatuses !== next.machineStatuses
|| previous.machineRuntimes !== next.machineRuntimes;
if (environmentChanged) {
for (const machineId of this.notificationMachineIds()) {
const wasEligible = this.machineSupportsNotificationsInState(previous, machineId) && this.machineIsReachableInState(previous, machineId);
const isEligible = this.machineSupportsNotificationsInState(next, machineId) && this.machineIsReachableInState(next, machineId);
if (!isEligible) this.markCatalogStale(machineId);
else if (!wasEligible || next.notificationCatalogsByMachine[machineId]?.status !== "fresh") void this.refreshCatalog(machineId);
}
const selected = this.selectedTarget;
if (selected !== undefined) {
if (!environmentChanged || selected === undefined) return;
const wasEligible = this.machineSupportsNotificationsInState(previous, selected.machineId)
&& this.machineIsReachableInState(previous, selected.machineId);
if (!this.machineSupportsNotifications(selected.machineId) || !this.machineIsReachable(selected.machineId)) {
this.markSelectedStale(selected);
} else {
this.ensureSelectedProjection(selected);
if (!this.machineSupportsNotificationsInState(previous, selected.machineId) || next.selectedNotificationInbox?.status !== "fresh") {
void this.refreshSelectedSession({ id: selected.sessionId, cwd: selected.cwd }, selected.machineId);
}
}
}
return;
}
if (environmentChanged
|| previous.projects !== next.projects
|| previous.workspacesByProjectId !== next.workspacesByProjectId
|| previous.selectedMachine !== next.selectedMachine
|| previous.notificationCatalogsByMachine !== next.notificationCatalogsByMachine) {
this.scheduleWorkspaceHydration();
this.ensureSelectedProjection(selected);
if (!wasEligible || this.getState().selectedNotificationInbox?.status !== "fresh") {
void this.refreshSelectedSession({ id: selected.sessionId, cwd: selected.cwd }, selected.machineId);
}
}
@@ -305,15 +239,12 @@ export class SessionNotificationController {
let inbox = current === undefined || shouldInstallSelectedSnapshot(current, target, snapshot)
? installSelectedNotificationSnapshot(current, target, snapshot)
: current;
const catalogEvents = [snapshotSummaryEvent(snapshot)];
for (const event of [...join.events].sort((left, right) => left.summary.inboxRevision - right.summary.inboxRevision)) {
const result = applySelectedNotificationEvent(inbox, target, event);
inbox = result.value;
catalogEvents.push(inboxSummaryEvent(event));
if (result.needsRefresh) operation.trailing = true;
}
this.setState({ selectedNotificationInbox: inbox });
for (const event of catalogEvents) this.applyCatalogSummary(target.machineId, event);
} catch (error) {
if (this.isCurrentTarget(target, operation.generation)) {
const current = this.getState().selectedNotificationInbox;
@@ -327,60 +258,6 @@ export class SessionNotificationController {
} while (operation.trailing && this.isCurrentTarget(target, operation.generation) && this.machineIsReachable(target.machineId));
}
private refreshCatalog(machineId: string): Promise<void> {
if (this.disposed || !this.machineIsKnown(machineId) || !this.machineSupportsNotifications(machineId) || !this.machineIsReachable(machineId)) return Promise.resolve();
const existing = this.catalogRefreshes.get(machineId);
if (existing !== undefined) {
existing.trailing = true;
return existing.promise;
}
const operation: CatalogRefreshOperation = { promise: Promise.resolve(), trailing: false };
operation.promise = this.runCatalogRefresh(machineId, operation).finally(() => {
if (this.catalogRefreshes.get(machineId) === operation) this.catalogRefreshes.delete(machineId);
});
this.catalogRefreshes.set(machineId, operation);
return operation.promise;
}
private async runCatalogRefresh(machineId: string, operation: CatalogRefreshOperation): Promise<void> {
do {
operation.trailing = false;
const join: CatalogJoin = { events: [] };
this.catalogJoins.set(machineId, join);
try {
const snapshot = await this.api.notificationCatalog(machineId);
if (this.disposed || !this.machineIsKnown(machineId) || !this.machineIsReachable(machineId)) return;
this.acceptedSupportByMachine.add(machineId);
let projection = freshNotificationCatalog(machineId, snapshot);
for (const event of [...join.events].sort((left, right) => left.catalogRevision - right.catalogRevision)) {
const result = applyNotificationCatalogEvent(projection, machineId, event);
projection = result.value;
if (result.needsRefresh) operation.trailing = true;
}
this.setCatalog(machineId, projection);
} catch (error) {
if (this.disposed) return;
this.markCatalogStale(machineId);
this.onBackgroundError(`Failed to refresh notification catalog for machine ${machineId}`, error);
return;
} finally {
if (this.catalogJoins.get(machineId) === join) this.catalogJoins.delete(machineId);
}
} while (operation.trailing && this.machineIsKnown(machineId) && this.machineIsReachable(machineId));
}
private applyCatalogSummary(machineId: string, event: SessionNotificationSummaryEvent, reconcileSelected = true): void {
const join = this.catalogJoins.get(machineId);
if (join !== undefined) {
join.events.push(event);
return;
}
const current = this.getState().notificationCatalogsByMachine[machineId];
const result = applyNotificationCatalogEvent(current, machineId, event);
if (result.changed) this.setCatalog(machineId, result.value, reconcileSelected);
if (result.needsRefresh) this.scheduleCatalogRefresh(machineId);
}
private applyMutationSnapshot(
target: SessionNotificationTarget,
snapshot: SessionNotificationInboxSnapshot,
@@ -396,7 +273,6 @@ export class SessionNotificationController {
? installSelectedNotificationSnapshot(current, target, snapshot)
: current;
this.setState({ selectedNotificationInbox: removeOverlay(authoritative) });
this.applyCatalogSummary(target.machineId, snapshotSummaryEvent(snapshot));
}
private patchSelectedOverlay(
@@ -408,15 +284,6 @@ export class SessionNotificationController {
this.setState({ selectedNotificationInbox: update(current) });
}
private ensureSelectedSupport(machineId: string): void {
const target = this.selectedTarget;
if (target?.machineId !== machineId) return;
const current = this.getState().selectedNotificationInbox;
if (current !== undefined && notificationTargetsEqual(current, target) && current.status === "fresh") return;
this.ensureSelectedProjection(target);
void this.refreshSelectedSession({ id: target.sessionId, cwd: target.cwd }, machineId);
}
private ensureSelectedProjection(target: SessionNotificationTarget): void {
const current = this.getState().selectedNotificationInbox;
if (current === undefined || !notificationTargetsEqual(current, target)) this.setState({ selectedNotificationInbox: loadingSelectedNotificationInbox(target) });
@@ -430,50 +297,6 @@ export class SessionNotificationController {
});
}
private scheduleCatalogRefresh(machineId: string): void {
queueMicrotask(() => {
if (!this.disposed) void this.refreshCatalog(machineId);
});
}
private setCatalog(machineId: string, projection: SessionNotificationCatalogProjection, reconcileSelected = true): void {
const current = this.getState().notificationCatalogsByMachine;
if (current[machineId] === projection) return;
this.setState({ notificationCatalogsByMachine: { ...current, [machineId]: projection } });
if (reconcileSelected) this.reconcileSelectedWithCatalog(projection);
}
private reconcileSelectedWithCatalog(catalog: SessionNotificationCatalogProjection): void {
if (catalog.status !== "fresh" || catalog.daemonInstanceId === undefined) return;
const target = this.selectedTarget;
if (target?.machineId !== catalog.machineId || !this.machineIsReachable(target.machineId)) return;
const inbox = this.getState().selectedNotificationInbox;
if (inbox === undefined || !notificationTargetsEqual(inbox, target) || inbox.status !== "fresh" || inbox.daemonInstanceId === undefined || inbox.summary === undefined) {
this.scheduleSelectedRefresh(target);
return;
}
if (inbox.daemonInstanceId !== catalog.daemonInstanceId) {
this.scheduleSelectedRefresh(target);
return;
}
const catalogSummary = catalog.summariesBySessionId[target.sessionId];
if (catalogSummary === undefined) {
if (!notificationSummaryIsEmpty(inbox.summary)) this.scheduleSelectedRefresh(target);
return;
}
if (catalogSummary.cwd !== target.cwd
|| catalogSummary.inboxRevision > inbox.summary.inboxRevision
|| (catalogSummary.inboxRevision === inbox.summary.inboxRevision && !notificationSummariesEqual(catalogSummary, inbox.summary))) {
this.scheduleSelectedRefresh(target);
}
}
private markCatalogStale(machineId: string): void {
const current = this.getState().notificationCatalogsByMachine[machineId];
if (current === undefined || current.status === "stale") return;
this.setCatalog(machineId, { ...current, status: "stale" });
}
private markSelectedStale(target: SessionNotificationTarget): void {
const current = this.getState().selectedNotificationInbox;
if (current === undefined || !notificationTargetsEqual(current, target) || current.status === "stale") return;
@@ -483,43 +306,9 @@ export class SessionNotificationController {
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
@@ -546,34 +335,6 @@ export class SessionNotificationController {
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 {
@@ -591,34 +352,6 @@ function shouldInstallSelectedSnapshot(
|| snapshot.summary.inboxRevision >= current.summary.inboxRevision;
}
async function forEachWithConcurrency<T>(items: readonly T[], concurrency: number, worker: (item: T) => Promise<void>): Promise<void> {
let nextIndex = 0;
async function run(): Promise<void> {
while (nextIndex < items.length) {
const item = items[nextIndex];
nextIndex += 1;
if (item !== undefined) await worker(item);
}
}
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => run()));
}
function workspaceHydrationId(machineId: string, projectId: string): string {
return JSON.stringify([machineId, projectId]);
}
function notificationSummariesEqual(
left: SessionNotificationInboxSnapshot["summary"],
right: SessionNotificationInboxSnapshot["summary"],
): boolean {
return left.sessionId === right.sessionId
&& left.cwd === right.cwd
&& left.inboxRevision === right.inboxRevision
&& left.retainedCount === right.retainedCount
&& left.discardedCount === right.discardedCount
&& left.highestSeverity === right.highestSeverity;
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
+39 -80
View File
@@ -4,24 +4,22 @@ import type {
SessionNotificationInboxEvent,
SessionNotificationInboxSnapshot,
SessionNotificationSummary,
SessionNotificationSummaryEvent,
} from "../../shared/apiTypes";
import {
aggregateNotificationSummaries,
applyNotificationCatalogEvent,
applySelectedNotificationEvent,
effectiveNotificationSummaries,
freshNotificationCatalog,
installSelectedNotificationSnapshot,
notificationAggregateAcrossMachines,
notificationAggregateForCwd,
notificationAggregateForProject,
notificationBadgeModel,
notificationAnnouncementLabel,
notificationDismissLabel,
notificationFocusTargetAfterDismiss,
notificationInboxOverflowLabel,
notificationInboxTotalCount,
notificationMessageTruncationLabel,
notificationTargetKey,
notificationTrayHeading,
notificationTrayIsCollapsed,
selectedNotificationView,
setNotificationTrayCollapsed,
type SessionNotificationAnnouncement,
type SessionNotificationTarget,
} from "./sessionNotifications";
@@ -73,15 +71,6 @@ function addedEvent(entry: SessionNotification, inboxRevision: number, catalogRe
};
}
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());
@@ -161,62 +150,6 @@ describe("selected notification projection", () => {
});
});
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)];
@@ -226,14 +159,40 @@ describe("notification presentation helpers", () => {
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");
it("retains collapse state by exact machine, cwd, and session identity", () => {
const collapsed = setNotificationTrayCollapsed(new Set(), target, true);
expect(notificationTrayIsCollapsed(collapsed, target)).toBe(true);
expect(notificationTrayIsCollapsed(collapsed, { ...target, machineId: "remote" })).toBe(false);
expect(notificationTrayIsCollapsed(collapsed, { ...target, cwd: "/other" })).toBe(false);
expect(notificationTrayIsCollapsed(collapsed, { ...target, sessionId: "session-2" })).toBe(false);
expect(notificationTargetKey(target)).not.toBe(notificationTargetKey({ ...target, cwd: "/repo|session-1" }));
expect(notificationTrayIsCollapsed(setNotificationTrayCollapsed(collapsed, target, false), target)).toBe(false);
});
it("derives compact tray copy and a count that includes older unseen notifications", () => {
const counts = { retainedCount: 2, discardedCount: 23 };
expect(notificationInboxTotalCount(counts)).toBe(25);
expect(notificationTrayHeading(counts)).toBe("Notifications (25)");
expect(notificationInboxOverflowLabel(1)).toBe("1 older notification not shown.");
expect(notificationInboxOverflowLabel(23)).toBe("23 older notifications not shown.");
expect(notificationMessageTruncationLabel({ truncated: true })).toBe("Message truncated at 8 KiB.");
expect(notificationMessageTruncationLabel({ truncated: false })).toBeUndefined();
});
it("keeps live announcements concise and dismiss labels meaningful", () => {
const announcement: SessionNotificationAnnouncement = {
id: "daemon-a:2:daemon-a:2",
severity: "error",
message: "An arbitrarily long extension message that should not be read by the assertive live region",
};
const longNotification = notification(2, "warning", ` Build failed\n${"x".repeat(100)}`);
expect(notificationAnnouncementLabel(announcement)).toBe("Error notification received.");
expect(notificationDismissLabel(longNotification)).toMatch(/^Dismiss notification: Build failed x+$/u);
expect(notificationDismissLabel({ message: " ", severity: "warning" })).toBe("Dismiss warning notification");
});
});
function optionalHighestSeverity(notifications: readonly SessionNotification[]): { highestSeverity?: SessionNotification["severity"] } {
+35 -168
View File
@@ -2,25 +2,15 @@ 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;
@@ -57,19 +47,6 @@ export interface SelectedSessionNotificationView extends SessionNotificationTarg
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;
@@ -90,55 +67,6 @@ export function loadingSelectedNotificationInbox(target: SessionNotificationTarg
};
}
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,
@@ -264,84 +192,31 @@ export function selectedNotificationView(inbox: SelectedSessionNotificationInbox
};
}
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 function notificationAnnouncementLabel(announcement: Pick<SessionNotificationAnnouncement, "severity">): string {
return `${notificationSeverityLabel(announcement.severity)} notification received.`;
}
export function notificationInboxTotalCount(inbox: Pick<SelectedSessionNotificationView, "retainedCount" | "discardedCount">): number {
return inbox.retainedCount + inbox.discardedCount;
}
export function notificationTrayHeading(inbox: Pick<SelectedSessionNotificationView, "retainedCount" | "discardedCount">): string {
return `Notifications (${String(notificationInboxTotalCount(inbox))})`;
}
export function notificationDismissLabel(notification: Pick<SessionNotification, "message" | "severity">): string {
const message = notification.message.replace(/\s+/gu, " ").trim();
if (message === "") return `Dismiss ${notificationSeverityLabel(notification.severity).toLowerCase()} notification`;
const maxCharacters = 80;
const characters = Array.from(message);
const summary = characters.length <= maxCharacters ? message : `${characters.slice(0, maxCharacters - 1).join("").trimEnd()}`;
return `Dismiss notification: ${summary}`;
}
export type NotificationFocusTarget = { kind: "notification"; notificationId: string } | { kind: "header" };
@@ -355,32 +230,37 @@ export function notificationFocusTargetAfterDismiss(notifications: readonly Sess
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);
export function notificationTargetKey(target: SessionNotificationTarget): string {
return JSON.stringify([target.machineId, target.cwd, target.sessionId]);
}
export function notificationTrayIsCollapsed(collapsedTargetKeys: ReadonlySet<string>, target: SessionNotificationTarget): boolean {
return collapsedTargetKeys.has(notificationTargetKey(target));
}
export function setNotificationTrayCollapsed(collapsedTargetKeys: ReadonlySet<string>, target: SessionNotificationTarget, collapsed: boolean): ReadonlySet<string> {
const next = new Set(collapsedTargetKeys);
const key = notificationTargetKey(target);
if (collapsed) next.add(key);
else next.delete(key);
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)}.`;
return `${String(discardedCount)} older ${discardedCount === 1 ? "notification" : "notifications"} not shown.`;
}
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.`;
return `Message truncated at ${String(kibibytes)} KiB.`;
}
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(
function higherNotificationSeverity(
left: SessionNotificationSeverity | undefined,
right: SessionNotificationSeverity | undefined,
): SessionNotificationSeverity | undefined {
@@ -390,15 +270,6 @@ export function higherNotificationSeverity(
return undefined;
}
function staleNotificationCatalog(machineId: string): SessionNotificationCatalogProjection {
return {
machineId,
status: "stale",
catalogRevision: 0,
summariesBySessionId: {},
};
}
function staleSelectedNotificationInbox(target: SessionNotificationTarget): SelectedSessionNotificationInbox {
return {
...loadingSelectedNotificationInbox(target),
@@ -423,10 +294,6 @@ function effectiveDiscardedCount(discardedCount: number, overflowWatermark: numb
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 };
}
+9 -12
View File
@@ -35,7 +35,7 @@ function inboxEvent() {
}
describe("notification socket guards", () => {
it("accepts validated per-session and global notification events", () => {
it("accepts validated selected-session events and drops global notification summaries", () => {
expect(parseSessionSocketEvent(inboxEvent())).toMatchObject({ type: "notifications.inbox", delta: { kind: "added" } });
expect(parseRealtimeSocketEvent({
@@ -43,7 +43,7 @@ describe("notification socket guards", () => {
daemonInstanceId: "daemon-a",
catalogRevision: 1,
summary: summary(),
})).toMatchObject({ type: "notifications.summary", summary: { sessionId: "session-1" } });
})).toBeUndefined();
});
it("ignores malformed notification events instead of widening type-only acceptance", () => {
@@ -55,12 +55,6 @@ describe("notification socket guards", () => {
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", () => {
@@ -139,10 +133,13 @@ describe("socket instance isolation", () => {
const oldHandler = vi.fn();
const newHandler = vi.fn();
const event = {
type: "notifications.summary",
daemonInstanceId: "daemon-a",
catalogRevision: 1,
summary: summary(),
type: "workspace.activity",
activity: {
cwd: "/repo",
hasSessionActivity: true,
hasTerminalActivity: false,
updatedAt: "2026-07-18T00:00:00.000Z",
},
};
socket.connect(oldHandler, undefined, "machine-a");
const oldSocket = FakeWebSocket.instances[0];
+10 -8
View File
@@ -1,9 +1,13 @@
import { realtimeEvents, sessionEvents } from "./api";
import { parseSessionNotificationInboxEvent, parseSessionNotificationSummaryEvent } from "./api/parsers";
import { parseSessionNotificationInboxEvent } from "./api/parsers";
import type { GlobalSessionEvent, RealtimeEvent, SessionRef, SessionUiEvent } from "../../shared/apiTypes";
export type { GlobalSessionEvent, RealtimeEvent, SessionUiEvent } from "../../shared/apiTypes";
export type BrowserRealtimeEvent = Exclude<RealtimeEvent, { type: "notifications.summary" }>;
type BrowserGlobalSessionEvent = Exclude<GlobalSessionEvent, { type: "notifications.summary" }>;
type NonGlobalBrowserRealtimeEvent = Exclude<BrowserRealtimeEvent, BrowserGlobalSessionEvent>;
export class SessionSocket {
private socket: WebSocket | undefined;
private session: SessionRef | undefined;
@@ -90,14 +94,14 @@ export class SessionSocket {
export class RealtimeSocket {
private socket: WebSocket | undefined;
private onEvent: ((event: RealtimeEvent) => void) | undefined;
private onEvent: ((event: BrowserRealtimeEvent) => void) | undefined;
private onOpen: (() => void) | undefined;
private reconnectTimer?: number;
private reconnectDelay = 500;
private shouldReconnect = false;
private machineId = "local";
connect(onEvent: (event: RealtimeEvent) => void, onOpen?: () => void, machineId = "local"): void {
connect(onEvent: (event: BrowserRealtimeEvent) => void, onOpen?: () => void, machineId = "local"): void {
this.close();
this.machineId = machineId;
this.onEvent = onEvent;
@@ -154,9 +158,7 @@ export function parseSessionSocketEvent(event: unknown): SessionUiEvent | undefi
return isLegacySessionUiEvent(event) ? event : undefined;
}
export function parseRealtimeSocketEvent(event: unknown): RealtimeEvent | undefined {
const type = eventType(event);
if (type === "notifications.summary") return safelyParseNotificationEvent(() => parseSessionNotificationSummaryEvent(event));
export function parseRealtimeSocketEvent(event: unknown): BrowserRealtimeEvent | undefined {
if (isLegacyGlobalSessionEvent(event) || isLegacyRealtimeEvent(event)) return event;
return undefined;
}
@@ -165,12 +167,12 @@ 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 {
function isLegacyGlobalSessionEvent(event: unknown): event is BrowserGlobalSessionEvent {
const type = eventType(event);
return type === "status.update" || type === "activity.update" || type === "session.name" || type === "session.created";
}
function isLegacyRealtimeEvent(event: unknown): event is RealtimeEvent {
function isLegacyRealtimeEvent(event: unknown): event is NonGlobalBrowserRealtimeEvent {
const type = eventType(event);
return type === "terminal.created" || type === "terminal.exited" || type === "terminal.closed" || type === "workspace.activity";
}