feat(ui): derive bubble-up unread presence from unread projections

Add pure unreadPresence helpers mapping unread summary cwds to machine,
project, and workspace presence (machine = any summary, workspace = exact
path match, project = known workspace ownership), plus a PiWebApp seam
that keeps the derived UnreadPresence state current on any machine's
projection change and on app-state transitions. No UI consumption yet.
This commit is contained in:
Federico Jaramillo Martinez
2026-07-27 14:41:40 +02:00
parent b4a7a9230b
commit 9267174736
4 changed files with 414 additions and 2 deletions
+16
View File
@@ -27,6 +27,7 @@ import { sessionCleanupRequestKey, sessionCleanupUnavailableMessage } from "../s
import { selectedNotificationView } from "../sessionNotifications";
import { hasAuthoritativeSessionPersistence as runtimeHasAuthoritativeSessionPersistence } from "../sessionPersistence";
import { SessionUnreadController } from "../sessionUnread";
import { deriveUnreadPresence, EMPTY_UNREAD_PRESENCE, sameUnreadPresence, type UnreadPresence } from "../unreadPresence";
import { initialSessionWarningVisibilityState, reconcileSessionWarningVisibility, toggleSessionWarnings } 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";
@@ -109,6 +110,7 @@ export class PiWebApp extends LitElement {
private readonly sessionUnread = new SessionUnreadController({
onChange: (machineId) => {
this.syncUnreadPresence();
if (selectedMachineId(this.state) !== machineId) return;
this.syncUnreadSessionIds();
this.syncSelectedSessionReadState();
@@ -118,6 +120,7 @@ export class PiWebApp extends LitElement {
},
});
@state() private unreadSessionIds: ReadonlySet<string> = this.sessionUnread.unreadSessionIds(selectedMachineId(this.state), this.state.sessions);
@state() private unreadPresence: UnreadPresence = EMPTY_UNREAD_PRESENCE;
private unreadConnected = false;
private committedChatIdentity: string | undefined;
private readyChatIdentity: string | undefined;
@@ -319,6 +322,18 @@ export class PiWebApp extends LitElement {
if (!sameStringSet(next, this.unreadSessionIds)) this.unreadSessionIds = next;
}
private syncUnreadPresence(): void {
const next = deriveUnreadPresence({
machineIds: this.state.machines.map((machine) => machine.id),
projectionFor: (machineId) => this.sessionUnread.projection(machineId),
selectedMachineId: selectedMachineId(this.state),
projects: this.state.projects,
workspaces: this.state.workspaces,
workspacesByProjectId: this.state.workspacesByProjectId,
});
if (!sameUnreadPresence(next, this.unreadPresence)) this.unreadPresence = next;
}
private isSessionSeen(machineId: string, session: SessionInfo): boolean {
if (!this.unreadConnected) return false;
const identity = unreadChatIdentity(machineId, session);
@@ -404,6 +419,7 @@ export class PiWebApp extends LitElement {
}
if (machineUnreadInputsChanged(previous, this.state)) this.syncSessionUnreadMachines();
this.syncUnreadSessionIds();
this.syncUnreadPresence();
this.handleActivityTransition(previous, this.state);
this.handleWorkspaceChange(previous, this.state);
this.handleMachineChange(previous, this.state);
@@ -1,9 +1,10 @@
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 type { Machine, Project, SessionInfo, SessionUnreadEvent, SessionUnreadSummary, Workspace } from "../api";
import { initialAppState, type AppState } from "../appState";
import type { BrowserRealtimeEvent } from "../sessionSocket";
import type { UnreadPresence } from "../unreadPresence";
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.
@@ -277,11 +278,71 @@ describe("PiWebApp session unread wiring", () => {
expect(requests).toContain("https://pi.example.test/api/machines/local/sessions/beta/unread/acknowledge");
await vi.waitFor(() => { expect(navigationUnreadSessionIds(app).size).toBe(0); });
});
it("derives bubble-up unread presence for selected and background machines", () => {
stubJsonFetch({ catalogId: "catalog-a", catalogRevision: 2, sessions: [] });
const app = createApp();
enableUnread(app);
enableUnreadMachine(app, "remote");
const selected = session("selected");
setAppState(app, {
...initialAppState(),
machines: [machine("local"), machine("remote")],
selectedMachine: machine("local"),
projects: [project("project-1")],
workspaces: [workspace("ws-1", "project-1", "/repo")],
workspacesByProjectId: { "project-1": [workspace("ws-1", "project-1", "/repo")] },
sessions: [selected],
selectedSession: selected,
mainView: "chat",
});
handleRealtimeEvent(app, unreadEvent(1, unreadSummary(selected, 1)));
expect([...unreadPresence(app).machines]).toEqual(["local"]);
expect([...unreadPresence(app).projects]).toEqual(["project-1"]);
expect([...unreadPresence(app).workspaces]).toEqual(["ws-1"]);
const remoteSession = { ...session("remote-session"), cwd: "/unmapped" };
handleMachineActivityEvent(app, "remote", unreadEvent(1, unreadSummary(remoteSession, 1)));
expect([...unreadPresence(app).machines].sort()).toEqual(["local", "remote"]);
// Background cwds never leak into the selected machine's workspace/project rows.
expect([...unreadPresence(app).projects]).toEqual(["project-1"]);
expect([...unreadPresence(app).workspaces]).toEqual(["ws-1"]);
});
it("recomputes bubble-up presence when workspace data loads after the unread event", () => {
stubJsonFetch({ catalogId: "catalog-a", catalogRevision: 2, sessions: [] });
const app = createApp();
enableUnread(app);
const selected = session("selected");
setAppState(app, {
...initialAppState(),
machines: [machine("local")],
selectedMachine: machine("local"),
sessions: [selected],
selectedSession: selected,
mainView: "chat",
});
handleRealtimeEvent(app, unreadEvent(1, unreadSummary(selected, 1)));
expect([...unreadPresence(app).machines]).toEqual(["local"]);
expect(unreadPresence(app).projects.size).toBe(0);
expect(unreadPresence(app).workspaces.size).toBe(0);
setState(app, {
projects: [project("project-1")],
workspaces: [workspace("ws-1", "project-1", "/repo")],
workspacesByProjectId: { "project-1": [workspace("ws-1", "project-1", "/repo")] },
});
expect([...unreadPresence(app).projects]).toEqual(["project-1"]);
expect([...unreadPresence(app).workspaces]).toEqual(["ws-1"]);
});
});
type RenderNavigationPanel = (this: PiWebApp) => TemplateResult;
type SetAppState = (this: PiWebApp, patch: Partial<AppState>) => void;
type HandleRealtimeEvent = (this: PiWebApp, machineId: string, event: BrowserRealtimeEvent) => void;
type HandleMachineActivityEvent = (this: PiWebApp, machineId: string, event: BrowserRealtimeEvent) => void;
type MobileMainTabs = (this: PiWebApp) => AppMobileMainTab[];
type UpdatedHook = (this: PiWebApp) => void;
type DisconnectedHook = (this: PiWebApp) => void;
@@ -335,13 +396,23 @@ function handleRealtimeEvent(app: PiWebApp, event: BrowserRealtimeEvent): void {
method.call(app, "local", event);
}
function handleMachineActivityEvent(app: PiWebApp, machineId: string, event: BrowserRealtimeEvent): void {
const method: unknown = Reflect.get(app, "handleMachineActivityEvent");
if (!isHandleMachineActivityEvent(method)) throw new Error("PiWebApp.handleMachineActivityEvent is not callable");
method.call(app, machineId, event);
}
function enableUnread(app: PiWebApp): void {
if (!Reflect.set(app, "unreadConnected", true)) throw new Error("Could not connect PiWebApp unread state");
enableUnreadMachine(app, "local");
}
function enableUnreadMachine(app: PiWebApp, machineId: string): void {
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"]);
Reflect.apply(setCapability, controller, [machineId, "supported"]);
}
function exposeSelectedChat(app: PiWebApp): void {
@@ -429,6 +500,37 @@ function navigationPanelValue(app: PiWebApp, marker: string): unknown {
return templateValueAfterMarker(method.call(app), marker);
}
function unreadPresence(app: PiWebApp): UnreadPresence {
const value: unknown = Reflect.get(app, "unreadPresence");
if (!isUnreadPresence(value)) throw new Error("Expected derived unread presence on PiWebApp");
return value;
}
function isUnreadPresence(value: unknown): value is UnreadPresence {
if (typeof value !== "object" || value === null) return false;
return Reflect.get(value, "machines") instanceof Set
&& Reflect.get(value, "projects") instanceof Set
&& Reflect.get(value, "workspaces") instanceof Set;
}
function machine(id: string): Machine {
return {
id,
name: id,
kind: id === "local" ? "local" : "remote",
createdAt: "2026-07-20T00:00:00.000Z",
updatedAt: "2026-07-20T00:00:00.000Z",
};
}
function project(id: string): Project {
return { id, name: id, path: "/repo", createdAt: "2026-07-20T00:00:00.000Z" };
}
function workspace(id: string, projectId: string, path: string): Workspace {
return { id, projectId, path, label: id, isMain: true, isGitRepo: true, isGitWorktree: false };
}
function session(id: string): SessionInfo {
return {
id,
@@ -502,6 +604,10 @@ function isHandleRealtimeEvent(value: unknown): value is HandleRealtimeEvent {
return typeof value === "function";
}
function isHandleMachineActivityEvent(value: unknown): value is HandleMachineActivityEvent {
return typeof value === "function";
}
function isMobileMainTabs(value: unknown): value is MobileMainTabs {
return typeof value === "function";
}
+184
View File
@@ -0,0 +1,184 @@
import { describe, expect, it } from "vitest";
import type { Project, SessionUnreadSummary, Workspace } from "../../shared/apiTypes";
import type { SessionUnreadProjectionView } from "./sessionUnread";
import {
deriveUnreadPresence,
EMPTY_UNREAD_PRESENCE,
hasUnreadSessions,
machineUnreadPresence,
projectUnreadPresence,
sameUnreadPresence,
unreadCwds,
workspaceUnreadPresence,
type UnreadPresenceInputs,
} from "./unreadPresence";
describe("hasUnreadSessions", () => {
it("treats an unavailable projection as no presence", () => {
expect(hasUnreadSessions(undefined)).toBe(false);
});
it("treats an empty projection as no presence", () => {
expect(hasUnreadSessions(projection())).toBe(false);
});
it("treats any summary as presence, even when the projection is stale", () => {
expect(hasUnreadSessions(projection([summary("session-1", "/repo")]))).toBe(true);
expect(hasUnreadSessions(projection([summary("session-1", "/repo")], "stale"))).toBe(true);
});
});
describe("unreadCwds", () => {
it("is empty without a projection or without sessions", () => {
expect([...unreadCwds(undefined)]).toEqual([]);
expect([...unreadCwds(projection())]).toEqual([]);
});
it("collects the distinct cwds of unread summaries", () => {
const cwds = unreadCwds(projection([
summary("session-1", "/repo", 2),
summary("session-2", "/repo", 1),
summary("session-3", "/other", 3),
]));
expect([...cwds].sort()).toEqual(["/other", "/repo"]);
});
});
describe("machineUnreadPresence", () => {
it("flags machines with any unread summary, including cwds mapped to no known workspace", () => {
const projections = new Map<string, SessionUnreadProjectionView | undefined>([
["local", projection([summary("session-1", "/unmapped")])],
["empty", projection()],
["unsupported", undefined],
]);
const present = machineUnreadPresence(["local", "empty", "unsupported"], (machineId) => projections.get(machineId));
expect([...present]).toEqual(["local"]);
});
it("only considers the listed machine ids", () => {
const present = machineUnreadPresence(["local"], () => projection([summary("session-1", "/repo")]));
expect([...present]).toEqual(["local"]);
});
});
describe("workspaceUnreadPresence", () => {
it("flags the workspace whose path exactly matches an unread cwd", () => {
const workspaces = [workspace("ws-1", "project-1", "/repo"), workspace("ws-2", "project-1", "/repo/branch")];
expect([...workspaceUnreadPresence(workspaces, new Set(["/repo/branch"]))]).toEqual(["ws-2"]);
});
it("flags nothing when no unread cwd maps to a workspace path", () => {
const workspaces = [workspace("ws-1", "project-1", "/repo")];
expect([...workspaceUnreadPresence(workspaces, new Set(["/unmapped"]))]).toEqual([]);
expect([...workspaceUnreadPresence(workspaces, new Set())]).toEqual([]);
});
});
describe("projectUnreadPresence", () => {
it("flags the project owning a workspace with an unread cwd", () => {
const projects = [project("project-1"), project("project-2")];
const workspacesByProjectId = {
"project-1": [workspace("ws-1", "project-1", "/repo")],
"project-2": [workspace("ws-2", "project-2", "/other")],
};
expect([...projectUnreadPresence(projects, workspacesByProjectId, new Set(["/other"]))]).toEqual(["project-2"]);
});
it("flags the project through its main workspace at the project path", () => {
const projects = [project("project-1", "/repo")];
const workspacesByProjectId = { "project-1": [workspace("ws-1", "project-1", "/repo")] };
expect([...projectUnreadPresence(projects, workspacesByProjectId, new Set(["/repo"]))]).toEqual(["project-1"]);
});
it("does not flag a project whose workspaces are not loaded", () => {
const projects = [project("project-1")];
expect([...projectUnreadPresence(projects, {}, new Set(["/repo"]))]).toEqual([]);
});
it("leaves a cwd under the project path but matching no known workspace to the machine dot only", () => {
const projects = [project("project-1", "/repo")];
const workspacesByProjectId = { "project-1": [workspace("ws-1", "project-1", "/repo/main")] };
expect([...projectUnreadPresence(projects, workspacesByProjectId, new Set(["/repo/unmapped-subdir"]))]).toEqual([]);
});
});
describe("deriveUnreadPresence", () => {
it("maps the selected machine's unread cwds to workspace and project presence", () => {
const inputs = presenceInputs({
projections: new Map([["local", projection([summary("session-1", "/repo")])]]),
});
const presence = deriveUnreadPresence(inputs);
expect([...presence.machines]).toEqual(["local"]);
expect([...presence.workspaces]).toEqual(["ws-1"]);
expect([...presence.projects]).toEqual(["project-1"]);
});
it("reflects background machines at machine level without leaking their cwds into workspace or project rows", () => {
const inputs = presenceInputs({
machineIds: ["local", "remote"],
projections: new Map([
["local", projection()],
["remote", projection([summary("session-1", "/repo")])],
]),
});
const presence = deriveUnreadPresence(inputs);
expect([...presence.machines]).toEqual(["remote"]);
expect([...presence.workspaces]).toEqual([]);
expect([...presence.projects]).toEqual([]);
});
it("is empty when no machine has a usable projection", () => {
const presence = deriveUnreadPresence(presenceInputs({ projections: new Map([["local", undefined]]) }));
expect(sameUnreadPresence(presence, EMPTY_UNREAD_PRESENCE)).toBe(true);
});
});
describe("sameUnreadPresence", () => {
it("compares presence by set contents", () => {
const left = { machines: new Set(["local"]), projects: new Set(["project-1"]), workspaces: new Set(["ws-1"]) };
const matching = { machines: new Set(["local"]), projects: new Set(["project-1"]), workspaces: new Set(["ws-1"]) };
const different = { machines: new Set(["remote"]), projects: new Set(["project-1"]), workspaces: new Set(["ws-1"]) };
expect(sameUnreadPresence(left, matching)).toBe(true);
expect(sameUnreadPresence(left, different)).toBe(false);
expect(sameUnreadPresence(EMPTY_UNREAD_PRESENCE, { machines: new Set(), projects: new Set(), workspaces: new Set() })).toBe(true);
});
});
function presenceInputs(options: {
machineIds?: string[];
projections: Map<string, SessionUnreadProjectionView | undefined>;
}): UnreadPresenceInputs {
return {
machineIds: options.machineIds ?? ["local"],
projectionFor: (machineId) => options.projections.get(machineId),
selectedMachineId: "local",
projects: [project("project-1", "/repo")],
workspaces: [workspace("ws-1", "project-1", "/repo")],
workspacesByProjectId: { "project-1": [workspace("ws-1", "project-1", "/repo")] },
};
}
function summary(sessionId: string, cwd: string, completionOrder = 1): SessionUnreadSummary {
return { sessionId, cwd, completionOrder, completedAt: "2026-07-20T00:00:00.000Z" };
}
function projection(summaries: SessionUnreadSummary[] = [], status: "fresh" | "stale" = "fresh"): SessionUnreadProjectionView {
return {
status,
catalogId: "catalog-a",
catalogRevision: summaries.reduce((revision, entry) => Math.max(revision, entry.completionOrder), 0),
sessions: summaries,
};
}
function workspace(id: string, projectId: string, path: string): Workspace {
return { id, projectId, path, label: id, isMain: false, isGitRepo: true, isGitWorktree: false };
}
function project(id: string, path = `/${id}`): Project {
return { id, name: id, path, createdAt: "2026-07-20T00:00:00.000Z" };
}
+106
View File
@@ -0,0 +1,106 @@
import type { Project, Workspace } from "./api";
import type { SessionUnreadProjectionView } from "./sessionUnread";
/**
* Bubble-up unread *presence* (booleans, never counts) derived from
* `SessionUnreadController` projections, following the charter mapping chain
* cwd → workspace → project → machine:
*
* - A machine has presence when its projection carries ANY unread summary,
* including cwds that map to no known workspace — the honest catch-all.
* - A workspace has presence when an unread cwd equals its path exactly.
* - A project has presence only when one of its known workspaces has presence;
* a cwd matching no known workspace lights the machine dot only, never a
* project or workspace row (even when it sits under the project path).
* - An undefined projection (unsupported/unknown capability or not yet
* loaded) yields no presence. Stale-but-present data still counts, the same
* tolerance `SessionUnreadController.unreadSessionIds` applies.
*
* Workspace/project presence can only be derived for the machine whose
* projects and workspaces are loaded — the selected one.
*/
export interface UnreadPresence {
readonly machines: ReadonlySet<string>;
readonly projects: ReadonlySet<string>;
readonly workspaces: ReadonlySet<string>;
}
/** Shared empty value; consumers must treat it as immutable. */
export const EMPTY_UNREAD_PRESENCE: UnreadPresence = {
machines: new Set(),
projects: new Set(),
workspaces: new Set(),
};
export interface UnreadPresenceInputs {
readonly machineIds: readonly string[];
readonly projectionFor: (machineId: string) => SessionUnreadProjectionView | undefined;
readonly selectedMachineId: string;
readonly projects: readonly Project[];
/** Visible workspace rows (the selected project's workspaces). */
readonly workspaces: readonly Workspace[];
readonly workspacesByProjectId: Record<string, readonly Workspace[]>;
}
export function deriveUnreadPresence(input: UnreadPresenceInputs): UnreadPresence {
const cwds = unreadCwds(input.projectionFor(input.selectedMachineId));
return {
machines: machineUnreadPresence(input.machineIds, input.projectionFor),
projects: projectUnreadPresence(input.projects, input.workspacesByProjectId, cwds),
workspaces: workspaceUnreadPresence(input.workspaces, cwds),
};
}
export function hasUnreadSessions(projection: Pick<SessionUnreadProjectionView, "sessions"> | undefined): boolean {
return projection !== undefined && projection.sessions.length > 0;
}
export function unreadCwds(projection: Pick<SessionUnreadProjectionView, "sessions"> | undefined): ReadonlySet<string> {
if (projection === undefined || projection.sessions.length === 0) return EMPTY_CWDS;
return new Set(projection.sessions.map((summary) => summary.cwd));
}
export function machineUnreadPresence(
machineIds: readonly string[],
projectionFor: (machineId: string) => SessionUnreadProjectionView | undefined,
): ReadonlySet<string> {
const present = new Set<string>();
for (const machineId of machineIds) {
if (hasUnreadSessions(projectionFor(machineId))) present.add(machineId);
}
return present;
}
export function workspaceUnreadPresence(workspaces: readonly Workspace[], cwds: ReadonlySet<string>): ReadonlySet<string> {
const present = new Set<string>();
for (const workspace of workspaces) {
if (cwds.has(workspace.path)) present.add(workspace.id);
}
return present;
}
export function projectUnreadPresence(
projects: readonly Project[],
workspacesByProjectId: Record<string, readonly Workspace[]>,
cwds: ReadonlySet<string>,
): ReadonlySet<string> {
const present = new Set<string>();
for (const project of projects) {
const workspaces = workspacesByProjectId[project.id] ?? [];
if (workspaces.some((workspace) => cwds.has(workspace.path))) present.add(project.id);
}
return present;
}
export function sameUnreadPresence(left: UnreadPresence, right: UnreadPresence): boolean {
return sameStringSet(left.machines, right.machines)
&& sameStringSet(left.projects, right.projects)
&& sameStringSet(left.workspaces, right.workspaces);
}
const EMPTY_CWDS: ReadonlySet<string> = new Set();
function sameStringSet(left: ReadonlySet<string>, right: ReadonlySet<string>): boolean {
return left.size === right.size && [...left].every((value) => right.has(value));
}