Archived
fix: scope notifications to the selected chat
This commit is contained in:
@@ -2,4 +2,4 @@
|
|||||||
"@jmfederico/pi-web": patch
|
"@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.
|
||||||
|
|||||||
@@ -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");
|
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 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 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 inbox = { daemonInstanceId: "daemon-a", catalogRevision: 1, summary, notifications: [notification], dismissThrough: { order: 1, overflowWatermark: 0 } };
|
||||||
const fetchMock = stubSequenceFetch([
|
const fetchMock = stubSequenceFetch([
|
||||||
jsonResponse({ daemonInstanceId: "daemon-a", catalogRevision: 1, sessions: [summary] }),
|
|
||||||
jsonResponse(inbox),
|
jsonResponse(inbox),
|
||||||
jsonResponse(inbox),
|
jsonResponse(inbox),
|
||||||
jsonResponse(inbox),
|
jsonResponse(inbox),
|
||||||
]);
|
]);
|
||||||
const ref = { id: "s /?", cwd: "/repo with spaces" };
|
const ref = { id: "s /?", cwd: "/repo with spaces" };
|
||||||
|
|
||||||
await sessionsApi.notificationCatalog("remote /?");
|
|
||||||
await sessionsApi.notificationInbox(ref, "remote /?");
|
await sessionsApi.notificationInbox(ref, "remote /?");
|
||||||
await sessionsApi.dismissNotification(ref, "daemon-a", "opaque/id?", "remote /?");
|
await sessionsApi.dismissNotification(ref, "daemon-a", "opaque/id?", "remote /?");
|
||||||
await sessionsApi.dismissAllNotifications(ref, "daemon-a", { order: 1, overflowWatermark: 7 }, "remote /?");
|
await sessionsApi.dismissAllNotifications(ref, "daemon-a", { order: 1, overflowWatermark: 7 }, "remote /?");
|
||||||
|
|
||||||
expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([
|
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?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",
|
||||||
"https://pi.example.test/api/machines/remote%20%2F%3F/sessions/s%20%2F%3F/notifications/dismiss-all",
|
"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, 1)[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, 2)[1]))).toEqual({ cwd: ref.cwd, daemonInstanceId: "daemon-a", throughOrder: 1, throughOverflowWatermark: 7 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ import {
|
|||||||
parseSessionCleanupExecuteResponse,
|
parseSessionCleanupExecuteResponse,
|
||||||
parseSessionCleanupPreviewResponse,
|
parseSessionCleanupPreviewResponse,
|
||||||
parseSessionInfo,
|
parseSessionInfo,
|
||||||
parseSessionNotificationCatalogSnapshot,
|
|
||||||
parseSessionNotificationInboxSnapshot,
|
parseSessionNotificationInboxSnapshot,
|
||||||
parseSessionStatus,
|
parseSessionStatus,
|
||||||
parseSessionStreamSnapshot,
|
parseSessionStreamSnapshot,
|
||||||
@@ -205,7 +204,6 @@ export const workspacesApi = {
|
|||||||
|
|
||||||
export const sessionsApi = {
|
export const sessionsApi = {
|
||||||
sessions: (cwd: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions?cwd=${encodeURIComponent(cwd)}`, arrayOf(parseSessionInfo)),
|
sessions: (cwd: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions?cwd=${encodeURIComponent(cwd)}`, arrayOf(parseSessionInfo)),
|
||||||
notificationCatalog: (machineId = "local") => request(`${machinePrefix(machineId)}/sessions/notifications`, parseSessionNotificationCatalogSnapshot),
|
|
||||||
notificationInbox: (session: SessionLookup, machineId = "local") => request(sessionQueryPath(session, "notifications", machineId), parseSessionNotificationInboxSnapshot),
|
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 }) }),
|
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 }) }),
|
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 }) }),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
|
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
|
||||||
import { SESSION_NOTIFICATION_LIMIT, SESSION_NOTIFICATION_MESSAGE_BYTES } from "../../../shared/apiTypes";
|
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", () => {
|
describe("API parsers", () => {
|
||||||
it("preserves additive interactive API-key flow hints and defaults legacy options", () => {
|
it("preserves additive interactive API-key flow hints and defaults legacy options", () => {
|
||||||
@@ -514,14 +514,9 @@ describe("API parsers", () => {
|
|||||||
expect(() => parseCommandResult({ type: "later" })).toThrow("Invalid command result type");
|
expect(() => parseCommandResult({ type: "later" })).toThrow("Invalid command result type");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("strictly parses notification snapshots and realtime events", () => {
|
it("strictly parses selected notification snapshots and realtime events", () => {
|
||||||
const inbox = notificationInboxWire();
|
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(parseSessionNotificationInboxSnapshot(inbox)).toEqual(inbox);
|
||||||
expect(parseSessionNotificationInboxEvent({
|
expect(parseSessionNotificationInboxEvent({
|
||||||
type: "notifications.inbox",
|
type: "notifications.inbox",
|
||||||
@@ -531,12 +526,6 @@ describe("API parsers", () => {
|
|||||||
dismissThrough: { order: 2, overflowWatermark: 0 },
|
dismissThrough: { order: 2, overflowWatermark: 0 },
|
||||||
delta: { kind: "added", notification: notificationWire(2, "warning") },
|
delta: { kind: "added", notification: notificationWire(2, "warning") },
|
||||||
})).toMatchObject({ type: "notifications.inbox", delta: { kind: "added", notification: { severity: "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", () => {
|
it("rejects malformed, unsafe, over-cap, and oversized notification payloads", () => {
|
||||||
@@ -545,10 +534,9 @@ describe("API parsers", () => {
|
|||||||
...inbox,
|
...inbox,
|
||||||
notifications: [{ ...notificationWire(1), severity: "fatal" }],
|
notifications: [{ ...notificationWire(1), severity: "fatal" }],
|
||||||
})).toThrow("Invalid notification severity");
|
})).toThrow("Invalid notification severity");
|
||||||
expect(() => parseSessionNotificationCatalogSnapshot({
|
expect(() => parseSessionNotificationInboxSnapshot({
|
||||||
daemonInstanceId: "daemon-a",
|
...inbox,
|
||||||
catalogRevision: Number.MAX_SAFE_INTEGER + 1,
|
catalogRevision: Number.MAX_SAFE_INTEGER + 1,
|
||||||
sessions: [],
|
|
||||||
})).toThrow("safe integer");
|
})).toThrow("safe integer");
|
||||||
expect(() => parseSessionNotificationInboxSnapshot({
|
expect(() => parseSessionNotificationInboxSnapshot({
|
||||||
...inbox,
|
...inbox,
|
||||||
|
|||||||
@@ -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 type { PiPackageInfo, PiPackageMutationAction, PiPackageMutationResponse, PiPackageScope, PiPackagesResponse } from "../../../shared/apiTypes";
|
||||||
import { parseActiveAgentProfileDescriptor } from "../../../shared/activeAgentProfile";
|
import { parseActiveAgentProfileDescriptor } from "../../../shared/activeAgentProfile";
|
||||||
import { parseKnownPiWebCapabilities } from "../../../shared/capabilities";
|
import { parseKnownPiWebCapabilities } from "../../../shared/capabilities";
|
||||||
@@ -233,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 {
|
export function parseSessionNotificationInboxSnapshot(value: unknown): SessionNotificationInboxSnapshot {
|
||||||
const record = requireRecord(value);
|
const record = requireRecord(value);
|
||||||
const summary = parseSessionNotificationSummary(record["summary"]);
|
const summary = parseSessionNotificationSummary(record["summary"]);
|
||||||
@@ -286,18 +273,7 @@ export function parseSessionNotificationInboxEvent(value: unknown): SessionNotif
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseSessionNotificationSummaryEvent(value: unknown): SessionNotificationSummaryEvent {
|
function parseSessionNotificationSummary(value: unknown): SessionNotificationSummary {
|
||||||
const record = requireRecord(value);
|
|
||||||
if (record["type"] !== "notifications.summary") throw new Error("Invalid notification summary event type");
|
|
||||||
return {
|
|
||||||
type: "notifications.summary",
|
|
||||||
daemonInstanceId: requireNonEmptyString(record, "daemonInstanceId"),
|
|
||||||
catalogRevision: requireNonNegativeSafeInteger(record, "catalogRevision"),
|
|
||||||
summary: parseSessionNotificationSummary(record["summary"]),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseSessionNotificationSummary(value: unknown): SessionNotificationSummary {
|
|
||||||
const record = requireRecord(value);
|
const record = requireRecord(value);
|
||||||
const retainedCount = requireNonNegativeSafeInteger(record, "retainedCount");
|
const retainedCount = requireNonNegativeSafeInteger(record, "retainedCount");
|
||||||
if (retainedCount > SESSION_NOTIFICATION_LIMIT) throw new Error("Notification retained count exceeds limit");
|
if (retainedCount > SESSION_NOTIFICATION_LIMIT) throw new Error("Notification retained count exceeds limit");
|
||||||
|
|||||||
@@ -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 { AuthProviderOption, CommandOption, CommandResult, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Machine, MachineHealth, MachineRuntime, OAuthFlowState, PiWebStatusResponse, Project, QueuedSessionMessage, SessionActivity, SessionInfo, SessionStatus, TerminalCommandRun, Workspace, WorkspaceActivity } from "./api";
|
||||||
import type { ChatLine } from "./components/shared";
|
import type { ChatLine } from "./components/shared";
|
||||||
import type { QualifiedContributionId } from "./plugins/ids";
|
import type { QualifiedContributionId } from "./plugins/ids";
|
||||||
import type { SelectedSessionNotificationInbox, SessionNotificationCatalogProjection } from "./sessionNotifications";
|
import type { SelectedSessionNotificationInbox } from "./sessionNotifications";
|
||||||
import type { WorkspaceUploadBatchState } from "./workspaceUploadState";
|
import type { WorkspaceUploadBatchState } from "./workspaceUploadState";
|
||||||
|
|
||||||
export interface AppState {
|
export interface AppState {
|
||||||
@@ -37,8 +37,6 @@ export interface AppState {
|
|||||||
sessionActivities: Record<string, SessionActivity>;
|
sessionActivities: Record<string, SessionActivity>;
|
||||||
workspaceActivities: Record<string, WorkspaceActivity>;
|
workspaceActivities: Record<string, WorkspaceActivity>;
|
||||||
machineActivities: Record<string, Record<string, WorkspaceActivity>>;
|
machineActivities: Record<string, Record<string, WorkspaceActivity>>;
|
||||||
/** Fresh/stale daemon notification catalogs, isolated by exact machine id. */
|
|
||||||
notificationCatalogsByMachine: Record<string, SessionNotificationCatalogProjection>;
|
|
||||||
/** Authoritative projection plus browser-local optimistic overlays for the selected inbox. */
|
/** Authoritative projection plus browser-local optimistic overlays for the selected inbox. */
|
||||||
selectedNotificationInbox: SelectedSessionNotificationInbox | undefined;
|
selectedNotificationInbox: SelectedSessionNotificationInbox | undefined;
|
||||||
workspacesByProjectId: Record<string, Workspace[]>;
|
workspacesByProjectId: Record<string, Workspace[]>;
|
||||||
@@ -148,7 +146,6 @@ export function initialAppState(): AppState {
|
|||||||
sessionActivities: {},
|
sessionActivities: {},
|
||||||
workspaceActivities: {},
|
workspaceActivities: {},
|
||||||
machineActivities: {},
|
machineActivities: {},
|
||||||
notificationCatalogsByMachine: {},
|
|
||||||
selectedNotificationInbox: undefined,
|
selectedNotificationInbox: undefined,
|
||||||
workspacesByProjectId: {},
|
workspacesByProjectId: {},
|
||||||
workspaceDeletionRuns: {},
|
workspaceDeletionRuns: {},
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import type { TemplateResult } from "lit";
|
import type { TemplateResult } from "lit";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import type { QueuedSessionMessage, SessionStatus, SessionWarning } from "../api";
|
import type { QueuedSessionMessage, SessionStatus, SessionWarning } from "../api";
|
||||||
import type { SelectedSessionNotificationView } from "../sessionNotifications";
|
import {
|
||||||
|
notificationTargetKey,
|
||||||
|
notificationTrayIsCollapsed,
|
||||||
|
type SelectedSessionNotificationView,
|
||||||
|
} from "../sessionNotifications";
|
||||||
import type { ChatLine } from "./shared";
|
import type { ChatLine } from "./shared";
|
||||||
import {
|
import {
|
||||||
ChatView,
|
ChatView,
|
||||||
@@ -144,35 +148,79 @@ describe("ChatView session-warning dismiss wiring", () => {
|
|||||||
|
|
||||||
describe("ChatView notification tray wiring", () => {
|
describe("ChatView notification tray wiring", () => {
|
||||||
// Escape hatch: these cases verify only the tray buttons' Lit callback wiring.
|
// Escape hatch: these cases verify only the tray buttons' Lit callback wiring.
|
||||||
// Notification content, ordering, severity, collapse, and focus decisions are
|
// Content and identity decisions use pure seams; Vitest has no shadow-DOM
|
||||||
// covered through pure seams; the node test environment has no shadow-DOM
|
// harness, so stable semantic class markers keep handler extraction narrow.
|
||||||
// harness, so semantic class markers keep direct handler extraction narrow.
|
// A minimal render-root fake verifies the resulting focus move without
|
||||||
it("wires individual and dismiss-all actions to their injected callbacks", () => {
|
// recreating a browser DOM harness.
|
||||||
|
it("wires individual dismissal and recovers header focus after the final row", () => {
|
||||||
const view = withNotificationInbox(new ChatView());
|
const view = withNotificationInbox(new ChatView());
|
||||||
const onDismissNotification = vi.fn();
|
const onDismissNotification = vi.fn();
|
||||||
const onDismissAllNotifications = vi.fn();
|
const headerFocus = installNotificationFocusRoot(view);
|
||||||
view.onDismissNotification = onDismissNotification;
|
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;
|
view.onDismissAllNotifications = onDismissAllNotifications;
|
||||||
|
|
||||||
const rendered = renderNotificationTray(view);
|
const rendered = renderNotificationTray(view);
|
||||||
if (rendered === null) throw new Error("expected a notification tray");
|
if (rendered === null) throw new Error("expected a notification tray");
|
||||||
templateEventHandlerAfterMarker(rendered, "notification-card-dismiss")(new Event("click"));
|
templateEventHandlerAfterMarker(rendered, "notification-clear")(new Event("click"));
|
||||||
templateEventHandlerAfterMarker(rendered, "notification-dismiss-all")(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(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 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);
|
const rendered = renderNotificationTray(view);
|
||||||
if (rendered === null) throw new Error("expected a notification tray");
|
if (rendered === null) throw new Error("expected a notification tray");
|
||||||
|
|
||||||
templateEventHandlerAfterMarker(rendered, "notification-toggle")(new Event("click"));
|
templateEventHandlerAfterMarker(rendered, "notification-toggle")(new Event("click"));
|
||||||
|
|
||||||
const collapsedSessionIds: unknown = Reflect.get(view, "collapsedNotificationSessionIds");
|
const collapsedTargetKeys: unknown = Reflect.get(view, "collapsedNotificationTargetKeys");
|
||||||
if (!(collapsedSessionIds instanceof Set)) throw new Error("Expected collapsed notification session ids");
|
if (!(collapsedTargetKeys instanceof Set)) throw new Error("Expected collapsed notification target keys");
|
||||||
expect(collapsedSessionIds.has("session-1")).toBe(true);
|
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 RenderMessageGroupBody = (this: ChatView, messages: ChatLine[], startIndex: number) => TemplateResult;
|
||||||
type RenderWarnings = (this: ChatView) => TemplateResult | null;
|
type RenderWarnings = (this: ChatView) => TemplateResult | null;
|
||||||
type RenderNotificationTray = (this: ChatView) => TemplateResult | null;
|
type RenderNotificationTray = (this: ChatView) => TemplateResult | null;
|
||||||
|
type FocusPendingNotificationTarget = (this: ChatView) => void;
|
||||||
type TemplateEventHandler = (event: Event) => void;
|
type TemplateEventHandler = (event: Event) => void;
|
||||||
|
|
||||||
function renderQueuedMessages(view: ChatView): TemplateResult {
|
function renderQueuedMessages(view: ChatView): TemplateResult {
|
||||||
@@ -294,6 +343,12 @@ function renderNotificationTray(view: ChatView): TemplateResult | null {
|
|||||||
return method.call(view);
|
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[] {
|
function observeGroupBodyRenders(view: ChatView): GroupBodyRenderCall[] {
|
||||||
const method: unknown = Reflect.get(view, "renderMessageGroupBody");
|
const method: unknown = Reflect.get(view, "renderMessageGroupBody");
|
||||||
if (!isRenderMessageGroupBody(method)) throw new Error("ChatView.renderMessageGroupBody is not callable");
|
if (!isRenderMessageGroupBody(method)) throw new Error("ChatView.renderMessageGroupBody is not callable");
|
||||||
@@ -326,6 +381,10 @@ function isRenderNotificationTray(value: unknown): value is RenderNotificationTr
|
|||||||
return typeof value === "function";
|
return typeof value === "function";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isFocusPendingNotificationTarget(value: unknown): value is FocusPendingNotificationTarget {
|
||||||
|
return typeof value === "function";
|
||||||
|
}
|
||||||
|
|
||||||
function dispatchDetailsToggle(handler: TemplateEventHandler, open: boolean): void {
|
function dispatchDetailsToggle(handler: TemplateEventHandler, open: boolean): void {
|
||||||
const hadDetailsElement = Reflect.has(globalThis, "HTMLDetailsElement");
|
const hadDetailsElement = Reflect.has(globalThis, "HTMLDetailsElement");
|
||||||
const previousDetailsElement = Reflect.get(globalThis, "HTMLDetailsElement");
|
const previousDetailsElement = Reflect.get(globalThis, "HTMLDetailsElement");
|
||||||
@@ -382,6 +441,34 @@ function withNotificationInbox(view: ChatView): ChatView {
|
|||||||
return view;
|
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 {
|
function warningStatus(warnings: SessionWarning[]): SessionStatus {
|
||||||
return {
|
return {
|
||||||
...queuedStatus([]),
|
...queuedStatus([]),
|
||||||
|
|||||||
@@ -9,14 +9,20 @@ import { shouldRequestEarlierMessages } from "../chatHistoryLoading";
|
|||||||
import { ChatScrollController, distanceFromScrollBottom, findFirstVisibleArticle, isNearScrollBottom, type ChatAnchorScrollPosition, type ChatScrollRestoreResult } from "../chatScrollPosition";
|
import { ChatScrollController, distanceFromScrollBottom, findFirstVisibleArticle, isNearScrollBottom, type ChatAnchorScrollPosition, type ChatScrollRestoreResult } from "../chatScrollPosition";
|
||||||
import type { QueuedSessionMessage, SessionActivity, SessionStatus, SessionWarningSeverity } from "../api";
|
import type { QueuedSessionMessage, SessionActivity, SessionStatus, SessionWarningSeverity } from "../api";
|
||||||
import {
|
import {
|
||||||
|
notificationAnnouncementLabel,
|
||||||
|
notificationDismissLabel,
|
||||||
notificationFocusTargetAfterDismiss,
|
notificationFocusTargetAfterDismiss,
|
||||||
notificationInboxOverflowLabel,
|
notificationInboxOverflowLabel,
|
||||||
|
notificationInboxTotalCount,
|
||||||
notificationMessageTruncationLabel,
|
notificationMessageTruncationLabel,
|
||||||
notificationSeverityIcon,
|
|
||||||
notificationSeverityLabel,
|
notificationSeverityLabel,
|
||||||
|
notificationTargetKey,
|
||||||
|
notificationTrayHeading,
|
||||||
|
notificationTrayIsCollapsed,
|
||||||
setNotificationTrayCollapsed,
|
setNotificationTrayCollapsed,
|
||||||
type NotificationFocusTarget,
|
type NotificationFocusTarget,
|
||||||
type SelectedSessionNotificationView,
|
type SelectedSessionNotificationView,
|
||||||
|
type SessionNotificationTarget,
|
||||||
} from "../sessionNotifications";
|
} from "../sessionNotifications";
|
||||||
import type { ChatLine, ChatPart } from "./shared";
|
import type { ChatLine, ChatPart } from "./shared";
|
||||||
import { chatStyles } from "./shared";
|
import { chatStyles } from "./shared";
|
||||||
@@ -25,7 +31,7 @@ import "./FormattedText";
|
|||||||
import "./ToolExecutionView";
|
import "./ToolExecutionView";
|
||||||
|
|
||||||
const messageTimestampFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "medium" });
|
const messageTimestampFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "medium" });
|
||||||
const notificationTimestampFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" });
|
const notificationTimestampFormatter = new Intl.DateTimeFormat(undefined, { timeStyle: "short" });
|
||||||
|
|
||||||
function warningSeverityIcon(severity: SessionWarningSeverity): string {
|
function warningSeverityIcon(severity: SessionWarningSeverity): string {
|
||||||
if (severity === "error") return "⛔";
|
if (severity === "error") return "⛔";
|
||||||
@@ -33,6 +39,31 @@ function warningSeverityIcon(severity: SessionWarningSeverity): string {
|
|||||||
return "⚠️";
|
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 {
|
function clampPercent(value: number): number {
|
||||||
return clampNumber(value, 0, 100);
|
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));
|
return Math.min(max, Math.max(min, value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PendingNotificationFocus {
|
||||||
|
chatKey: string;
|
||||||
|
focusTarget: NotificationFocusTarget;
|
||||||
|
}
|
||||||
|
|
||||||
export interface QueuedMessageSection {
|
export interface QueuedMessageSection {
|
||||||
source: "client" | "server";
|
source: "client" | "server";
|
||||||
heading: string;
|
heading: string;
|
||||||
@@ -176,9 +212,9 @@ export class ChatView extends LitElement {
|
|||||||
@state() private expandedMetaKey: string | undefined;
|
@state() private expandedMetaKey: string | undefined;
|
||||||
@state() private copiedMessageKey: string | undefined;
|
@state() private copiedMessageKey: string | undefined;
|
||||||
@state() private currentConversationIndex: number | undefined;
|
@state() private currentConversationIndex: number | undefined;
|
||||||
@state() private collapsedNotificationSessionIds: ReadonlySet<string> = new Set();
|
@state() private collapsedNotificationTargetKeys: ReadonlySet<string> = new Set();
|
||||||
@state() private retainedEmptyNotificationTraySessionId: string | undefined;
|
@state() private retainedEmptyNotificationTrayTargetKey: string | undefined;
|
||||||
private pendingNotificationFocus: NotificationFocusTarget | undefined;
|
private pendingNotificationFocus: PendingNotificationFocus | undefined;
|
||||||
private readonly disclosures = new ChatDisclosureController();
|
private readonly disclosures = new ChatDisclosureController();
|
||||||
private readonly scrollController = new ChatScrollController();
|
private readonly scrollController = new ChatScrollController();
|
||||||
private suppressScrollSave = false;
|
private suppressScrollSave = false;
|
||||||
@@ -255,7 +291,7 @@ export class ChatView extends LitElement {
|
|||||||
private prepareSessionUiState(): void {
|
private prepareSessionUiState(): void {
|
||||||
this.disclosures.syncSession(this.sessionId);
|
this.disclosures.syncSession(this.sessionId);
|
||||||
this.pendingNotificationFocus = undefined;
|
this.pendingNotificationFocus = undefined;
|
||||||
this.retainedEmptyNotificationTraySessionId = undefined;
|
this.retainedEmptyNotificationTrayTargetKey = undefined;
|
||||||
this.scrollController.clearScheduledSave();
|
this.scrollController.clearScheduledSave();
|
||||||
this.suppressScrollSave = false;
|
this.suppressScrollSave = false;
|
||||||
this.suppressLoadMoreRequests = false;
|
this.suppressLoadMoreRequests = false;
|
||||||
@@ -272,6 +308,9 @@ export class ChatView extends LitElement {
|
|||||||
if (changed.has("sessionId")) {
|
if (changed.has("sessionId")) {
|
||||||
this.savePreviousSessionScrollPosition(changed.get("sessionId"));
|
this.savePreviousSessionScrollPosition(changed.get("sessionId"));
|
||||||
this.prepareSessionUiState();
|
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());
|
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();
|
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() {
|
override render() {
|
||||||
const groups = this.groupedMessages();
|
const groups = this.groupedMessages();
|
||||||
return html`
|
return html`
|
||||||
@@ -338,58 +383,65 @@ export class ChatView extends LitElement {
|
|||||||
private renderNotificationTray() {
|
private renderNotificationTray() {
|
||||||
const inbox = this.notificationInbox;
|
const inbox = this.notificationInbox;
|
||||||
if (inbox?.sessionId !== this.sessionId) return null;
|
if (inbox?.sessionId !== this.sessionId) return null;
|
||||||
|
const chatKey = notificationTargetKey(inbox);
|
||||||
const hasPendingOverlay = inbox.pendingDismissedIds.size > 0 || inbox.dismissAllPending;
|
const hasPendingOverlay = inbox.pendingDismissedIds.size > 0 || inbox.dismissAllPending;
|
||||||
const retainsFocusTarget = this.retainedEmptyNotificationTraySessionId === this.sessionId;
|
const retainsFocusTarget = this.retainedEmptyNotificationTrayTargetKey === chatKey;
|
||||||
if (inbox.retainedCount === 0 && inbox.discardedCount === 0 && !hasPendingOverlay && !retainsFocusTarget) return null;
|
const totalCount = notificationInboxTotalCount(inbox);
|
||||||
const collapsed = this.collapsedNotificationSessionIds.has(this.sessionId);
|
if (totalCount === 0 && !hasPendingOverlay && !retainsFocusTarget) return null;
|
||||||
const severity = inbox.highestSeverity ?? "info";
|
const collapsed = notificationTrayIsCollapsed(this.collapsedNotificationTargetKeys, inbox);
|
||||||
const severityLabel = notificationSeverityLabel(severity);
|
const toggleLabel = collapsed ? "Expand notifications" : "Collapse notifications";
|
||||||
const countLabel = `${String(inbox.retainedCount)} undismissed ${inbox.retainedCount === 1 ? "notification" : "notifications"}`;
|
|
||||||
const discardedLabel = inbox.discardedCount === 0 ? "" : ` · ${String(inbox.discardedCount)} older ${inbox.discardedCount === 1 ? "notification" : "notifications"} discarded`;
|
|
||||||
return html`
|
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">
|
<header class="notification-header" data-notification-focus="header" tabindex="-1">
|
||||||
<div class="notification-heading-group">
|
<strong class="notification-heading" id="session-notifications-heading">${notificationTrayHeading(inbox)}</strong>
|
||||||
<span class="notification-heading-icon" aria-hidden="true">${notificationSeverityIcon(severity)}</span>
|
|
||||||
<span class="notification-heading-copy">
|
|
||||||
<strong id="session-notifications-heading">Notifications</strong>
|
|
||||||
<small>${countLabel}${discardedLabel} · highest severity ${severityLabel}</small>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="notification-header-actions">
|
<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
|
||||||
<button type="button" class="notification-control notification-dismiss-all" ?disabled=${inbox.dismissAllPending || inbox.retainedCount + inbox.discardedCount === 0} @click=${() => { this.onDismissAllNotifications?.(); }}>Dismiss all</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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
${collapsed ? null : html`
|
<div class="notification-list" id="session-notification-list" ?hidden=${collapsed}>
|
||||||
<div class="notification-cards" id="session-notification-cards">
|
${inbox.discardedCount === 0 ? null : html`
|
||||||
${inbox.discardedCount === 0 ? null : html`
|
<p class="notification-overflow">${notificationInboxOverflowLabel(inbox.discardedCount)}</p>
|
||||||
<p class="notification-overflow" role="status">${notificationInboxOverflowLabel(inbox.discardedCount)}</p>
|
`}
|
||||||
`}
|
${inbox.notifications.map((notification) => {
|
||||||
${inbox.notifications.map((notification) => {
|
const label = notificationSeverityLabel(notification.severity);
|
||||||
const label = notificationSeverityLabel(notification.severity);
|
const truncationLabel = notificationMessageTruncationLabel(notification);
|
||||||
const truncationLabel = notificationMessageTruncationLabel(notification);
|
return html`
|
||||||
return html`
|
<article class=${`notification-row ${notification.severity}`} data-notification-id=${notification.id} tabindex="-1">
|
||||||
<article class=${`notification-card ${notification.severity}`} data-notification-id=${notification.id} tabindex="-1">
|
<div class="notification-metadata">
|
||||||
<div class="notification-card-head">
|
<strong class="notification-severity">${label}</strong>
|
||||||
<strong class="notification-severity"><span aria-hidden="true">${notificationSeverityIcon(notification.severity)}</span> ${label}</strong>
|
<span aria-hidden="true">·</span>
|
||||||
<time datetime=${notification.receivedAt}>${notificationTimestampFormatter.format(new Date(notification.receivedAt))}</time>
|
<time datetime=${notification.receivedAt}>${notificationTimestampFormatter.format(new Date(notification.receivedAt))}</time>
|
||||||
</div>
|
</div>
|
||||||
<p class="notification-message" dir="auto">${notification.message}</p>
|
<p class="notification-message" dir="auto">${notification.message}</p>
|
||||||
${truncationLabel === undefined ? null : html`<p class="notification-truncated">${truncationLabel}</p>`}
|
${truncationLabel === undefined ? null : html`<p class="notification-truncated">${truncationLabel}</p>`}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="notification-card-dismiss"
|
class="notification-row-dismiss"
|
||||||
aria-label=${`Dismiss ${label.toLocaleLowerCase()} notification`}
|
aria-label=${notificationDismissLabel(notification)}
|
||||||
title="Dismiss 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); }}
|
@click=${() => { this.dismissNotification(notification.id); }}
|
||||||
>×</button>
|
>${renderNotificationCloseIcon()}</button>
|
||||||
</article>
|
</article>
|
||||||
`;
|
`;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
`}
|
|
||||||
</section>
|
</section>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -399,42 +451,64 @@ export class ChatView extends LitElement {
|
|||||||
const polite = announcements.filter((announcement) => announcement.severity !== "error");
|
const polite = announcements.filter((announcement) => announcement.severity !== "error");
|
||||||
const assertive = announcements.filter((announcement) => announcement.severity === "error");
|
const assertive = announcements.filter((announcement) => announcement.severity === "error");
|
||||||
return html`
|
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="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}>Error 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}>${notificationAnnouncementLabel(announcement)}</span>`)}</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private toggleNotificationTray(collapsed: boolean): void {
|
private toggleNotificationTray(inbox: SelectedSessionNotificationView, collapsed: boolean): void {
|
||||||
this.collapsedNotificationSessionIds = setNotificationTrayCollapsed(this.collapsedNotificationSessionIds, this.sessionId, !collapsed);
|
this.collapsedNotificationTargetKeys = setNotificationTrayCollapsed(this.collapsedNotificationTargetKeys, inbox, !collapsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
private dismissNotification(notificationId: string): void {
|
private dismissNotification(notificationId: string): void {
|
||||||
const inbox = this.notificationInbox;
|
const inbox = this.notificationInbox;
|
||||||
if (inbox === undefined) return;
|
if (inbox === undefined || this.onDismissNotification === undefined) return;
|
||||||
this.pendingNotificationFocus = notificationFocusTargetAfterDismiss(inbox.notifications, notificationId);
|
const focusTarget = notificationFocusTargetAfterDismiss(inbox.notifications, notificationId);
|
||||||
if (this.pendingNotificationFocus.kind === "header") this.retainedEmptyNotificationTraySessionId = this.sessionId;
|
const chatKey = notificationTargetKey(inbox);
|
||||||
this.onDismissNotification?.(notificationId);
|
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 {
|
private releaseEmptyNotificationTray(event: FocusEvent): void {
|
||||||
const tray = event.currentTarget;
|
const tray = event.currentTarget;
|
||||||
const next = event.relatedTarget;
|
const next = event.relatedTarget;
|
||||||
if (tray instanceof HTMLElement && next instanceof Node && tray.contains(next)) return;
|
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;
|
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 {
|
private focusPendingNotificationTarget(): void {
|
||||||
const target = this.pendingNotificationFocus;
|
const pending = this.pendingNotificationFocus;
|
||||||
this.pendingNotificationFocus = undefined;
|
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") {
|
if (target.kind === "header") {
|
||||||
this.renderRoot.querySelector<HTMLElement>("[data-notification-focus='header']")?.focus();
|
this.renderRoot.querySelector<HTMLElement>("[data-notification-focus='header']")?.focus();
|
||||||
return;
|
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);
|
.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() {
|
private renderWarnings() {
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import { LitElement, css, html, type PropertyValues } from "lit";
|
import { LitElement, css, html, type PropertyValues } from "lit";
|
||||||
import { customElement, property, state } from "lit/decorators.js";
|
import { customElement, property, state } from "lit/decorators.js";
|
||||||
import type { Machine, MachineHealth, WorkspaceActivity } from "../api";
|
import type { Machine, MachineHealth, WorkspaceActivity } from "../api";
|
||||||
import type { SessionNotificationBadgeModel } from "../sessionNotifications";
|
|
||||||
import { machineActivityIndicator } from "../workspaceActivity";
|
import { machineActivityIndicator } from "../workspaceActivity";
|
||||||
import { actionMenuPanelStyle } from "./actionMenu";
|
import { actionMenuPanelStyle } from "./actionMenu";
|
||||||
import { renderActionActivityIndicator } from "./activityBadge";
|
import { renderActionActivityIndicator } from "./activityBadge";
|
||||||
import type { KeyboardNavigableSection } from "./navigationFocus";
|
import type { KeyboardNavigableSection } from "./navigationFocus";
|
||||||
import { activateSelectableRow, focusSelectedOrFirstSelectableRow, handleSelectableRowKeyboard } from "./selectableRow";
|
import { activateSelectableRow, focusSelectedOrFirstSelectableRow, handleSelectableRowKeyboard } from "./selectableRow";
|
||||||
import { listStyles } from "./shared";
|
import { listStyles } from "./shared";
|
||||||
import "./NotificationBadge";
|
|
||||||
|
|
||||||
@customElement("machine-list")
|
@customElement("machine-list")
|
||||||
export class MachineList extends LitElement implements KeyboardNavigableSection {
|
export class MachineList extends LitElement implements KeyboardNavigableSection {
|
||||||
@@ -16,8 +14,6 @@ export class MachineList extends LitElement implements KeyboardNavigableSection
|
|||||||
@property({ attribute: false }) selected?: Machine;
|
@property({ attribute: false }) selected?: Machine;
|
||||||
@property({ attribute: false }) statuses: Record<string, MachineHealth> = {};
|
@property({ attribute: false }) statuses: Record<string, MachineHealth> = {};
|
||||||
@property({ attribute: false }) activities: Record<string, Record<string, WorkspaceActivity>> = {};
|
@property({ attribute: false }) activities: Record<string, Record<string, WorkspaceActivity>> = {};
|
||||||
@property({ attribute: false }) notificationBadges: Record<string, SessionNotificationBadgeModel | undefined> = {};
|
|
||||||
@property({ attribute: false }) notificationHeadingBadge?: SessionNotificationBadgeModel;
|
|
||||||
@property({ type: Boolean, reflect: true }) collapsible = false;
|
@property({ type: Boolean, reflect: true }) collapsible = false;
|
||||||
@property({ type: Boolean, reflect: true }) collapsed = false;
|
@property({ type: Boolean, reflect: true }) collapsed = false;
|
||||||
@property({ attribute: false }) onSelect?: (machine: Machine) => void;
|
@property({ attribute: false }) onSelect?: (machine: Machine) => void;
|
||||||
@@ -79,7 +75,7 @@ export class MachineList extends LitElement implements KeyboardNavigableSection
|
|||||||
@keydown=${(event: KeyboardEvent) => { this.handleMachineKeydown(event, machine); }}
|
@keydown=${(event: KeyboardEvent) => { this.handleMachineKeydown(event, machine); }}
|
||||||
>
|
>
|
||||||
<div class="action-main">
|
<div class="action-main">
|
||||||
<span class="action-name machine-primary"><span class="machine-primary-label">${machine.name}</span>${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)}
|
${this.renderActivity(machine)}
|
||||||
</div>
|
</div>
|
||||||
${hasRemoveAction ? this.renderMachineMenu(machine) : null}
|
${hasRemoveAction ? this.renderMachineMenu(machine) : null}
|
||||||
@@ -117,10 +113,10 @@ export class MachineList extends LitElement implements KeyboardNavigableSection
|
|||||||
}
|
}
|
||||||
|
|
||||||
private renderHeading() {
|
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 selectedSummary = this.selected?.name ?? "No machine selected";
|
||||||
const selectedTitle = this.selected?.baseUrl ?? selectedSummary;
|
const selectedTitle = this.selected?.baseUrl ?? selectedSummary;
|
||||||
return html`<button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span class="section-title"><span class="section-name">${this.collapsed ? "▸" : "▾"} Machines</span>${this.collapsed ? html`<small class="section-selected" title=${selectedTitle}>${selectedSummary}</small>` : null}</span>${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 {
|
private toggleMenu(machineId: string, target: EventTarget | null): void {
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { LitElement, css, html, type PropertyValues, type TemplateResult } from "lit";
|
import { LitElement, css, html, type PropertyValues, type TemplateResult } from "lit";
|
||||||
import { customElement, property, state } from "lit/decorators.js";
|
import { customElement, property, state } from "lit/decorators.js";
|
||||||
import type { Machine, MachineHealth, MachineStatus, WorkspaceActivity } from "../api";
|
import type { Machine, MachineHealth, MachineStatus, WorkspaceActivity } from "../api";
|
||||||
import type { SessionNotificationBadgeModel } from "../sessionNotifications";
|
|
||||||
import { machineActivityIndicator } from "../workspaceActivity";
|
import { machineActivityIndicator } from "../workspaceActivity";
|
||||||
import { actionMenuPanelStyle } from "./actionMenu";
|
import { actionMenuPanelStyle } from "./actionMenu";
|
||||||
import { renderActivityIndicator } from "./activityBadge";
|
import { renderActivityIndicator } from "./activityBadge";
|
||||||
import { canRemoveMachine } from "./MachineList";
|
import { canRemoveMachine } from "./MachineList";
|
||||||
import type { KeyboardNavigableSection } from "./navigationFocus";
|
import type { KeyboardNavigableSection } from "./navigationFocus";
|
||||||
import "./NotificationBadge";
|
|
||||||
|
|
||||||
@customElement("machine-switcher")
|
@customElement("machine-switcher")
|
||||||
export class MachineSwitcher extends LitElement implements KeyboardNavigableSection {
|
export class MachineSwitcher extends LitElement implements KeyboardNavigableSection {
|
||||||
@@ -15,8 +13,6 @@ export class MachineSwitcher extends LitElement implements KeyboardNavigableSect
|
|||||||
@property({ attribute: false }) selected?: Machine;
|
@property({ attribute: false }) selected?: Machine;
|
||||||
@property({ attribute: false }) statuses: Record<string, MachineHealth> = {};
|
@property({ attribute: false }) statuses: Record<string, MachineHealth> = {};
|
||||||
@property({ attribute: false }) activities: Record<string, Record<string, WorkspaceActivity>> = {};
|
@property({ attribute: false }) activities: Record<string, Record<string, WorkspaceActivity>> = {};
|
||||||
@property({ attribute: false }) notificationBadges: Record<string, SessionNotificationBadgeModel | undefined> = {};
|
|
||||||
@property({ attribute: false }) notificationHeadingBadge?: SessionNotificationBadgeModel;
|
|
||||||
@property({ attribute: false }) onSelect?: (machine: Machine) => void | Promise<void>;
|
@property({ attribute: false }) onSelect?: (machine: Machine) => void | Promise<void>;
|
||||||
@property({ attribute: false }) onRemove?: (machine: Machine) => void | Promise<void>;
|
@property({ attribute: false }) onRemove?: (machine: Machine) => void | Promise<void>;
|
||||||
@property({ attribute: false }) onFocusNextSection?: () => void | Promise<void>;
|
@property({ attribute: false }) onFocusNextSection?: () => void | Promise<void>;
|
||||||
@@ -58,14 +54,13 @@ export class MachineSwitcher extends LitElement implements KeyboardNavigableSect
|
|||||||
if (selected === undefined) return null;
|
if (selected === undefined) return null;
|
||||||
const status = machineStatus(selected, this.statuses);
|
const status = machineStatus(selected, this.statuses);
|
||||||
const label = selected.name;
|
const label = selected.name;
|
||||||
const notificationBadge = machineSwitcherNotificationBadge(selected.id, this.notificationBadges, this.notificationHeadingBadge);
|
|
||||||
return html`
|
return html`
|
||||||
<div class="machine-switcher">
|
<div class="machine-switcher">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="machine-switcher-button"
|
class="machine-switcher-button"
|
||||||
title=${machineTitle(selected)}
|
title=${machineTitle(selected)}
|
||||||
aria-label=${this.machineSwitcherAriaLabel(selected, notificationBadge)}
|
aria-label=${this.machineSwitcherAriaLabel(selected)}
|
||||||
aria-expanded=${String(this.open)}
|
aria-expanded=${String(this.open)}
|
||||||
@click=${(event: MouseEvent) => { this.toggleMenu(event.currentTarget); }}
|
@click=${(event: MouseEvent) => { this.toggleMenu(event.currentTarget); }}
|
||||||
@keydown=${(event: KeyboardEvent) => { this.handleSwitcherButtonKeydown(event); }}
|
@keydown=${(event: KeyboardEvent) => { this.handleSwitcherButtonKeydown(event); }}
|
||||||
@@ -76,7 +71,6 @@ export class MachineSwitcher extends LitElement implements KeyboardNavigableSect
|
|||||||
<span class="machine-switcher-label">${label}</span>
|
<span class="machine-switcher-label">${label}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class=${`machine-status ${status}`}>${machineStatusLabel(status)}</span>
|
<span class=${`machine-status ${status}`}>${machineStatusLabel(status)}</span>
|
||||||
${notificationBadge === undefined ? null : html`<notification-badge .model=${notificationBadge}></notification-badge>`}
|
|
||||||
<span class="machine-chevron" aria-hidden="true">▾</span>
|
<span class="machine-chevron" aria-hidden="true">▾</span>
|
||||||
</button>
|
</button>
|
||||||
${this.open ? html`
|
${this.open ? html`
|
||||||
@@ -103,7 +97,7 @@ export class MachineSwitcher extends LitElement implements KeyboardNavigableSect
|
|||||||
@click=${() => { this.select(machine); }}
|
@click=${() => { this.select(machine); }}
|
||||||
@keydown=${(event: KeyboardEvent) => { this.handleMachineOptionKeydown(event); }}
|
@keydown=${(event: KeyboardEvent) => { this.handleMachineOptionKeydown(event); }}
|
||||||
>
|
>
|
||||||
<span class="machine-option-name">${this.renderActivity(machine)}<span>${machine.name}</span>${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>
|
<small>${machine.kind === "local" ? "Local Pi Web" : machine.baseUrl ?? "Remote Pi Web"} · ${machineStatusLabel(status)}</small>
|
||||||
</button>
|
</button>
|
||||||
${hasActions ? html`
|
${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];
|
return this.selected ?? this.machines.find((machine) => machine.id === "local") ?? this.machines[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
private machineSwitcherAriaLabel(machine: Machine, notificationBadge: SessionNotificationBadgeModel | undefined): string {
|
private machineSwitcherAriaLabel(machine: Machine): string {
|
||||||
const notificationLabel = notificationBadge?.accessibleLabel;
|
return `Machine: ${machine.name}. Switch machine.`;
|
||||||
return `Machine: ${machine.name}.${notificationLabel === undefined ? "" : ` Notifications across machines: ${notificationLabel}.`} Switch machine.`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private switcherButton(): HTMLElement | null {
|
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 {
|
function machineStatus(machine: Machine, statuses: Record<string, MachineHealth>): MachineStatus {
|
||||||
return statuses[machine.id]?.status ?? machine.status ?? "unknown";
|
return statuses[machine.id]?.status ?? machine.status ?? "unknown";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { LitElement, html } from "lit";
|
import { LitElement, html } from "lit";
|
||||||
import { customElement, query, state } from "lit/decorators.js";
|
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 type { AppAction } from "../actions";
|
||||||
import { initialAppState, type AppState } from "../appState";
|
import { initialAppState, type AppState } from "../appState";
|
||||||
import { isSessionActive } from "../../../shared/activity";
|
import { isSessionActive } from "../../../shared/activity";
|
||||||
@@ -23,18 +23,9 @@ import { SessionStorageWorkspaceSelectionMemory } from "../controllers/workspace
|
|||||||
import { KeyboardShortcutDispatcher } from "../keyboardShortcuts";
|
import { KeyboardShortcutDispatcher } from "../keyboardShortcuts";
|
||||||
import { selectedMachineId } from "../controllers/types";
|
import { selectedMachineId } from "../controllers/types";
|
||||||
import { sessionCleanupRequestKey, sessionCleanupUnavailableMessage } from "../sessionCleanupUi";
|
import { sessionCleanupRequestKey, sessionCleanupUnavailableMessage } from "../sessionCleanupUi";
|
||||||
import {
|
import { selectedNotificationView } from "../sessionNotifications";
|
||||||
aggregateNotificationSummaries,
|
|
||||||
effectiveNotificationSummaries,
|
|
||||||
notificationAggregateAcrossMachines,
|
|
||||||
notificationAggregateForCwd,
|
|
||||||
notificationAggregateForProject,
|
|
||||||
notificationBadgeModel,
|
|
||||||
selectedNotificationView,
|
|
||||||
type SessionNotificationBadgeModel,
|
|
||||||
} from "../sessionNotifications";
|
|
||||||
import { hasAuthoritativeSessionPersistence as runtimeHasAuthoritativeSessionPersistence } from "../sessionPersistence";
|
import { hasAuthoritativeSessionPersistence as runtimeHasAuthoritativeSessionPersistence } from "../sessionPersistence";
|
||||||
import { RealtimeSocket } from "../sessionSocket";
|
import { RealtimeSocket, type BrowserRealtimeEvent } from "../sessionSocket";
|
||||||
import type { PiWebPluginRegistration, PluginMachine, PluginPromptEditor, QualifiedContributionId, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspacePanelContribution, PluginRuntimeContext, TerminalCommandRunsInternalRuntime, WorkspaceFiles, WorkspaceHost, WorkspaceLabelContext, WorkspaceLabelItem, WorkspacePanelContext } from "../plugins/types";
|
import type { PiWebPluginRegistration, PluginMachine, PluginPromptEditor, QualifiedContributionId, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspacePanelContribution, PluginRuntimeContext, TerminalCommandRunsInternalRuntime, WorkspaceFiles, WorkspaceHost, WorkspaceLabelContext, WorkspaceLabelItem, WorkspacePanelContext } from "../plugins/types";
|
||||||
import { CLASSIC_THEME_ID, DEFAULT_THEME_PREFERENCE, applyPiWebTheme, findThemePairForTheme, readStoredThemePreference, resolveThemePreference, writeStoredThemePreference, type ThemePreference, type ThemePreferenceResolution } from "../theme";
|
import { CLASSIC_THEME_ID, DEFAULT_THEME_PREFERENCE, applyPiWebTheme, findThemePairForTheme, readStoredThemePreference, resolveThemePreference, writeStoredThemePreference, type ThemePreference, type ThemePreferenceResolution } from "../theme";
|
||||||
import { corePlugin } from "../plugins/core";
|
import { corePlugin } from "../plugins/core";
|
||||||
@@ -74,7 +65,7 @@ import type { WorkspacePanelEmptyState } from "./WorkspacePanel";
|
|||||||
import "./appShell/AppContextBar";
|
import "./appShell/AppContextBar";
|
||||||
import "./appShell/AppMobileMainTabs";
|
import "./appShell/AppMobileMainTabs";
|
||||||
import type { AppMobileMainTab, AppMobileMainTabIcon } from "./appShell/AppMobileMainTabs";
|
import type { AppMobileMainTab, AppMobileMainTabIcon } from "./appShell/AppMobileMainTabs";
|
||||||
import { shouldShowMachinesSection, type AppNavigationPanel, type NavigationFocusTarget, type NavigationNotificationBadges } from "./appShell/AppNavigationPanel";
|
import { shouldShowMachinesSection, type AppNavigationPanel, type NavigationFocusTarget } from "./appShell/AppNavigationPanel";
|
||||||
import "./appShell/AppPanelEdgeControl";
|
import "./appShell/AppPanelEdgeControl";
|
||||||
import "./appShell/AppRefreshControl";
|
import "./appShell/AppRefreshControl";
|
||||||
import { appStyles } from "./shared";
|
import { appStyles } from "./shared";
|
||||||
@@ -327,7 +318,6 @@ export class PiWebApp extends LitElement {
|
|||||||
private async refreshAfterBrowserResume(): Promise<void> {
|
private async refreshAfterBrowserResume(): Promise<void> {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.sessions.refreshSelectedSession(),
|
this.sessions.refreshSelectedSession(),
|
||||||
this.notifications.refreshAfterBrowserResume(),
|
|
||||||
this.refreshMachineActivities(),
|
this.refreshMachineActivities(),
|
||||||
this.refreshWorkspaceDeletionRuns(),
|
this.refreshWorkspaceDeletionRuns(),
|
||||||
]);
|
]);
|
||||||
@@ -383,7 +373,6 @@ export class PiWebApp extends LitElement {
|
|||||||
try {
|
try {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.sessions.refreshSelectedSession(),
|
this.sessions.refreshSelectedSession(),
|
||||||
this.notifications.refreshAfterBrowserResume(),
|
|
||||||
this.refreshMachineActivities(),
|
this.refreshMachineActivities(),
|
||||||
this.loadClientConfig(),
|
this.loadClientConfig(),
|
||||||
this.refreshWorkspaceDeletionRuns(),
|
this.refreshWorkspaceDeletionRuns(),
|
||||||
@@ -818,9 +807,8 @@ export class PiWebApp extends LitElement {
|
|||||||
private connectRealtime(): void {
|
private connectRealtime(): void {
|
||||||
const machineId = selectedMachineId(this.state);
|
const machineId = selectedMachineId(this.state);
|
||||||
this.realtime.connect(
|
this.realtime.connect(
|
||||||
(event) => { this.handleRealtimeEvent(machineId, event); },
|
(event) => { this.handleRealtimeEvent(event); },
|
||||||
() => {
|
() => {
|
||||||
this.notifications.globalSocketOpened(machineId);
|
|
||||||
const workspace = this.state.selectedWorkspace;
|
const workspace = this.state.selectedWorkspace;
|
||||||
if (workspace !== undefined) void this.refreshActiveTerminals(workspace);
|
if (workspace !== undefined) void this.refreshActiveTerminals(workspace);
|
||||||
void this.refreshWorkspaceActivity(machineId);
|
void this.refreshWorkspaceActivity(machineId);
|
||||||
@@ -842,7 +830,6 @@ export class PiWebApp extends LitElement {
|
|||||||
socket.connect(
|
socket.connect(
|
||||||
(event) => { this.handleMachineActivityEvent(machineId, event); },
|
(event) => { this.handleMachineActivityEvent(machineId, event); },
|
||||||
() => {
|
() => {
|
||||||
this.notifications.globalSocketOpened(machineId);
|
|
||||||
void this.refreshWorkspaceActivity(machineId);
|
void this.refreshWorkspaceActivity(machineId);
|
||||||
},
|
},
|
||||||
machineId,
|
machineId,
|
||||||
@@ -864,14 +851,12 @@ export class PiWebApp extends LitElement {
|
|||||||
.map((machine) => machine.id));
|
.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);
|
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);
|
if (event.type === "workspace.activity") this.activity.applyWorkspaceActivity(event.activity);
|
||||||
else if (event.type === "notifications.summary") this.notifications.applySummaryEvent(machineId, event);
|
|
||||||
else if (isTerminalEvent(event)) {
|
else if (isTerminalEvent(event)) {
|
||||||
this.applyTerminalEvent(event);
|
this.applyTerminalEvent(event);
|
||||||
if (event.type === "terminal.exited") void this.refreshWorkspaceDeletionRuns();
|
if (event.type === "terminal.exited") void this.refreshWorkspaceDeletionRuns();
|
||||||
@@ -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() {
|
private renderNavigationPanel() {
|
||||||
return html`
|
return html`
|
||||||
<app-navigation-panel
|
<app-navigation-panel
|
||||||
@@ -1196,7 +1138,6 @@ export class PiWebApp extends LitElement {
|
|||||||
.selectedMachine=${this.state.selectedMachine}
|
.selectedMachine=${this.state.selectedMachine}
|
||||||
.machineStatuses=${this.state.machineStatuses}
|
.machineStatuses=${this.state.machineStatuses}
|
||||||
.machineActivities=${this.state.machineActivities}
|
.machineActivities=${this.state.machineActivities}
|
||||||
.notificationBadges=${this.navigationNotificationBadges()}
|
|
||||||
.machinesCollapsed=${this.navigationSections.isCollapsed("machines")}
|
.machinesCollapsed=${this.navigationSections.isCollapsed("machines")}
|
||||||
.onToggleMachines=${() => { this.navigationSections.toggle("machines"); }}
|
.onToggleMachines=${() => { this.navigationSections.toggle("machines"); }}
|
||||||
.onSelectMachine=${(machine: Machine) => this.selectNavigationItem("machines", "projects", () => this.selectMachineWithMemory(machine))}
|
.onSelectMachine=${(machine: Machine) => this.selectNavigationItem("machines", "projects", () => this.selectMachineWithMemory(machine))}
|
||||||
@@ -2014,7 +1955,7 @@ export class PiWebApp extends LitElement {
|
|||||||
|
|
||||||
private mobileMainTabs(): AppMobileMainTab[] {
|
private mobileMainTabs(): AppMobileMainTab[] {
|
||||||
return [
|
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" },
|
{ id: "chat", label: "Chat", icon: "chat" },
|
||||||
...this.visibleWorkspacePanels().map((panel): AppMobileMainTab => {
|
...this.visibleWorkspacePanels().map((panel): AppMobileMainTab => {
|
||||||
const icon = panel.icon ?? this.mobilePanelIcon(panel);
|
const icon = panel.icon ?? this.mobilePanelIcon(panel);
|
||||||
@@ -2105,7 +2046,7 @@ function isActive(state: Pick<AppState, "status" | "activity">): boolean {
|
|||||||
return isSessionActive(state.status, state.activity);
|
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";
|
return event.type === "terminal.created" || event.type === "terminal.exited" || event.type === "terminal.closed";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import { LitElement, html, type PropertyValues } from "lit";
|
import { LitElement, html, type PropertyValues } from "lit";
|
||||||
import { customElement, property, state } from "lit/decorators.js";
|
import { customElement, property, state } from "lit/decorators.js";
|
||||||
import type { Project, Workspace, WorkspaceActivity } from "../api";
|
import type { Project, Workspace, WorkspaceActivity } from "../api";
|
||||||
import type { SessionNotificationBadgeModel } from "../sessionNotifications";
|
|
||||||
import { projectActivityIndicator } from "../workspaceActivity";
|
import { projectActivityIndicator } from "../workspaceActivity";
|
||||||
import { actionMenuPanelStyle } from "./actionMenu";
|
import { actionMenuPanelStyle } from "./actionMenu";
|
||||||
import { renderActionActivityIndicator } from "./activityBadge";
|
import { renderActionActivityIndicator } from "./activityBadge";
|
||||||
import type { KeyboardNavigableSection } from "./navigationFocus";
|
import type { KeyboardNavigableSection } from "./navigationFocus";
|
||||||
import { activateSelectableRow, focusSelectedOrFirstSelectableRow, handleSelectableRowKeyboard } from "./selectableRow";
|
import { activateSelectableRow, focusSelectedOrFirstSelectableRow, handleSelectableRowKeyboard } from "./selectableRow";
|
||||||
import { listStyles } from "./shared";
|
import { listStyles } from "./shared";
|
||||||
import "./NotificationBadge";
|
|
||||||
|
|
||||||
@customElement("project-list")
|
@customElement("project-list")
|
||||||
export class ProjectList extends LitElement implements KeyboardNavigableSection {
|
export class ProjectList extends LitElement implements KeyboardNavigableSection {
|
||||||
@@ -16,8 +14,6 @@ export class ProjectList extends LitElement implements KeyboardNavigableSection
|
|||||||
@property({ attribute: false }) selected?: Project;
|
@property({ attribute: false }) selected?: Project;
|
||||||
@property({ attribute: false }) activities: Record<string, WorkspaceActivity> = {};
|
@property({ attribute: false }) activities: Record<string, WorkspaceActivity> = {};
|
||||||
@property({ attribute: false }) workspacesByProjectId: Record<string, Workspace[]> = {};
|
@property({ attribute: false }) workspacesByProjectId: Record<string, Workspace[]> = {};
|
||||||
@property({ attribute: false }) notificationBadges: Record<string, SessionNotificationBadgeModel | undefined> = {};
|
|
||||||
@property({ attribute: false }) notificationHeadingBadge?: SessionNotificationBadgeModel;
|
|
||||||
@property({ type: Boolean, reflect: true }) collapsible = false;
|
@property({ type: Boolean, reflect: true }) collapsible = false;
|
||||||
@property({ type: Boolean, reflect: true }) collapsed = false;
|
@property({ type: Boolean, reflect: true }) collapsed = false;
|
||||||
@property({ attribute: false }) onSelect?: (project: Project) => void;
|
@property({ attribute: false }) onSelect?: (project: Project) => void;
|
||||||
@@ -68,7 +64,7 @@ export class ProjectList extends LitElement implements KeyboardNavigableSection
|
|||||||
@keydown=${(event: KeyboardEvent) => { this.handleProjectKeydown(event, project); }}
|
@keydown=${(event: KeyboardEvent) => { this.handleProjectKeydown(event, project); }}
|
||||||
>
|
>
|
||||||
<div class="action-main">
|
<div class="action-main">
|
||||||
<span class="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)}
|
${this.renderActivity(project)}
|
||||||
</div>
|
</div>
|
||||||
<div class="action-menu">
|
<div class="action-menu">
|
||||||
@@ -97,10 +93,10 @@ export class ProjectList extends LitElement implements KeyboardNavigableSection
|
|||||||
}
|
}
|
||||||
|
|
||||||
private renderHeading() {
|
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 selectedSummary = this.selected?.name ?? "No project selected";
|
||||||
const selectedTitle = this.selected?.path ?? selectedSummary;
|
const selectedTitle = this.selected?.path ?? selectedSummary;
|
||||||
return html`<button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span class="section-title"><span class="section-name">${this.collapsed ? "▸" : "▾"} Projects</span>${this.collapsed ? html`<small class="section-selected" title=${selectedTitle}>${selectedSummary}</small>` : null}</span>${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) {
|
private renderActivity(project: Project) {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { LitElement, css, html, type PropertyValues } from "lit";
|
|||||||
import { customElement, property, state } from "lit/decorators.js";
|
import { customElement, property, state } from "lit/decorators.js";
|
||||||
import type { SessionActivity, SessionInfo, SessionStatus } from "../api";
|
import type { SessionActivity, SessionInfo, SessionStatus } from "../api";
|
||||||
import { isCachedNewSessionInfo } from "../cachedNewSessions";
|
import { isCachedNewSessionInfo } from "../cachedNewSessions";
|
||||||
import type { SessionNotificationBadgeModel } from "../sessionNotifications";
|
|
||||||
import { shortSessionId } from "../sessionLabels";
|
import { shortSessionId } from "../sessionLabels";
|
||||||
import { isArchivableSessionInfo, isTransientNewSessionInfo } from "../sessionPersistence";
|
import { isArchivableSessionInfo, isTransientNewSessionInfo } from "../sessionPersistence";
|
||||||
import { isSessionActive } from "../../../shared/activity";
|
import { isSessionActive } from "../../../shared/activity";
|
||||||
@@ -11,7 +10,6 @@ import { renderActionActivityIndicator, type ActivityIndicatorKind } from "./act
|
|||||||
import type { KeyboardNavigableSection } from "./navigationFocus";
|
import type { KeyboardNavigableSection } from "./navigationFocus";
|
||||||
import { activateSelectableRow, focusSelectedOrFirstSelectableRow, handleSelectableRowKeyboard } from "./selectableRow";
|
import { activateSelectableRow, focusSelectedOrFirstSelectableRow, handleSelectableRowKeyboard } from "./selectableRow";
|
||||||
import { listStyles } from "./shared";
|
import { listStyles } from "./shared";
|
||||||
import "./NotificationBadge";
|
|
||||||
|
|
||||||
function sessionLabel(session: SessionInfo): string {
|
function sessionLabel(session: SessionInfo): string {
|
||||||
if (session.name !== undefined && session.name !== "") return session.name;
|
if (session.name !== undefined && session.name !== "") return session.name;
|
||||||
@@ -32,8 +30,6 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
|
|||||||
@property({ attribute: false }) statuses: Record<string, SessionStatus> = {};
|
@property({ attribute: false }) statuses: Record<string, SessionStatus> = {};
|
||||||
@property({ attribute: false }) activities: Record<string, SessionActivity> = {};
|
@property({ attribute: false }) activities: Record<string, SessionActivity> = {};
|
||||||
@property({ attribute: false }) sending: Record<string, true> = {};
|
@property({ attribute: false }) sending: Record<string, true> = {};
|
||||||
@property({ attribute: false }) notificationBadges: Record<string, SessionNotificationBadgeModel | undefined> = {};
|
|
||||||
@property({ attribute: false }) notificationHeadingBadge?: SessionNotificationBadgeModel;
|
|
||||||
@property({ attribute: false }) selected?: SessionInfo;
|
@property({ attribute: false }) selected?: SessionInfo;
|
||||||
@property({ type: Number }) startingCount = 0;
|
@property({ type: Number }) startingCount = 0;
|
||||||
@property({ type: Boolean }) canStart = false;
|
@property({ type: Boolean }) canStart = false;
|
||||||
@@ -137,7 +133,6 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
|
|||||||
return html`
|
return html`
|
||||||
<h2>
|
<h2>
|
||||||
<span class="plain-heading">Sessions</span>
|
<span class="plain-heading">Sessions</span>
|
||||||
${this.notificationHeadingBadge === undefined ? null : html`<notification-badge .model=${this.notificationHeadingBadge}></notification-badge>`}
|
|
||||||
${this.renderCurrentSelectionButton(currentSessions)}
|
${this.renderCurrentSelectionButton(currentSessions)}
|
||||||
${this.renderCleanupButton()}
|
${this.renderCleanupButton()}
|
||||||
${this.renderStartButton()}
|
${this.renderStartButton()}
|
||||||
@@ -149,7 +144,6 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
|
|||||||
return html`
|
return html`
|
||||||
<h2>
|
<h2>
|
||||||
<button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span class="section-title"><span class="section-name">${this.collapsed ? "▸" : "▾"} Sessions</span>${this.collapsed ? html`<small class="section-selected" dir="auto" title=${selectedTitle}>${selectedSummary}</small>` : null}</span></button>
|
<button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span class="section-title"><span class="section-name">${this.collapsed ? "▸" : "▾"} Sessions</span>${this.collapsed ? html`<small class="section-selected" dir="auto" title=${selectedTitle}>${selectedSummary}</small>` : null}</span></button>
|
||||||
${this.notificationHeadingBadge === undefined ? null : html`<notification-badge .model=${this.notificationHeadingBadge}></notification-badge>`}
|
|
||||||
${this.renderCurrentSelectionButton(currentSessions)}
|
${this.renderCurrentSelectionButton(currentSessions)}
|
||||||
<small class="section-count">${sessionCount}</small>
|
<small class="section-count">${sessionCount}</small>
|
||||||
${this.renderCleanupButton()}
|
${this.renderCleanupButton()}
|
||||||
@@ -256,7 +250,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
|
|||||||
>
|
>
|
||||||
<div class="action-main ${selectionActive ? "selecting" : ""}">
|
<div class="action-main ${selectionActive ? "selecting" : ""}">
|
||||||
${showsCheckbox ? html`<input class="session-checkbox" type="checkbox" aria-label=${`Select ${sessionLabel(session)}`} .checked=${bulkSelected} @click=${(event: MouseEvent) => { event.stopPropagation(); }} @change=${() => { this.toggleSelected(session.id); }}>` : null}
|
${showsCheckbox ? html`<input class="session-checkbox" type="checkbox" aria-label=${`Select ${sessionLabel(session)}`} .checked=${bulkSelected} @click=${(event: MouseEvent) => { event.stopPropagation(); }} @change=${() => { this.toggleSelected(session.id); }}>` : null}
|
||||||
<span class="action-name-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)}
|
${this.renderActivity(session)}
|
||||||
</div>
|
</div>
|
||||||
<div class="action-menu">
|
<div class="action-menu">
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { LitElement, html, type PropertyValues, type TemplateResult } from "lit"
|
|||||||
import { customElement, property, state } from "lit/decorators.js";
|
import { customElement, property, state } from "lit/decorators.js";
|
||||||
import type { Workspace, WorkspaceActivity } from "../api";
|
import type { Workspace, WorkspaceActivity } from "../api";
|
||||||
import type { WorkspaceLabelItem } from "../plugins/types";
|
import type { WorkspaceLabelItem } from "../plugins/types";
|
||||||
import type { SessionNotificationBadgeModel } from "../sessionNotifications";
|
|
||||||
import { workspaceActivityFor, workspaceActivityIndicator } from "../workspaceActivity";
|
import { workspaceActivityFor, workspaceActivityIndicator } from "../workspaceActivity";
|
||||||
import { actionMenuPanelStyle } from "./actionMenu";
|
import { actionMenuPanelStyle } from "./actionMenu";
|
||||||
import { renderActionActivityIndicator } from "./activityBadge";
|
import { renderActionActivityIndicator } from "./activityBadge";
|
||||||
@@ -10,7 +9,6 @@ import type { KeyboardNavigableSection } from "./navigationFocus";
|
|||||||
import { activateSelectableRow, focusSelectedOrFirstSelectableRow, handleSelectableRowKeyboard } from "./selectableRow";
|
import { activateSelectableRow, focusSelectedOrFirstSelectableRow, handleSelectableRowKeyboard } from "./selectableRow";
|
||||||
import { listStyles } from "./shared";
|
import { listStyles } from "./shared";
|
||||||
import { renderWorkspaceLabelInlineItems } from "./workspaceLabel";
|
import { renderWorkspaceLabelInlineItems } from "./workspaceLabel";
|
||||||
import "./NotificationBadge";
|
|
||||||
|
|
||||||
@customElement("workspace-list")
|
@customElement("workspace-list")
|
||||||
export class WorkspaceList extends LitElement implements KeyboardNavigableSection {
|
export class WorkspaceList extends LitElement implements KeyboardNavigableSection {
|
||||||
@@ -21,8 +19,6 @@ export class WorkspaceList extends LitElement implements KeyboardNavigableSectio
|
|||||||
@property({ attribute: false }) workspaceLabelItems: (workspace: Workspace) => WorkspaceLabelItem[] = () => [];
|
@property({ attribute: false }) workspaceLabelItems: (workspace: Workspace) => WorkspaceLabelItem[] = () => [];
|
||||||
@property({ attribute: false }) activities: Record<string, WorkspaceActivity> = {};
|
@property({ attribute: false }) activities: Record<string, WorkspaceActivity> = {};
|
||||||
@property({ attribute: false }) deletingWorkspaceIds: string[] = [];
|
@property({ attribute: false }) deletingWorkspaceIds: string[] = [];
|
||||||
@property({ attribute: false }) notificationBadges: Record<string, SessionNotificationBadgeModel | undefined> = {};
|
|
||||||
@property({ attribute: false }) notificationHeadingBadge?: SessionNotificationBadgeModel;
|
|
||||||
@property({ attribute: false }) onSelect?: (workspace: Workspace) => void;
|
@property({ attribute: false }) onSelect?: (workspace: Workspace) => void;
|
||||||
@property({ attribute: false }) onDelete?: (workspace: Workspace) => void;
|
@property({ attribute: false }) onDelete?: (workspace: Workspace) => void;
|
||||||
@property({ attribute: false }) onToggleCollapsed?: () => void;
|
@property({ attribute: false }) onToggleCollapsed?: () => void;
|
||||||
@@ -89,10 +85,10 @@ export class WorkspaceList extends LitElement implements KeyboardNavigableSectio
|
|||||||
}
|
}
|
||||||
|
|
||||||
private renderHeading() {
|
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 selectedSummary = this.selected === undefined ? "No workspace selected" : `${this.selected.label}${this.selected.isMain ? " · main" : ""} · ${this.selected.path}`;
|
||||||
const selectedTitle = this.selected?.path ?? selectedSummary;
|
const selectedTitle = this.selected?.path ?? selectedSummary;
|
||||||
return html`<button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span class="section-title"><span class="section-name">${this.collapsed ? "▸" : "▾"} Workspaces</span>${this.collapsed ? html`<small class="section-selected" title=${selectedTitle}>${selectedSummary}</small>` : null}</span>${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 {
|
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">
|
||||||
<span class="workspace-primary-label">${label}</span>
|
<span class="workspace-primary-label">${label}</span>
|
||||||
${this.isDeleting(workspace) ? html`<span class="workspace-status">Deleting…</span>` : null}
|
${this.isDeleting(workspace) ? html`<span class="workspace-status">Deleting…</span>` : null}
|
||||||
${this.notificationBadges[workspace.id] === undefined ? null : html`<notification-badge .model=${this.notificationBadges[workspace.id]}></notification-badge>`}
|
|
||||||
</span>
|
</span>
|
||||||
${items.length === 0 ? null : html`
|
${items.length === 0 ? null : html`
|
||||||
<small class="workspace-secondary">
|
<small class="workspace-secondary">
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { LitElement, css, html, type TemplateResult } from "lit";
|
import { LitElement, css, html, type TemplateResult } from "lit";
|
||||||
import { customElement, property, query, state } from "lit/decorators.js";
|
import { customElement, property, query, state } from "lit/decorators.js";
|
||||||
import type { AppState } from "../../appState";
|
import type { AppState } from "../../appState";
|
||||||
import type { SessionNotificationBadgeModel } from "../../sessionNotifications";
|
|
||||||
import { renderAppTabIcon, type AppTabBuiltinIcon } from "../tabIcons";
|
import { renderAppTabIcon, type AppTabBuiltinIcon } from "../tabIcons";
|
||||||
import "../NotificationBadge";
|
|
||||||
|
|
||||||
export type AppMobileMainTabBuiltinIcon = AppTabBuiltinIcon;
|
export type AppMobileMainTabBuiltinIcon = AppTabBuiltinIcon;
|
||||||
export type AppMobileMainTabIcon = AppMobileMainTabBuiltinIcon | TemplateResult;
|
export type AppMobileMainTabIcon = AppMobileMainTabBuiltinIcon | TemplateResult;
|
||||||
@@ -76,7 +74,6 @@ export class AppMobileMainTabs extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private tabAriaLabel(tab: AppMobileMainTab): string {
|
private tabAriaLabel(tab: AppMobileMainTab): string {
|
||||||
if (isSessionNotificationBadgeModel(tab.badge)) return `${tab.label}, ${tab.badge.accessibleLabel}`;
|
|
||||||
if (typeof tab.badge !== "string" && typeof tab.badge !== "number") return tab.label;
|
if (typeof tab.badge !== "string" && typeof tab.badge !== "number") return tab.label;
|
||||||
const badge = String(tab.badge).trim();
|
const badge = String(tab.badge).trim();
|
||||||
return badge === "" ? tab.label : `${tab.label}, ${badge}`;
|
return badge === "" ? tab.label : `${tab.label}, ${badge}`;
|
||||||
@@ -84,7 +81,6 @@ export class AppMobileMainTabs extends LitElement {
|
|||||||
|
|
||||||
private renderBadge(badge: unknown) {
|
private renderBadge(badge: unknown) {
|
||||||
if (badge === undefined || badge === "") return null;
|
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>`;
|
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 { customElement, property, query } from "lit/decorators.js";
|
||||||
import type { Machine, MachineHealth, Project, SessionActivity, SessionInfo, SessionStatus, Workspace, WorkspaceActivity } from "../../api";
|
import type { Machine, MachineHealth, Project, SessionActivity, SessionInfo, SessionStatus, Workspace, WorkspaceActivity } from "../../api";
|
||||||
import type { WorkspaceLabelItem } from "../../plugins/types";
|
import type { WorkspaceLabelItem } from "../../plugins/types";
|
||||||
import type { SessionNotificationBadgeModel } from "../../sessionNotifications";
|
|
||||||
import type { NavigationSection } from "../../appShell/navigationState";
|
import type { NavigationSection } from "../../appShell/navigationState";
|
||||||
import { NAVIGATION_SECTION_ORDER } from "../../appShell/navigationState";
|
import { NAVIGATION_SECTION_ORDER } from "../../appShell/navigationState";
|
||||||
import type { KeyboardNavigableSection } from "../navigationFocus";
|
import type { KeyboardNavigableSection } from "../navigationFocus";
|
||||||
@@ -14,19 +13,6 @@ import "../SessionList";
|
|||||||
|
|
||||||
export type NavigationFocusTarget = NavigationSection | "chat";
|
export type NavigationFocusTarget = NavigationSection | "chat";
|
||||||
|
|
||||||
export interface NavigationNotificationBadges {
|
|
||||||
machines: Record<string, SessionNotificationBadgeModel | undefined>;
|
|
||||||
projects: Record<string, SessionNotificationBadgeModel | undefined>;
|
|
||||||
workspaces: Record<string, SessionNotificationBadgeModel | undefined>;
|
|
||||||
sessions: Record<string, SessionNotificationBadgeModel | undefined>;
|
|
||||||
machinesHeading?: SessionNotificationBadgeModel | undefined;
|
|
||||||
projectsHeading?: SessionNotificationBadgeModel | undefined;
|
|
||||||
workspacesHeading?: SessionNotificationBadgeModel | undefined;
|
|
||||||
sessionsHeading?: SessionNotificationBadgeModel | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const emptyNavigationNotificationBadges = (): NavigationNotificationBadges => ({ machines: {}, projects: {}, workspaces: {}, sessions: {} });
|
|
||||||
|
|
||||||
@customElement("app-navigation-panel")
|
@customElement("app-navigation-panel")
|
||||||
export class AppNavigationPanel extends LitElement {
|
export class AppNavigationPanel extends LitElement {
|
||||||
@property({ attribute: false }) machines: Machine[] = [];
|
@property({ attribute: false }) machines: Machine[] = [];
|
||||||
@@ -44,7 +30,6 @@ export class AppNavigationPanel extends LitElement {
|
|||||||
@property({ attribute: false }) sessionStatuses: Record<string, SessionStatus> = {};
|
@property({ attribute: false }) sessionStatuses: Record<string, SessionStatus> = {};
|
||||||
@property({ attribute: false }) sendingPrompts: Record<string, true> = {};
|
@property({ attribute: false }) sendingPrompts: Record<string, true> = {};
|
||||||
@property({ attribute: false }) workspacesByProjectId: Record<string, Workspace[]> = {};
|
@property({ attribute: false }) workspacesByProjectId: Record<string, Workspace[]> = {};
|
||||||
@property({ attribute: false }) notificationBadges: NavigationNotificationBadges = emptyNavigationNotificationBadges();
|
|
||||||
@property({ attribute: false }) deletingWorkspaceIds: string[] = [];
|
@property({ attribute: false }) deletingWorkspaceIds: string[] = [];
|
||||||
@property({ attribute: false }) workspaceLabelItems: (workspace: Workspace) => WorkspaceLabelItem[] = () => [];
|
@property({ attribute: false }) workspaceLabelItems: (workspace: Workspace) => WorkspaceLabelItem[] = () => [];
|
||||||
@property({ attribute: false }) refreshControl: unknown;
|
@property({ attribute: false }) refreshControl: unknown;
|
||||||
@@ -115,8 +100,6 @@ export class AppNavigationPanel extends LitElement {
|
|||||||
.selected=${this.selectedMachine}
|
.selected=${this.selectedMachine}
|
||||||
.statuses=${this.machineStatuses}
|
.statuses=${this.machineStatuses}
|
||||||
.activities=${this.machineActivities}
|
.activities=${this.machineActivities}
|
||||||
.notificationBadges=${this.notificationBadges.machines}
|
|
||||||
.notificationHeadingBadge=${this.notificationBadges.machinesHeading}
|
|
||||||
.onSelect=${(machine: Machine) => this.onSelectMachine?.(machine)}
|
.onSelect=${(machine: Machine) => this.onSelectMachine?.(machine)}
|
||||||
.onRemove=${(machine: Machine) => this.onRemoveMachine?.(machine)}
|
.onRemove=${(machine: Machine) => this.onRemoveMachine?.(machine)}
|
||||||
.onFocusNextSection=${() => { this.focusNextFrom("machines"); }}
|
.onFocusNextSection=${() => { this.focusNextFrom("machines"); }}
|
||||||
@@ -134,8 +117,6 @@ export class AppNavigationPanel extends LitElement {
|
|||||||
.selected=${this.selectedMachine}
|
.selected=${this.selectedMachine}
|
||||||
.statuses=${this.machineStatuses}
|
.statuses=${this.machineStatuses}
|
||||||
.activities=${this.machineActivities}
|
.activities=${this.machineActivities}
|
||||||
.notificationBadges=${this.notificationBadges.machines}
|
|
||||||
.notificationHeadingBadge=${this.notificationBadges.machinesHeading}
|
|
||||||
.collapsible=${this.collapsible}
|
.collapsible=${this.collapsible}
|
||||||
.collapsed=${this.machinesCollapsed}
|
.collapsed=${this.machinesCollapsed}
|
||||||
.onToggleCollapsed=${() => { this.onToggleMachines?.(); }}
|
.onToggleCollapsed=${() => { this.onToggleMachines?.(); }}
|
||||||
@@ -150,8 +131,6 @@ export class AppNavigationPanel extends LitElement {
|
|||||||
.selected=${this.selectedProject}
|
.selected=${this.selectedProject}
|
||||||
.activities=${this.workspaceActivities}
|
.activities=${this.workspaceActivities}
|
||||||
.workspacesByProjectId=${this.workspacesByProjectId}
|
.workspacesByProjectId=${this.workspacesByProjectId}
|
||||||
.notificationBadges=${this.notificationBadges.projects}
|
|
||||||
.notificationHeadingBadge=${this.notificationBadges.projectsHeading}
|
|
||||||
.collapsible=${this.collapsible}
|
.collapsible=${this.collapsible}
|
||||||
.collapsed=${this.projectsCollapsed}
|
.collapsed=${this.projectsCollapsed}
|
||||||
.onToggleCollapsed=${() => { this.onToggleProjects?.(); }}
|
.onToggleCollapsed=${() => { this.onToggleProjects?.(); }}
|
||||||
@@ -166,8 +145,6 @@ export class AppNavigationPanel extends LitElement {
|
|||||||
.selected=${this.selectedWorkspace}
|
.selected=${this.selectedWorkspace}
|
||||||
.activities=${this.workspaceActivities}
|
.activities=${this.workspaceActivities}
|
||||||
.deletingWorkspaceIds=${this.deletingWorkspaceIds}
|
.deletingWorkspaceIds=${this.deletingWorkspaceIds}
|
||||||
.notificationBadges=${this.notificationBadges.workspaces}
|
|
||||||
.notificationHeadingBadge=${this.notificationBadges.workspacesHeading}
|
|
||||||
.collapsible=${this.collapsible}
|
.collapsible=${this.collapsible}
|
||||||
.collapsed=${this.workspacesCollapsed}
|
.collapsed=${this.workspacesCollapsed}
|
||||||
.workspaceLabelItems=${this.workspaceLabelItems}
|
.workspaceLabelItems=${this.workspaceLabelItems}
|
||||||
@@ -183,8 +160,6 @@ export class AppNavigationPanel extends LitElement {
|
|||||||
.statuses=${this.sessionStatuses}
|
.statuses=${this.sessionStatuses}
|
||||||
.activities=${this.sessionActivities}
|
.activities=${this.sessionActivities}
|
||||||
.sending=${this.sendingPrompts}
|
.sending=${this.sendingPrompts}
|
||||||
.notificationBadges=${this.notificationBadges.sessions}
|
|
||||||
.notificationHeadingBadge=${this.notificationBadges.sessionsHeading}
|
|
||||||
.selected=${this.selectedSession}
|
.selected=${this.selectedSession}
|
||||||
.startingCount=${this.startingSessionCount}
|
.startingCount=${this.startingSessionCount}
|
||||||
.canStart=${this.canStartSession}
|
.canStart=${this.canStartSession}
|
||||||
|
|||||||
@@ -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 { position: absolute; top: 6px; right: 6px; display: inline-grid; place-items: center; width: 22px; height: 22px; padding: 0; border: 1px solid var(--pi-border); border-radius: 6px; background: var(--pi-surface); color: var(--pi-muted); font: 15px/1 system-ui, sans-serif; cursor: pointer; }
|
||||||
.session-warning-dismiss:hover, .session-warning-dismiss:focus-visible { color: var(--pi-text-bright); border-color: var(--pi-accent); background: var(--pi-bg-overlay); }
|
.session-warning-dismiss:hover, .session-warning-dismiss:focus-visible { color: var(--pi-text-bright); border-color: var(--pi-accent); background: var(--pi-bg-overlay); }
|
||||||
.session-warning-dismiss:focus-visible { outline: 1px solid var(--pi-border); outline-offset: 2px; }
|
.session-warning-dismiss:focus-visible { outline: 1px solid var(--pi-border); outline-offset: 2px; }
|
||||||
.notification-tray { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; border-top: 2px solid var(--pi-accent-border); background: var(--pi-bg-overlay); }
|
.notification-tray { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; background: var(--pi-bg-overlay); }
|
||||||
.notification-tray.warning { border-top-color: var(--pi-warning-border); }
|
|
||||||
.notification-tray.error { border-top-color: var(--pi-danger); }
|
|
||||||
.notification-tray.collapsed { flex: 0 0 auto; }
|
.notification-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-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 { min-width: 0; flex: 1 1 auto; overflow: hidden; color: var(--pi-text-bright); font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.notification-heading-icon { flex: 0 0 auto; font-size: 16px; }
|
.notification-header-actions { flex: 0 0 auto; display: flex; align-items: center; gap: 2px; }
|
||||||
.notification-heading-copy { min-width: 0; display: grid; 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-heading-copy strong { color: var(--pi-text-bright); }
|
.notification-control { padding: 0 7px; font: 12px system-ui, sans-serif; white-space: nowrap; }
|
||||||
.notification-heading-copy small { color: var(--pi-muted); white-space: normal; overflow-wrap: anywhere; }
|
.notification-toggle { display: inline-grid; place-items: center; width: 32px; height: 32px; padding: 0; }
|
||||||
.notification-header-actions { flex: 0 0 auto; display: flex; align-items: center; gap: 6px; }
|
.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 { 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:focus-visible, .notification-row-dismiss:focus-visible { outline: 2px solid var(--pi-accent); outline-offset: 1px; }
|
||||||
.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:disabled, .notification-row-dismiss:disabled { opacity: .5; background: transparent; cursor: default; }
|
||||||
.notification-control:focus-visible, .notification-card-dismiss:focus-visible { outline: 2px solid var(--pi-accent); outline-offset: 2px; }
|
.notification-icon { width: 17px; height: 17px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; }
|
||||||
.notification-control:disabled, .notification-card-dismiss:disabled { opacity: .55; cursor: default; }
|
.notification-disclosure-icon.expanded { transform: rotate(90deg); }
|
||||||
.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-close-icon { width: 16px; height: 16px; }
|
||||||
.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-list { flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior-y: contain; box-sizing: border-box; padding: 0 10px 5px; }
|
||||||
.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-list[hidden] { display: none; }
|
||||||
.notification-card.warning { border-color: var(--pi-warning-border); background: var(--pi-warning-surface); }
|
.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-card.error { border-color: var(--pi-danger); background: color-mix(in srgb, var(--pi-danger) 12%, var(--pi-surface)); }
|
.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-card:focus-visible { outline: 2px solid var(--pi-accent); outline-offset: -3px; }
|
.notification-row:focus-visible { outline: 2px solid var(--pi-accent); outline-offset: -2px; }
|
||||||
.notification-card-head { min-width: 0; display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 5px 10px; }
|
.notification-metadata { min-width: 0; display: flex; align-items: baseline; gap: 5px; color: var(--pi-muted); font-size: 11px; }
|
||||||
.notification-severity { color: var(--pi-accent); font-size: 12px; text-transform: uppercase; letter-spacing: .03em; }
|
.notification-severity { color: var(--pi-muted); font-size: inherit; font-weight: 600; }
|
||||||
.notification-card.warning .notification-severity { color: var(--pi-warning); }
|
.notification-row.warning .notification-severity { color: var(--pi-warning); }
|
||||||
.notification-card.error .notification-severity { color: var(--pi-danger); }
|
.notification-row.error .notification-severity { color: var(--pi-danger); }
|
||||||
.notification-card time { color: var(--pi-muted); font-size: 11px; }
|
|
||||||
.notification-message { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; text-align: start; unicode-bidi: plaintext; }
|
.notification-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-truncated { margin: 0; color: var(--pi-muted); font-size: 11px; 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-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; }
|
.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; }
|
.notification-live span { display: block; }
|
||||||
@media (pointer: coarse) {
|
@media (pointer: coarse) {
|
||||||
.notification-control { min-width: 44px; min-height: 44px; }
|
.notification-control, .notification-row-dismiss { min-height: 34px; }
|
||||||
.notification-card { padding-right: 58px; }
|
.notification-toggle, .notification-row-dismiss { width: 34px; height: 34px; }
|
||||||
.notification-card-dismiss { top: 6px; right: 6px; width: 44px; height: 44px; }
|
.notification-row { padding-right: 40px; }
|
||||||
}
|
}
|
||||||
@media (max-width: 520px) {
|
@media (max-width: 520px) {
|
||||||
.notification-header { align-items: flex-start; flex-wrap: wrap; }
|
.notification-header { gap: 4px; padding-inline: 8px; }
|
||||||
.notification-heading-group { flex: 1 1 180px; }
|
.notification-list { padding-inline: 8px; }
|
||||||
.notification-header-actions { margin-left: auto; }
|
|
||||||
.notification-control { padding-inline: 8px; }
|
|
||||||
}
|
}
|
||||||
.chat { height: 100%; min-height: 0; overflow: auto; overflow-anchor: none; padding: 26px 16px 64px; box-sizing: border-box; }
|
.chat { height: 100%; min-height: 0; overflow: auto; overflow-anchor: none; padding: 26px 16px 64px; box-sizing: border-box; }
|
||||||
.scroll-marker { display: block; height: 0; overflow: hidden; pointer-events: none; }
|
.scroll-marker { display: block; height: 0; overflow: hidden; pointer-events: none; }
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import type {
|
|||||||
Machine,
|
Machine,
|
||||||
SessionInfo,
|
SessionInfo,
|
||||||
SessionNotification,
|
SessionNotification,
|
||||||
SessionNotificationCatalogSnapshot,
|
|
||||||
SessionNotificationInboxEvent,
|
SessionNotificationInboxEvent,
|
||||||
SessionNotificationInboxSnapshot,
|
SessionNotificationInboxSnapshot,
|
||||||
} from "../../../shared/apiTypes";
|
} 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 {
|
function addedEvent(notification: SessionNotification, inboxRevision: number, retainedCount: number): SessionNotificationInboxEvent {
|
||||||
return {
|
return {
|
||||||
type: "notifications.inbox",
|
type: "notifications.inbox",
|
||||||
@@ -111,11 +102,9 @@ function capableState(): AppState {
|
|||||||
function createHarness(initialState = capableState(), overrides: Partial<SessionNotificationApi> = {}) {
|
function createHarness(initialState = capableState(), overrides: Partial<SessionNotificationApi> = {}) {
|
||||||
let state = initialState;
|
let state = initialState;
|
||||||
const api: SessionNotificationApi = {
|
const api: SessionNotificationApi = {
|
||||||
notificationCatalog: vi.fn(() => Promise.resolve(catalogSnapshot())),
|
|
||||||
notificationInbox: vi.fn(() => Promise.resolve(inboxSnapshot())),
|
notificationInbox: vi.fn(() => Promise.resolve(inboxSnapshot())),
|
||||||
dismissNotification: vi.fn(() => Promise.resolve(inboxSnapshot([], { inboxRevision: 2, catalogRevision: 2 }))),
|
dismissNotification: vi.fn(() => Promise.resolve(inboxSnapshot([], { inboxRevision: 2, catalogRevision: 2 }))),
|
||||||
dismissAllNotifications: 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,
|
...overrides,
|
||||||
};
|
};
|
||||||
const controller = new SessionNotificationController(
|
const controller = new SessionNotificationController(
|
||||||
@@ -131,149 +120,83 @@ function createHarness(initialState = capableState(), overrides: Partial<Session
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("SessionNotificationController capability and joins", () => {
|
describe("SessionNotificationController selected inbox ownership", () => {
|
||||||
it("makes no notification requests and preserves marked legacy output without effective capability support", async () => {
|
it("makes no notification request and preserves marked legacy output without effective capability support", async () => {
|
||||||
const state = { ...capableState(), machineRuntimes: {} };
|
const state = { ...capableState(), machineRuntimes: {} };
|
||||||
const harness = createHarness(state);
|
const harness = createHarness(state);
|
||||||
|
|
||||||
harness.controller.prepareSelectedSession(session, "local");
|
harness.controller.prepareSelectedSession(session, "local");
|
||||||
await harness.controller.refreshSelectedSession(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.notificationInbox).not.toHaveBeenCalled();
|
||||||
expect(harness.api.notificationCatalog).not.toHaveBeenCalled();
|
|
||||||
expect(harness.controller.shouldFilterLegacyNotification("local", "notification-1")).toBe(false);
|
expect(harness.controller.shouldFilterLegacyNotification("local", "notification-1")).toBe(false);
|
||||||
expect(harness.state.selectedNotificationInbox).toBeUndefined();
|
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 state = { ...capableState(), machineRuntimes: {} };
|
||||||
const harness = createHarness(state);
|
const harness = createHarness(state);
|
||||||
harness.controller.prepareSelectedSession(session, "local");
|
harness.controller.prepareSelectedSession(session, "local");
|
||||||
|
|
||||||
harness.controller.applySummaryEvent("local", {
|
harness.controller.applyInboxEvent("local", addedEvent(entry(1), 1, 1));
|
||||||
type: "notifications.summary",
|
|
||||||
daemonInstanceId: "daemon-a",
|
|
||||||
catalogRevision: 1,
|
|
||||||
summary: inboxSnapshot().summary,
|
|
||||||
});
|
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => { expect(harness.api.notificationInbox).toHaveBeenCalledOnce(); });
|
||||||
expect(harness.api.notificationCatalog).toHaveBeenCalledOnce();
|
expect(selectedNotificationView(harness.state.selectedNotificationInbox)?.notifications).toHaveLength(1);
|
||||||
expect(harness.api.notificationInbox).toHaveBeenCalledOnce();
|
|
||||||
});
|
|
||||||
expect(harness.controller.shouldFilterLegacyNotification("local", "daemon-a:1")).toBe(true);
|
expect(harness.controller.shouldFilterLegacyNotification("local", "daemon-a:1")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("buffers global events around catalog hydration and applies newer revisions in order", async () => {
|
it("joins selected live events that arrive while the selected snapshot is loading", async () => {
|
||||||
const catalog = deferred<SessionNotificationCatalogSnapshot>();
|
const pendingInbox = deferred<SessionNotificationInboxSnapshot>();
|
||||||
const harness = createHarness(capableState(), { notificationCatalog: vi.fn(() => catalog.promise) });
|
const harness = createHarness(capableState(), { notificationInbox: vi.fn(() => pendingInbox.promise) });
|
||||||
|
|
||||||
harness.controller.globalSocketOpened("local");
|
|
||||||
harness.controller.applySummaryEvent("local", {
|
|
||||||
type: "notifications.summary",
|
|
||||||
daemonInstanceId: "daemon-a",
|
|
||||||
catalogRevision: 2,
|
|
||||||
summary: {
|
|
||||||
...inboxSnapshot([entry(2, "warning"), entry(1)], { inboxRevision: 2, catalogRevision: 2 }).summary,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(harness.state.notificationCatalogsByMachine["local"]).toBeUndefined();
|
|
||||||
catalog.resolve(catalogSnapshot(1));
|
|
||||||
await vi.waitFor(() => { expect(harness.state.notificationCatalogsByMachine["local"]?.catalogRevision).toBe(2); });
|
|
||||||
|
|
||||||
expect(harness.state.notificationCatalogsByMachine["local"]).toMatchObject({ status: "fresh", daemonInstanceId: "daemon-a" });
|
|
||||||
expect(harness.state.notificationCatalogsByMachine["local"]?.summariesBySessionId[session.id]).toMatchObject({ retainedCount: 2, highestSeverity: "warning" });
|
|
||||||
expect(harness.controller.shouldFilterLegacyNotification("local", "notification-1")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("lets the selected live event announce before its matching global summary can trigger a snapshot", async () => {
|
|
||||||
const first = inboxSnapshot([entry(1)], { inboxRevision: 1, catalogRevision: 1 });
|
|
||||||
const state = {
|
|
||||||
...capableState(),
|
|
||||||
notificationCatalogsByMachine: {
|
|
||||||
local: {
|
|
||||||
machineId: "local",
|
|
||||||
status: "fresh" as const,
|
|
||||||
daemonInstanceId: first.daemonInstanceId,
|
|
||||||
catalogRevision: first.catalogRevision,
|
|
||||||
summariesBySessionId: { [session.id]: first.summary },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const notificationInbox = vi.fn(() => Promise.resolve(first));
|
|
||||||
const harness = createHarness(state, { notificationInbox });
|
|
||||||
harness.controller.prepareSelectedSession(session, "local");
|
|
||||||
await harness.controller.refreshSelectedSession(session, "local");
|
|
||||||
|
|
||||||
const event = addedEvent(entry(2, "warning"), 2, 2);
|
|
||||||
harness.controller.applySummaryEvent("local", {
|
|
||||||
type: "notifications.summary",
|
|
||||||
daemonInstanceId: event.daemonInstanceId,
|
|
||||||
catalogRevision: event.catalogRevision,
|
|
||||||
summary: event.summary,
|
|
||||||
});
|
|
||||||
await Promise.resolve();
|
|
||||||
expect(notificationInbox).toHaveBeenCalledOnce();
|
|
||||||
|
|
||||||
harness.controller.applyInboxEvent("local", event);
|
|
||||||
expect(selectedNotificationView(harness.state.selectedNotificationInbox)?.announcements).toMatchObject([
|
|
||||||
{ severity: "warning", message: "notice 2" },
|
|
||||||
]);
|
|
||||||
expect(notificationInbox).toHaveBeenCalledOnce();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("refetches the selected inbox when a newly opened global socket finds a newer catalog revision", async () => {
|
|
||||||
const first = inboxSnapshot([entry(1)], { inboxRevision: 1, catalogRevision: 1 });
|
|
||||||
const second = inboxSnapshot([entry(2, "error"), entry(1)], { inboxRevision: 2, catalogRevision: 2 });
|
|
||||||
const notificationInbox = vi.fn()
|
|
||||||
.mockResolvedValueOnce(first)
|
|
||||||
.mockResolvedValueOnce(second);
|
|
||||||
const notificationCatalog = vi.fn(() => Promise.resolve<SessionNotificationCatalogSnapshot>({
|
|
||||||
daemonInstanceId: second.daemonInstanceId,
|
|
||||||
catalogRevision: second.catalogRevision,
|
|
||||||
sessions: [second.summary],
|
|
||||||
}));
|
|
||||||
const harness = createHarness(capableState(), { notificationInbox, notificationCatalog });
|
|
||||||
|
|
||||||
harness.controller.prepareSelectedSession(session, "local");
|
harness.controller.prepareSelectedSession(session, "local");
|
||||||
await harness.controller.refreshSelectedSession(session, "local");
|
const refresh = harness.controller.refreshSelectedSession(session, "local");
|
||||||
harness.controller.globalSocketOpened("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([
|
expect(selectedNotificationView(harness.state.selectedNotificationInbox)?.notifications.map((notification) => notification.id)).toEqual([
|
||||||
"daemon-a:2",
|
"daemon-a:2",
|
||||||
"daemon-a:1",
|
"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 () => {
|
it("ignores notification events for an unselected chat", async () => {
|
||||||
const project = { id: "project-1", name: "Repo", path: "/repo", createdAt: "now" };
|
const harness = createHarness();
|
||||||
const next = {
|
harness.controller.prepareSelectedSession(session, "local");
|
||||||
...capableState(),
|
await harness.controller.refreshSelectedSession(session, "local");
|
||||||
projects: [project],
|
const selectedBefore = harness.state.selectedNotificationInbox;
|
||||||
selectedProject: project,
|
|
||||||
notificationCatalogsByMachine: {
|
|
||||||
local: {
|
|
||||||
machineId: "local",
|
|
||||||
status: "fresh" as const,
|
|
||||||
daemonInstanceId: "daemon-a",
|
|
||||||
catalogRevision: 1,
|
|
||||||
summariesBySessionId: { [session.id]: inboxSnapshot().summary },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const workspace = { id: "workspace-1", projectId: project.id, path: "/repo", label: "repo", isMain: true, isGitRepo: true, isGitWorktree: false };
|
|
||||||
const workspaces = vi.fn(() => Promise.resolve([workspace]));
|
|
||||||
const harness = createHarness(next, { workspaces });
|
|
||||||
|
|
||||||
harness.controller.syncEnvironment(initialAppState(), next);
|
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(harness.api.notificationInbox).toHaveBeenCalledOnce();
|
||||||
expect(workspaces).toHaveBeenCalledExactlyOnceWith(project.id, "local");
|
expect(harness.state.selectedNotificationInbox).toBe(selectedBefore);
|
||||||
expect(harness.state.selectedProject).toBe(project);
|
});
|
||||||
|
|
||||||
|
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 () => {
|
it("ignores an old selected-inbox response after selection changes", async () => {
|
||||||
@@ -372,12 +295,7 @@ describe("SessionNotificationController optimistic mutations", () => {
|
|||||||
const dismissNotification = vi.fn()
|
const dismissNotification = vi.fn()
|
||||||
.mockImplementationOnce(() => dismiss.promise)
|
.mockImplementationOnce(() => dismiss.promise)
|
||||||
.mockRejectedValueOnce(new Error("offline"));
|
.mockRejectedValueOnce(new Error("offline"));
|
||||||
const notificationCatalog = vi.fn(() => Promise.resolve<SessionNotificationCatalogSnapshot>({
|
const harness = createHarness(capableState(), { notificationInbox, dismissNotification });
|
||||||
daemonInstanceId: initialInbox.daemonInstanceId,
|
|
||||||
catalogRevision: initialInbox.catalogRevision,
|
|
||||||
sessions: [initialInbox.summary],
|
|
||||||
}));
|
|
||||||
const harness = createHarness(capableState(), { notificationInbox, dismissNotification, notificationCatalog });
|
|
||||||
|
|
||||||
harness.controller.prepareSelectedSession(session, "local");
|
harness.controller.prepareSelectedSession(session, "local");
|
||||||
await harness.controller.refreshSelectedSession(session, "local");
|
await harness.controller.refreshSelectedSession(session, "local");
|
||||||
|
|||||||
@@ -1,35 +1,26 @@
|
|||||||
import { api as defaultApi, type Machine, type SessionInfo } from "../api";
|
import { api as defaultApi, type Machine, type SessionInfo } from "../api";
|
||||||
import type { AppState } from "../appState";
|
import type { AppState } from "../appState";
|
||||||
import {
|
import {
|
||||||
applyNotificationCatalogEvent,
|
|
||||||
applySelectedNotificationEvent,
|
applySelectedNotificationEvent,
|
||||||
freshNotificationCatalog,
|
|
||||||
installSelectedNotificationSnapshot,
|
installSelectedNotificationSnapshot,
|
||||||
loadingSelectedNotificationInbox,
|
loadingSelectedNotificationInbox,
|
||||||
notificationSummaryIsEmpty,
|
|
||||||
notificationTargetsEqual,
|
notificationTargetsEqual,
|
||||||
selectedNotificationView,
|
selectedNotificationView,
|
||||||
type SelectedSessionNotificationInbox,
|
type SelectedSessionNotificationInbox,
|
||||||
type SessionNotificationCatalogProjection,
|
|
||||||
type SessionNotificationTarget,
|
type SessionNotificationTarget,
|
||||||
} from "../sessionNotifications";
|
} from "../sessionNotifications";
|
||||||
import { PI_WEB_CAPABILITIES, supportsPiWebCapability } from "../../../shared/capabilities";
|
import { PI_WEB_CAPABILITIES, supportsPiWebCapability } from "../../../shared/capabilities";
|
||||||
import type {
|
import type {
|
||||||
SessionNotificationInboxEvent,
|
SessionNotificationInboxEvent,
|
||||||
SessionNotificationInboxSnapshot,
|
SessionNotificationInboxSnapshot,
|
||||||
SessionNotificationSummaryEvent,
|
|
||||||
SessionRef,
|
SessionRef,
|
||||||
} from "../../../shared/apiTypes";
|
} from "../../../shared/apiTypes";
|
||||||
import { selectedMachineId, type GetState, type SetState } from "./types";
|
import type { GetState, SetState } from "./types";
|
||||||
|
|
||||||
const WORKSPACE_HYDRATION_CONCURRENCY = 3;
|
|
||||||
|
|
||||||
export interface SessionNotificationApi {
|
export interface SessionNotificationApi {
|
||||||
notificationCatalog: typeof defaultApi.notificationCatalog;
|
|
||||||
notificationInbox: typeof defaultApi.notificationInbox;
|
notificationInbox: typeof defaultApi.notificationInbox;
|
||||||
dismissNotification: typeof defaultApi.dismissNotification;
|
dismissNotification: typeof defaultApi.dismissNotification;
|
||||||
dismissAllNotifications: typeof defaultApi.dismissAllNotifications;
|
dismissAllNotifications: typeof defaultApi.dismissAllNotifications;
|
||||||
workspaces: typeof defaultApi.workspaces;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionNotificationControllerDependencies {
|
export interface SessionNotificationControllerDependencies {
|
||||||
@@ -37,15 +28,6 @@ export interface SessionNotificationControllerDependencies {
|
|||||||
onBackgroundError?: (message: string, error: unknown) => void;
|
onBackgroundError?: (message: string, error: unknown) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CatalogJoin {
|
|
||||||
events: SessionNotificationSummaryEvent[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CatalogRefreshOperation {
|
|
||||||
promise: Promise<void>;
|
|
||||||
trailing: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SelectedJoin {
|
interface SelectedJoin {
|
||||||
generation: number;
|
generation: number;
|
||||||
events: SessionNotificationInboxEvent[];
|
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
|
* Network and socket inputs enter through explicit methods; all transcript state
|
||||||
* remains owned by SessionController/ChatTranscriptStore and is never touched.
|
* remains owned by SessionController/ChatTranscriptStore and is never touched.
|
||||||
@@ -67,16 +49,12 @@ export class SessionNotificationController {
|
|||||||
private readonly api: SessionNotificationApi;
|
private readonly api: SessionNotificationApi;
|
||||||
private readonly onBackgroundError: (message: string, error: unknown) => void;
|
private readonly onBackgroundError: (message: string, error: unknown) => void;
|
||||||
private readonly acceptedSupportByMachine = new Set<string>();
|
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 selectedTarget: SessionNotificationTarget | undefined;
|
||||||
private selectedGeneration = 0;
|
private selectedGeneration = 0;
|
||||||
private selectedJoin: SelectedJoin | undefined;
|
private selectedJoin: SelectedJoin | undefined;
|
||||||
private selectedRefresh: SelectedRefreshOperation | undefined;
|
private selectedRefresh: SelectedRefreshOperation | undefined;
|
||||||
private readonly dismissingNotificationIds = new Set<string>();
|
private readonly dismissingNotificationIds = new Set<string>();
|
||||||
private dismissAllPending = false;
|
private dismissAllPending = false;
|
||||||
private workspaceHydrationKey = "";
|
|
||||||
private readonly workspaceHydrationsInFlight = new Set<string>();
|
|
||||||
private disposed = false;
|
private disposed = false;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -93,11 +71,9 @@ export class SessionNotificationController {
|
|||||||
this.selectedGeneration += 1;
|
this.selectedGeneration += 1;
|
||||||
this.selectedTarget = undefined;
|
this.selectedTarget = undefined;
|
||||||
this.selectedJoin = undefined;
|
this.selectedJoin = undefined;
|
||||||
this.catalogJoins.clear();
|
this.acceptedSupportByMachine.clear();
|
||||||
this.catalogRefreshes.clear();
|
|
||||||
this.dismissingNotificationIds.clear();
|
this.dismissingNotificationIds.clear();
|
||||||
this.dismissAllPending = false;
|
this.dismissAllPending = false;
|
||||||
this.workspaceHydrationsInFlight.clear();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
prepareSelectedSession(session: SessionInfo, machineId: string): void {
|
prepareSelectedSession(session: SessionInfo, machineId: string): void {
|
||||||
@@ -124,7 +100,7 @@ export class SessionNotificationController {
|
|||||||
|
|
||||||
refreshSelectedSession(session: SessionRef, machineId: string): Promise<void> {
|
refreshSelectedSession(session: SessionRef, machineId: string): Promise<void> {
|
||||||
const target = this.selectedTarget;
|
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();
|
if (!this.machineSupportsNotifications(machineId) || !this.machineIsReachable(machineId)) return Promise.resolve();
|
||||||
const generation = this.selectedGeneration;
|
const generation = this.selectedGeneration;
|
||||||
const existing = this.selectedRefresh;
|
const existing = this.selectedRefresh;
|
||||||
@@ -143,12 +119,9 @@ export class SessionNotificationController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
applyInboxEvent(machineId: string, event: SessionNotificationInboxEvent): void {
|
applyInboxEvent(machineId: string, event: SessionNotificationInboxEvent): void {
|
||||||
this.acceptedSupportByMachine.add(machineId);
|
|
||||||
const target = this.selectedTarget;
|
const target = this.selectedTarget;
|
||||||
if (target?.machineId !== machineId || target.sessionId !== event.summary.sessionId || target.cwd !== event.summary.cwd) {
|
if (target?.machineId !== machineId || target.sessionId !== event.summary.sessionId || target.cwd !== event.summary.cwd) return;
|
||||||
this.applyCatalogSummary(machineId, inboxSummaryEvent(event));
|
this.acceptedSupportByMachine.add(machineId);
|
||||||
return;
|
|
||||||
}
|
|
||||||
const join = this.selectedJoin;
|
const join = this.selectedJoin;
|
||||||
if (join?.generation === this.selectedGeneration) {
|
if (join?.generation === this.selectedGeneration) {
|
||||||
join.events.push(event);
|
join.events.push(event);
|
||||||
@@ -156,35 +129,9 @@ export class SessionNotificationController {
|
|||||||
}
|
}
|
||||||
const result = applySelectedNotificationEvent(this.getState().selectedNotificationInbox, target, event);
|
const result = applySelectedNotificationEvent(this.getState().selectedNotificationInbox, target, event);
|
||||||
if (result.changed) this.setState({ selectedNotificationInbox: result.value });
|
if (result.changed) this.setState({ selectedNotificationInbox: result.value });
|
||||||
this.applyCatalogSummary(machineId, inboxSummaryEvent(event));
|
|
||||||
if (result.needsRefresh) this.scheduleSelectedRefresh(target);
|
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 {
|
syncEnvironment(previous: AppState, next: AppState): void {
|
||||||
if (this.disposed) return;
|
if (this.disposed) return;
|
||||||
if (previous.machines !== next.machines) this.pruneRemovedMachines(next.machines);
|
if (previous.machines !== next.machines) this.pruneRemovedMachines(next.machines);
|
||||||
@@ -192,32 +139,19 @@ export class SessionNotificationController {
|
|||||||
const environmentChanged = previous.machines !== next.machines
|
const environmentChanged = previous.machines !== next.machines
|
||||||
|| previous.machineStatuses !== next.machineStatuses
|
|| previous.machineStatuses !== next.machineStatuses
|
||||||
|| previous.machineRuntimes !== next.machineRuntimes;
|
|| previous.machineRuntimes !== next.machineRuntimes;
|
||||||
if (environmentChanged) {
|
const selected = this.selectedTarget;
|
||||||
for (const machineId of this.notificationMachineIds()) {
|
if (!environmentChanged || selected === undefined) return;
|
||||||
const wasEligible = this.machineSupportsNotificationsInState(previous, machineId) && this.machineIsReachableInState(previous, machineId);
|
|
||||||
const isEligible = this.machineSupportsNotificationsInState(next, machineId) && this.machineIsReachableInState(next, machineId);
|
const wasEligible = this.machineSupportsNotificationsInState(previous, selected.machineId)
|
||||||
if (!isEligible) this.markCatalogStale(machineId);
|
&& this.machineIsReachableInState(previous, selected.machineId);
|
||||||
else if (!wasEligible || next.notificationCatalogsByMachine[machineId]?.status !== "fresh") void this.refreshCatalog(machineId);
|
if (!this.machineSupportsNotifications(selected.machineId) || !this.machineIsReachable(selected.machineId)) {
|
||||||
}
|
this.markSelectedStale(selected);
|
||||||
const selected = this.selectedTarget;
|
return;
|
||||||
if (selected !== undefined) {
|
|
||||||
if (!this.machineSupportsNotifications(selected.machineId) || !this.machineIsReachable(selected.machineId)) {
|
|
||||||
this.markSelectedStale(selected);
|
|
||||||
} else {
|
|
||||||
this.ensureSelectedProjection(selected);
|
|
||||||
if (!this.machineSupportsNotificationsInState(previous, selected.machineId) || next.selectedNotificationInbox?.status !== "fresh") {
|
|
||||||
void this.refreshSelectedSession({ id: selected.sessionId, cwd: selected.cwd }, selected.machineId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (environmentChanged
|
this.ensureSelectedProjection(selected);
|
||||||
|| previous.projects !== next.projects
|
if (!wasEligible || this.getState().selectedNotificationInbox?.status !== "fresh") {
|
||||||
|| previous.workspacesByProjectId !== next.workspacesByProjectId
|
void this.refreshSelectedSession({ id: selected.sessionId, cwd: selected.cwd }, selected.machineId);
|
||||||
|| previous.selectedMachine !== next.selectedMachine
|
|
||||||
|| previous.notificationCatalogsByMachine !== next.notificationCatalogsByMachine) {
|
|
||||||
this.scheduleWorkspaceHydration();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,15 +239,12 @@ export class SessionNotificationController {
|
|||||||
let inbox = current === undefined || shouldInstallSelectedSnapshot(current, target, snapshot)
|
let inbox = current === undefined || shouldInstallSelectedSnapshot(current, target, snapshot)
|
||||||
? installSelectedNotificationSnapshot(current, target, snapshot)
|
? installSelectedNotificationSnapshot(current, target, snapshot)
|
||||||
: current;
|
: current;
|
||||||
const catalogEvents = [snapshotSummaryEvent(snapshot)];
|
|
||||||
for (const event of [...join.events].sort((left, right) => left.summary.inboxRevision - right.summary.inboxRevision)) {
|
for (const event of [...join.events].sort((left, right) => left.summary.inboxRevision - right.summary.inboxRevision)) {
|
||||||
const result = applySelectedNotificationEvent(inbox, target, event);
|
const result = applySelectedNotificationEvent(inbox, target, event);
|
||||||
inbox = result.value;
|
inbox = result.value;
|
||||||
catalogEvents.push(inboxSummaryEvent(event));
|
|
||||||
if (result.needsRefresh) operation.trailing = true;
|
if (result.needsRefresh) operation.trailing = true;
|
||||||
}
|
}
|
||||||
this.setState({ selectedNotificationInbox: inbox });
|
this.setState({ selectedNotificationInbox: inbox });
|
||||||
for (const event of catalogEvents) this.applyCatalogSummary(target.machineId, event);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (this.isCurrentTarget(target, operation.generation)) {
|
if (this.isCurrentTarget(target, operation.generation)) {
|
||||||
const current = this.getState().selectedNotificationInbox;
|
const current = this.getState().selectedNotificationInbox;
|
||||||
@@ -327,60 +258,6 @@ export class SessionNotificationController {
|
|||||||
} while (operation.trailing && this.isCurrentTarget(target, operation.generation) && this.machineIsReachable(target.machineId));
|
} 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(
|
private applyMutationSnapshot(
|
||||||
target: SessionNotificationTarget,
|
target: SessionNotificationTarget,
|
||||||
snapshot: SessionNotificationInboxSnapshot,
|
snapshot: SessionNotificationInboxSnapshot,
|
||||||
@@ -396,7 +273,6 @@ export class SessionNotificationController {
|
|||||||
? installSelectedNotificationSnapshot(current, target, snapshot)
|
? installSelectedNotificationSnapshot(current, target, snapshot)
|
||||||
: current;
|
: current;
|
||||||
this.setState({ selectedNotificationInbox: removeOverlay(authoritative) });
|
this.setState({ selectedNotificationInbox: removeOverlay(authoritative) });
|
||||||
this.applyCatalogSummary(target.machineId, snapshotSummaryEvent(snapshot));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private patchSelectedOverlay(
|
private patchSelectedOverlay(
|
||||||
@@ -408,15 +284,6 @@ export class SessionNotificationController {
|
|||||||
this.setState({ selectedNotificationInbox: update(current) });
|
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 {
|
private ensureSelectedProjection(target: SessionNotificationTarget): void {
|
||||||
const current = this.getState().selectedNotificationInbox;
|
const current = this.getState().selectedNotificationInbox;
|
||||||
if (current === undefined || !notificationTargetsEqual(current, target)) this.setState({ selectedNotificationInbox: loadingSelectedNotificationInbox(target) });
|
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 {
|
private markSelectedStale(target: SessionNotificationTarget): void {
|
||||||
const current = this.getState().selectedNotificationInbox;
|
const current = this.getState().selectedNotificationInbox;
|
||||||
if (current === undefined || !notificationTargetsEqual(current, target) || current.status === "stale") return;
|
if (current === undefined || !notificationTargetsEqual(current, target) || current.status === "stale") return;
|
||||||
@@ -483,43 +306,9 @@ export class SessionNotificationController {
|
|||||||
private pruneRemovedMachines(machines: readonly Machine[]): void {
|
private pruneRemovedMachines(machines: readonly Machine[]): void {
|
||||||
const machineIds = new Set(machines.map((machine) => machine.id));
|
const machineIds = new Set(machines.map((machine) => machine.id));
|
||||||
if (machineIds.size === 0) machineIds.add("local");
|
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);
|
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 {
|
private isCurrentTarget(target: SessionNotificationTarget, generation: number): boolean {
|
||||||
return !this.disposed
|
return !this.disposed
|
||||||
&& generation === this.selectedGeneration
|
&& generation === this.selectedGeneration
|
||||||
@@ -546,34 +335,6 @@ export class SessionNotificationController {
|
|||||||
const status = state.machineStatuses[machineId]?.status ?? machine?.status;
|
const status = state.machineStatuses[machineId]?.status ?? machine?.status;
|
||||||
return status === undefined || status === "unknown" || status === "online";
|
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 {
|
function targetFromInbox(inbox: SelectedSessionNotificationInbox): SessionNotificationTarget {
|
||||||
@@ -591,34 +352,6 @@ function shouldInstallSelectedSnapshot(
|
|||||||
|| snapshot.summary.inboxRevision >= current.summary.inboxRevision;
|
|| 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 {
|
function errorMessage(error: unknown): string {
|
||||||
return error instanceof Error ? error.message : String(error);
|
return error instanceof Error ? error.message : String(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,24 +4,22 @@ import type {
|
|||||||
SessionNotificationInboxEvent,
|
SessionNotificationInboxEvent,
|
||||||
SessionNotificationInboxSnapshot,
|
SessionNotificationInboxSnapshot,
|
||||||
SessionNotificationSummary,
|
SessionNotificationSummary,
|
||||||
SessionNotificationSummaryEvent,
|
|
||||||
} from "../../shared/apiTypes";
|
} from "../../shared/apiTypes";
|
||||||
import {
|
import {
|
||||||
aggregateNotificationSummaries,
|
|
||||||
applyNotificationCatalogEvent,
|
|
||||||
applySelectedNotificationEvent,
|
applySelectedNotificationEvent,
|
||||||
effectiveNotificationSummaries,
|
|
||||||
freshNotificationCatalog,
|
|
||||||
installSelectedNotificationSnapshot,
|
installSelectedNotificationSnapshot,
|
||||||
notificationAggregateAcrossMachines,
|
notificationAnnouncementLabel,
|
||||||
notificationAggregateForCwd,
|
notificationDismissLabel,
|
||||||
notificationAggregateForProject,
|
|
||||||
notificationBadgeModel,
|
|
||||||
notificationFocusTargetAfterDismiss,
|
notificationFocusTargetAfterDismiss,
|
||||||
notificationInboxOverflowLabel,
|
notificationInboxOverflowLabel,
|
||||||
|
notificationInboxTotalCount,
|
||||||
notificationMessageTruncationLabel,
|
notificationMessageTruncationLabel,
|
||||||
|
notificationTargetKey,
|
||||||
|
notificationTrayHeading,
|
||||||
|
notificationTrayIsCollapsed,
|
||||||
selectedNotificationView,
|
selectedNotificationView,
|
||||||
setNotificationTrayCollapsed,
|
setNotificationTrayCollapsed,
|
||||||
|
type SessionNotificationAnnouncement,
|
||||||
type SessionNotificationTarget,
|
type SessionNotificationTarget,
|
||||||
} from "./sessionNotifications";
|
} 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", () => {
|
describe("selected notification projection", () => {
|
||||||
it("joins a snapshot with newer buffered events without duplicate cards or replay announcements", () => {
|
it("joins a snapshot with newer buffered events without duplicate cards or replay announcements", () => {
|
||||||
let inbox = installSelectedNotificationSnapshot(undefined, target, snapshot());
|
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", () => {
|
describe("notification presentation helpers", () => {
|
||||||
it("chooses next, previous, then header focus targets", () => {
|
it("chooses next, previous, then header focus targets", () => {
|
||||||
const notifications = [notification(3), notification(2), notification(1)];
|
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" });
|
expect(notificationFocusTargetAfterDismiss([notification(1)], "daemon-a:1")).toEqual({ kind: "header" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("retains explicit collapse state and exposes a visible truncation label", () => {
|
it("retains collapse state by exact machine, cwd, and session identity", () => {
|
||||||
const collapsed = setNotificationTrayCollapsed(new Set(), "session-1", true);
|
const collapsed = setNotificationTrayCollapsed(new Set(), target, true);
|
||||||
expect(collapsed.has("session-1")).toBe(true);
|
|
||||||
expect(setNotificationTrayCollapsed(collapsed, "session-1", false).has("session-1")).toBe(false);
|
expect(notificationTrayIsCollapsed(collapsed, target)).toBe(true);
|
||||||
expect(notificationInboxOverflowLabel(23)).toBe("23 older notifications were discarded because this inbox keeps the latest 100.");
|
expect(notificationTrayIsCollapsed(collapsed, { ...target, machineId: "remote" })).toBe(false);
|
||||||
expect(notificationMessageTruncationLabel({ truncated: true })).toContain("8 KiB");
|
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();
|
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"] } {
|
function optionalHighestSeverity(notifications: readonly SessionNotification[]): { highestSeverity?: SessionNotification["severity"] } {
|
||||||
|
|||||||
@@ -2,25 +2,15 @@ import {
|
|||||||
SESSION_NOTIFICATION_LIMIT,
|
SESSION_NOTIFICATION_LIMIT,
|
||||||
SESSION_NOTIFICATION_MESSAGE_BYTES,
|
SESSION_NOTIFICATION_MESSAGE_BYTES,
|
||||||
type SessionNotification,
|
type SessionNotification,
|
||||||
type SessionNotificationCatalogSnapshot,
|
|
||||||
type SessionNotificationDismissThrough,
|
type SessionNotificationDismissThrough,
|
||||||
type SessionNotificationInboxEvent,
|
type SessionNotificationInboxEvent,
|
||||||
type SessionNotificationInboxSnapshot,
|
type SessionNotificationInboxSnapshot,
|
||||||
type SessionNotificationSeverity,
|
type SessionNotificationSeverity,
|
||||||
type SessionNotificationSummary,
|
type SessionNotificationSummary,
|
||||||
type SessionNotificationSummaryEvent,
|
|
||||||
} from "../../shared/apiTypes";
|
} from "../../shared/apiTypes";
|
||||||
|
|
||||||
export type SessionNotificationProjectionStatus = "loading" | "fresh" | "stale";
|
export type SessionNotificationProjectionStatus = "loading" | "fresh" | "stale";
|
||||||
|
|
||||||
export interface SessionNotificationCatalogProjection {
|
|
||||||
machineId: string;
|
|
||||||
status: SessionNotificationProjectionStatus;
|
|
||||||
daemonInstanceId?: string;
|
|
||||||
catalogRevision: number;
|
|
||||||
summariesBySessionId: Record<string, SessionNotificationSummary>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SessionNotificationTarget {
|
export interface SessionNotificationTarget {
|
||||||
machineId: string;
|
machineId: string;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
@@ -57,19 +47,6 @@ export interface SelectedSessionNotificationView extends SessionNotificationTarg
|
|||||||
announcements: SessionNotificationAnnouncement[];
|
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> {
|
export interface NotificationReducerResult<T> {
|
||||||
value: T;
|
value: T;
|
||||||
needsRefresh: boolean;
|
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(
|
export function installSelectedNotificationSnapshot(
|
||||||
current: SelectedSessionNotificationInbox | undefined,
|
current: SelectedSessionNotificationInbox | undefined,
|
||||||
target: SessionNotificationTarget,
|
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" {
|
export function notificationSeverityLabel(severity: SessionNotificationSeverity): "Info" | "Warning" | "Error" {
|
||||||
if (severity === "error") return "Error";
|
if (severity === "error") return "Error";
|
||||||
if (severity === "warning") return "Warning";
|
if (severity === "warning") return "Warning";
|
||||||
return "Info";
|
return "Info";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function notificationSeverityIcon(severity: SessionNotificationSeverity): string {
|
export function notificationAnnouncementLabel(announcement: Pick<SessionNotificationAnnouncement, "severity">): string {
|
||||||
if (severity === "error") return "⛔";
|
return `${notificationSeverityLabel(announcement.severity)} notification received.`;
|
||||||
if (severity === "warning") return "⚠";
|
}
|
||||||
return "ℹ";
|
|
||||||
|
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" };
|
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 };
|
return previous === undefined ? { kind: "header" } : { kind: "notification", notificationId: previous.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setNotificationTrayCollapsed(collapsedSessionIds: ReadonlySet<string>, sessionId: string, collapsed: boolean): ReadonlySet<string> {
|
export function notificationTargetKey(target: SessionNotificationTarget): string {
|
||||||
const next = new Set(collapsedSessionIds);
|
return JSON.stringify([target.machineId, target.cwd, target.sessionId]);
|
||||||
if (collapsed) next.add(sessionId);
|
}
|
||||||
else next.delete(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;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function notificationInboxOverflowLabel(discardedCount: number): string {
|
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 {
|
export function notificationMessageTruncationLabel(notification: Pick<SessionNotification, "truncated">): string | undefined {
|
||||||
if (!notification.truncated) return undefined;
|
if (!notification.truncated) return undefined;
|
||||||
const kibibytes = SESSION_NOTIFICATION_MESSAGE_BYTES / 1024;
|
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 {
|
export function notificationTargetsEqual(left: SessionNotificationTarget, right: SessionNotificationTarget): boolean {
|
||||||
return left.machineId === right.machineId && left.sessionId === right.sessionId && left.cwd === right.cwd;
|
return left.machineId === right.machineId && left.sessionId === right.sessionId && left.cwd === right.cwd;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function notificationSummaryIsEmpty(summary: SessionNotificationSummary): boolean {
|
function higherNotificationSeverity(
|
||||||
return summary.retainedCount === 0 && summary.discardedCount === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function higherNotificationSeverity(
|
|
||||||
left: SessionNotificationSeverity | undefined,
|
left: SessionNotificationSeverity | undefined,
|
||||||
right: SessionNotificationSeverity | undefined,
|
right: SessionNotificationSeverity | undefined,
|
||||||
): SessionNotificationSeverity | undefined {
|
): SessionNotificationSeverity | undefined {
|
||||||
@@ -390,15 +270,6 @@ export function higherNotificationSeverity(
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function staleNotificationCatalog(machineId: string): SessionNotificationCatalogProjection {
|
|
||||||
return {
|
|
||||||
machineId,
|
|
||||||
status: "stale",
|
|
||||||
catalogRevision: 0,
|
|
||||||
summariesBySessionId: {},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function staleSelectedNotificationInbox(target: SessionNotificationTarget): SelectedSessionNotificationInbox {
|
function staleSelectedNotificationInbox(target: SessionNotificationTarget): SelectedSessionNotificationInbox {
|
||||||
return {
|
return {
|
||||||
...loadingSelectedNotificationInbox(target),
|
...loadingSelectedNotificationInbox(target),
|
||||||
@@ -423,10 +294,6 @@ function effectiveDiscardedCount(discardedCount: number, overflowWatermark: numb
|
|||||||
return discardedCount - acknowledged;
|
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 } {
|
function optionalSeverity(severity: SessionNotificationSeverity | undefined): { highestSeverity?: SessionNotificationSeverity } {
|
||||||
return severity === undefined ? {} : { highestSeverity: severity };
|
return severity === undefined ? {} : { highestSeverity: severity };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ function inboxEvent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("notification socket guards", () => {
|
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(parseSessionSocketEvent(inboxEvent())).toMatchObject({ type: "notifications.inbox", delta: { kind: "added" } });
|
||||||
|
|
||||||
expect(parseRealtimeSocketEvent({
|
expect(parseRealtimeSocketEvent({
|
||||||
@@ -43,7 +43,7 @@ describe("notification socket guards", () => {
|
|||||||
daemonInstanceId: "daemon-a",
|
daemonInstanceId: "daemon-a",
|
||||||
catalogRevision: 1,
|
catalogRevision: 1,
|
||||||
summary: summary(),
|
summary: summary(),
|
||||||
})).toMatchObject({ type: "notifications.summary", summary: { sessionId: "session-1" } });
|
})).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ignores malformed notification events instead of widening type-only acceptance", () => {
|
it("ignores malformed notification events instead of widening type-only acceptance", () => {
|
||||||
@@ -55,12 +55,6 @@ describe("notification socket guards", () => {
|
|||||||
dismissThrough: { order: 1, overflowWatermark: 0 },
|
dismissThrough: { order: 1, overflowWatermark: 0 },
|
||||||
delta: { kind: "added", notification: notification() },
|
delta: { kind: "added", notification: notification() },
|
||||||
})).toBeUndefined();
|
})).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", () => {
|
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 oldHandler = vi.fn();
|
||||||
const newHandler = vi.fn();
|
const newHandler = vi.fn();
|
||||||
const event = {
|
const event = {
|
||||||
type: "notifications.summary",
|
type: "workspace.activity",
|
||||||
daemonInstanceId: "daemon-a",
|
activity: {
|
||||||
catalogRevision: 1,
|
cwd: "/repo",
|
||||||
summary: summary(),
|
hasSessionActivity: true,
|
||||||
|
hasTerminalActivity: false,
|
||||||
|
updatedAt: "2026-07-18T00:00:00.000Z",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
socket.connect(oldHandler, undefined, "machine-a");
|
socket.connect(oldHandler, undefined, "machine-a");
|
||||||
const oldSocket = FakeWebSocket.instances[0];
|
const oldSocket = FakeWebSocket.instances[0];
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { realtimeEvents, sessionEvents } from "./api";
|
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";
|
import type { GlobalSessionEvent, RealtimeEvent, SessionRef, SessionUiEvent } from "../../shared/apiTypes";
|
||||||
|
|
||||||
export type { GlobalSessionEvent, RealtimeEvent, SessionUiEvent } from "../../shared/apiTypes";
|
export type { GlobalSessionEvent, RealtimeEvent, SessionUiEvent } from "../../shared/apiTypes";
|
||||||
|
|
||||||
|
export type BrowserRealtimeEvent = Exclude<RealtimeEvent, { type: "notifications.summary" }>;
|
||||||
|
type BrowserGlobalSessionEvent = Exclude<GlobalSessionEvent, { type: "notifications.summary" }>;
|
||||||
|
type NonGlobalBrowserRealtimeEvent = Exclude<BrowserRealtimeEvent, BrowserGlobalSessionEvent>;
|
||||||
|
|
||||||
export class SessionSocket {
|
export class SessionSocket {
|
||||||
private socket: WebSocket | undefined;
|
private socket: WebSocket | undefined;
|
||||||
private session: SessionRef | undefined;
|
private session: SessionRef | undefined;
|
||||||
@@ -90,14 +94,14 @@ export class SessionSocket {
|
|||||||
|
|
||||||
export class RealtimeSocket {
|
export class RealtimeSocket {
|
||||||
private socket: WebSocket | undefined;
|
private socket: WebSocket | undefined;
|
||||||
private onEvent: ((event: RealtimeEvent) => void) | undefined;
|
private onEvent: ((event: BrowserRealtimeEvent) => void) | undefined;
|
||||||
private onOpen: (() => void) | undefined;
|
private onOpen: (() => void) | undefined;
|
||||||
private reconnectTimer?: number;
|
private reconnectTimer?: number;
|
||||||
private reconnectDelay = 500;
|
private reconnectDelay = 500;
|
||||||
private shouldReconnect = false;
|
private shouldReconnect = false;
|
||||||
private machineId = "local";
|
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.close();
|
||||||
this.machineId = machineId;
|
this.machineId = machineId;
|
||||||
this.onEvent = onEvent;
|
this.onEvent = onEvent;
|
||||||
@@ -154,9 +158,7 @@ export function parseSessionSocketEvent(event: unknown): SessionUiEvent | undefi
|
|||||||
return isLegacySessionUiEvent(event) ? event : undefined;
|
return isLegacySessionUiEvent(event) ? event : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseRealtimeSocketEvent(event: unknown): RealtimeEvent | undefined {
|
export function parseRealtimeSocketEvent(event: unknown): BrowserRealtimeEvent | undefined {
|
||||||
const type = eventType(event);
|
|
||||||
if (type === "notifications.summary") return safelyParseNotificationEvent(() => parseSessionNotificationSummaryEvent(event));
|
|
||||||
if (isLegacyGlobalSessionEvent(event) || isLegacyRealtimeEvent(event)) return event;
|
if (isLegacyGlobalSessionEvent(event) || isLegacyRealtimeEvent(event)) return event;
|
||||||
return undefined;
|
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));
|
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);
|
const type = eventType(event);
|
||||||
return type === "status.update" || type === "activity.update" || type === "session.name" || type === "session.created";
|
return type === "status.update" || type === "activity.update" || type === "session.name" || type === "session.created";
|
||||||
}
|
}
|
||||||
|
|
||||||
function isLegacyRealtimeEvent(event: unknown): event is RealtimeEvent {
|
function isLegacyRealtimeEvent(event: unknown): event is NonGlobalBrowserRealtimeEvent {
|
||||||
const type = eventType(event);
|
const type = eventType(event);
|
||||||
return type === "terminal.created" || type === "terminal.exited" || type === "terminal.closed" || type === "workspace.activity";
|
return type === "terminal.created" || type === "terminal.exited" || type === "terminal.closed" || type === "workspace.activity";
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user