feat(sessions): persist shared unread state

This commit is contained in:
Federico Jaramillo Martinez
2026-07-20 19:36:16 +02:00
parent a20a8c8c09
commit 115d74e79a
37 changed files with 4272 additions and 66 deletions
+1 -1
View File
@@ -2,4 +2,4 @@ export { activityApi, api, configApi, filesApi, gitApi, machinesApi, piPackagesA
export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets";
export { DEFAULT_WORKSPACE_UPLOADS_FOLDER, effectiveWorkspaceUploadFolder, uploadWorkspaceFile, uploadWorkspaceFiles, workspaceEffectiveUploadFolder, workspaceUploadPath, WorkspaceUploadBatchError, WorkspaceUploadCancelledError } from "./api/workspaceUploads";
export type { UploadWorkspaceFileOptions, UploadWorkspaceFilesOptions, WorkspaceFileUploadProgress, WorkspaceUploadBatchFileProgress, WorkspaceUploadBatchProgress, WorkspaceUploadFileFailure, WorkspaceUploadFileInput, WorkspaceUploadFolderConfig, WorkspaceUploadTask, WorkspaceUploadXhr, WorkspaceUploadXhrFactory } from "./api/workspaceUploads";
export type { ActiveAgentProfileDescriptor, ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, OAuthFlowState, PiPackageInfo, PiPackageInstallRequest, PiPackageMutationAction, PiPackageMutationResponse, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiPackagesResponse, PiWebAgentDirEnvSource, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebDockerMode, PiWebInstallationInfo, PiWebInstallationKind, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebUploadsConfig, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef, SessionBulkMutationRequest, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupRequest, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionRef, SessionStatus, SessionStreamSnapshot, SessionTreeNavigateRequest, SessionTreeNavigateResult, SessionTreeNode, SessionTreeNodeKind, SessionTreeSnapshot, SessionTreeSummaryChoice, SessionWarning, SessionWarningSeverity, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
export type { ActiveAgentProfileDescriptor, ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, OAuthFlowState, PiPackageInfo, PiPackageInstallRequest, PiPackageMutationAction, PiPackageMutationResponse, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiPackagesResponse, PiWebAgentDirEnvSource, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebDockerMode, PiWebInstallationInfo, PiWebInstallationKind, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebUploadsConfig, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef, SessionBulkMutationRequest, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupRequest, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionRef, SessionStatus, SessionStreamSnapshot, SessionUnreadAcknowledgeRequest, SessionUnreadCatalogSnapshot, SessionUnreadEvent, SessionUnreadSummary, SessionTreeNavigateRequest, SessionTreeNavigateResult, SessionTreeNode, SessionTreeNodeKind, SessionTreeSnapshot, SessionTreeSummaryChoice, SessionWarning, SessionWarningSeverity, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
+23
View File
@@ -196,6 +196,29 @@ describe("Pi package API", () => {
});
describe("session API compatibility", () => {
it("reads and acknowledges daemon-owned unread state through encoded machine routes", async () => {
const unread = {
catalogId: "catalog-a",
catalogRevision: 1,
sessions: [{ sessionId: "s /?", cwd: "/repo", completionOrder: 1, completedAt: "2026-07-20T00:00:01.000Z" }],
};
const cleared = { catalogId: "catalog-a", catalogRevision: 2, sessions: [] };
const fetchMock = stubSequenceFetch([jsonResponse(unread), jsonResponse(cleared)]);
await expect(sessionsApi.unreadCatalog("remote a")).resolves.toEqual(unread);
await expect(sessionsApi.acknowledgeUnread({ id: "s /?", cwd: "/repo" }, "catalog-a", 1, "remote a")).resolves.toEqual(cleared);
expect(fetchCall(fetchMock, 0)[0]).toBe("https://pi.example.test/api/machines/remote%20a/sessions/unread");
expect(fetchCall(fetchMock, 0)[1]?.cache).toBe("no-store");
expect(fetchCall(fetchMock, 1)[0]).toBe("https://pi.example.test/api/machines/remote%20a/sessions/s%20%2F%3F/unread/acknowledge");
expect(fetchCall(fetchMock, 1)[1]?.method).toBe("POST");
expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({
cwd: "/repo",
catalogId: "catalog-a",
throughCompletionOrder: 1,
});
});
it("posts session cleanup preview and execute requests through the selected machine", async () => {
const preview = { generatedAt: "2026-06-25T12:00:00.000Z", thresholds: { archiveIdleDays: 7 }, projects: [{ cwd: "/repo", archiveCount: 2, deleteCount: 0 }], totals: { archiveCount: 2, deleteCount: 0 } };
const executed = { ...preview, archivedSessionIds: ["s1", "s2"], deletedSessionIds: [] };
+7 -1
View File
@@ -1,4 +1,4 @@
import type { DeleteWorkspaceFileResponse, FileSuggestion, MoveWorkspaceFileOptions, PiPackageInstallRequest, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiWebConfigValues, PromptAttachment, RunTerminalCommandInput, SessionBulkMutationRef, SessionCleanupRequest, SessionNotificationDismissThrough, SessionRef, SessionTreeNavigateRequest, TerminalCommandRun, TerminalCommandRunFilter, WriteWorkspaceFileOptions } from "../../../shared/apiTypes";
import type { DeleteWorkspaceFileResponse, FileSuggestion, MoveWorkspaceFileOptions, PiPackageInstallRequest, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiWebConfigValues, PromptAttachment, RunTerminalCommandInput, SessionBulkMutationRef, SessionCleanupRequest, SessionNotificationDismissThrough, SessionRef, SessionTreeNavigateRequest, SessionUnreadAcknowledgeRequest, TerminalCommandRun, TerminalCommandRunFilter, WriteWorkspaceFileOptions } from "../../../shared/apiTypes";
import { resolveAppUrl } from "../appUrl";
import { request } from "./http";
import {
@@ -42,6 +42,7 @@ import {
parseSessionInfo,
parseSessionNotificationInboxSnapshot,
parseSessionStatus,
parseSessionUnreadCatalogSnapshot,
parseSessionStreamSnapshot,
parseSessionTreeNavigateResult,
parseSlashCommand,
@@ -205,6 +206,11 @@ export const workspacesApi = {
export const sessionsApi = {
sessions: (cwd: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions?cwd=${encodeURIComponent(cwd)}`, arrayOf(parseSessionInfo)),
unreadCatalog: (machineId = "local") => request(`${machinePrefix(machineId)}/sessions/unread`, parseSessionUnreadCatalogSnapshot, { cache: "no-store" }),
acknowledgeUnread: (session: SessionRef, catalogId: string, throughCompletionOrder: number, machineId = "local") => {
const body: SessionUnreadAcknowledgeRequest = { cwd: session.cwd, catalogId, throughCompletionOrder };
return request(sessionPath(session, "unread/acknowledge", machineId), parseSessionUnreadCatalogSnapshot, { method: "POST", body: JSON.stringify(body) });
},
notificationInbox: (session: SessionLookup, machineId = "local") => request(sessionQueryPath(session, "notifications", machineId), parseSessionNotificationInboxSnapshot),
dismissNotification: (session: SessionLookup, daemonInstanceId: string, notificationId: string, machineId = "local") => request(sessionPath(session, "notifications/dismiss", machineId), parseSessionNotificationInboxSnapshot, { method: "POST", body: sessionBody(session, { daemonInstanceId, notificationId }) }),
dismissAllNotifications: (session: SessionLookup, daemonInstanceId: string, through: SessionNotificationDismissThrough, machineId = "local") => request(sessionPath(session, "notifications/dismiss-all", machineId), parseSessionNotificationInboxSnapshot, { method: "POST", body: sessionBody(session, { daemonInstanceId, throughOrder: through.order, throughOverflowWatermark: through.overflowWatermark }) }),
@@ -36,6 +36,14 @@ describe("federated route contract", () => {
expect(FEDERATED_WEBSOCKET_ROUTES.some((path) => path.includes("notifications"))).toBe(false);
});
it("allowlists daemon-authoritative unread HTTP routes on the existing global socket", () => {
expect(FEDERATED_HTTP_ROUTES.filter((route) => route.path.includes("unread"))).toEqual([
{ method: "GET", path: "/sessions/unread" },
{ method: "POST", path: "/sessions/:sessionId/unread/acknowledge" },
]);
expect(FEDERATED_WEBSOCKET_ROUTES.some((path) => path.includes("unread"))).toBe(false);
});
it("allowlists session tree navigation with a long model-operation timeout and no new WebSocket", () => {
expect(FEDERATED_HTTP_ROUTES.find((route) => route.path === "/sessions/:sessionId/tree/navigate")).toEqual({
method: "POST",
@@ -77,6 +85,8 @@ describe("federated route contract", () => {
ignoreParseFailure(gitApi.gitStatus("p 1", "w 1", machineId)),
ignoreParseFailure(gitApi.gitDiff("p 1", "w 1", { path: "README.md", staged: true }, machineId)),
ignoreParseFailure(sessionsApi.sessions("/repo", machineId)),
ignoreParseFailure(sessionsApi.unreadCatalog(machineId)),
ignoreParseFailure(sessionsApi.acknowledgeUnread(session, "catalog-a", 7, machineId)),
ignoreParseFailure(sessionsApi.startSession("/repo", machineId)),
ignoreParseFailure(sessionsApi.cleanupPreview({ archiveIdleDays: 14 }, machineId)),
ignoreParseFailure(sessionsApi.cleanup({ archiveIdleDays: 14, deleteArchivedDays: 30, projectCwds: ["/repo"] }, machineId)),
+74 -2
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
import { SESSION_NOTIFICATION_LIMIT, SESSION_NOTIFICATION_MESSAGE_BYTES } from "../../../shared/apiTypes";
import { parseAuthProvidersResponse, parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMachineRuntime, parseMessagePage, parseOAuthFlowState, parsePiPackageMutationResponse, parsePiPackagesResponse, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parsePiWebStatusResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionInfo, parseSessionNotificationInboxEvent, parseSessionNotificationInboxSnapshot, parseSessionStatus, parseSessionStreamSnapshot, parseSessionTreeNavigateResult, parseSessionTreeSnapshot, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers";
import { SESSION_NOTIFICATION_LIMIT, SESSION_NOTIFICATION_MESSAGE_BYTES, SESSION_UNREAD_CATALOG_ID_MAX_LENGTH } from "../../../shared/apiTypes";
import { parseAuthProvidersResponse, parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMachineRuntime, parseMessagePage, parseOAuthFlowState, parsePiPackageMutationResponse, parsePiPackagesResponse, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parsePiWebStatusResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionInfo, parseSessionNotificationInboxEvent, parseSessionNotificationInboxSnapshot, parseSessionStatus, parseSessionStreamSnapshot, parseSessionTreeNavigateResult, parseSessionTreeSnapshot, parseSessionUnreadCatalogSnapshot, parseSessionUnreadEvent, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers";
describe("API parsers", () => {
it("preserves additive interactive API-key flow hints and defaults legacy options", () => {
@@ -217,6 +217,78 @@ describe("API parsers", () => {
expect(() => parseSessionStreamSnapshot({ partial: null })).toThrow("Expected number field: seq");
});
it("strictly parses unread snapshots and identity-matched deltas", () => {
const newest = { sessionId: "session-2", cwd: "/repo", completionOrder: 2, completedAt: "2026-07-20T00:00:02.000Z" };
const oldest = { sessionId: "session-1", cwd: "/repo", completionOrder: 1, completedAt: "2026-07-20T00:00:01.000Z" };
expect(parseSessionUnreadCatalogSnapshot({ catalogId: "catalog-a", catalogRevision: 2, sessions: [newest, oldest] })).toEqual({
catalogId: "catalog-a",
catalogRevision: 2,
sessions: [newest, oldest],
});
expect(parseSessionUnreadEvent({
type: "sessions.unread",
catalogId: "catalog-a",
catalogRevision: 3,
sessionId: newest.sessionId,
cwd: newest.cwd,
unread: newest,
})).toMatchObject({ type: "sessions.unread", unread: newest });
expect(parseSessionUnreadEvent({
type: "sessions.unread",
catalogId: "catalog-a",
catalogRevision: 4,
sessionId: newest.sessionId,
cwd: newest.cwd,
unread: null,
})).toMatchObject({ type: "sessions.unread", unread: null });
});
it("rejects malformed, duplicate, unsorted, and mismatched unread payloads", () => {
const summary = { sessionId: "session-1", cwd: "/repo", completionOrder: 1, completedAt: "2026-07-20T00:00:01.000Z" };
expect(() => parseSessionUnreadCatalogSnapshot({ catalogId: "catalog-a", catalogRevision: 2, sessions: [summary, summary] })).toThrow("Duplicate session unread identity");
expect(() => parseSessionUnreadCatalogSnapshot({
catalogId: "catalog-a",
catalogRevision: 2,
sessions: [summary, { ...summary, sessionId: "session-2", completionOrder: 2 }],
})).toThrow("not newest-first");
expect(() => parseSessionUnreadCatalogSnapshot({ catalogId: "catalog-a", catalogRevision: 1, sessions: [{ ...summary, completedAt: "never" }] })).toThrow("Invalid canonical session unread completion time");
expect(() => parseSessionUnreadCatalogSnapshot({ catalogId: "catalog-a", catalogRevision: 1, sessions: [{ ...summary, completedAt: "2026-07-20" }] })).toThrow("Invalid canonical session unread completion time");
expect(() => parseSessionUnreadCatalogSnapshot({
catalogId: "x".repeat(SESSION_UNREAD_CATALOG_ID_MAX_LENGTH + 1),
catalogRevision: 0,
sessions: [],
})).toThrow("String field exceeds limit: catalogId");
expect(() => parseSessionUnreadCatalogSnapshot({
catalogId: "catalog-a",
catalogRevision: 0,
sessions: [summary],
})).toThrow("completion order exceeds catalog revision");
expect(() => parseSessionUnreadEvent({
type: "sessions.unread",
catalogId: "catalog-a",
catalogRevision: 1,
sessionId: "session-1",
cwd: "/repo",
unread: { ...summary, completionOrder: 2 },
})).toThrow("completion order exceeds catalog revision");
expect(() => parseSessionUnreadEvent({
type: "sessions.unread",
catalogId: "catalog-a",
catalogRevision: 1,
sessionId: "other-session",
cwd: "/repo",
unread: summary,
})).toThrow("identity mismatch");
expect(() => parseSessionUnreadEvent({
type: "sessions.unread",
catalogId: "catalog-a",
catalogRevision: 0,
sessionId: "session-1",
cwd: "/repo",
unread: null,
})).toThrow("positive safe integer");
});
it("parses session cleanup preview and execute responses", () => {
const preview = {
generatedAt: "2026-06-25T12:00:00.000Z",
+88 -1
View File
@@ -1,4 +1,4 @@
import { SESSION_NOTIFICATION_LIMIT, SESSION_NOTIFICATION_MESSAGE_BYTES, type ArchiveSessionsResponse, type AuthProviderOption, type AuthProviderStatus, type AuthProvidersResponse, type AuthStatusSource, type AuthType, type CommandOption, type CommandResult, type DeleteWorkspaceFileResponse, type FileContentResponse, type FileSuggestion, type FileTreeEntry, type FileTreeResponse, type GitDiffResponse, type GitFileState, type GitStatusFile, type GitStatusResponse, type Machine, type MachineHealth, type MachineKind, type MachineRuntime, type MachineStatus, type MessagePage, type ModelSelectionResponse, type MoveWorkspaceFileResponse, type OAuthFlowState, type PiWebAgentDirEnvSource, type PiWebCapability, type PiWebComponentStatus, type PiWebConfigEnvOverrides, type PiWebConfigResponse, type PiWebConfigValues, type PiWebInstallationInfo, type PiWebPluginConfigMap, type PiWebPluginInfo, type PiWebPluginsResponse, type PiWebPluginScope, type PiWebReleaseStatus, type PiWebRuntimeComponent, type PiWebRuntimeResponse, type PiWebServiceComponent, type PiWebShortcutConfig, type PiWebStatusMessage, type PiWebStatusResponse, type PiWebStatusSeverity, type Project, type QueuedSessionMessage, type SavedPromptAttachment, type SessionBulkArchiveResponse, type SessionBulkDeleteArchivedResponse, type SessionBulkFailure, type SessionCleanupExecuteResponse, type SessionCleanupPreviewResponse, type SessionCleanupProjectSummary, type SessionCleanupThresholds, type SessionCleanupTotals, type SessionInfo, type SessionModel, type SessionNotification, type 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 { SESSION_NOTIFICATION_LIMIT, SESSION_NOTIFICATION_MESSAGE_BYTES, SESSION_UNREAD_CATALOG_ID_MAX_LENGTH, SESSION_UNREAD_COMPLETED_AT_MAX_LENGTH, SESSION_UNREAD_CWD_MAX_LENGTH, SESSION_UNREAD_LIMIT, SESSION_UNREAD_SESSION_ID_MAX_LENGTH, 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 SessionUnreadCatalogSnapshot, type SessionUnreadEvent, type SessionUnreadSummary, 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, SessionTreeNavigateResult, SessionTreeNode, SessionTreeNodeKind, SessionTreeSnapshot } from "../../../shared/apiTypes";
import { parseActiveAgentProfileDescriptor } from "../../../shared/activeAgentProfile";
import { parseKnownPiWebCapabilities } from "../../../shared/capabilities";
@@ -233,6 +233,93 @@ export function parseSessionStreamSnapshot(value: unknown): SessionStreamSnapsho
};
}
export function parseSessionUnreadCatalogSnapshot(value: unknown): SessionUnreadCatalogSnapshot {
const record = requireRecord(value);
const catalogRevision = requireNonNegativeSafeInteger(record, "catalogRevision");
const sessions = boundedArrayOf(record["sessions"], parseSessionUnreadSummary, SESSION_UNREAD_LIMIT, "sessions");
assertUniqueUnreadSummaries(sessions);
assertUnreadNewestFirst(sessions);
if (sessions.some((summary) => summary.completionOrder > catalogRevision)) {
throw new Error("Session unread completion order exceeds catalog revision");
}
return {
catalogId: requireBoundedNonEmptyString(record, "catalogId", SESSION_UNREAD_CATALOG_ID_MAX_LENGTH),
catalogRevision,
sessions,
};
}
export function parseSessionUnreadEvent(value: unknown): SessionUnreadEvent {
const record = requireRecord(value);
if (record["type"] !== "sessions.unread") throw new Error("Invalid session unread event type");
const sessionId = requireBoundedNonEmptyString(record, "sessionId", SESSION_UNREAD_SESSION_ID_MAX_LENGTH);
const cwd = requireBoundedNonEmptyString(record, "cwd", SESSION_UNREAD_CWD_MAX_LENGTH);
const catalogRevision = requirePositiveSafeInteger(record, "catalogRevision");
const unread = record["unread"] === null ? null : parseSessionUnreadSummary(record["unread"]);
if (unread !== null && (unread.sessionId !== sessionId || unread.cwd !== cwd)) {
throw new Error("Session unread event identity mismatch");
}
if (unread !== null && unread.completionOrder > catalogRevision) {
throw new Error("Session unread completion order exceeds catalog revision");
}
return {
type: "sessions.unread",
catalogId: requireBoundedNonEmptyString(record, "catalogId", SESSION_UNREAD_CATALOG_ID_MAX_LENGTH),
catalogRevision,
sessionId,
cwd,
unread,
};
}
function parseSessionUnreadSummary(value: unknown): SessionUnreadSummary {
const record = requireRecord(value);
const completedAt = requireBoundedNonEmptyString(
record,
"completedAt",
SESSION_UNREAD_COMPLETED_AT_MAX_LENGTH,
);
const completedDate = new Date(completedAt);
if (!Number.isFinite(completedDate.getTime()) || completedDate.toISOString() !== completedAt) {
throw new Error("Invalid canonical session unread completion time");
}
return {
sessionId: requireBoundedNonEmptyString(record, "sessionId", SESSION_UNREAD_SESSION_ID_MAX_LENGTH),
cwd: requireBoundedNonEmptyString(record, "cwd", SESSION_UNREAD_CWD_MAX_LENGTH),
completionOrder: requirePositiveSafeInteger(record, "completionOrder"),
completedAt,
};
}
function assertUniqueUnreadSummaries(summaries: readonly SessionUnreadSummary[]): void {
const identities = summaries.map((summary) => JSON.stringify([summary.sessionId, summary.cwd]));
if (new Set(identities).size !== identities.length) throw new Error("Duplicate session unread identity");
const completionOrders = summaries.map((summary) => summary.completionOrder);
if (new Set(completionOrders).size !== completionOrders.length) throw new Error("Duplicate session unread completion order");
}
function assertUnreadNewestFirst(summaries: readonly SessionUnreadSummary[]): void {
for (let index = 1; index < summaries.length; index += 1) {
const previous = summaries[index - 1];
const current = summaries[index];
if (previous === undefined || current === undefined || previous.completionOrder <= current.completionOrder) {
throw new Error("Session unread summaries are not newest-first");
}
}
}
function requireBoundedNonEmptyString(record: Record<string, unknown>, key: string, maxLength: number): string {
const value = requireNonEmptyString(record, key);
if (value.length > maxLength) throw new Error(`String field exceeds limit: ${key}`);
return value;
}
function requirePositiveSafeInteger(record: Record<string, unknown>, key: string): number {
const value = requireNonNegativeSafeInteger(record, key);
if (value === 0) throw new Error(`Expected positive safe integer field: ${key}`);
return value;
}
export function parseSessionNotificationInboxSnapshot(value: unknown): SessionNotificationInboxSnapshot {
const record = requireRecord(value);
const summary = parseSessionNotificationSummary(record["summary"]);
+180 -7
View File
@@ -26,6 +26,7 @@ import { machineSessionKey } from "../machineKeys";
import { sessionCleanupRequestKey, sessionCleanupUnavailableMessage } from "../sessionCleanupUi";
import { selectedNotificationView } from "../sessionNotifications";
import { hasAuthoritativeSessionPersistence as runtimeHasAuthoritativeSessionPersistence } from "../sessionPersistence";
import { SessionUnreadController } from "../sessionUnread";
import { collapseSessionWarnings, initialSessionWarningVisibilityState, reconcileSessionWarningVisibility, restoreSessionWarnings } from "../sessionWarningVisibility";
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";
@@ -48,7 +49,7 @@ import { isWorkspaceDeletionPending, isWorkspaceDeletionRunPending, latestWorksp
import "./MachineList";
import "./ProjectList";
import "./WorkspaceList";
import "./SessionList";
import { unreadSessionCount } from "./SessionList";
import "./SessionCleanupDialog";
import "./SessionTreeNavigator";
import "./ChatView";
@@ -106,6 +107,21 @@ export class PiWebApp extends LitElement {
@query("#navigation-panel") private navigationPanelFrame?: HTMLElement;
@query("#workspace-panel") private workspacePanelFrame?: HTMLElement;
private readonly sessionUnread = new SessionUnreadController({
onChange: (machineId) => {
if (selectedMachineId(this.state) !== machineId) return;
this.syncUnreadSessionIds();
this.syncSelectedSessionReadState();
},
onBackgroundError: (operation, machineId, error) => {
console.warn(`Failed to ${operation} session unread state for ${machineId}`, error);
},
});
@state() private unreadSessionIds: ReadonlySet<string> = this.sessionUnread.unreadSessionIds(selectedMachineId(this.state), this.state.sessions);
private unreadConnected = false;
private committedChatIdentity: string | undefined;
private readyChatIdentity: string | undefined;
private readonly notifications = new SessionNotificationController(
() => this.state,
(patch) => { this.setState(patch); },
@@ -118,6 +134,9 @@ export class PiWebApp extends LitElement {
new SessionStorageSessionSelectionMemory(),
{
notifications: this.notifications,
onSelectedSessionReady: ({ machineId, session }) => {
void this.commitReadyChatAfterRender(machineId, session);
},
replacePromptEditorText: async ({ machineId, sessionId, text }) => {
await this.updateComplete;
if (selectedMachineId(this.state) !== machineId || this.state.selectedSession?.id !== sessionId) return;
@@ -182,6 +201,7 @@ export class PiWebApp extends LitElement {
private readonly keyboard = new KeyboardShortcutDispatcher();
private readonly realtime = new RealtimeSocket();
private readonly machineRealtimeSockets = new Map<string, RealtimeSocket>();
private readonly unreadRuntimeRefreshes = new Map<string, Promise<void>>();
private readonly activeTerminalIds = new Set<string>();
private readonly machineNavigation = new SessionStorageMachineNavigationMemory();
private readonly terminalSelection = new SessionStorageTerminalSelectionMemory();
@@ -232,6 +252,7 @@ export class PiWebApp extends LitElement {
await this.restoreRoute(false);
});
private readonly onPageShow = () => {
void this.renegotiateUnreadMachines();
this.appShell.repairViewportPosition();
this.retryPendingRemoteRouteRestoreSoon();
};
@@ -255,6 +276,14 @@ export class PiWebApp extends LitElement {
this.syncSessionWarningVisibility();
}
protected override updated(): void {
// Lit has now committed the selected chat and app-shell visibility state.
// Recheck after every rendered transition; the unread controller
// deduplicates acknowledgements for the observed completion order.
this.committedChatIdentity = selectedChatIdentity(this.state);
this.syncSelectedSessionReadState();
}
private syncSessionWarningVisibility(): void {
const session = this.state.selectedSession;
this.sessionWarningVisibility = reconcileSessionWarningVisibility(
@@ -264,8 +293,60 @@ export class PiWebApp extends LitElement {
);
}
private syncSelectedSessionReadState(): void {
const session = this.state.selectedSession;
if (session === undefined) return;
const machineId = selectedMachineId(this.state);
if (!this.isSessionSeen(machineId, session)) return;
void this.sessionUnread.acknowledge(machineId, session);
}
private async commitReadyChatAfterRender(machineId: string, session: SessionInfo): Promise<void> {
const identity = unreadChatIdentity(machineId, session);
await this.updateComplete;
if (!this.unreadConnected || selectedChatIdentity(this.state) !== identity) return;
this.readyChatIdentity = identity;
this.syncSelectedSessionReadState();
}
private syncUnreadSessionIds(): void {
const next = this.sessionUnread.unreadSessionIds(selectedMachineId(this.state), this.state.sessions);
if (!sameStringSet(next, this.unreadSessionIds)) this.unreadSessionIds = next;
}
private isSessionSeen(machineId: string, session: SessionInfo): boolean {
if (!this.unreadConnected) return false;
const identity = unreadChatIdentity(machineId, session);
if (selectedChatIdentity(this.state) !== identity
|| this.committedChatIdentity !== identity
|| this.readyChatIdentity !== identity) return false;
if (typeof document !== "undefined") {
if (document.visibilityState !== "visible") return false;
if (typeof document.hasFocus === "function" && !document.hasFocus()) return false;
}
if (this.isChatObscured()) return false;
if (this.state.mainView === "chat") return true;
if (this.state.mainView === "navigation") return !this.appShell.isMobileNavigationLayout;
return this.isDesktopSideBySideLayout();
}
private isChatObscured(): boolean {
return this.settingsSection !== undefined
|| this.sessionCleanupDialog !== undefined
|| this.state.actionPaletteOpen
|| this.state.projectDialogOpen
|| this.state.machineDialogOpen
|| this.state.commandDialog !== undefined
|| this.state.treeDialog !== undefined
|| this.state.modelDialog !== undefined
|| this.state.thinkingDialog !== undefined
|| this.state.themeDialog !== undefined
|| this.state.authDialog !== undefined;
}
override connectedCallback(): void {
super.connectedCallback();
this.unreadConnected = true;
window.addEventListener("popstate", this.onPopState);
window.addEventListener("pageshow", this.onPageShow);
this.browserResume.connect();
@@ -273,6 +354,7 @@ export class PiWebApp extends LitElement {
this.systemLightThemeMedia?.addEventListener("change", this.onSystemLightThemeChange);
this.applyPreferredTheme(false);
this.connectRealtime();
void this.renegotiateUnreadMachines();
this.piWebStatusTimer = window.setInterval(() => { this.schedulePiWebStatusRefresh(); }, PI_WEB_STATUS_REFRESH_MS);
void this.refreshWorkspaceActivity();
void this.loadClientConfig();
@@ -281,6 +363,11 @@ export class PiWebApp extends LitElement {
}
override disconnectedCallback(): void {
this.unreadConnected = false;
this.committedChatIdentity = undefined;
this.readyChatIdentity = undefined;
this.unreadRuntimeRefreshes.clear();
this.sessionUnread.retainMachines(new Set<string>());
window.removeEventListener("popstate", this.onPopState);
window.removeEventListener("pageshow", this.onPageShow);
this.browserResume.disconnect();
@@ -306,6 +393,12 @@ export class PiWebApp extends LitElement {
if (!patchChangesState(this.state, patch)) return;
const previous = this.state;
this.state = { ...this.state, ...patch };
if (selectedChatIdentity(previous) !== selectedChatIdentity(this.state)) {
this.committedChatIdentity = undefined;
this.readyChatIdentity = undefined;
}
if (machineUnreadInputsChanged(previous, this.state)) this.syncSessionUnreadMachines();
this.syncUnreadSessionIds();
this.handleActivityTransition(previous, this.state);
this.handleWorkspaceChange(previous, this.state);
this.handleMachineChange(previous, this.state);
@@ -337,6 +430,7 @@ export class PiWebApp extends LitElement {
}
private async refreshAfterBrowserResume(): Promise<void> {
await this.renegotiateUnreadMachines();
await Promise.all([
this.sessions.refreshSelectedSession(),
this.refreshMachineActivities(),
@@ -825,11 +919,58 @@ export class PiWebApp extends LitElement {
this.git.updatePolling();
}
private syncSessionUnreadMachines(): void {
if (!this.unreadConnected) {
this.sessionUnread.retainMachines(new Set<string>());
return;
}
const machineIds = new Set(this.state.machines.map((machine) => machine.id));
this.sessionUnread.retainMachines(machineIds);
for (const machineId of machineIds) {
const runtime = this.state.machineRuntimes[machineId];
if (runtime === undefined) continue;
const capability = this.unreadRuntimeRefreshes.has(machineId) || !runtime.ok
? "unknown"
: supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsUnread)
? "supported"
: "unsupported";
if (this.sessionUnread.setCapability(machineId, capability)) void this.sessionUnread.refresh(machineId);
}
}
private async renegotiateUnreadMachines(): Promise<void> {
if (!this.unreadConnected) return;
const machineIds = new Set(this.state.machines.map((machine) => machine.id));
machineIds.add(selectedMachineId(this.state));
await Promise.all([...machineIds].map(async (machineId) => { await this.renegotiateUnreadMachine(machineId); }));
}
private renegotiateUnreadMachine(machineId: string): Promise<void> {
const existing = this.unreadRuntimeRefreshes.get(machineId);
if (existing !== undefined) return existing;
this.sessionUnread.setCapability(machineId, "unknown");
let refreshed = false;
const refresh = Promise.resolve().then(async () => {
refreshed = await this.machines.refreshMachineRuntime(machineId) !== undefined;
});
this.unreadRuntimeRefreshes.set(machineId, refresh);
const finishRefresh = () => {
if (this.unreadRuntimeRefreshes.get(machineId) !== refresh) return;
this.unreadRuntimeRefreshes.delete(machineId);
if (!this.unreadConnected) return;
if (refreshed) this.syncSessionUnreadMachines();
else this.sessionUnread.setCapability(machineId, "unknown");
};
void refresh.then(finishRefresh, finishRefresh);
return refresh;
}
private connectRealtime(): void {
const machineId = selectedMachineId(this.state);
this.realtime.connect(
(event) => { this.handleRealtimeEvent(event); },
(event) => { this.handleRealtimeEvent(machineId, event); },
() => {
void this.renegotiateUnreadMachine(machineId);
const workspace = this.state.selectedWorkspace;
if (workspace !== undefined) void this.refreshActiveTerminals(workspace);
void this.refreshWorkspaceActivity(machineId);
@@ -851,6 +992,7 @@ export class PiWebApp extends LitElement {
socket.connect(
(event) => { this.handleMachineActivityEvent(machineId, event); },
() => {
void this.renegotiateUnreadMachine(machineId);
void this.refreshWorkspaceActivity(machineId);
},
machineId,
@@ -873,11 +1015,13 @@ export class PiWebApp extends LitElement {
}
private handleMachineActivityEvent(machineId: string, event: BrowserRealtimeEvent): void {
if (event.type === "workspace.activity") this.activity.applyWorkspaceActivity(event.activity, machineId);
if (event.type === "sessions.unread") this.sessionUnread.applyEvent(machineId, event);
else if (event.type === "workspace.activity") this.activity.applyWorkspaceActivity(event.activity, machineId);
}
private handleRealtimeEvent(event: BrowserRealtimeEvent): void {
if (event.type === "workspace.activity") this.activity.applyWorkspaceActivity(event.activity);
private handleRealtimeEvent(machineId: string, event: BrowserRealtimeEvent): void {
if (event.type === "sessions.unread") this.sessionUnread.applyEvent(machineId, event);
else if (event.type === "workspace.activity") this.activity.applyWorkspaceActivity(event.activity);
else if (isTerminalEvent(event)) {
this.applyTerminalEvent(event);
if (event.type === "terminal.exited") void this.refreshWorkspaceDeletionRuns();
@@ -1174,6 +1318,7 @@ export class PiWebApp extends LitElement {
.sessionStatuses=${this.state.sessionStatuses}
.sessionActivities=${this.state.sessionActivities}
.sendingPrompts=${this.state.sendingPrompts}
.unreadSessionIds=${this.unreadSessionIds}
.selectedSession=${this.state.selectedSession}
.startingSessionCount=${this.state.startingSessionCount}
.canStartSession=${!!this.state.selectedWorkspace}
@@ -2027,8 +2172,19 @@ export class PiWebApp extends LitElement {
}
private mobileMainTabs(): AppMobileMainTab[] {
const unreadCount = unreadSessionCount(this.state.sessions, this.unreadSessionIds, {
statuses: this.state.sessionStatuses,
activities: this.state.sessionActivities,
sending: this.state.sendingPrompts,
});
return [
{ id: "navigation", label: "Sessions", icon: "navigation", className: "navigation-tab" },
{
id: "navigation",
label: "Sessions",
icon: "navigation",
className: "navigation-tab",
...(unreadCount === 0 ? {} : { badge: unreadCount, badgeLabel: `${String(unreadCount)} unread`, badgeTone: "unread" }),
},
{ id: "chat", label: "Chat", icon: "chat" },
...this.visibleWorkspacePanels().map((panel): AppMobileMainTab => {
const icon = panel.icon ?? this.mobilePanelIcon(panel);
@@ -2075,7 +2231,7 @@ export class PiWebApp extends LitElement {
${state.machineDialogOpen ? html`<machine-dialog .error=${state.error} .onSubmit=${(input: MachineDialogSubmit) => this.submitMachineDialog(input)} .onCancel=${() => { this.setState({ machineDialogOpen: false }); }}></machine-dialog>` : null}
${this.sessionCleanupDialog !== undefined ? html`<session-cleanup-dialog .canCleanup=${this.canCleanupSessions()} .unavailableMessage=${this.sessionCleanupUnavailableMessage()} .preview=${this.sessionCleanupDialog.preview} .previewRequest=${this.sessionCleanupDialog.previewRequest} .result=${this.sessionCleanupDialog.result} .loading=${this.sessionCleanupDialog.loading === true} .running=${this.sessionCleanupDialog.running === true} .error=${this.sessionCleanupDialog.error ?? ""} .onPreview=${(request: SessionCleanupRequest) => { void this.previewSessionCleanup(request); }} .onRun=${(request: SessionCleanupRequest) => { void this.runSessionCleanup(request); }} .onClose=${() => { this.closeSessionCleanupDialog(); }}></session-cleanup-dialog>` : null}
${state.themeDialog !== undefined ? html`<command-picker title=${state.themeDialog.title} .options=${state.themeDialog.options} .selectedValue=${state.themeDialog.selectedValue} .onPick=${(value: string) => { this.pickTheme(value); }} .onCancel=${() => { this.setState({ themeDialog: undefined }); }}></command-picker>` : null}
${this.settingsSection !== undefined ? html`<settings-dialog .section=${this.settingsSection} .machine=${state.selectedMachine} .machineRuntime=${this.selectedMachineRuntime()} .actions=${this.getDefaultActions()} .onNavigate=${(section: SettingsSection) => { this.navigateSettings(section); }} .onClose=${() => { this.closeSettings(); }} .onConfigSaved=${(config: PiWebConfigValues) => { this.applyClientConfig(config); }} .onRefreshMachineRuntime=${(machineId: string) => this.machines.refreshMachineRuntime(machineId)}></settings-dialog>` : null}
${this.settingsSection !== undefined ? html`<settings-dialog .section=${this.settingsSection} .machine=${state.selectedMachine} .machineRuntime=${this.selectedMachineRuntime()} .actions=${this.getDefaultActions()} .onNavigate=${(section: SettingsSection) => { this.navigateSettings(section); }} .onClose=${() => { this.closeSettings(); }} .onConfigSaved=${(config: PiWebConfigValues) => { this.applyClientConfig(config); }} .onRefreshMachineRuntime=${async (machineId: string) => { await this.machines.refreshMachineRuntime(machineId); }}></settings-dialog>` : null}
</div>
`;
}
@@ -2096,6 +2252,19 @@ function pluginMachineFromState(state: Pick<AppState, "selectedMachine">): Plugi
return { id: "local", name: "local", kind: "local" };
}
function unreadChatIdentity(machineId: string, session: Pick<SessionInfo, "id" | "cwd">): string {
return JSON.stringify([machineId, session.id, session.cwd]);
}
function selectedChatIdentity(state: Pick<AppState, "selectedMachine" | "selectedSession">): string | undefined {
const session = state.selectedSession;
return session === undefined ? undefined : unreadChatIdentity(selectedMachineId(state), session);
}
function machineUnreadInputsChanged(previous: AppState, next: AppState): boolean {
return previous.machines !== next.machines || previous.machineRuntimes !== next.machineRuntimes;
}
function machineActivitySubscriptionInputsChanged(previous: AppState, next: AppState): boolean {
return previous.machines !== next.machines
|| previous.machineStatuses !== next.machineStatuses
@@ -2116,6 +2285,10 @@ function patchChangesState(state: AppState, patch: Partial<AppState>): boolean {
return Object.entries(patch).some(([key, value]) => Reflect.get(state, key) !== value);
}
function sameStringSet(left: ReadonlySet<string>, right: ReadonlySet<string>): boolean {
return left.size === right.size && [...left].every((value) => right.has(value));
}
function isActive(state: Pick<AppState, "status" | "activity">): boolean {
return isSessionActive(state.status, state.activity);
}
@@ -0,0 +1,459 @@
import type { TemplateResult } from "lit";
import { afterEach, describe, expect, it, vi } from "vitest";
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
import type { SessionInfo, SessionUnreadEvent, SessionUnreadSummary } from "../api";
import { initialAppState, type AppState } from "../appState";
import type { BrowserRealtimeEvent } from "../sessionSocket";
import type { AppMobileMainTab } from "./appShell/AppMobileMainTabs";
// Template inspection is proportionate here because this node-environment test
// verifies only PiWebApp's unread-state property wiring into navigation.
import { templateValueAfterMarker } from "../templateInspection.testSupport";
import { PiWebApp } from "./PiWebApp";
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("PiWebApp session unread wiring", () => {
it("shows a server completion for a background chat and acknowledges the exact observed order when viewed", async () => {
const fetchMock = stubJsonFetch({ catalogId: "catalog-a", catalogRevision: 2, sessions: [] });
const app = createApp();
enableUnread(app);
const foreground = session("foreground");
const background = session("background");
setAppState(app, { ...initialAppState(), sessions: [foreground, background], selectedSession: foreground, mainView: "chat" });
exposeSelectedChat(app);
handleRealtimeEvent(app, unreadEvent(1, unreadSummary(background, 1)));
expect([...navigationUnreadSessionIds(app)]).toEqual([background.id]);
expect(mobileNavigationTab(app)).toMatchObject({ badge: 1, badgeLabel: "1 unread", badgeTone: "unread" });
setState(app, { selectedSession: background });
// Selection alone cannot clear unread before the new transcript is ready
// and Lit has committed the corresponding chat.
expect([...navigationUnreadSessionIds(app)]).toEqual([background.id]);
expect(fetchMock).not.toHaveBeenCalled();
exposeSelectedChat(app);
await vi.waitFor(() => { expect(navigationUnreadSessionIds(app).size).toBe(0); });
expect(mobileNavigationTab(app)).not.toHaveProperty("badge");
expect(fetchMock).toHaveBeenCalledOnce();
expect(fetchMock.mock.calls[0]?.[0]).toBe("https://pi.example.test/api/machines/local/sessions/background/unread/acknowledge");
const init = fetchMock.mock.calls[0]?.[1];
expect(JSON.parse(typeof init?.body === "string" ? init.body : "{}")).toEqual({
cwd: "/repo",
catalogId: "catalog-a",
throughCompletionOrder: 1,
});
});
it("keeps the selected chat unread while hidden or unfocused, then acknowledges on a visible focus check", async () => {
const fetchMock = stubJsonFetch({ catalogId: "catalog-a", catalogRevision: 2, sessions: [] });
const documentState = { visible: false, focused: false };
vi.stubGlobal("document", {
baseURI: "https://pi.example.test/",
get visibilityState() { return documentState.visible ? "visible" : "hidden"; },
hasFocus: () => documentState.focused,
});
const app = createApp();
enableUnread(app);
const selected = session("selected");
setAppState(app, { ...initialAppState(), sessions: [selected], selectedSession: selected, mainView: "chat" });
exposeSelectedChat(app);
handleRealtimeEvent(app, unreadEvent(1, unreadSummary(selected, 1)));
expect([...navigationUnreadSessionIds(app)]).toEqual([selected.id]);
expect(fetchMock).not.toHaveBeenCalled();
documentState.visible = true;
documentState.focused = true;
setState(app, { error: "refresh" });
invokeUpdated(app);
await vi.waitFor(() => { expect(navigationUnreadSessionIds(app).size).toBe(0); });
expect(fetchMock).toHaveBeenCalledOnce();
});
it("keeps a focused mobile chat unread while navigation is stacked over it, then acknowledges on a layout-only reveal", async () => {
const fetchMock = stubJsonFetch({ catalogId: "catalog-a", catalogRevision: 2, sessions: [] });
const app = createApp({}, true);
enableUnread(app);
const selected = session("selected");
setAppState(app, { ...initialAppState(), sessions: [selected], selectedSession: selected, mainView: "navigation" });
exposeSelectedChat(app);
handleRealtimeEvent(app, unreadEvent(1, unreadSummary(selected, 1)));
expect([...navigationUnreadSessionIds(app)]).toEqual([selected.id]);
expect(fetchMock).not.toHaveBeenCalled();
setMobileNavigationLayout(app, false);
invokeUpdated(app);
await vi.waitFor(() => { expect(navigationUnreadSessionIds(app).size).toBe(0); });
expect(fetchMock).toHaveBeenCalledOnce();
});
it("does not acknowledge a selected chat hidden behind a full-screen dialog", async () => {
const fetchMock = stubJsonFetch({ catalogId: "catalog-a", catalogRevision: 2, sessions: [] });
const app = createApp();
enableUnread(app);
const selected = session("selected");
setAppState(app, { ...initialAppState(), sessions: [selected], selectedSession: selected, mainView: "chat" });
exposeSelectedChat(app);
if (!Reflect.set(app, "settingsSection", "general")) throw new Error("Could not open settings");
handleRealtimeEvent(app, unreadEvent(1, unreadSummary(selected, 1)));
expect([...navigationUnreadSessionIds(app)]).toEqual([selected.id]);
expect(fetchMock).not.toHaveBeenCalled();
if (!Reflect.set(app, "settingsSection", undefined)) throw new Error("Could not close settings");
invokeUpdated(app);
await vi.waitFor(() => { expect(navigationUnreadSessionIds(app).size).toBe(0); });
expect(fetchMock).toHaveBeenCalledOnce();
});
it("applies another client's authoritative clear without issuing a redundant acknowledgement", () => {
const fetchMock = stubJsonFetch({ catalogId: "catalog-a", catalogRevision: 2, sessions: [] });
const app = createApp();
enableUnread(app);
const foreground = session("foreground");
const background = session("background");
setAppState(app, { ...initialAppState(), sessions: [foreground, background], selectedSession: foreground, mainView: "chat" });
exposeSelectedChat(app);
handleRealtimeEvent(app, unreadEvent(1, unreadSummary(background, 1)));
handleRealtimeEvent(app, unreadEvent(2, null, background));
expect(navigationUnreadSessionIds(app).size).toBe(0);
expect(fetchMock).not.toHaveBeenCalled();
});
it("fetches initial state only after the selected runtime advertises unread support", async () => {
const background = session("background");
const durable = { catalogId: "catalog-a", catalogRevision: 1, sessions: [unreadSummary(background, 1)] };
const fetchMock = stubJsonFetch(durable);
const app = createApp();
enableUnread(app);
const foreground = session("foreground");
const localMachine = {
id: "local",
name: "Local",
kind: "local" as const,
createdAt: "2026-07-20T00:00:00.000Z",
updatedAt: "2026-07-20T00:00:00.000Z",
};
setAppState(app, {
...initialAppState(),
machines: [localMachine],
selectedMachine: localMachine,
sessions: [foreground, background],
selectedSession: foreground,
mainView: "chat",
});
setState(app, { machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [] } } });
expect(fetchMock).not.toHaveBeenCalled();
setState(app, {
machineRuntimes: {
local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsUnread] },
},
});
await vi.waitFor(() => { expect([...navigationUnreadSessionIds(app)]).toEqual([background.id]); });
expect(fetchMock).toHaveBeenCalledOnce();
expect(fetchMock.mock.calls[0]?.[0]).toBe("https://pi.example.test/api/machines/local/sessions/unread");
});
it("drops a deferred unread snapshot on disconnect without acknowledging it", async () => {
const response = deferred<Response>();
const fetchMock = vi.fn((input: RequestInfo | URL) => {
void input;
return response.promise;
});
vi.stubGlobal("fetch", fetchMock);
const app = createApp();
enableUnread(app);
const selected = session("selected");
setAppState(app, { ...initialAppState(), sessions: [selected], selectedSession: selected, mainView: "chat" });
exposeSelectedChat(app);
const refreshing = refreshUnread(app, "local");
invokeDisconnected(app);
response.resolve(new Response(JSON.stringify({
catalogId: "catalog-a",
catalogRevision: 1,
sessions: [unreadSummary(selected, 1)],
}), { status: 200, headers: { "Content-Type": "application/json" } }));
await refreshing;
expect(fetchMock).toHaveBeenCalledOnce();
expect(fetchMock.mock.calls[0]?.[0]).toBe("https://pi.example.test/api/machines/local/sessions/unread");
});
it("renegotiates cached support before refreshing after a runtime rollback", async () => {
const fetchMock = vi.fn(() => Promise.reject(new Error("Unexpected unread request")));
vi.stubGlobal("fetch", fetchMock);
const app = createApp();
enableUnread(app);
const localMachine = {
id: "local",
name: "Local",
kind: "local" as const,
createdAt: "2026-07-20T00:00:00.000Z",
updatedAt: "2026-07-20T00:00:00.000Z",
};
setAppState(app, {
...initialAppState(),
machines: [localMachine],
selectedMachine: localMachine,
machineRuntimes: {
local: { machineId: "local", ok: true, checkedAt: "before", capabilities: [PI_WEB_CAPABILITIES.sessionsUnread] },
},
});
const machines: unknown = Reflect.get(app, "machines");
if (typeof machines !== "object" || machines === null) throw new Error("PiWebApp machine controller is unavailable");
if (!Reflect.set(machines, "refreshMachineRuntime", () => {
const runtime = { machineId: "local", ok: true as const, checkedAt: "after", capabilities: [] };
setState(app, { machineRuntimes: { local: runtime } });
return Promise.resolve(runtime);
})) throw new Error("Could not stub machine runtime refresh");
await renegotiateUnreadMachine(app, "local");
await refreshUnread(app, "local");
expect(fetchMock).not.toHaveBeenCalled();
});
it("does not treat the legacy browser-local key as unread authority", () => {
const app = createApp({ "pi-web-session-unread-v1": JSON.stringify([["local", "background"]]) });
const foreground = session("foreground");
const background = session("background");
setAppState(app, { ...initialAppState(), sessions: [foreground, background], selectedSession: foreground, mainView: "chat" });
expect(navigationUnreadSessionIds(app).size).toBe(0);
});
});
type RenderNavigationPanel = (this: PiWebApp) => TemplateResult;
type SetAppState = (this: PiWebApp, patch: Partial<AppState>) => void;
type HandleRealtimeEvent = (this: PiWebApp, machineId: string, event: BrowserRealtimeEvent) => void;
type MobileMainTabs = (this: PiWebApp) => AppMobileMainTab[];
type UpdatedHook = (this: PiWebApp) => void;
type DisconnectedHook = (this: PiWebApp) => void;
type RenegotiateUnreadMachine = (this: PiWebApp, machineId: string) => Promise<void>;
type RefreshUnread = (machineId: string) => Promise<void>;
function createApp(storedValues: Record<string, string> = {}, mobileNavigation = false): PiWebApp {
const values = new Map(Object.entries(storedValues));
const storage = {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => { values.set(key, value); },
removeItem: (key: string) => { values.delete(key); },
};
const matchMedia = (query: string) => ({
matches: mobileNavigation && query.includes("max-width: 760px"),
media: query,
addEventListener: () => undefined,
removeEventListener: () => undefined,
});
vi.stubGlobal("window", {
location: { search: "" },
localStorage: storage,
matchMedia,
addEventListener: () => undefined,
removeEventListener: () => undefined,
clearInterval: () => undefined,
clearTimeout: () => undefined,
});
if (typeof document === "undefined") {
vi.stubGlobal("document", { baseURI: "https://pi.example.test/", visibilityState: "visible", hasFocus: () => true });
}
vi.stubGlobal("requestAnimationFrame", () => 1);
return new PiWebApp();
}
function setAppState(app: PiWebApp, state: AppState): void {
if (!Reflect.set(app, "state", state)) throw new Error("Could not set PiWebApp state");
}
function setState(app: PiWebApp, patch: Partial<AppState>): void {
const method: unknown = Reflect.get(app, "setState");
if (!isSetAppState(method)) throw new Error("PiWebApp.setState is not callable");
method.call(app, patch);
}
function handleRealtimeEvent(app: PiWebApp, event: BrowserRealtimeEvent): void {
const method: unknown = Reflect.get(app, "handleRealtimeEvent");
if (!isHandleRealtimeEvent(method)) throw new Error("PiWebApp.handleRealtimeEvent is not callable");
method.call(app, "local", event);
}
function enableUnread(app: PiWebApp): void {
if (!Reflect.set(app, "unreadConnected", true)) throw new Error("Could not connect PiWebApp unread state");
const controller: unknown = Reflect.get(app, "sessionUnread");
if (typeof controller !== "object" || controller === null) throw new Error("PiWebApp unread controller is unavailable");
const setCapability: unknown = Reflect.get(controller, "setCapability");
if (typeof setCapability !== "function") throw new Error("PiWebApp unread capability setter is unavailable");
Reflect.apply(setCapability, controller, ["local", "supported"]);
}
function exposeSelectedChat(app: PiWebApp): void {
const state: unknown = Reflect.get(app, "state");
if (typeof state !== "object" || state === null) throw new Error("PiWebApp state is unavailable");
const selectedSession: unknown = Reflect.get(state, "selectedSession");
if (typeof selectedSession !== "object" || selectedSession === null) throw new Error("Expected a selected chat");
const sessionId: unknown = Reflect.get(selectedSession, "id");
const cwd: unknown = Reflect.get(selectedSession, "cwd");
if (typeof sessionId !== "string" || typeof cwd !== "string") throw new Error("Selected chat identity is invalid");
const selectedMachine: unknown = Reflect.get(state, "selectedMachine");
const machineId = typeof selectedMachine === "object" && selectedMachine !== null && typeof Reflect.get(selectedMachine, "id") === "string"
? String(Reflect.get(selectedMachine, "id"))
: "local";
if (!Reflect.set(app, "readyChatIdentity", JSON.stringify([machineId, sessionId, cwd]))) {
throw new Error("Could not mark selected chat ready");
}
invokeUpdated(app);
}
function setMobileNavigationLayout(app: PiWebApp, mobile: boolean): void {
const appShell: unknown = Reflect.get(app, "appShell");
if (typeof appShell !== "object" || appShell === null || !Reflect.set(appShell, "isMobileNavigationLayout", mobile)) {
throw new Error("Could not update the app-shell layout");
}
}
function invokeUpdated(app: PiWebApp): void {
const method: unknown = Reflect.get(app, "updated");
if (!isUpdatedHook(method)) throw new Error("PiWebApp.updated is not callable");
method.call(app);
}
function invokeDisconnected(app: PiWebApp): void {
const method: unknown = Reflect.get(app, "disconnectedCallback");
if (!isDisconnectedHook(method)) throw new Error("PiWebApp.disconnectedCallback is not callable");
method.call(app);
}
async function renegotiateUnreadMachine(app: PiWebApp, machineId: string): Promise<void> {
const method: unknown = Reflect.get(app, "renegotiateUnreadMachine");
if (!isRenegotiateUnreadMachine(method)) throw new Error("PiWebApp unread renegotiation is not callable");
await method.call(app, machineId);
}
function refreshUnread(app: PiWebApp, machineId: string): Promise<void> {
const controller: unknown = Reflect.get(app, "sessionUnread");
if (typeof controller !== "object" || controller === null) throw new Error("PiWebApp unread controller is unavailable");
const refresh: unknown = Reflect.get(controller, "refresh");
if (!isRefreshUnread(refresh)) throw new Error("PiWebApp unread refresh is not callable");
return refresh.call(controller, machineId);
}
function mobileNavigationTab(app: PiWebApp): AppMobileMainTab {
const method: unknown = Reflect.get(app, "mobileMainTabs");
if (!isMobileMainTabs(method)) throw new Error("PiWebApp.mobileMainTabs is not callable");
const tab = method.call(app).find((candidate) => candidate.id === "navigation");
if (tab === undefined) throw new Error("Expected the mobile Sessions tab");
return tab;
}
function navigationUnreadSessionIds(app: PiWebApp): ReadonlySet<string> {
const method: unknown = Reflect.get(app, "renderNavigationPanel");
if (!isRenderNavigationPanel(method)) throw new Error("PiWebApp.renderNavigationPanel is not callable");
const value = templateValueAfterMarker(method.call(app), ".unreadSessionIds=");
if (!(value instanceof Set) || ![...value].every((entry: unknown) => typeof entry === "string")) {
throw new Error("Expected unread session ids in navigation");
}
return value;
}
function session(id: string): SessionInfo {
return {
id,
cwd: "/repo",
path: `/repo/${id}.jsonl`,
created: "2026-07-20T00:00:00.000Z",
modified: "2026-07-20T00:00:00.000Z",
messageCount: 1,
firstMessage: id,
};
}
function unreadSummary(target: SessionInfo, completionOrder: number): SessionUnreadSummary {
return {
sessionId: target.id,
cwd: target.cwd,
completionOrder,
completedAt: `2026-07-20T00:00:0${String(completionOrder)}.000Z`,
};
}
function unreadEvent(
catalogRevision: number,
unread: SessionUnreadSummary | null,
target: SessionInfo = unread === null ? session("selected") : session(unread.sessionId),
): SessionUnreadEvent {
return {
type: "sessions.unread",
catalogId: "catalog-a",
catalogRevision,
sessionId: unread?.sessionId ?? target.id,
cwd: unread?.cwd ?? target.cwd,
unread,
};
}
function deferred<T>(): { promise: Promise<T>; resolve(value: T): void } {
let resolvePromise: ((value: T) => void) | undefined;
const promise = new Promise<T>((resolve) => { resolvePromise = resolve; });
return {
promise,
resolve(value) {
if (resolvePromise === undefined) throw new Error("Deferred promise is unavailable");
resolvePromise(value);
},
};
}
function stubJsonFetch(body: unknown) {
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
void input;
void init;
return Promise.resolve(new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json" },
}));
});
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
function isRenderNavigationPanel(value: unknown): value is RenderNavigationPanel {
return typeof value === "function";
}
function isSetAppState(value: unknown): value is SetAppState {
return typeof value === "function";
}
function isHandleRealtimeEvent(value: unknown): value is HandleRealtimeEvent {
return typeof value === "function";
}
function isMobileMainTabs(value: unknown): value is MobileMainTabs {
return typeof value === "function";
}
function isUpdatedHook(value: unknown): value is UpdatedHook {
return typeof value === "function";
}
function isDisconnectedHook(value: unknown): value is DisconnectedHook {
return typeof value === "function";
}
function isRenegotiateUnreadMachine(value: unknown): value is RenegotiateUnreadMachine {
return typeof value === "function";
}
function isRefreshUnread(value: unknown): value is RefreshUnread {
return typeof value === "function";
}
+25 -5
View File
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import type { SessionInfo, SessionStatus } from "../api";
import { markCachedNewSessionInfo } from "../cachedNewSessions";
import { isArchivableSessionInfo, isTransientNewSessionInfo } from "../sessionPersistence";
import { sessionRowActivityKind, sessionRowsForCurrentTree } from "./SessionList";
import { sessionRowActivityKind, sessionRowsForCurrentTree, unreadSessionCount } from "./SessionList";
describe("sessionRowActivityKind", () => {
const idle = sessionStatus("s");
@@ -16,13 +16,33 @@ describe("sessionRowActivityKind", () => {
expect(sessionRowActivityKind(session("s"), { ...idle, isStreaming: true }, undefined, false)).toBe("session");
});
it("reports undefined when idle and not sending", () => {
it("reports unread only while the session is idle", () => {
expect(sessionRowActivityKind(session("s"), idle, undefined, false, true)).toBe("unread");
expect(sessionRowActivityKind(session("s"), { ...idle, isStreaming: true }, undefined, false, true)).toBe("session");
expect(sessionRowActivityKind(session("s"), idle, undefined, true, true)).toBe("sending");
});
it("reports undefined when idle, read, and not sending", () => {
expect(sessionRowActivityKind(session("s"), idle, undefined, false)).toBeUndefined();
});
it("never shows an indicator for archived or cached-new sessions, even while sending", () => {
expect(sessionRowActivityKind({ ...session("s"), archived: true }, idle, undefined, true)).toBeUndefined();
expect(sessionRowActivityKind(markCachedNewSessionInfo(session("s")), idle, undefined, true)).toBeUndefined();
it("never shows an indicator for archived or cached-new sessions, even while sending or unread", () => {
expect(sessionRowActivityKind({ ...session("s"), archived: true }, idle, undefined, true, true)).toBeUndefined();
expect(sessionRowActivityKind(markCachedNewSessionInfo(session("s")), idle, undefined, true, true)).toBeUndefined();
});
});
describe("unreadSessionCount", () => {
it("counts only current persisted sessions", () => {
const current = session("current");
const archived = { ...session("archived"), archived: true, archivedAt: "2026-06-09T00:00:00.000Z" };
const cached = markCachedNewSessionInfo(session("cached"));
const unreadIds = new Set([current.id, archived.id, cached.id]);
expect(unreadSessionCount([current, archived, cached], unreadIds)).toBe(1);
expect(unreadSessionCount([current, archived, cached], unreadIds, {
statuses: { [current.id]: sessionStatus(current.id, { isStreaming: true }) },
})).toBe(0);
});
});
+51 -9
View File
@@ -30,6 +30,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
@property({ attribute: false }) statuses: Record<string, SessionStatus> = {};
@property({ attribute: false }) activities: Record<string, SessionActivity> = {};
@property({ attribute: false }) sending: Record<string, true> = {};
@property({ attribute: false }) unreadSessionIds: ReadonlySet<string> = new Set();
@property({ attribute: false }) selected?: SessionInfo;
@property({ type: Number }) startingCount = 0;
@property({ type: Boolean }) canStart = false;
@@ -107,9 +108,14 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
const currentSelectableSessions = currentRows.map((row) => row.session).filter((session) => sessionSelectionScope(session) === "current");
const archivedRows = sessionRows(this.sessions.filter((session) => session.archived === true && !currentRowIds.has(session.id)));
const descendantCounts = unarchivedDescendantCounts(this.sessions);
const unreadCount = unreadSessionCount(currentSelectableSessions, this.unreadSessionIds, {
statuses: this.statuses,
activities: this.activities,
sending: this.sending,
});
return html`
<section>
${this.renderHeading(currentRows.length + archivedRows.length, currentSelectableSessions)}
${this.renderHeading(currentRows.length + archivedRows.length, currentSelectableSessions, unreadCount)}
${this.collapsed ? null : html`
<div class="list-body">
${this.renderCurrentSelectionToolbar(currentSelectableSessions)}
@@ -128,12 +134,13 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
`;
}
private renderHeading(sessionCount: number, currentSessions: SessionInfo[]) {
private renderHeading(sessionCount: number, currentSessions: SessionInfo[], unreadCount: number) {
if (!this.collapsible) {
return html`
<h2>
<span class="plain-heading">Sessions</span>
${this.renderCurrentSelectionButton(currentSessions)}
${this.renderUnreadCount(unreadCount)}
${this.renderCleanupButton()}
${this.renderStartButton()}
</h2>
@@ -145,6 +152,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
<h2>
<button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span class="section-title"><span class="section-name">${this.collapsed ? "▸" : "▾"} Sessions</span>${this.collapsed ? html`<small class="section-selected" dir="auto" title=${selectedTitle}>${selectedSummary}</small>` : null}</span></button>
${this.renderCurrentSelectionButton(currentSessions)}
${this.renderUnreadCount(unreadCount)}
<small class="section-count">${sessionCount}</small>
${this.renderCleanupButton()}
${this.renderStartButton()}
@@ -152,6 +160,12 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
`;
}
private renderUnreadCount(unreadCount: number) {
if (unreadCount === 0) return null;
const label = `${String(unreadCount)} unread`;
return html`<small class="section-unread-count" title=${label}>${label}</small>`;
}
private renderCurrentSelectionButton(currentSessions: SessionInfo[]) {
if (this.collapsed || currentSessions.length === 0) return null;
const active = this.selectionScopes.has("current");
@@ -235,13 +249,14 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
const bulkSelected = showsCheckbox && this.selectedSessionIds.has(session.id);
const status = this.statuses[session.id];
const activity = this.activities[session.id];
const indicatorKind = sessionRowActivityKind(session, status, activity, this.sending[session.id] === true, this.unreadSessionIds.has(session.id));
const persistenceOptions = this.sessionPersistenceOptions();
const canArchive = isArchivableSessionInfo(session, status, persistenceOptions);
const canDeleteTransient = isTransientNewSessionInfo(session, status, persistenceOptions);
const canReloadSession = canArchive && this.canReload;
return html`
<div
class="action-row ${this.selected?.id === session.id ? "selected" : ""} ${bulkSelected ? "bulk-selected" : ""} ${session.archived === true ? "archived" : ""} ${selectionActive ? "selecting" : ""}"
class="action-row ${this.selected?.id === session.id ? "selected" : ""} ${bulkSelected ? "bulk-selected" : ""} ${session.archived === true ? "archived" : ""} ${selectionActive ? "selecting" : ""} ${indicatorKind === "unread" ? "unread" : ""}"
style=${`--depth:${String(cappedDepth)}`}
tabindex="0"
title=${session.path}
@@ -251,7 +266,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
<div class="action-main ${selectionActive ? "selecting" : ""}">
${showsCheckbox ? html`<input class="session-checkbox" type="checkbox" aria-label=${`Select ${sessionLabel(session)}`} .checked=${bulkSelected} @click=${(event: MouseEvent) => { event.stopPropagation(); }} @change=${() => { this.toggleSelected(session.id); }}>` : null}
<span class="action-name-line"><span class="action-name" dir="auto">${row.depth > 0 ? html`<span class="tree-marker">↳</span>` : null}${sessionLabel(session)}${row.depth > 2 ? html` <span class="badge">depth ${row.depth}</span>` : null}${row.hasMissingParent ? html` <span class="badge">parent unavailable</span>` : null}</span></span><small>${this.renderSessionMetaPrefix(session, status, activity)}${String(session.messageCount)} messages</small>
${this.renderActivity(session)}
${this.renderActivity(indicatorKind)}
</div>
<div class="action-menu">
<button class="action-menu-toggle" title="Session actions" @click=${(event: MouseEvent) => { event.stopPropagation(); this.toggleMenu(session.id, event.currentTarget); }}>⋯</button>
@@ -410,14 +425,19 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
return { authoritative: this.authoritativeSessionPersistence };
}
private renderActivity(session: SessionInfo) {
const kind = sessionRowActivityKind(session, this.statuses[session.id], this.activities[session.id], this.sending[session.id] === true);
return renderActionActivityIndicator(kind, kind === "sending" ? "Sending message" : "Session active");
private renderActivity(kind: ActivityIndicatorKind | undefined) {
const label = kind === "sending"
? "Sending message"
: kind === "unread"
? "Unread session activity"
: "Session active";
return renderActionActivityIndicator(kind, label);
}
static override styles = [listStyles, css`
h2 { min-height: 30px; }
h2 > .section-count { flex: 0 0 auto; display: inline; color: var(--pi-muted); font-size: inherit; }
h2 > .section-unread-count { flex: 0 0 auto; display: inline; color: var(--pi-accent); font-size: inherit; text-transform: none; }
.bulk-select-entry { box-sizing: border-box; flex: 0 0 auto; display: inline-grid; place-items: center; width: 30px; height: 30px; padding: 0; font-size: 13px; line-height: 1; text-transform: none; }
.start-session-button { box-sizing: border-box; flex: 0 0 auto; display: inline-grid; place-items: center; min-width: 30px; height: 30px; padding: 0 9px; }
.cleanup-entry { flex: 0 0 auto; padding: 5px 7px; font-size: 12px; text-transform: none; }
@@ -425,6 +445,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
.bulk-row button { padding: 5px 7px; font-size: 12px; }
.bulk-row small { display: inline; min-width: 0; color: var(--pi-muted); }
.action-name, .section-selected { text-align: start; unicode-bidi: plaintext; }
.action-row.unread .action-name { color: var(--pi-text-bright); font-weight: 650; }
.plain-heading { min-width: 0; }
.action-name-line { min-width: 0; display: flex; align-items: flex-start; gap: 6px; }
.action-name-line .action-name { flex: 1 1 auto; min-width: 0; }
@@ -442,6 +463,24 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
`];
}
export function unreadSessionCount(
sessions: readonly SessionInfo[],
unreadSessionIds: ReadonlySet<string>,
runtime: {
statuses?: Record<string, SessionStatus> | undefined;
activities?: Record<string, SessionActivity> | undefined;
sending?: Record<string, true> | undefined;
} = {},
): number {
return sessions.filter((session) => sessionRowActivityKind(
session,
runtime.statuses?.[session.id],
runtime.activities?.[session.id],
runtime.sending?.[session.id] === true,
unreadSessionIds.has(session.id),
) === "unread").length;
}
function sessionSelectionScope(session: SessionInfo): SessionSelectionScope {
return session.archived === true ? "archived" : "current";
}
@@ -482,17 +521,20 @@ function unarchivedDescendantCounts(sessions: SessionInfo[]): Map<string, number
*
* "sending" (client-side upload in flight) is reported with its own kind, and
* takes precedence over server activity, so it can be colored distinctly to
* signal that it is not yet propagated to workspace/machine activity.
* signal that it is not yet propagated to workspace/machine activity. Unread is
* the idle fallback, so it never replaces an indicator for ongoing work.
*/
export function sessionRowActivityKind(
session: SessionInfo,
status: SessionStatus | undefined,
activity: SessionActivity | undefined,
sending: boolean,
unread = false,
): ActivityIndicatorKind | undefined {
if (isCachedNewSessionInfo(session) || session.archived === true) return undefined;
if (sending) return "sending";
return isSessionActive(status, activity) ? "session" : undefined;
if (isSessionActive(status, activity)) return "session";
return unread ? "unread" : undefined;
}
export function sessionRowsForCurrentTree(sessions: SessionInfo[]): SessionRow[] {
+1 -1
View File
@@ -1,6 +1,6 @@
import { html, type TemplateResult } from "lit";
export type ActivityIndicatorKind = "session" | "terminal" | "sending";
export type ActivityIndicatorKind = "session" | "terminal" | "sending" | "unread";
export function renderActivityIndicator(kind: ActivityIndicatorKind | undefined, label = "Active"): TemplateResult | undefined {
if (kind === undefined) return undefined;
@@ -11,6 +11,8 @@ export interface AppMobileMainTab {
label: string;
icon?: AppMobileMainTabIcon;
badge?: unknown;
badgeLabel?: string | undefined;
badgeTone?: "unread" | undefined;
className?: string | undefined;
}
@@ -53,7 +55,7 @@ export class AppMobileMainTabs extends LitElement {
<button class=${this.tabClass(tab)} title=${tab.label} aria-label=${this.tabAriaLabel(tab)} aria-pressed=${String(selected)} @click=${() => { this.onSelect?.(tab.id); }}>
${this.renderTabMark(tab, fallbackLabels)}
<span class="tab-label">${tab.label}</span>
${this.renderBadge(tab.badge)}
${this.renderBadge(tab.badge, tab.badgeTone)}
</button>
`;
})}
@@ -74,14 +76,13 @@ export class AppMobileMainTabs extends LitElement {
}
private tabAriaLabel(tab: AppMobileMainTab): string {
if (typeof tab.badge !== "string" && typeof tab.badge !== "number") return tab.label;
const badge = String(tab.badge).trim();
const badge = tab.badgeLabel ?? (typeof tab.badge === "string" || typeof tab.badge === "number" ? String(tab.badge).trim() : "");
return badge === "" ? tab.label : `${tab.label}, ${badge}`;
}
private renderBadge(badge: unknown) {
private renderBadge(badge: unknown, tone: AppMobileMainTab["badgeTone"]) {
if (badge === undefined || badge === "") return null;
return html`<span class="tab-badge">${badge}</span>`;
return html`<span class=${`tab-badge${tone === undefined ? "" : ` ${tone}`}`}>${badge}</span>`;
}
private renderTabMark(tab: AppMobileMainTab, fallbackLabels: Map<AppState["mainView"], string>) {
@@ -165,6 +166,7 @@ export class AppMobileMainTabs extends LitElement {
.tab-fallback { display: none; font-weight: 650; letter-spacing: .01em; pointer-events: none; }
.tab-label { min-width: 0; }
.tab-badge { flex: 0 0 auto; display: inline-block; min-width: 14px; margin-left: 0; border: 1px solid var(--pi-success-border); border-radius: 999px; background: var(--pi-success-surface); color: var(--pi-success); padding: 0 5px; font-size: 11px; line-height: 16px; text-align: center; }
.tab-badge.unread { border-color: var(--pi-accent-border); background: var(--pi-selection-bg); color: var(--pi-accent); }
button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; }
@media (max-width: 760px) {
.mobile-tabs { gap: 4px; padding: 6px 8px; }
@@ -29,6 +29,7 @@ export class AppNavigationPanel extends LitElement {
@property({ attribute: false }) sessionActivities: Record<string, SessionActivity> = {};
@property({ attribute: false }) sessionStatuses: Record<string, SessionStatus> = {};
@property({ attribute: false }) sendingPrompts: Record<string, true> = {};
@property({ attribute: false }) unreadSessionIds: ReadonlySet<string> = new Set();
@property({ attribute: false }) workspacesByProjectId: Record<string, Workspace[]> = {};
@property({ attribute: false }) deletingWorkspaceIds: string[] = [];
@property({ attribute: false }) workspaceLabelItems: (workspace: Workspace) => WorkspaceLabelItem[] = () => [];
@@ -160,6 +161,7 @@ export class AppNavigationPanel extends LitElement {
.statuses=${this.sessionStatuses}
.activities=${this.sessionActivities}
.sending=${this.sendingPrompts}
.unreadSessionIds=${this.unreadSessionIds}
.selected=${this.selectedSession}
.startingCount=${this.startingSessionCount}
.canStart=${this.canStartSession}
+2
View File
@@ -280,6 +280,8 @@ export const listStyles = css`
.activity-indicator.terminal { border-radius: 2px; background: var(--pi-accent); }
/* Client-side sending (upload in flight); distinct from server activity, which propagates to workspace/machine rows. */
.activity-indicator.sending { border-radius: 50%; background: var(--pi-warning); }
/* Unread is a stable state, not ongoing work: keep it static and accent-colored. */
.activity-indicator.unread { border-radius: 50%; background: var(--pi-accent); animation: none; box-shadow: 0 0 0 2px color-mix(in srgb, var(--pi-accent) 20%, transparent); }
.action-menu { position: relative; align-self: stretch; }
.action-menu-toggle { display: grid; place-items: center; height: 100%; min-width: 32px; padding: 0; color: var(--pi-muted); border-left: 0; border-top-left-radius: 0; border-bottom-left-radius: 0; }
.action-menu-toggle:hover { color: var(--pi-text); background: var(--pi-surface-hover); }
@@ -197,6 +197,25 @@ describe("MachineController", () => {
expect(updateUrl).not.toHaveBeenCalled();
});
it("does not let an older runtime response overwrite a newer capability negotiation", async () => {
let state: AppState = { ...initialAppState(), machines: [localMachine], selectedMachine: localMachine };
const setState = (patch: Partial<AppState>) => { state = { ...state, ...patch }; };
let resolveOlder: ((runtime: Awaited<ReturnType<typeof api.runtime>>) => void) | undefined;
const older = new Promise<Awaited<ReturnType<typeof api.runtime>>>((resolve) => { resolveOlder = resolve; });
vi.spyOn(api, "runtime")
.mockImplementationOnce(() => older)
.mockResolvedValueOnce({ machineId: "local", ok: true, checkedAt: "new", capabilities: [] });
const controller = new MachineController(() => state, setState, vi.fn(), { loadProjects: vi.fn() });
const first = controller.refreshMachineRuntime("local");
const second = controller.refreshMachineRuntime("local");
await second;
resolveOlder?.({ machineId: "local", ok: true, checkedAt: "old", capabilities: ["sessions.unread"] });
await first;
expect(state.machineRuntimes["local"]).toMatchObject({ checkedAt: "new", capabilities: [] });
});
it("selects the fallback machine after deleting the selected machine by default", async () => {
let state: AppState = { ...initialAppState(), machines: [localMachine, remoteMachine], selectedMachine: remoteMachine, selectedProject: { id: "p1", name: "Project", path: "/repo", createdAt: "now" } };
const setState = (patch: Partial<AppState>) => { state = { ...state, ...patch }; };
@@ -1,9 +1,11 @@
import { api, type Machine, type MachineHealth } from "../api";
import { api, type Machine, type MachineHealth, type MachineRuntime } from "../api";
import { resetWorkspaceScopedState } from "../appState";
import type { GetState, SetState, UpdateUrl } from "./types";
import type { ProjectController } from "./projectController";
export class MachineController {
private readonly runtimeRefreshSeqByMachine = new Map<string, number>();
constructor(private readonly getState: GetState, private readonly setState: SetState, private readonly updateUrl: UpdateUrl, private readonly projects: Pick<ProjectController, "loadProjects">) {}
async loadMachines(routeMachineId?: string): Promise<void> {
@@ -100,12 +102,17 @@ export class MachineController {
}
}
async refreshMachineRuntime(machineId = this.getState().selectedMachine?.id ?? "local"): Promise<void> {
async refreshMachineRuntime(machineId = this.getState().selectedMachine?.id ?? "local"): Promise<MachineRuntime | undefined> {
const seq = (this.runtimeRefreshSeqByMachine.get(machineId) ?? 0) + 1;
this.runtimeRefreshSeqByMachine.set(machineId, seq);
try {
const runtime = await api.runtime(machineId, true);
if (this.runtimeRefreshSeqByMachine.get(machineId) !== seq) return undefined;
this.setState({ machineRuntimes: { ...this.getState().machineRuntimes, [runtime.machineId]: runtime } });
return runtime;
} catch (error) {
this.setState({ error: String(error) });
if (this.runtimeRefreshSeqByMachine.get(machineId) === seq) this.setState({ error: String(error) });
return undefined;
}
}
@@ -8,6 +8,41 @@ function page(text: string, total: number): MessagePage {
}
describe("SessionController selected-session refresh", () => {
it("signals selection readiness only after the initial transcript join succeeds", async () => {
const messages = deferred<MessagePage>();
const selectedStatus = deferred<SessionStatus>();
const ready: string[] = [];
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [oldSession] };
const api: typeof defaultApi = {
...defaultApi,
messages: () => messages.promise,
status: () => selectedStatus.promise,
streamSnapshot: () => Promise.resolve({ seq: 0, partial: null }),
thinkingLevels: () => Promise.resolve({ levels: [] }),
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{
api,
socket: new FakeSocket(),
onSelectedSessionReady: ({ machineId, session }) => { ready.push(`${machineId}:${session.id}`); },
},
);
const selecting = controller.selectSession(oldSession, { updateUrl: false });
await Promise.resolve();
expect(ready).toEqual([]);
messages.resolve(page("ready", 1));
selectedStatus.resolve(status(oldSession.id));
await selecting;
expect(ready).toEqual([`local:${oldSession.id}`]);
});
it("shares same-turn requests and runs one trailing refresh requested during the active fetch", async () => {
const firstPage = deferred<MessagePage>();
const firstStatus = deferred<SessionStatus>();
@@ -45,12 +45,18 @@ export interface PromptEditorTextReplacement {
text: string;
}
export interface SelectedSessionReady {
machineId: string;
session: SessionInfo;
}
export interface SessionControllerDependencies {
api?: typeof defaultApi;
socket?: SessionEventSocket;
transcripts?: ChatTranscriptStore;
notifications?: SessionNotificationSessionBridge;
replacePromptEditorText?: (replacement: PromptEditorTextReplacement) => void | Promise<void>;
onSelectedSessionReady?: (selection: SelectedSessionReady) => void;
}
interface BulkSessionMutationResult {
@@ -95,6 +101,7 @@ export class SessionController {
private readonly transcripts: ChatTranscriptStore;
private readonly notifications: SessionNotificationSessionBridge | undefined;
private readonly replacePromptEditorText: SessionControllerDependencies["replacePromptEditorText"];
private readonly onSelectedSessionReady: SessionControllerDependencies["onSelectedSessionReady"];
private selectionSeq = 0;
private disposed = false;
// Join-time stream watermark for the selected session. `seq` is the
@@ -125,6 +132,7 @@ export class SessionController {
this.transcripts = deps.transcripts ?? new ChatTranscriptStore();
this.notifications = deps.notifications;
this.replacePromptEditorText = deps.replacePromptEditorText;
this.onSelectedSessionReady = deps.onSelectedSessionReady;
}
applyGlobalEvent(event: GlobalSessionEvent): void {
@@ -218,6 +226,7 @@ export class SessionController {
if (seq !== this.selectionSeq || this.getState().selectedSession?.id !== session.id) return;
const history = this.transcripts.mergeHistory(transcriptKey, page);
this.setState({ ...history, isLoadingEarlierMessages: false, status: undefined, activity: undefined });
this.onSelectedSessionReady?.({ machineId, session });
if (options?.updateUrl !== false) this.updateUrl();
return;
}
@@ -235,6 +244,7 @@ export class SessionController {
void this.refreshAvailableThinkingLevels();
for (const event of socketBuffer) this.applyEvent(event);
this.socket.setHandler((event) => { this.applyEvent(event); });
this.onSelectedSessionReady?.({ machineId, session });
if (options?.updateUrl !== false) this.updateUrl();
} catch (error) {
if (seq !== this.selectionSeq || this.getState().selectedSession?.id !== session.id) {
+33
View File
@@ -57,6 +57,39 @@ describe("notification socket guards", () => {
})).toBeUndefined();
});
it("accepts only strictly validated global unread deltas", () => {
const unread = {
sessionId: "session-1",
cwd: "/repo",
completionOrder: 1,
completedAt: "2026-07-20T00:00:01.000Z",
};
expect(parseRealtimeSocketEvent({
type: "sessions.unread",
catalogId: "catalog-a",
catalogRevision: 1,
sessionId: unread.sessionId,
cwd: unread.cwd,
unread,
})).toMatchObject({ type: "sessions.unread", unread });
expect(parseRealtimeSocketEvent({
type: "sessions.unread",
catalogId: "catalog-a",
catalogRevision: 1,
sessionId: "other-session",
cwd: unread.cwd,
unread,
})).toBeUndefined();
expect(parseRealtimeSocketEvent({
type: "sessions.unread",
catalogId: "catalog-a",
catalogRevision: 3.5,
sessionId: unread.sessionId,
cwd: unread.cwd,
unread: null,
})).toBeUndefined();
});
it("preserves existing event acceptance without treating unknown types as realtime events", () => {
expect(parseSessionSocketEvent({ type: "command.output", level: "info", message: "legacy" })).toMatchObject({ type: "command.output" });
expect(parseRealtimeSocketEvent({ type: "future.notification", payload: {} })).toBeUndefined();
+6 -1
View File
@@ -1,5 +1,5 @@
import { realtimeEvents, sessionEvents } from "./api";
import { parseSessionNotificationInboxEvent } from "./api/parsers";
import { parseSessionNotificationInboxEvent, parseSessionUnreadEvent } from "./api/parsers";
import type { GlobalSessionEvent, RealtimeEvent, SessionRef, SessionUiEvent } from "../../shared/apiTypes";
export type { GlobalSessionEvent, RealtimeEvent, SessionUiEvent } from "../../shared/apiTypes";
@@ -159,6 +159,7 @@ export function parseSessionSocketEvent(event: unknown): SessionUiEvent | undefi
}
export function parseRealtimeSocketEvent(event: unknown): BrowserRealtimeEvent | undefined {
if (eventType(event) === "sessions.unread") return safelyParseValidatedEvent(() => parseSessionUnreadEvent(event));
if (isLegacyGlobalSessionEvent(event) || isLegacyRealtimeEvent(event)) return event;
return undefined;
}
@@ -178,6 +179,10 @@ function isLegacyRealtimeEvent(event: unknown): event is NonGlobalBrowserRealtim
}
function safelyParseNotificationEvent<T>(parse: () => T): T | undefined {
return safelyParseValidatedEvent(parse);
}
function safelyParseValidatedEvent<T>(parse: () => T): T | undefined {
try {
return parse();
} catch {
+353
View File
@@ -0,0 +1,353 @@
import { describe, expect, it, vi } from "vitest";
import type { SessionRef, SessionUnreadCatalogSnapshot, SessionUnreadEvent, SessionUnreadSummary } from "../../shared/apiTypes";
import { SessionUnreadController, type SessionUnreadApi } from "./sessionUnread";
describe("SessionUnreadController", () => {
it("restores the durable server snapshot for a new browser controller", async () => {
const api = fakeApi({ snapshots: [snapshot("catalog-a", 2, [summary("session-2", 2), summary("session-1", 1)])] });
const controller = new SessionUnreadController({ api });
controller.setCapability("local", "supported");
await controller.refresh("local");
expect([...controller.unreadSessionIds("local", [ref("session-1"), ref("session-2")])]).toEqual(["session-1", "session-2"]);
expect(controller.projection("local")).toMatchObject({ status: "fresh", catalogId: "catalog-a", catalogRevision: 2 });
const restarted = new SessionUnreadController({
api: fakeApi({ snapshots: [snapshot("catalog-a", 2, [summary("session-2", 2), summary("session-1", 1)])] }),
});
restarted.setCapability("local", "supported");
await restarted.refresh("local");
expect(restarted.isUnread("local", ref("session-2"))).toBe(true);
});
it("refreshes the projection again on reconnect", async () => {
const api = fakeApi({
snapshots: [
snapshot("catalog-a", 1, [summary("session-1", 1)]),
snapshot("catalog-a", 2, [summary("session-2", 2), summary("session-1", 1)]),
],
});
const controller = new SessionUnreadController({ api });
controller.setCapability("local", "supported");
await controller.refresh("local");
await controller.refresh("local");
expect(controller.projection("local")).toMatchObject({ catalogRevision: 2 });
expect([...controller.unreadSessionIds("local", [ref("session-1"), ref("session-2")])]).toEqual(["session-1", "session-2"]);
});
it("replays a contiguous event that races the initial snapshot join and performs a trailing refresh", async () => {
const response = deferred<SessionUnreadCatalogSnapshot>();
const unreadCatalog = vi.fn(() => response.promise);
const controller = new SessionUnreadController({ api: fakeApi({ unreadCatalog }) });
controller.setCapability("local", "supported");
const refreshing = controller.refresh("local");
controller.applyEvent("local", unreadEvent("catalog-a", 2, summary("session-2", 2)));
response.resolve(snapshot("catalog-a", 1, [summary("session-1", 1)]));
await refreshing;
expect(unreadCatalog).toHaveBeenCalledTimes(2);
expect(controller.projection("local")).toEqual({
status: "fresh",
catalogId: "catalog-a",
catalogRevision: 2,
sessions: [summary("session-2", 2), summary("session-1", 1)],
});
});
it("drains reconnect refreshes that overlap an active request", async () => {
const firstResponse = deferred<SessionUnreadCatalogSnapshot>();
const unreadCatalog = vi.fn()
.mockImplementationOnce(() => firstResponse.promise)
.mockResolvedValueOnce(snapshot("catalog-a", 2, [summary("session-2", 2)]));
const controller = new SessionUnreadController({ api: fakeApi({ unreadCatalog }) });
controller.setCapability("local", "supported");
const firstRefresh = controller.refresh("local");
const reconnectRefresh = controller.refresh("local");
expect(reconnectRefresh).toBe(firstRefresh);
firstResponse.resolve(snapshot("catalog-a", 1, [summary("session-1", 1)]));
await firstRefresh;
expect(unreadCatalog).toHaveBeenCalledTimes(2);
expect(controller.projection("local")).toEqual({
status: "fresh",
catalogId: "catalog-a",
catalogRevision: 2,
sessions: [summary("session-2", 2)],
});
});
it("retries a queued gap refresh after the active snapshot request fails", async () => {
const firstResponse = deferred<SessionUnreadCatalogSnapshot>();
const unreadCatalog = vi.fn()
.mockImplementationOnce(() => firstResponse.promise)
.mockResolvedValueOnce(snapshot("catalog-a", 3, [summary("session-3", 3), summary("session-1", 1)]));
const onBackgroundError = vi.fn();
const controller = new SessionUnreadController({ api: fakeApi({ unreadCatalog }), onBackgroundError });
controller.setCapability("local", "supported");
controller.applyEvent("local", unreadEvent("catalog-a", 1, summary("session-1", 1)));
const refreshing = controller.refresh("local");
controller.applyEvent("local", unreadEvent("catalog-a", 3, summary("session-3", 3)));
const error = new Error("disconnected");
firstResponse.reject(error);
await refreshing;
expect(onBackgroundError).toHaveBeenCalledWith("snapshot", "local", error);
expect(unreadCatalog).toHaveBeenCalledTimes(2);
expect(controller.projection("local")).toMatchObject({ status: "fresh", catalogRevision: 3 });
});
it("resnapshots revision gaps and replaces state on a new catalog epoch", async () => {
const unreadCatalog = vi.fn()
.mockResolvedValueOnce(snapshot("catalog-a", 3, [summary("session-3", 3), summary("session-1", 1)]))
.mockResolvedValueOnce(snapshot("catalog-b", 1, [summary("new-epoch", 1)]));
const controller = new SessionUnreadController({ api: fakeApi({ unreadCatalog }) });
controller.setCapability("local", "supported");
controller.applyEvent("local", unreadEvent("catalog-a", 1, summary("session-1", 1)));
controller.applyEvent("local", unreadEvent("catalog-a", 3, summary("session-3", 3)));
await vi.waitFor(() => {
expect(controller.projection("local")).toMatchObject({ status: "fresh", catalogRevision: 3 });
});
expect(unreadCatalog).toHaveBeenCalledOnce();
controller.applyEvent("local", unreadEvent("catalog-b", 1, summary("new-epoch", 1)));
await vi.waitFor(() => {
expect(controller.projection("local")).toEqual({
status: "fresh",
catalogId: "catalog-b",
catalogRevision: 1,
sessions: [summary("new-epoch", 1)],
});
});
expect(unreadCatalog).toHaveBeenCalledTimes(2);
});
it("keeps a newer completion when an acknowledgement of the observed order is in flight", async () => {
const response = deferred<SessionUnreadCatalogSnapshot>();
const acknowledgeUnread = vi.fn(() => response.promise);
const controller = new SessionUnreadController({ api: fakeApi({ acknowledgeUnread }) });
const session = ref("session-1");
controller.setCapability("local", "supported");
controller.applyEvent("local", unreadEvent("catalog-a", 1, summary(session.id, 1)));
const acknowledging = controller.acknowledge("local", session);
controller.applyEvent("local", unreadEvent("catalog-a", 2, summary(session.id, 2)));
// Even a delayed response that only represents the old revision is merged
// with socket events observed while the request was pending.
response.resolve(snapshot("catalog-a", 1, []));
await acknowledging;
expect(acknowledgeUnread).toHaveBeenCalledWith(session, "catalog-a", 1, "local");
expect(controller.projection("local")).toEqual({
status: "fresh",
catalogId: "catalog-a",
catalogRevision: 2,
sessions: [summary(session.id, 2)],
});
});
it("does not let a delayed acknowledgement response regress an epoch installed by another request", async () => {
const acknowledgementResponse = deferred<SessionUnreadCatalogSnapshot>();
const unreadCatalog = vi.fn().mockResolvedValue(snapshot("catalog-b", 1, [summary("epoch-b", 1)]));
const acknowledgeUnread = vi.fn(() => acknowledgementResponse.promise);
const controller = new SessionUnreadController({ api: fakeApi({ unreadCatalog, acknowledgeUnread }) });
const session = ref("session-1");
controller.setCapability("local", "supported");
controller.applyEvent("local", unreadEvent("catalog-a", 1, summary(session.id, 1)));
const acknowledging = controller.acknowledge("local", session);
await controller.refresh("local");
expect(controller.projection("local")).toMatchObject({ catalogId: "catalog-b", status: "fresh" });
acknowledgementResponse.resolve(snapshot("catalog-a", 2, []));
await acknowledging;
await vi.waitFor(() => {
expect(controller.projection("local")).toMatchObject({ catalogId: "catalog-b", status: "fresh" });
});
expect(unreadCatalog).toHaveBeenCalledTimes(2);
expect(controller.isUnread("local", ref("epoch-b"))).toBe(true);
});
it("deduplicates acknowledgements and converges another client from the authoritative clear event", async () => {
const response = deferred<SessionUnreadCatalogSnapshot>();
const acknowledgeUnread = vi.fn(() => response.promise);
const first = new SessionUnreadController({ api: fakeApi({ acknowledgeUnread }) });
const second = new SessionUnreadController({ api: fakeApi() });
const session = ref("session-1");
first.setCapability("local", "supported");
second.setCapability("local", "supported");
const completion = unreadEvent("catalog-a", 1, summary(session.id, 1));
first.applyEvent("local", completion);
second.applyEvent("local", completion);
const firstAttempt = first.acknowledge("local", session);
const duplicateAttempt = first.acknowledge("local", session);
expect(duplicateAttempt).toBe(firstAttempt);
expect(acknowledgeUnread).toHaveBeenCalledOnce();
response.resolve(snapshot("catalog-a", 2, []));
await firstAttempt;
second.applyEvent("local", unreadEvent("catalog-a", 2, null, session));
expect(first.isUnread("local", session)).toBe(false);
expect(second.isUnread("local", session)).toBe(false);
expect(acknowledgeUnread).toHaveBeenCalledOnce();
});
it("keeps canonical identities machine- and cwd-scoped and prunes removed machines", () => {
const controller = new SessionUnreadController({ api: fakeApi() });
controller.setCapability("machine-a", "supported");
controller.setCapability("machine-b", "supported");
controller.applyEvent("machine-a", unreadEvent("catalog-a", 1, summary("shared", 1, "/repo-a")));
controller.applyEvent("machine-b", unreadEvent("catalog-b", 1, summary("shared", 1, "/repo-b")));
expect(controller.isUnread("machine-a", ref("shared", "/repo-a"))).toBe(true);
expect(controller.isUnread("machine-a", ref("shared", "/repo-b"))).toBe(false);
expect(controller.isUnread("machine-b", ref("shared", "/repo-b"))).toBe(true);
controller.retainMachines(new Set(["machine-b"]));
expect(controller.projection("machine-a")).toBeUndefined();
expect(controller.isUnread("machine-b", ref("shared", "/repo-b"))).toBe(true);
});
it("invalidates an in-flight snapshot when its machine is removed", async () => {
const response = deferred<SessionUnreadCatalogSnapshot>();
const onChange = vi.fn();
const controller = new SessionUnreadController({
api: fakeApi({ unreadCatalog: () => response.promise }),
onChange,
});
controller.setCapability("local", "supported");
const refreshing = controller.refresh("local");
onChange.mockClear();
controller.retainMachines(new Set());
response.resolve(snapshot("catalog-a", 1, [summary("session-1", 1)]));
await refreshing;
expect(controller.projection("local")).toBeUndefined();
expect(onChange).not.toHaveBeenCalled();
});
it("ignores socket deltas and endpoints until joint support is known, then clears state on downgrade", async () => {
const unreadCatalog = vi.fn().mockResolvedValue(snapshot("catalog-a", 0, []));
const controller = new SessionUnreadController({ api: fakeApi({ unreadCatalog }) });
const completion = unreadEvent("catalog-a", 1, summary("session-1", 1));
controller.applyEvent("legacy", completion);
await controller.refresh("legacy");
controller.setCapability("legacy", "unsupported");
controller.applyEvent("legacy", completion);
await controller.refresh("legacy");
expect(unreadCatalog).not.toHaveBeenCalled();
expect(controller.projection("legacy")).toBeUndefined();
expect(controller.setCapability("legacy", "supported")).toBe(true);
await controller.refresh("legacy");
expect(unreadCatalog).toHaveBeenCalledOnce();
controller.applyEvent("legacy", completion);
controller.setCapability("legacy", "unsupported");
expect(controller.projection("legacy")).toBeUndefined();
expect(controller.unreadSessionIds("legacy", [ref("session-1")]).size).toBe(0);
});
it("preserves unread state when acknowledgement fails so a later visible check can retry", async () => {
const error = new Error("offline");
const onBackgroundError = vi.fn();
const acknowledgeUnread = vi.fn().mockRejectedValueOnce(error).mockResolvedValueOnce(snapshot("catalog-a", 2, []));
const controller = new SessionUnreadController({ api: fakeApi({ acknowledgeUnread }), onBackgroundError });
const session = ref("session-1");
controller.setCapability("local", "supported");
controller.applyEvent("local", unreadEvent("catalog-a", 1, summary(session.id, 1)));
await controller.acknowledge("local", session);
expect(controller.isUnread("local", session)).toBe(true);
expect(onBackgroundError).toHaveBeenCalledWith("acknowledge", "local", error);
await controller.acknowledge("local", session);
expect(controller.isUnread("local", session)).toBe(false);
expect(acknowledgeUnread).toHaveBeenCalledTimes(2);
});
});
interface FakeApiOptions {
snapshots?: SessionUnreadCatalogSnapshot[] | undefined;
unreadCatalog?: SessionUnreadApi["unreadCatalog"] | undefined;
acknowledgeUnread?: SessionUnreadApi["acknowledgeUnread"] | undefined;
}
function fakeApi(options: FakeApiOptions = {}): SessionUnreadApi {
const snapshots = [...(options.snapshots ?? [])];
return {
unreadCatalog: options.unreadCatalog ?? (() => {
const next = snapshots.shift();
return next === undefined
? Promise.reject(new Error("Unexpected unread snapshot request"))
: Promise.resolve(next);
}),
acknowledgeUnread: options.acknowledgeUnread ?? (() => (
Promise.reject(new Error("Unexpected unread acknowledgement"))
)),
};
}
function snapshot(catalogId: string, catalogRevision: number, sessions: SessionUnreadSummary[]): SessionUnreadCatalogSnapshot {
return { catalogId, catalogRevision, sessions };
}
function summary(sessionId: string, completionOrder: number, cwd = "/repo"): SessionUnreadSummary {
return {
sessionId,
cwd,
completionOrder,
completedAt: `2026-07-20T00:00:${String(completionOrder).padStart(2, "0")}.000Z`,
};
}
function unreadEvent(
catalogId: string,
catalogRevision: number,
unread: SessionUnreadSummary | null,
identity: SessionRef = unread === null ? ref("session-1") : { id: unread.sessionId, cwd: unread.cwd },
): SessionUnreadEvent {
return {
type: "sessions.unread",
catalogId,
catalogRevision,
sessionId: identity.id,
cwd: identity.cwd,
unread,
};
}
function ref(id: string, cwd = "/repo"): SessionRef {
return { id, cwd };
}
function deferred<T>(): { promise: Promise<T>; resolve(value: T): void; reject(error: unknown): void } {
let resolvePromise: ((value: T) => void) | undefined;
let rejectPromise: ((error: unknown) => void) | undefined;
const promise = new Promise<T>((resolve, reject) => {
resolvePromise = resolve;
rejectPromise = reject;
});
return {
promise,
resolve: (value) => {
if (resolvePromise === undefined) throw new Error("Deferred promise is unavailable");
resolvePromise(value);
},
reject: (error) => {
if (rejectPromise === undefined) throw new Error("Deferred promise is unavailable");
rejectPromise(error);
},
};
}
+479
View File
@@ -0,0 +1,479 @@
import { sessionsApi } from "./api";
import { SESSION_UNREAD_LIMIT, type SessionRef, type SessionUnreadCatalogSnapshot, type SessionUnreadEvent, type SessionUnreadSummary } from "../../shared/apiTypes";
const EMPTY_SESSION_IDS: ReadonlySet<string> = new Set();
const MAX_BUFFERED_NETWORK_EVENTS = SESSION_UNREAD_LIMIT + 1;
export type SessionUnreadCapabilityState = "unknown" | "supported" | "unsupported";
export type SessionUnreadProjectionStatus = "loading" | "fresh" | "stale";
export interface SessionUnreadApi {
unreadCatalog(machineId: string): Promise<SessionUnreadCatalogSnapshot>;
acknowledgeUnread(session: SessionRef, catalogId: string, throughCompletionOrder: number, machineId: string): Promise<SessionUnreadCatalogSnapshot>;
}
export interface SessionUnreadControllerOptions {
api?: SessionUnreadApi | undefined;
onChange?: ((machineId: string) => void) | undefined;
onBackgroundError?: ((operation: "snapshot" | "acknowledge", machineId: string, error: unknown) => void) | undefined;
}
export interface SessionUnreadProjectionView extends SessionUnreadCatalogSnapshot {
status: SessionUnreadProjectionStatus;
}
interface ProjectionData {
catalogId: string;
catalogRevision: number;
summariesByIdentity: Map<string, SessionUnreadSummary>;
}
interface NetworkObserver {
generation: number;
projectionVersion: number;
projectionStatus: SessionUnreadProjectionStatus;
events: SessionUnreadEvent[];
overflowed: boolean;
}
interface MachineUnreadState {
readonly machineId: string;
capability: SessionUnreadCapabilityState;
status: SessionUnreadProjectionStatus;
projection: ProjectionData | undefined;
projectionVersion: number;
generation: number;
readonly observers: Set<NetworkObserver>;
readonly acknowledgements: Map<string, Promise<void>>;
refreshPromise: Promise<void> | undefined;
refreshQueued: boolean;
}
interface ProjectionTransition {
projection: ProjectionData;
status: SessionUnreadProjectionStatus;
requiresRefresh: boolean;
}
const defaultApi: SessionUnreadApi = {
unreadCatalog: (machineId) => sessionsApi.unreadCatalog(machineId),
acknowledgeUnread: (session, catalogId, throughCompletionOrder, machineId) => (
sessionsApi.acknowledgeUnread(session, catalogId, throughCompletionOrder, machineId)
),
};
/**
* Per-machine browser projection of the daemon-owned unread catalog.
*
* HTTP snapshots establish join state, while contiguous socket revisions keep
* it current. Network responses observe socket events that race their request;
* same-epoch events are replayed and ambiguous epoch/gap races force a trailing
* snapshot. Read acknowledgements always carry the exact epoch and completion
* order shown to the user, so the daemon can reject stale clears.
*/
export class SessionUnreadController {
private readonly api: SessionUnreadApi;
private readonly onChange: (machineId: string) => void;
private readonly onBackgroundError: (operation: "snapshot" | "acknowledge", machineId: string, error: unknown) => void;
private readonly machines = new Map<string, MachineUnreadState>();
constructor(options: SessionUnreadControllerOptions = {}) {
this.api = options.api ?? defaultApi;
this.onChange = options.onChange ?? (() => undefined);
this.onBackgroundError = options.onBackgroundError ?? (() => undefined);
}
/** Remove projections and invalidate pending responses for deleted machines. */
retainMachines(machineIds: ReadonlySet<string>): void {
for (const [machineId, state] of this.machines) {
if (machineIds.has(machineId)) continue;
const changed = state.projection !== undefined;
state.generation += 1;
state.observers.clear();
state.acknowledgements.clear();
this.machines.delete(machineId);
if (changed) this.onChange(machineId);
}
}
/** Returns true when capability discovery newly makes a snapshot eligible. */
setCapability(machineId: string, capability: SessionUnreadCapabilityState): boolean {
const state = this.machine(machineId);
const previous = state.capability;
if (previous === capability) return false;
state.capability = capability;
if (capability === "unsupported") {
const changed = state.projection !== undefined;
state.generation += 1;
state.projection = undefined;
if (changed) state.projectionVersion += 1;
state.status = "stale";
state.refreshPromise = undefined;
state.refreshQueued = false;
state.observers.clear();
state.acknowledgements.clear();
if (changed) this.onChange(machineId);
return false;
}
if (capability === "unknown") {
if (state.projection !== undefined && state.status !== "stale") {
state.status = "stale";
this.onChange(machineId);
}
return false;
}
return previous !== "supported";
}
capability(machineId: string): SessionUnreadCapabilityState {
return this.machines.get(machineId)?.capability ?? "unknown";
}
projection(machineId: string): SessionUnreadProjectionView | undefined {
const state = this.machines.get(machineId);
const projection = state?.projection;
if (state === undefined || projection === undefined) return undefined;
return {
status: state.status,
catalogId: projection.catalogId,
catalogRevision: projection.catalogRevision,
sessions: [...projection.summariesByIdentity.values()]
.sort((left, right) => right.completionOrder - left.completionOrder)
.map((summary) => ({ ...summary })),
};
}
unreadSessionIds(machineId: string, sessions: readonly SessionRef[]): ReadonlySet<string> {
const projection = this.machines.get(machineId)?.projection;
if (projection === undefined || sessions.length === 0) return EMPTY_SESSION_IDS;
const unread = new Set<string>();
for (const session of sessions) {
if (projection.summariesByIdentity.has(sessionIdentityKey(session))) unread.add(session.id);
}
return unread.size === 0 ? EMPTY_SESSION_IDS : unread;
}
isUnread(machineId: string, session: SessionRef): boolean {
return this.machines.get(machineId)?.projection?.summariesByIdentity.has(sessionIdentityKey(session)) === true;
}
applyEvent(machineId: string, event: SessionUnreadEvent): void {
const state = this.machine(machineId);
// Events alone do not prove that the web/API hop exposes the matching HTTP
// routes (for example, a new sessiond behind an older federated web host).
// Only jointly negotiated support may activate this projection.
if (state.capability !== "supported") return;
this.recordObservedEvent(state, event);
const transition = applyUnreadEvent(state.projection, state.status, event);
const changed = this.installProjection(state, transition.projection, transition.status);
if (changed) this.onChange(machineId);
if (transition.requiresRefresh) {
if (state.refreshPromise === undefined) void this.refresh(machineId);
else state.refreshQueued = true;
}
}
refresh(machineId: string): Promise<void> {
const state = this.machine(machineId);
if (state.capability !== "supported") return Promise.resolve();
if (state.refreshPromise !== undefined) {
state.refreshQueued = true;
return state.refreshPromise;
}
const generation = state.generation;
const refreshPromise = this.runRefreshLoop(state, generation);
state.refreshPromise = refreshPromise;
void refreshPromise.finally(() => {
if (!this.isCurrent(state, generation) || state.refreshPromise !== refreshPromise) return;
state.refreshPromise = undefined;
const refreshAgain = state.refreshQueued && state.capability === "supported";
state.refreshQueued = false;
if (refreshAgain) void this.refresh(state.machineId);
});
return refreshPromise;
}
async refreshAll(): Promise<void> {
await Promise.all([...this.machines.values()]
.filter((state) => state.capability === "supported")
.map(async (state) => { await this.refresh(state.machineId); }));
}
acknowledge(machineId: string, session: SessionRef): Promise<void> {
const state = this.machines.get(machineId);
const projection = state?.projection;
if (state?.capability !== "supported" || projection === undefined) return Promise.resolve();
const summary = projection.summariesByIdentity.get(sessionIdentityKey(session));
if (summary === undefined) return Promise.resolve();
const key = acknowledgementKey(projection.catalogId, summary);
const pending = state.acknowledgements.get(key);
if (pending !== undefined) return pending;
const generation = state.generation;
const acknowledgement = this.runAcknowledgement(state, generation, session, projection.catalogId, summary.completionOrder);
state.acknowledgements.set(key, acknowledgement);
void acknowledgement.finally(() => {
if (state.acknowledgements.get(key) === acknowledgement) state.acknowledgements.delete(key);
});
return acknowledgement;
}
private async runRefreshLoop(state: MachineUnreadState, generation: number): Promise<void> {
do {
state.refreshQueued = false;
if (!this.isCurrentSupported(state, generation)) return;
const projectionStatus = state.status;
const changed = this.markRefreshStarted(state);
if (changed) this.onChange(state.machineId);
const observer = this.beginNetworkObservation(state, generation, projectionStatus);
try {
const snapshot = await this.api.unreadCatalog(state.machineId);
if (!this.isCurrentSupported(state, generation)) return;
const requiresRefresh = this.applyNetworkSnapshot(state, snapshot, observer);
if (requiresRefresh) state.refreshQueued = true;
} catch (error: unknown) {
if (this.isCurrent(state, generation)) {
const becameStale = state.status !== "stale";
state.status = "stale";
if (becameStale) this.onChange(state.machineId);
this.reportError("snapshot", state.machineId, error);
}
} finally {
state.observers.delete(observer);
}
} while (state.refreshQueued && this.isCurrentSupported(state, generation));
}
private async runAcknowledgement(
state: MachineUnreadState,
generation: number,
session: SessionRef,
catalogId: string,
throughCompletionOrder: number,
): Promise<void> {
const observer = this.beginNetworkObservation(state, generation);
try {
const snapshot = await this.api.acknowledgeUnread(
session,
catalogId,
throughCompletionOrder,
state.machineId,
);
if (!this.isCurrentSupported(state, generation)) return;
if (this.applyNetworkSnapshot(state, snapshot, observer)) void this.refresh(state.machineId);
} catch (error: unknown) {
if (this.isCurrent(state, generation)) this.reportError("acknowledge", state.machineId, error);
} finally {
state.observers.delete(observer);
}
}
private applyNetworkSnapshot(
state: MachineUnreadState,
snapshot: SessionUnreadCatalogSnapshot,
observer: NetworkObserver,
): boolean {
const current = state.projection;
const ambiguousConcurrentEpoch = state.projectionVersion !== observer.projectionVersion
&& current !== undefined
&& current.catalogId !== snapshot.catalogId;
if (ambiguousConcurrentEpoch
|| observer.overflowed
|| observer.events.some((event) => event.catalogId !== snapshot.catalogId)) {
const changed = state.status !== "stale";
state.status = "stale";
if (changed) this.onChange(state.machineId);
return true;
}
let candidate = projectionFromSnapshot(snapshot);
let candidateStatus: SessionUnreadProjectionStatus = "fresh";
let requiresRefresh = false;
for (const event of observer.events) {
const transition = applyUnreadEvent(candidate, candidateStatus, event);
candidate = transition.projection;
candidateStatus = transition.status;
requiresRefresh ||= transition.requiresRefresh;
}
if (current?.catalogId === candidate.catalogId
&& current.catalogRevision > candidate.catalogRevision) {
if (state.projectionVersion === observer.projectionVersion && observer.projectionStatus === "fresh") {
const changed = state.status !== "fresh";
state.status = "fresh";
if (changed) this.onChange(state.machineId);
}
requiresRefresh ||= state.status === "stale";
} else {
const changed = this.installProjection(state, candidate, candidateStatus);
if (changed) this.onChange(state.machineId);
}
return requiresRefresh;
}
private beginNetworkObservation(
state: MachineUnreadState,
generation: number,
projectionStatus = state.status,
): NetworkObserver {
const observer: NetworkObserver = {
generation,
projectionVersion: state.projectionVersion,
projectionStatus,
events: [],
overflowed: false,
};
state.observers.add(observer);
return observer;
}
private recordObservedEvent(state: MachineUnreadState, event: SessionUnreadEvent): void {
for (const observer of state.observers) {
if (observer.generation !== state.generation || observer.overflowed) continue;
if (observer.events.length >= MAX_BUFFERED_NETWORK_EVENTS) {
observer.events.length = 0;
observer.overflowed = true;
} else {
observer.events.push(event);
}
}
}
private markRefreshStarted(state: MachineUnreadState): boolean {
const status: SessionUnreadProjectionStatus = state.projection === undefined ? "loading" : "stale";
if (state.status === status) return false;
state.status = status;
return true;
}
private installProjection(
state: MachineUnreadState,
projection: ProjectionData,
status: SessionUnreadProjectionStatus,
): boolean {
const projectionChanged = !projectionsEqual(state.projection, projection);
if (state.status === status && !projectionChanged) return false;
if (projectionChanged) {
state.projection = projection;
state.projectionVersion += 1;
}
state.status = status;
return true;
}
private machine(machineId: string): MachineUnreadState {
const existing = this.machines.get(machineId);
if (existing !== undefined) return existing;
const state: MachineUnreadState = {
machineId,
capability: "unknown",
status: "stale",
projection: undefined,
projectionVersion: 0,
generation: 0,
observers: new Set(),
acknowledgements: new Map(),
refreshPromise: undefined,
refreshQueued: false,
};
this.machines.set(machineId, state);
return state;
}
private isCurrent(state: MachineUnreadState, generation: number): boolean {
return this.machines.get(state.machineId) === state && state.generation === generation;
}
private isCurrentSupported(state: MachineUnreadState, generation: number): boolean {
return this.isCurrent(state, generation) && state.capability === "supported";
}
private reportError(operation: "snapshot" | "acknowledge", machineId: string, error: unknown): void {
this.onBackgroundError(operation, machineId, error);
}
}
function applyUnreadEvent(
projection: ProjectionData | undefined,
status: SessionUnreadProjectionStatus,
event: SessionUnreadEvent,
): ProjectionTransition {
if (projection === undefined) {
const empty = emptyProjection(event.catalogId);
if (event.catalogRevision !== 1) return { projection: empty, status: "stale", requiresRefresh: true };
return applyContiguousEvent(empty, "fresh", event);
}
if (projection.catalogId !== event.catalogId) {
const empty = emptyProjection(event.catalogId);
if (event.catalogRevision !== 1) return { projection: empty, status: "stale", requiresRefresh: true };
return applyContiguousEvent(empty, "stale", event);
}
if (event.catalogRevision <= projection.catalogRevision) {
return { projection, status, requiresRefresh: status === "stale" };
}
if (event.catalogRevision !== projection.catalogRevision + 1) {
return { projection, status: "stale", requiresRefresh: true };
}
return applyContiguousEvent(projection, status, event);
}
function applyContiguousEvent(
projection: ProjectionData,
status: SessionUnreadProjectionStatus,
event: SessionUnreadEvent,
): ProjectionTransition {
const summariesByIdentity = new Map(projection.summariesByIdentity);
const key = sessionIdentityKey(event);
if (event.unread === null) summariesByIdentity.delete(key);
else summariesByIdentity.set(key, { ...event.unread });
const nextStatus: SessionUnreadProjectionStatus = status === "stale" ? "stale" : "fresh";
return {
projection: {
catalogId: event.catalogId,
catalogRevision: event.catalogRevision,
summariesByIdentity,
},
status: nextStatus,
requiresRefresh: nextStatus === "stale",
};
}
function projectionFromSnapshot(snapshot: SessionUnreadCatalogSnapshot): ProjectionData {
return {
catalogId: snapshot.catalogId,
catalogRevision: snapshot.catalogRevision,
summariesByIdentity: new Map(snapshot.sessions.map((summary) => [sessionIdentityKey(summary), { ...summary }])),
};
}
function emptyProjection(catalogId: string): ProjectionData {
return { catalogId, catalogRevision: 0, summariesByIdentity: new Map() };
}
function projectionsEqual(left: ProjectionData | undefined, right: ProjectionData): boolean {
if (left?.catalogId !== right.catalogId || left.catalogRevision !== right.catalogRevision) return false;
if (left.summariesByIdentity.size !== right.summariesByIdentity.size) return false;
for (const [key, summary] of left.summariesByIdentity) {
const candidate = right.summariesByIdentity.get(key);
if (candidate?.completionOrder !== summary.completionOrder
|| candidate.completedAt !== summary.completedAt) return false;
}
return true;
}
function sessionIdentityKey(session: Pick<SessionRef, "id" | "cwd"> | Pick<SessionUnreadSummary, "sessionId" | "cwd"> | Pick<SessionUnreadEvent, "sessionId" | "cwd">): string {
const sessionId = "id" in session ? session.id : session.sessionId;
return JSON.stringify([sessionId, session.cwd]);
}
function acknowledgementKey(catalogId: string, summary: SessionUnreadSummary): string {
return JSON.stringify([catalogId, summary.sessionId, summary.cwd, summary.completionOrder]);
}