Archived
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:
@@ -27,6 +27,7 @@ import { sessionCleanupRequestKey, sessionCleanupUnavailableMessage } from "../s
|
|||||||
import { selectedNotificationView } from "../sessionNotifications";
|
import { selectedNotificationView } from "../sessionNotifications";
|
||||||
import { hasAuthoritativeSessionPersistence as runtimeHasAuthoritativeSessionPersistence } from "../sessionPersistence";
|
import { hasAuthoritativeSessionPersistence as runtimeHasAuthoritativeSessionPersistence } from "../sessionPersistence";
|
||||||
import { SessionUnreadController } from "../sessionUnread";
|
import { SessionUnreadController } from "../sessionUnread";
|
||||||
|
import { deriveUnreadPresence, EMPTY_UNREAD_PRESENCE, sameUnreadPresence, type UnreadPresence } from "../unreadPresence";
|
||||||
import { initialSessionWarningVisibilityState, reconcileSessionWarningVisibility, toggleSessionWarnings } from "../sessionWarningVisibility";
|
import { initialSessionWarningVisibilityState, reconcileSessionWarningVisibility, toggleSessionWarnings } from "../sessionWarningVisibility";
|
||||||
import { RealtimeSocket, type BrowserRealtimeEvent } from "../sessionSocket";
|
import { RealtimeSocket, type BrowserRealtimeEvent } from "../sessionSocket";
|
||||||
import type { PiWebPluginRegistration, PluginMachine, PluginPromptEditor, QualifiedContributionId, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspacePanelContribution, PluginRuntimeContext, TerminalCommandRunsInternalRuntime, WorkspaceFiles, WorkspaceHost, WorkspaceLabelContext, WorkspaceLabelItem, WorkspacePanelContext } from "../plugins/types";
|
import type { PiWebPluginRegistration, PluginMachine, PluginPromptEditor, QualifiedContributionId, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspacePanelContribution, PluginRuntimeContext, TerminalCommandRunsInternalRuntime, WorkspaceFiles, WorkspaceHost, WorkspaceLabelContext, WorkspaceLabelItem, WorkspacePanelContext } from "../plugins/types";
|
||||||
@@ -109,6 +110,7 @@ export class PiWebApp extends LitElement {
|
|||||||
|
|
||||||
private readonly sessionUnread = new SessionUnreadController({
|
private readonly sessionUnread = new SessionUnreadController({
|
||||||
onChange: (machineId) => {
|
onChange: (machineId) => {
|
||||||
|
this.syncUnreadPresence();
|
||||||
if (selectedMachineId(this.state) !== machineId) return;
|
if (selectedMachineId(this.state) !== machineId) return;
|
||||||
this.syncUnreadSessionIds();
|
this.syncUnreadSessionIds();
|
||||||
this.syncSelectedSessionReadState();
|
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 unreadSessionIds: ReadonlySet<string> = this.sessionUnread.unreadSessionIds(selectedMachineId(this.state), this.state.sessions);
|
||||||
|
@state() private unreadPresence: UnreadPresence = EMPTY_UNREAD_PRESENCE;
|
||||||
private unreadConnected = false;
|
private unreadConnected = false;
|
||||||
private committedChatIdentity: string | undefined;
|
private committedChatIdentity: string | undefined;
|
||||||
private readyChatIdentity: string | undefined;
|
private readyChatIdentity: string | undefined;
|
||||||
@@ -319,6 +322,18 @@ export class PiWebApp extends LitElement {
|
|||||||
if (!sameStringSet(next, this.unreadSessionIds)) this.unreadSessionIds = next;
|
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 {
|
private isSessionSeen(machineId: string, session: SessionInfo): boolean {
|
||||||
if (!this.unreadConnected) return false;
|
if (!this.unreadConnected) return false;
|
||||||
const identity = unreadChatIdentity(machineId, session);
|
const identity = unreadChatIdentity(machineId, session);
|
||||||
@@ -404,6 +419,7 @@ export class PiWebApp extends LitElement {
|
|||||||
}
|
}
|
||||||
if (machineUnreadInputsChanged(previous, this.state)) this.syncSessionUnreadMachines();
|
if (machineUnreadInputsChanged(previous, this.state)) this.syncSessionUnreadMachines();
|
||||||
this.syncUnreadSessionIds();
|
this.syncUnreadSessionIds();
|
||||||
|
this.syncUnreadPresence();
|
||||||
this.handleActivityTransition(previous, this.state);
|
this.handleActivityTransition(previous, this.state);
|
||||||
this.handleWorkspaceChange(previous, this.state);
|
this.handleWorkspaceChange(previous, this.state);
|
||||||
this.handleMachineChange(previous, this.state);
|
this.handleMachineChange(previous, this.state);
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import type { TemplateResult } from "lit";
|
import type { TemplateResult } from "lit";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
|
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 { initialAppState, type AppState } from "../appState";
|
||||||
import type { BrowserRealtimeEvent } from "../sessionSocket";
|
import type { BrowserRealtimeEvent } from "../sessionSocket";
|
||||||
|
import type { UnreadPresence } from "../unreadPresence";
|
||||||
import type { AppMobileMainTab } from "./appShell/AppMobileMainTabs";
|
import type { AppMobileMainTab } from "./appShell/AppMobileMainTabs";
|
||||||
// Template inspection is proportionate here because this node-environment test
|
// Template inspection is proportionate here because this node-environment test
|
||||||
// verifies only PiWebApp's unread-state property wiring into navigation.
|
// 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");
|
expect(requests).toContain("https://pi.example.test/api/machines/local/sessions/beta/unread/acknowledge");
|
||||||
await vi.waitFor(() => { expect(navigationUnreadSessionIds(app).size).toBe(0); });
|
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 RenderNavigationPanel = (this: PiWebApp) => TemplateResult;
|
||||||
type SetAppState = (this: PiWebApp, patch: Partial<AppState>) => void;
|
type SetAppState = (this: PiWebApp, patch: Partial<AppState>) => void;
|
||||||
type HandleRealtimeEvent = (this: PiWebApp, machineId: string, event: BrowserRealtimeEvent) => 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 MobileMainTabs = (this: PiWebApp) => AppMobileMainTab[];
|
||||||
type UpdatedHook = (this: PiWebApp) => void;
|
type UpdatedHook = (this: PiWebApp) => void;
|
||||||
type DisconnectedHook = (this: PiWebApp) => void;
|
type DisconnectedHook = (this: PiWebApp) => void;
|
||||||
@@ -335,13 +396,23 @@ function handleRealtimeEvent(app: PiWebApp, event: BrowserRealtimeEvent): void {
|
|||||||
method.call(app, "local", event);
|
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 {
|
function enableUnread(app: PiWebApp): void {
|
||||||
if (!Reflect.set(app, "unreadConnected", true)) throw new Error("Could not connect PiWebApp unread state");
|
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");
|
const controller: unknown = Reflect.get(app, "sessionUnread");
|
||||||
if (typeof controller !== "object" || controller === null) throw new Error("PiWebApp unread controller is unavailable");
|
if (typeof controller !== "object" || controller === null) throw new Error("PiWebApp unread controller is unavailable");
|
||||||
const setCapability: unknown = Reflect.get(controller, "setCapability");
|
const setCapability: unknown = Reflect.get(controller, "setCapability");
|
||||||
if (typeof setCapability !== "function") throw new Error("PiWebApp unread capability setter is unavailable");
|
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 {
|
function exposeSelectedChat(app: PiWebApp): void {
|
||||||
@@ -429,6 +500,37 @@ function navigationPanelValue(app: PiWebApp, marker: string): unknown {
|
|||||||
return templateValueAfterMarker(method.call(app), marker);
|
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 {
|
function session(id: string): SessionInfo {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -502,6 +604,10 @@ function isHandleRealtimeEvent(value: unknown): value is HandleRealtimeEvent {
|
|||||||
return typeof value === "function";
|
return typeof value === "function";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isHandleMachineActivityEvent(value: unknown): value is HandleMachineActivityEvent {
|
||||||
|
return typeof value === "function";
|
||||||
|
}
|
||||||
|
|
||||||
function isMobileMainTabs(value: unknown): value is MobileMainTabs {
|
function isMobileMainTabs(value: unknown): value is MobileMainTabs {
|
||||||
return typeof value === "function";
|
return typeof value === "function";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" };
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user