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 { 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";
|
||||
}
|
||||
|
||||
@@ -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