fix(sessions): preserve notification inbox authority

This commit is contained in:
Federico Jaramillo Martinez
2026-07-19 02:17:59 +02:00
parent 503c2c743d
commit 71fd091e0e
7 changed files with 84 additions and 28 deletions
+15 -5
View File
@@ -16,6 +16,7 @@ export class MachineSwitcher extends LitElement implements KeyboardNavigableSect
@property({ attribute: false }) statuses: Record<string, MachineHealth> = {};
@property({ attribute: false }) activities: Record<string, Record<string, WorkspaceActivity>> = {};
@property({ attribute: false }) notificationBadges: Record<string, SessionNotificationBadgeModel | undefined> = {};
@property({ attribute: false }) notificationHeadingBadge?: SessionNotificationBadgeModel;
@property({ attribute: false }) onSelect?: (machine: Machine) => void | Promise<void>;
@property({ attribute: false }) onRemove?: (machine: Machine) => void | Promise<void>;
@property({ attribute: false }) onFocusNextSection?: () => void | Promise<void>;
@@ -57,13 +58,14 @@ export class MachineSwitcher extends LitElement implements KeyboardNavigableSect
if (selected === undefined) return null;
const status = machineStatus(selected, this.statuses);
const label = selected.name;
const notificationBadge = machineSwitcherNotificationBadge(selected.id, this.notificationBadges, this.notificationHeadingBadge);
return html`
<div class="machine-switcher">
<button
type="button"
class="machine-switcher-button"
title=${machineTitle(selected)}
aria-label=${this.machineSwitcherAriaLabel(selected)}
aria-label=${this.machineSwitcherAriaLabel(selected, notificationBadge)}
aria-expanded=${String(this.open)}
@click=${(event: MouseEvent) => { this.toggleMenu(event.currentTarget); }}
@keydown=${(event: KeyboardEvent) => { this.handleSwitcherButtonKeydown(event); }}
@@ -74,7 +76,7 @@ export class MachineSwitcher extends LitElement implements KeyboardNavigableSect
<span class="machine-switcher-label">${label}</span>
</span>
<span class=${`machine-status ${status}`}>${machineStatusLabel(status)}</span>
${this.notificationBadges[selected.id] === undefined ? null : html`<notification-badge .model=${this.notificationBadges[selected.id]}></notification-badge>`}
${notificationBadge === undefined ? null : html`<notification-badge .model=${notificationBadge}></notification-badge>`}
<span class="machine-chevron" aria-hidden="true">▾</span>
</button>
${this.open ? html`
@@ -136,9 +138,9 @@ export class MachineSwitcher extends LitElement implements KeyboardNavigableSect
return this.selected ?? this.machines.find((machine) => machine.id === "local") ?? this.machines[0];
}
private machineSwitcherAriaLabel(machine: Machine): string {
const notificationLabel = this.notificationBadges[machine.id]?.accessibleLabel;
return `Machine: ${machine.name}.${notificationLabel === undefined ? "" : ` ${notificationLabel}.`} Switch machine.`;
private machineSwitcherAriaLabel(machine: Machine, notificationBadge: SessionNotificationBadgeModel | undefined): string {
const notificationLabel = notificationBadge?.accessibleLabel;
return `Machine: ${machine.name}.${notificationLabel === undefined ? "" : ` Notifications across machines: ${notificationLabel}.`} Switch machine.`;
}
private switcherButton(): HTMLElement | null {
@@ -312,6 +314,14 @@ export class MachineSwitcher extends LitElement implements KeyboardNavigableSect
`;
}
export function machineSwitcherNotificationBadge(
selectedMachineId: string,
notificationBadges: Readonly<Record<string, SessionNotificationBadgeModel | undefined>>,
notificationHeadingBadge: SessionNotificationBadgeModel | undefined,
): SessionNotificationBadgeModel | undefined {
return notificationHeadingBadge ?? notificationBadges[selectedMachineId];
}
function machineStatus(machine: Machine, statuses: Record<string, MachineHealth>): MachineStatus {
return statuses[machine.id]?.status ?? machine.status ?? "unknown";
}
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { initialAppState, type AppState } from "../appState";
import type { SessionInfo } from "../api";
import type { NavigationNotificationBadges } from "./appShell/AppNavigationPanel";
import { machineSwitcherNotificationBadge } from "./MachineSwitcher";
import { PiWebApp } from "./PiWebApp";
const currentSession: SessionInfo = {
@@ -83,6 +84,8 @@ describe("PiWebApp notification hierarchy models", () => {
expect(badges.projects["project-1"]).toMatchObject({ text: "3+", severity: "error" });
expect(badges.machines["local"]).toMatchObject({ text: "3+", severity: "error" });
expect(badges.machines["remote"]).toMatchObject({ text: "5", severity: "info" });
expect(badges.machinesHeading).toMatchObject({ text: "8+", severity: "error" });
expect(machineSwitcherNotificationBadge("local", badges.machines, badges.machinesHeading)).toBe(badges.machinesHeading);
expect(badges.sessionsHeading).toMatchObject({ text: "2", severity: "error" });
expect(mobile).toMatchObject({ text: "8+", severity: "error" });
});
@@ -116,6 +116,7 @@ export class AppNavigationPanel extends LitElement {
.statuses=${this.machineStatuses}
.activities=${this.machineActivities}
.notificationBadges=${this.notificationBadges.machines}
.notificationHeadingBadge=${this.notificationBadges.machinesHeading}
.onSelect=${(machine: Machine) => this.onSelectMachine?.(machine)}
.onRemove=${(machine: Machine) => this.onRemoveMachine?.(machine)}
.onFocusNextSection=${() => { this.focusNextFrom("machines"); }}
@@ -337,6 +337,31 @@ describe("SessionNotificationController capability and joins", () => {
});
describe("SessionNotificationController optimistic mutations", () => {
it("does not let a delayed refresh snapshot roll back a newer dismissal response", async () => {
const initial = inboxSnapshot([entry(1)], { inboxRevision: 1, catalogRevision: 1 });
const delayedRefresh = deferred<SessionNotificationInboxSnapshot>();
const notificationInbox = vi.fn()
.mockResolvedValueOnce(initial)
.mockImplementationOnce(() => delayedRefresh.promise);
const dismissed = inboxSnapshot([], { inboxRevision: 2, catalogRevision: 2 });
const harness = createHarness(capableState(), {
notificationInbox,
dismissNotification: vi.fn(() => Promise.resolve(dismissed)),
});
harness.controller.prepareSelectedSession(session, "local");
await harness.controller.refreshSelectedSession(session, "local");
const refresh = harness.controller.refreshSelectedSession(session, "local");
await harness.controller.dismissNotification("daemon-a:1");
expect(selectedNotificationView(harness.state.selectedNotificationInbox)?.notifications).toEqual([]);
delayedRefresh.resolve(initial);
await refresh;
expect(harness.state.selectedNotificationInbox?.summary?.inboxRevision).toBe(2);
expect(selectedNotificationView(harness.state.selectedNotificationInbox)?.notifications).toEqual([]);
});
it("optimistically dismisses one card, reconciles the response, and rolls back/refetches on failure", async () => {
const dismiss = deferred<SessionNotificationInboxSnapshot>();
const refreshAfterFailure = deferred<SessionNotificationInboxSnapshot>();
@@ -301,7 +301,10 @@ export class SessionNotificationController {
return;
}
this.acceptedSupportByMachine.add(target.machineId);
let inbox = installSelectedNotificationSnapshot(this.getState().selectedNotificationInbox, target, snapshot);
const current = this.getState().selectedNotificationInbox;
let inbox = current === undefined || shouldInstallSelectedSnapshot(current, target, snapshot)
? installSelectedNotificationSnapshot(current, target, snapshot)
: current;
const catalogEvents = [snapshotSummaryEvent(snapshot)];
for (const event of [...join.events].sort((left, right) => left.summary.inboxRevision - right.summary.inboxRevision)) {
const result = applySelectedNotificationEvent(inbox, target, event);
@@ -389,10 +392,9 @@ export class SessionNotificationController {
this.setState({ selectedNotificationInbox: { ...removeOverlay(current), status: "stale" } });
return;
}
const shouldInstall = current.daemonInstanceId !== snapshot.daemonInstanceId
|| current.summary === undefined
|| snapshot.summary.inboxRevision >= current.summary.inboxRevision;
const authoritative = shouldInstall ? installSelectedNotificationSnapshot(current, target, snapshot) : current;
const authoritative = shouldInstallSelectedSnapshot(current, target, snapshot)
? installSelectedNotificationSnapshot(current, target, snapshot)
: current;
this.setState({ selectedNotificationInbox: removeOverlay(authoritative) });
this.applyCatalogSummary(target.machineId, snapshotSummaryEvent(snapshot));
}
@@ -578,6 +580,17 @@ function targetFromInbox(inbox: SelectedSessionNotificationInbox): SessionNotifi
return { machineId: inbox.machineId, sessionId: inbox.sessionId, cwd: inbox.cwd };
}
function shouldInstallSelectedSnapshot(
current: SelectedSessionNotificationInbox,
target: SessionNotificationTarget,
snapshot: SessionNotificationInboxSnapshot,
): boolean {
return !notificationTargetsEqual(current, target)
|| current.daemonInstanceId !== snapshot.daemonInstanceId
|| current.summary === undefined
|| snapshot.summary.inboxRevision >= current.summary.inboxRevision;
}
async function forEachWithConcurrency<T>(items: readonly T[], concurrency: number, worker: (item: T) => Promise<void>): Promise<void> {
let nextIndex = 0;
async function run(): Promise<void> {