diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index b1d6394..4e4d01f 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -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 = 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); diff --git a/src/client/src/components/PiWebApp.unread.test.ts b/src/client/src/components/PiWebApp.unread.test.ts index 208aff1..e6a746e 100644 --- a/src/client/src/components/PiWebApp.unread.test.ts +++ b/src/client/src/components/PiWebApp.unread.test.ts @@ -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) => 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"; } diff --git a/src/client/src/unreadPresence.test.ts b/src/client/src/unreadPresence.test.ts new file mode 100644 index 0000000..4298da8 --- /dev/null +++ b/src/client/src/unreadPresence.test.ts @@ -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([ + ["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; +}): 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" }; +} diff --git a/src/client/src/unreadPresence.ts b/src/client/src/unreadPresence.ts new file mode 100644 index 0000000..20b48e5 --- /dev/null +++ b/src/client/src/unreadPresence.ts @@ -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; + readonly projects: ReadonlySet; + readonly workspaces: ReadonlySet; +} + +/** 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; +} + +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 | undefined): boolean { + return projection !== undefined && projection.sessions.length > 0; +} + +export function unreadCwds(projection: Pick | undefined): ReadonlySet { + 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 { + const present = new Set(); + for (const machineId of machineIds) { + if (hasUnreadSessions(projectionFor(machineId))) present.add(machineId); + } + return present; +} + +export function workspaceUnreadPresence(workspaces: readonly Workspace[], cwds: ReadonlySet): ReadonlySet { + const present = new Set(); + for (const workspace of workspaces) { + if (cwds.has(workspace.path)) present.add(workspace.id); + } + return present; +} + +export function projectUnreadPresence( + projects: readonly Project[], + workspacesByProjectId: Record, + cwds: ReadonlySet, +): ReadonlySet { + const present = new Set(); + 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 = new Set(); + +function sameStringSet(left: ReadonlySet, right: ReadonlySet): boolean { + return left.size === right.size && [...left].every((value) => right.has(value)); +}