Archived
Merge pull request #105 from jmfederico/feat/unread-ux
feat(ui): improve unread-messages experience
This commit is contained in:
@@ -1,6 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Machine } from "../api";
|
||||
import { canRemoveMachine } from "./MachineList";
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { Machine, MachineHealth, MachineStatus, WorkspaceActivity } from "../api";
|
||||
import { canRemoveMachine, MachineList } from "./MachineList";
|
||||
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
describe("canRemoveMachine", () => {
|
||||
it("only allows remote machines to be removed from the machine list", () => {
|
||||
@@ -9,6 +15,87 @@ describe("canRemoveMachine", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("machine unread indicator", () => {
|
||||
it("shows an unread dot only on machines tracked as unread, including offline ones", async () => {
|
||||
const list = await mountMachineList(
|
||||
[machine("local", "local"), machine("remote-a", "remote"), machine("remote-b", "remote")],
|
||||
new Set(["remote-a", "remote-b"]),
|
||||
{ "remote-b": machineHealth("remote-b", "offline") },
|
||||
);
|
||||
|
||||
expect(unreadDot(rowFor(list, "local"))).toBeNull();
|
||||
const remoteDot = unreadDot(rowFor(list, "remote-a"));
|
||||
expect(remoteDot).not.toBeNull();
|
||||
expect(remoteDot?.getAttribute("title")).toBe("Unread sessions on this machine");
|
||||
// Stale-but-present counts: an offline machine keeps its last-known unread state.
|
||||
expect(unreadDot(rowFor(list, "remote-b"))).not.toBeNull();
|
||||
});
|
||||
|
||||
it("clears the dot once the machine is no longer tracked as unread", async () => {
|
||||
const list = await mountMachineList([machine("local", "local")], new Set(["local"]));
|
||||
expect(list.shadowRoot?.querySelector(".activity-indicator.unread")).not.toBeNull();
|
||||
|
||||
list.unreadMachineIds = new Set();
|
||||
await list.updateComplete;
|
||||
|
||||
expect(list.shadowRoot?.querySelector(".activity-indicator.unread")).toBeNull();
|
||||
});
|
||||
|
||||
it("wraps the work dot in an unread ring when a machine is busy and unread", async () => {
|
||||
const list = await mountMachineList(
|
||||
[machine("local", "local"), machine("remote-a", "remote")],
|
||||
new Set(["local", "remote-a"]),
|
||||
{},
|
||||
{
|
||||
local: { "/repo": workspaceActivity("/repo", true, false) },
|
||||
"remote-a": { "/repo": workspaceActivity("/repo", false, true) },
|
||||
},
|
||||
);
|
||||
|
||||
const localRing = rowFor(list, "local").querySelector(".unread-ring");
|
||||
expect(localRing?.querySelector(".activity-indicator.session")).not.toBeNull();
|
||||
expect(localRing?.getAttribute("title")).toBe("Unread sessions on this machine · Machine active");
|
||||
|
||||
const remoteRing = rowFor(list, "remote-a").querySelector(".unread-ring");
|
||||
expect(remoteRing?.querySelector(".activity-indicator.terminal")).not.toBeNull();
|
||||
expect(remoteRing?.getAttribute("title")).toBe("Unread sessions on this machine · Machine terminal active");
|
||||
|
||||
// One mark per row: the ring replaces the standalone unread dot.
|
||||
expect(rowFor(list, "local").querySelector(".activity-indicator.unread")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
async function mountMachineList(
|
||||
machines: Machine[],
|
||||
unreadMachineIds: ReadonlySet<string>,
|
||||
statuses: Record<string, MachineHealth> = {},
|
||||
activities: Record<string, Record<string, WorkspaceActivity>> = {},
|
||||
): Promise<MachineList> {
|
||||
const list = new MachineList();
|
||||
list.machines = machines;
|
||||
list.unreadMachineIds = unreadMachineIds;
|
||||
list.statuses = statuses;
|
||||
list.activities = activities;
|
||||
document.body.append(list);
|
||||
await list.updateComplete;
|
||||
return list;
|
||||
}
|
||||
|
||||
function rowFor(list: MachineList, machineName: string): Element {
|
||||
const rows = [...(list.shadowRoot?.querySelectorAll(".machine-row") ?? [])];
|
||||
const row = rows.find((candidate) => candidate.textContent.includes(machineName));
|
||||
if (row === undefined) throw new Error(`Expected a machine row for ${machineName}`);
|
||||
return row;
|
||||
}
|
||||
|
||||
function unreadDot(row: Element): Element | null {
|
||||
return row.querySelector(".activity-indicator.unread");
|
||||
}
|
||||
|
||||
function workspaceActivity(cwd: string, hasSessionActivity: boolean, hasTerminalActivity: boolean): WorkspaceActivity {
|
||||
return { cwd, hasSessionActivity, hasTerminalActivity, updatedAt: "2026-06-04T00:00:00.000Z" };
|
||||
}
|
||||
|
||||
function machine(id: string, kind: Machine["kind"]): Machine {
|
||||
return {
|
||||
id,
|
||||
@@ -18,3 +105,7 @@ function machine(id: string, kind: Machine["kind"]): Machine {
|
||||
updatedAt: "2026-06-04T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
function machineHealth(machineId: string, status: MachineStatus): MachineHealth {
|
||||
return { machineId, ok: status === "online", checkedAt: "2026-06-04T00:00:00.000Z", status };
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ export class MachineList extends LitElement implements KeyboardNavigableSection
|
||||
@property({ attribute: false }) selected?: Machine;
|
||||
@property({ attribute: false }) statuses: Record<string, MachineHealth> = {};
|
||||
@property({ attribute: false }) activities: Record<string, Record<string, WorkspaceActivity>> = {};
|
||||
@property({ attribute: false }) unreadMachineIds: ReadonlySet<string> = new Set();
|
||||
@property({ type: Boolean, reflect: true }) collapsible = false;
|
||||
@property({ type: Boolean, reflect: true }) collapsed = false;
|
||||
@property({ attribute: false }) onSelect?: (machine: Machine) => void;
|
||||
@@ -85,9 +86,11 @@ export class MachineList extends LitElement implements KeyboardNavigableSection
|
||||
|
||||
private renderActivity(machine: Machine) {
|
||||
const status = this.statuses[machine.id]?.status ?? machine.status;
|
||||
if (status === "offline" || status === "error") return undefined;
|
||||
const kind = machineActivityIndicator(this.activities[machine.id]);
|
||||
return renderActionActivityIndicator(kind, kind === "terminal" ? "Machine terminal active" : "Machine active");
|
||||
// Unread survives offline: an offline machine keeps its last-known unread
|
||||
// state (stale-but-present still counts), so only the work dot is gated.
|
||||
const kind = status === "offline" || status === "error" ? undefined : machineActivityIndicator(this.activities[machine.id]);
|
||||
const unreadLabel = this.unreadMachineIds.has(machine.id) ? "Unread sessions on this machine" : undefined;
|
||||
return renderActionActivityIndicator(kind, kind === "terminal" ? "Machine terminal active" : "Machine active", unreadLabel);
|
||||
}
|
||||
|
||||
private renderMachineMenu(machine: Machine) {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { Machine, WorkspaceActivity } from "../api";
|
||||
import { MachineSwitcher } from "./MachineSwitcher";
|
||||
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
describe("machine-switcher unread indicator", () => {
|
||||
it("shows an unread dot on the switcher button while the selected machine has unread sessions", async () => {
|
||||
const switcher = await mountSwitcher([machine("local", "local")], new Set(["local"]));
|
||||
const button = switcherButton(switcher);
|
||||
const dot = button.querySelector(".activity-indicator.unread");
|
||||
expect(dot).not.toBeNull();
|
||||
expect(dot?.getAttribute("title")).toBe("Unread sessions on this machine");
|
||||
|
||||
switcher.unreadMachineIds = new Set();
|
||||
await switcher.updateComplete;
|
||||
|
||||
expect(switcherButton(switcher).querySelector(".activity-indicator.unread")).toBeNull();
|
||||
});
|
||||
|
||||
it("marks only the unread machines among the dropdown options", async () => {
|
||||
const switcher = await mountSwitcher(
|
||||
[machine("local", "local"), machine("remote-a", "remote"), machine("remote-b", "remote")],
|
||||
new Set(["remote-b"]),
|
||||
);
|
||||
|
||||
switcherButton(switcher).click();
|
||||
await switcher.updateComplete;
|
||||
|
||||
expect(unreadDot(optionFor(switcher, "local"))).toBeNull();
|
||||
expect(unreadDot(optionFor(switcher, "remote-a"))).toBeNull();
|
||||
expect(unreadDot(optionFor(switcher, "remote-b"))).not.toBeNull();
|
||||
});
|
||||
|
||||
it("wraps the work dot in an unread ring when the machine is busy and unread", async () => {
|
||||
const switcher = await mountSwitcher([machine("local", "local")], new Set(["local"]));
|
||||
switcher.activities = { local: { "/repo": workspaceActivity("/repo", true, false) } };
|
||||
await switcher.updateComplete;
|
||||
|
||||
const button = switcherButton(switcher);
|
||||
const ring = button.querySelector(".unread-ring");
|
||||
expect(ring?.querySelector(".activity-indicator.session")).not.toBeNull();
|
||||
expect(ring?.getAttribute("title")).toBe("Unread sessions on this machine · Machine active");
|
||||
// One mark only: the ring replaces the standalone unread dot.
|
||||
expect(button.querySelector(".activity-indicator.unread")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
async function mountSwitcher(machines: Machine[], unreadMachineIds: ReadonlySet<string>): Promise<MachineSwitcher> {
|
||||
const switcher = new MachineSwitcher();
|
||||
switcher.machines = machines;
|
||||
const selected = machines[0];
|
||||
if (selected === undefined) throw new Error("Expected at least one machine");
|
||||
switcher.selected = selected;
|
||||
switcher.unreadMachineIds = unreadMachineIds;
|
||||
document.body.append(switcher);
|
||||
await switcher.updateComplete;
|
||||
return switcher;
|
||||
}
|
||||
|
||||
function switcherButton(switcher: MachineSwitcher): HTMLElement {
|
||||
const button = switcher.shadowRoot?.querySelector(".machine-switcher-button");
|
||||
if (!(button instanceof HTMLElement)) throw new Error("Expected the machine switcher button");
|
||||
return button;
|
||||
}
|
||||
|
||||
function optionFor(switcher: MachineSwitcher, machineName: string): Element {
|
||||
const options = [...(switcher.shadowRoot?.querySelectorAll(".machine-option") ?? [])];
|
||||
const option = options.find((candidate) => candidate.textContent.includes(machineName));
|
||||
if (option === undefined) throw new Error(`Expected a machine option for ${machineName}`);
|
||||
return option;
|
||||
}
|
||||
|
||||
function unreadDot(option: Element): Element | null {
|
||||
return option.querySelector(".activity-indicator.unread");
|
||||
}
|
||||
|
||||
function workspaceActivity(cwd: string, hasSessionActivity: boolean, hasTerminalActivity: boolean): WorkspaceActivity {
|
||||
return { cwd, hasSessionActivity, hasTerminalActivity, updatedAt: "2026-06-04T00:00:00.000Z" };
|
||||
}
|
||||
|
||||
function machine(id: string, kind: Machine["kind"]): Machine {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
kind,
|
||||
createdAt: "2026-06-04T00:00:00.000Z",
|
||||
updatedAt: "2026-06-04T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
@@ -13,6 +13,7 @@ export class MachineSwitcher extends LitElement implements KeyboardNavigableSect
|
||||
@property({ attribute: false }) selected?: Machine;
|
||||
@property({ attribute: false }) statuses: Record<string, MachineHealth> = {};
|
||||
@property({ attribute: false }) activities: Record<string, Record<string, WorkspaceActivity>> = {};
|
||||
@property({ attribute: false }) unreadMachineIds: ReadonlySet<string> = new Set();
|
||||
@property({ attribute: false }) onSelect?: (machine: Machine) => void | Promise<void>;
|
||||
@property({ attribute: false }) onRemove?: (machine: Machine) => void | Promise<void>;
|
||||
@property({ attribute: false }) onFocusNextSection?: () => void | Promise<void>;
|
||||
@@ -123,9 +124,11 @@ export class MachineSwitcher extends LitElement implements KeyboardNavigableSect
|
||||
|
||||
private renderActivity(machine: Machine): TemplateResult | undefined {
|
||||
const status = machineStatus(machine, this.statuses);
|
||||
if (status === "offline" || status === "error") return undefined;
|
||||
const kind = machineActivityIndicator(this.activities[machine.id]);
|
||||
return renderActivityIndicator(kind, kind === "terminal" ? "Machine terminal active" : "Machine active");
|
||||
// Unread survives offline: an offline machine keeps its last-known unread
|
||||
// state (stale-but-present still counts), so only the work dot is gated.
|
||||
const kind = status === "offline" || status === "error" ? undefined : machineActivityIndicator(this.activities[machine.id]);
|
||||
const unreadLabel = this.unreadMachineIds.has(machine.id) ? "Unread sessions on this machine" : undefined;
|
||||
return renderActivityIndicator(kind, kind === "terminal" ? "Machine terminal active" : "Machine active", unreadLabel);
|
||||
}
|
||||
|
||||
private selectedMachine(): Machine | undefined {
|
||||
@@ -287,6 +290,9 @@ export class MachineSwitcher extends LitElement implements KeyboardNavigableSect
|
||||
.activity-indicator.session { border-radius: 50%; background: var(--pi-success); }
|
||||
.activity-indicator.terminal { border-radius: 2px; background: var(--pi-accent); }
|
||||
.activity-indicator.sending { border-radius: 50%; background: var(--pi-warning); }
|
||||
.activity-indicator.unread { border-radius: 50%; background: var(--pi-accent); animation: none; box-shadow: 0 0 0 2px color-mix(in srgb, var(--pi-accent) 20%, transparent); }
|
||||
.unread-ring { flex: 0 0 auto; box-sizing: border-box; display: inline-grid; place-items: center; width: 9px; height: 9px; border: 1.5px solid var(--pi-accent); border-radius: 50%; }
|
||||
.unread-ring .activity-indicator { width: 5px; height: 5px; }
|
||||
.machine-switcher-menu { position: fixed; z-index: 10000; box-sizing: border-box; min-width: min(280px, calc(100vw - 16px)); overflow: auto; padding: 4px; border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); box-shadow: 0 8px 24px var(--pi-shadow); }
|
||||
.machine-option { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 2px; align-items: stretch; margin: 2px 0; }
|
||||
.machine-option.no-actions { grid-template-columns: minmax(0, 1fr); }
|
||||
|
||||
@@ -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;
|
||||
@@ -301,6 +304,11 @@ export class PiWebApp extends LitElement {
|
||||
void this.sessionUnread.acknowledge(machineId, session);
|
||||
}
|
||||
|
||||
private markSessionsRead(sessions: readonly SessionInfo[]): void {
|
||||
const machineId = selectedMachineId(this.state);
|
||||
for (const session of sessions) void this.sessionUnread.acknowledge(machineId, session);
|
||||
}
|
||||
|
||||
private async commitReadyChatAfterRender(machineId: string, session: SessionInfo): Promise<void> {
|
||||
const identity = unreadChatIdentity(machineId, session);
|
||||
await this.updateComplete;
|
||||
@@ -314,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);
|
||||
@@ -399,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);
|
||||
@@ -1321,6 +1342,7 @@ export class PiWebApp extends LitElement {
|
||||
.sessionActivities=${this.state.sessionActivities}
|
||||
.sendingPrompts=${this.state.sendingPrompts}
|
||||
.unreadSessionIds=${this.unreadSessionIds}
|
||||
.unreadPresence=${this.unreadPresence}
|
||||
.selectedSession=${this.state.selectedSession}
|
||||
.startingSessionCount=${this.state.startingSessionCount}
|
||||
.canStartSession=${!!this.state.selectedWorkspace}
|
||||
@@ -1348,6 +1370,8 @@ export class PiWebApp extends LitElement {
|
||||
.onArchivedCollapsed=${() => { this.sessions.clearSelectionAfterArchivedCollapse(); }}
|
||||
.onStartSession=${() => this.startSessionFromNavigation()}
|
||||
.onSelectSession=${(session: SessionInfo) => this.selectNavigationItem("sessions", "chat", () => this.sessions.selectSession(session))}
|
||||
.onMarkSessionRead=${(session: SessionInfo) => { this.markSessionsRead([session]); }}
|
||||
.onMarkSessionsRead=${(sessions: SessionInfo[]) => { this.markSessionsRead(sessions); }}
|
||||
.onArchiveSession=${(session: SessionInfo) => this.sessions.archiveSession(session)}
|
||||
.onArchiveSessionWithDescendants=${(session: SessionInfo) => this.sessions.archiveSessionWithDescendants(session)}
|
||||
.onArchiveSessions=${(sessions: SessionInfo[]) => this.sessions.archiveSessions(sessions)}
|
||||
@@ -2169,11 +2193,7 @@ export class PiWebApp extends LitElement {
|
||||
}
|
||||
|
||||
private mobileMainTabs(): AppMobileMainTab[] {
|
||||
const unreadCount = unreadSessionCount(this.state.sessions, this.unreadSessionIds, {
|
||||
statuses: this.state.sessionStatuses,
|
||||
activities: this.state.sessionActivities,
|
||||
sending: this.state.sendingPrompts,
|
||||
});
|
||||
const unreadCount = unreadSessionCount(this.state.sessions, this.unreadSessionIds);
|
||||
return [
|
||||
{
|
||||
id: "navigation",
|
||||
|
||||
@@ -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.
|
||||
@@ -231,16 +232,146 @@ describe("PiWebApp session unread wiring", () => {
|
||||
|
||||
expect(navigationUnreadSessionIds(app).size).toBe(0);
|
||||
});
|
||||
|
||||
it("acknowledges a session explicitly marked as read from navigation", async () => {
|
||||
const fetchMock = stubJsonFetch({ catalogId: "catalog-a", catalogRevision: 2, sessions: [] });
|
||||
const app = createApp();
|
||||
enableUnread(app);
|
||||
const selected = session("selected");
|
||||
const alpha = session("alpha");
|
||||
setAppState(app, { ...initialAppState(), sessions: [selected, alpha], selectedSession: selected, mainView: "chat" });
|
||||
|
||||
handleRealtimeEvent(app, unreadEvent(1, unreadSummary(alpha, 1)));
|
||||
expect([...navigationUnreadSessionIds(app)]).toEqual([alpha.id]);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
navigationMarkSessionRead(app)(alpha);
|
||||
await vi.waitFor(() => { expect(fetchMock).toHaveBeenCalledOnce(); });
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe("https://pi.example.test/api/machines/local/sessions/alpha/unread/acknowledge");
|
||||
const init = fetchMock.mock.calls[0]?.[1];
|
||||
expect(JSON.parse(typeof init?.body === "string" ? init.body : "{}")).toEqual({
|
||||
cwd: "/repo",
|
||||
catalogId: "catalog-a",
|
||||
throughCompletionOrder: 1,
|
||||
});
|
||||
await vi.waitFor(() => { expect(navigationUnreadSessionIds(app).size).toBe(0); });
|
||||
});
|
||||
|
||||
it("acknowledges every session in a bulk mark-read request from navigation", async () => {
|
||||
const fetchMock = stubJsonFetch({ catalogId: "catalog-a", catalogRevision: 2, sessions: [] });
|
||||
const app = createApp();
|
||||
enableUnread(app);
|
||||
const selected = session("selected");
|
||||
const alpha = session("alpha");
|
||||
const beta = session("beta");
|
||||
setAppState(app, { ...initialAppState(), sessions: [selected, alpha, beta], selectedSession: selected, mainView: "chat" });
|
||||
|
||||
handleRealtimeEvent(app, unreadEvent(1, unreadSummary(alpha, 1)));
|
||||
handleRealtimeEvent(app, unreadEvent(2, unreadSummary(beta, 2)));
|
||||
expect([...navigationUnreadSessionIds(app)]).toEqual([alpha.id, beta.id]);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
navigationMarkSessionsRead(app)([alpha, beta]);
|
||||
await vi.waitFor(() => { expect(fetchMock).toHaveBeenCalledTimes(2); });
|
||||
const requests = fetchMock.mock.calls.map((call) => call[0]);
|
||||
expect(requests).toContain("https://pi.example.test/api/machines/local/sessions/alpha/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); });
|
||||
});
|
||||
|
||||
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"]);
|
||||
});
|
||||
|
||||
it("binds the derived unread presence into the navigation panel", () => {
|
||||
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)));
|
||||
|
||||
const bound = navigationPanelValue(app, ".unreadPresence=");
|
||||
if (!isUnreadPresence(bound)) throw new Error("Expected unread presence in navigation");
|
||||
expect(bound).toBe(unreadPresence(app));
|
||||
expect([...bound.machines]).toEqual(["local"]);
|
||||
});
|
||||
});
|
||||
|
||||
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;
|
||||
type RenegotiateUnreadMachine = (this: PiWebApp, machineId: string) => Promise<void>;
|
||||
type RefreshUnread = (machineId: string) => Promise<void>;
|
||||
type MarkSessionRead = (session: SessionInfo) => void;
|
||||
type MarkSessionsRead = (sessions: SessionInfo[]) => void;
|
||||
|
||||
function createApp(storedValues: Record<string, string> = {}, mobileNavigation = false): PiWebApp {
|
||||
const values = new Map(Object.entries(storedValues));
|
||||
@@ -287,13 +418,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 {
|
||||
@@ -356,15 +497,62 @@ function mobileNavigationTab(app: PiWebApp): AppMobileMainTab {
|
||||
}
|
||||
|
||||
function navigationUnreadSessionIds(app: PiWebApp): ReadonlySet<string> {
|
||||
const method: unknown = Reflect.get(app, "renderNavigationPanel");
|
||||
if (!isRenderNavigationPanel(method)) throw new Error("PiWebApp.renderNavigationPanel is not callable");
|
||||
const value = templateValueAfterMarker(method.call(app), ".unreadSessionIds=");
|
||||
const value = navigationPanelValue(app, ".unreadSessionIds=");
|
||||
if (!(value instanceof Set) || ![...value].every((entry: unknown) => typeof entry === "string")) {
|
||||
throw new Error("Expected unread session ids in navigation");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function navigationMarkSessionRead(app: PiWebApp): MarkSessionRead {
|
||||
const value = navigationPanelValue(app, ".onMarkSessionRead=");
|
||||
if (!isMarkSessionRead(value)) throw new Error("Expected mark-session-read callback in navigation");
|
||||
return value;
|
||||
}
|
||||
|
||||
function navigationMarkSessionsRead(app: PiWebApp): MarkSessionsRead {
|
||||
const value = navigationPanelValue(app, ".onMarkSessionsRead=");
|
||||
if (!isMarkSessionsRead(value)) throw new Error("Expected mark-sessions-read callback in navigation");
|
||||
return value;
|
||||
}
|
||||
|
||||
function navigationPanelValue(app: PiWebApp, marker: string): unknown {
|
||||
const method: unknown = Reflect.get(app, "renderNavigationPanel");
|
||||
if (!isRenderNavigationPanel(method)) throw new Error("PiWebApp.renderNavigationPanel is not callable");
|
||||
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,
|
||||
@@ -438,6 +626,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";
|
||||
}
|
||||
@@ -457,3 +649,11 @@ function isRenegotiateUnreadMachine(value: unknown): value is RenegotiateUnreadM
|
||||
function isRefreshUnread(value: unknown): value is RefreshUnread {
|
||||
return typeof value === "function";
|
||||
}
|
||||
|
||||
function isMarkSessionRead(value: unknown): value is MarkSessionRead {
|
||||
return typeof value === "function";
|
||||
}
|
||||
|
||||
function isMarkSessionsRead(value: unknown): value is MarkSessionsRead {
|
||||
return typeof value === "function";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { Project, WorkspaceActivity } from "../api";
|
||||
import { ProjectList } from "./ProjectList";
|
||||
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
describe("project unread indicator", () => {
|
||||
it("shows an unread dot only on projects tracked as unread", async () => {
|
||||
const list = await mountProjectList([project("project-a"), project("project-b")], new Set(["project-b"]));
|
||||
|
||||
expect(unreadDot(rowFor(list, "project-a"))).toBeNull();
|
||||
const dot = unreadDot(rowFor(list, "project-b"));
|
||||
expect(dot).not.toBeNull();
|
||||
expect(dot?.getAttribute("title")).toBe("Unread sessions in this project");
|
||||
});
|
||||
|
||||
it("clears the dot once the project is no longer tracked as unread", async () => {
|
||||
const list = await mountProjectList([project("project-a")], new Set(["project-a"]));
|
||||
expect(list.shadowRoot?.querySelector(".activity-indicator.unread")).not.toBeNull();
|
||||
|
||||
list.unreadProjectIds = new Set();
|
||||
await list.updateComplete;
|
||||
|
||||
expect(list.shadowRoot?.querySelector(".activity-indicator.unread")).toBeNull();
|
||||
});
|
||||
|
||||
it("wraps the work dot in an unread ring when the project is busy and unread", async () => {
|
||||
const list = await mountProjectList([project("project-a")], new Set(["project-a"]));
|
||||
list.activities = { "/repo/project-a": workspaceActivity("/repo/project-a", true, false) };
|
||||
await list.updateComplete;
|
||||
|
||||
const row = rowFor(list, "project-a");
|
||||
const ring = row.querySelector(".unread-ring");
|
||||
expect(ring?.querySelector(".activity-indicator.session")).not.toBeNull();
|
||||
expect(ring?.getAttribute("title")).toBe("Unread sessions in this project · Project active");
|
||||
expect(row.querySelector(".activity-indicator.unread")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
async function mountProjectList(projects: Project[], unreadProjectIds: ReadonlySet<string>): Promise<ProjectList> {
|
||||
const list = new ProjectList();
|
||||
list.projects = projects;
|
||||
list.unreadProjectIds = unreadProjectIds;
|
||||
document.body.append(list);
|
||||
await list.updateComplete;
|
||||
return list;
|
||||
}
|
||||
|
||||
function rowFor(list: ProjectList, projectName: string): Element {
|
||||
const rows = [...(list.shadowRoot?.querySelectorAll(".action-row") ?? [])];
|
||||
const row = rows.find((candidate) => candidate.textContent.includes(projectName));
|
||||
if (row === undefined) throw new Error(`Expected a project row for ${projectName}`);
|
||||
return row;
|
||||
}
|
||||
|
||||
function unreadDot(row: Element): Element | null {
|
||||
return row.querySelector(".activity-indicator.unread");
|
||||
}
|
||||
|
||||
function workspaceActivity(cwd: string, hasSessionActivity: boolean, hasTerminalActivity: boolean): WorkspaceActivity {
|
||||
return { cwd, hasSessionActivity, hasTerminalActivity, updatedAt: "2026-06-04T00:00:00.000Z" };
|
||||
}
|
||||
|
||||
function project(id: string): Project {
|
||||
return { id, name: id, path: `/repo/${id}`, createdAt: "2026-06-04T00:00:00.000Z" };
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export class ProjectList extends LitElement implements KeyboardNavigableSection
|
||||
@property({ attribute: false }) selected?: Project;
|
||||
@property({ attribute: false }) activities: Record<string, WorkspaceActivity> = {};
|
||||
@property({ attribute: false }) workspacesByProjectId: Record<string, Workspace[]> = {};
|
||||
@property({ attribute: false }) unreadProjectIds: ReadonlySet<string> = new Set();
|
||||
@property({ type: Boolean, reflect: true }) collapsible = false;
|
||||
@property({ type: Boolean, reflect: true }) collapsed = false;
|
||||
@property({ attribute: false }) onSelect?: (project: Project) => void;
|
||||
@@ -101,7 +102,8 @@ export class ProjectList extends LitElement implements KeyboardNavigableSection
|
||||
|
||||
private renderActivity(project: Project) {
|
||||
const kind = projectActivityIndicator(project, this.workspacesByProjectId[project.id] ?? [], this.activities);
|
||||
return renderActionActivityIndicator(kind, kind === "terminal" ? "Project terminal active" : "Project active");
|
||||
const unreadLabel = this.unreadProjectIds.has(project.id) ? "Unread sessions in this project" : undefined;
|
||||
return renderActionActivityIndicator(kind, kind === "terminal" ? "Project terminal active" : "Project active", unreadLabel);
|
||||
}
|
||||
|
||||
private toggleMenu(projectId: string, target: EventTarget | null) {
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { SessionInfo, SessionStatus } from "../api";
|
||||
import { markCachedNewSessionInfo } from "../cachedNewSessions";
|
||||
import { isArchivableSessionInfo, isTransientNewSessionInfo } from "../sessionPersistence";
|
||||
import { sessionRowActivityKind, sessionRowsForCurrentTree, unreadSessionCount } from "./SessionList";
|
||||
// Vitest runs in the node environment with no DOM, so menu/bulk-bar wiring is
|
||||
// verified through the shared TemplateResult inspection escape hatch: handler
|
||||
// lookups stay anchored to the buttons' own user-facing text.
|
||||
import {
|
||||
findOptionalTemplateClickHandlerForText,
|
||||
isTemplateEventHandler,
|
||||
isTemplateResult,
|
||||
templateClickHandlerForText,
|
||||
templateStrings,
|
||||
templateValues,
|
||||
type TemplateEventHandler,
|
||||
} from "../templateInspection.testSupport";
|
||||
import { SessionList, sessionRowActivityKind, sessionRowsForCurrentTree, sessionRowUnread, unreadSessionCount } from "./SessionList";
|
||||
|
||||
describe("sessionRowActivityKind", () => {
|
||||
const idle = sessionStatus("s");
|
||||
@@ -24,33 +37,36 @@ describe("sessionRowActivityKind", () => {
|
||||
expect(sessionRowActivityKind(session("s"), idle, { sessionId: "s", phase: "active", label: "running tool", at: "now" }, false)).toBe("session");
|
||||
});
|
||||
|
||||
it("reports unread only while the session is idle", () => {
|
||||
expect(sessionRowActivityKind(session("s"), idle, undefined, false, true)).toBe("unread");
|
||||
expect(sessionRowActivityKind(session("s"), { ...idle, isStreaming: true }, undefined, false, true)).toBe("session");
|
||||
expect(sessionRowActivityKind(session("s"), idle, undefined, true, true)).toBe("sending");
|
||||
});
|
||||
|
||||
it("reports undefined when idle, read, and not sending", () => {
|
||||
it("reports undefined when idle and not sending, even for an unread session", () => {
|
||||
expect(sessionRowActivityKind(session("s"), idle, undefined, false)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("never shows an indicator for archived or cached-new sessions, even while sending or unread", () => {
|
||||
expect(sessionRowActivityKind({ ...session("s"), archived: true }, idle, undefined, true, true)).toBeUndefined();
|
||||
expect(sessionRowActivityKind(markCachedNewSessionInfo(session("s")), idle, undefined, true, true)).toBeUndefined();
|
||||
it("never shows an indicator for archived or cached-new sessions, even while sending", () => {
|
||||
expect(sessionRowActivityKind({ ...session("s"), archived: true }, idle, undefined, true)).toBeUndefined();
|
||||
expect(sessionRowActivityKind(markCachedNewSessionInfo(session("s")), idle, undefined, true)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sessionRowUnread", () => {
|
||||
it("flags tracked current sessions regardless of activity state", () => {
|
||||
expect(sessionRowUnread(session("s"), new Set(["s"]))).toBe(true);
|
||||
expect(sessionRowUnread(session("s"), new Set())).toBe(false);
|
||||
});
|
||||
|
||||
it("never flags archived or cached-new sessions, even when tracked as unread", () => {
|
||||
expect(sessionRowUnread({ ...session("s"), archived: true }, new Set(["s"]))).toBe(false);
|
||||
expect(sessionRowUnread(markCachedNewSessionInfo(session("s")), new Set(["s"]))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unreadSessionCount", () => {
|
||||
it("counts only current persisted sessions", () => {
|
||||
it("counts only current persisted sessions, including busy ones", () => {
|
||||
const current = session("current");
|
||||
const archived = { ...session("archived"), archived: true, archivedAt: "2026-06-09T00:00:00.000Z" };
|
||||
const cached = markCachedNewSessionInfo(session("cached"));
|
||||
|
||||
const unreadIds = new Set([current.id, archived.id, cached.id]);
|
||||
expect(unreadSessionCount([current, archived, cached], unreadIds)).toBe(1);
|
||||
expect(unreadSessionCount([current, archived, cached], unreadIds, {
|
||||
statuses: { [current.id]: sessionStatus(current.id, { isStreaming: true }) },
|
||||
})).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -88,6 +104,60 @@ describe("session action eligibility", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("mark-as-read actions", () => {
|
||||
it("offers Mark as read in the menu of an unread current session and forwards it", () => {
|
||||
const unread = session("unread");
|
||||
const list = sessionList([unread, session("read")], new Set([unread.id]));
|
||||
const onMarkRead = vi.fn<(session: SessionInfo) => void>();
|
||||
list.onMarkRead = onMarkRead;
|
||||
|
||||
openSessionMenu(list, unread.id);
|
||||
templateClickHandlerForText(renderList(list), "Mark as read")(new Event("click"));
|
||||
|
||||
expect(onMarkRead).toHaveBeenCalledWith(unread);
|
||||
expect(componentState(list, "openMenuSessionId")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("hides Mark as read for read, transient, and archived sessions even when tracked as unread", () => {
|
||||
const read = session("read");
|
||||
const cached = markCachedNewSessionInfo(session("cached"));
|
||||
const archived = { ...session("archived"), archived: true, archivedAt: "2026-06-09T00:00:00.000Z" };
|
||||
const list = sessionList([read, cached, archived], new Set([cached.id, archived.id]));
|
||||
|
||||
openSessionMenu(list, read.id);
|
||||
expect(findOptionalTemplateClickHandlerForText(renderList(list), "Mark as read")).toBeUndefined();
|
||||
|
||||
openSessionMenu(list, cached.id);
|
||||
expect(findOptionalTemplateClickHandlerForText(renderList(list), "Mark as read")).toBeUndefined();
|
||||
|
||||
setComponentState(list, "archivedExpanded", true);
|
||||
openSessionMenu(list, archived.id);
|
||||
expect(findOptionalTemplateClickHandlerForText(renderList(list), "Mark as read")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("enables bulk Mark read only when a selected session is unread and forwards only the unread selection", () => {
|
||||
const unreadA = session("unread-a");
|
||||
const readB = session("read-b");
|
||||
const unreadC = session("unread-c");
|
||||
const list = sessionList([unreadA, readB, unreadC], new Set([unreadA.id, unreadC.id]));
|
||||
const onMarkReadMany = vi.fn<(sessions: SessionInfo[]) => void>();
|
||||
list.onMarkReadMany = onMarkReadMany;
|
||||
setComponentState(list, "selectionScopes", new Set(["current"]));
|
||||
|
||||
setComponentState(list, "selectedSessionIds", new Set([readB.id]));
|
||||
const disabledButton = markReadButton(renderList(list));
|
||||
expect(disabledButton.disabled).toBe(true);
|
||||
disabledButton.click(new Event("click"));
|
||||
expect(onMarkReadMany).not.toHaveBeenCalled();
|
||||
|
||||
setComponentState(list, "selectedSessionIds", new Set([unreadA.id, readB.id, unreadC.id]));
|
||||
const enabledButton = markReadButton(renderList(list));
|
||||
expect(enabledButton.disabled).toBe(false);
|
||||
enabledButton.click(new Event("click"));
|
||||
expect(onMarkReadMany).toHaveBeenCalledWith([unreadA, unreadC]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sessionRowsForCurrentTree", () => {
|
||||
it("keeps archived ancestors visible while they have unarchived descendants", () => {
|
||||
const parent = { ...session("parent"), archived: true, archivedAt: "2026-06-09T00:00:00.000Z" };
|
||||
@@ -121,6 +191,70 @@ function rowSummaries(rows: ReturnType<typeof sessionRowsForCurrentTree>) {
|
||||
return rows.map((row) => ({ id: row.session.id, depth: row.depth, hasMissingParent: row.hasMissingParent }));
|
||||
}
|
||||
|
||||
function sessionList(sessions: SessionInfo[], unreadSessionIds: ReadonlySet<string>): SessionList {
|
||||
const list = new SessionList();
|
||||
list.sessions = sessions;
|
||||
list.unreadSessionIds = unreadSessionIds;
|
||||
return list;
|
||||
}
|
||||
|
||||
function renderList(list: SessionList): TemplateResult {
|
||||
return list.render();
|
||||
}
|
||||
|
||||
function openSessionMenu(list: SessionList, sessionId: string): void {
|
||||
setComponentState(list, "openMenuSessionId", sessionId);
|
||||
}
|
||||
|
||||
function componentState(list: SessionList, property: string): unknown {
|
||||
return Reflect.get(list, property);
|
||||
}
|
||||
|
||||
function setComponentState(list: SessionList, property: string, value: unknown): void {
|
||||
if (!Reflect.set(list, property, value)) throw new Error(`Could not set session list property ${property}`);
|
||||
}
|
||||
|
||||
// Locates the bulk "Mark read" button inside the selection toolbar template,
|
||||
// anchored to the button's own static text so unrelated toolbar changes do not
|
||||
// break the lookup. The disabled binding sits immediately before its @click.
|
||||
function markReadButton(template: TemplateResult): { disabled: boolean; click: TemplateEventHandler } {
|
||||
const host = findTemplateWithStaticText(template, ">Mark read</button>");
|
||||
const strings = templateStrings(host);
|
||||
const values = templateValues(host);
|
||||
for (let index = 0; index < values.length; index += 1) {
|
||||
if (strings[index + 1]?.includes(">Mark read</button>") !== true) continue;
|
||||
const click = values[index];
|
||||
const disabled = values[index - 1];
|
||||
if (!isTemplateEventHandler(click) || typeof disabled !== "boolean") throw new Error("Mark read button wiring is unavailable");
|
||||
return { disabled, click };
|
||||
}
|
||||
throw new Error("Expected a click handler before >Mark read</button>");
|
||||
}
|
||||
|
||||
function findTemplateWithStaticText(value: unknown, text: string): TemplateResult {
|
||||
const found = findOptionalTemplateWithStaticText(value, text);
|
||||
if (found === undefined) throw new Error(`Expected template containing ${text}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
function findOptionalTemplateWithStaticText(value: unknown, text: string): TemplateResult | undefined {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
const found = findOptionalTemplateWithStaticText(item, text);
|
||||
if (found !== undefined) return found;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
if (!isTemplateResult(value)) return undefined;
|
||||
if (templateStrings(value).some((chunk) => chunk.includes(text))) return value;
|
||||
for (const item of templateValues(value)) {
|
||||
const found = findOptionalTemplateWithStaticText(item, text);
|
||||
if (found !== undefined) return found;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
function sessionStatus(sessionId: string, overrides: Partial<SessionStatus> = {}): SessionStatus {
|
||||
return {
|
||||
sessionId,
|
||||
|
||||
@@ -57,6 +57,8 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
|
||||
@property({ attribute: false }) onDeleteArchived?: (session: SessionInfo) => void | Promise<void>;
|
||||
@property({ attribute: false }) onDeleteArchivedMany?: (sessions: SessionInfo[]) => void | Promise<void>;
|
||||
@property({ attribute: false }) onDetachParent?: (session: SessionInfo) => void;
|
||||
@property({ attribute: false }) onMarkRead?: (session: SessionInfo) => void;
|
||||
@property({ attribute: false }) onMarkReadMany?: (sessions: SessionInfo[]) => void | Promise<void>;
|
||||
@property({ attribute: false }) onReload?: (session: SessionInfo) => void;
|
||||
@property({ attribute: false }) onCleanup?: () => void;
|
||||
|
||||
@@ -125,11 +127,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
|
||||
const currentSelectableSessions = currentRows.map((row) => row.session).filter((session) => sessionSelectionScope(session) === "current");
|
||||
const archivedRows = sessionRows(this.sessions.filter((session) => session.archived === true && !currentRowIds.has(session.id)));
|
||||
const descendantCounts = unarchivedDescendantCounts(this.sessions);
|
||||
const unreadCount = unreadSessionCount(currentSelectableSessions, this.unreadSessionIds, {
|
||||
statuses: this.statuses,
|
||||
activities: this.activities,
|
||||
sending: this.sending,
|
||||
});
|
||||
const unreadCount = unreadSessionCount(currentSelectableSessions, this.unreadSessionIds);
|
||||
return html`
|
||||
<section>
|
||||
${this.renderHeading(currentRows.length + archivedRows.length, currentSelectableSessions, unreadCount)}
|
||||
@@ -226,6 +224,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
|
||||
|
||||
const selectedSessions = this.selectedSessions("current");
|
||||
const archivableSessions = selectedSessions.filter((session) => isArchivableSessionInfo(session, this.statuses[session.id], this.sessionPersistenceOptions()));
|
||||
const unreadSelectedSessions = selectedSessions.filter((session) => this.unreadSessionIds.has(session.id));
|
||||
const allVisibleSelected = visibleSessions.length > 0 && visibleSessions.every((session) => this.selectedSessionIds.has(session.id));
|
||||
const visibleSelectedCount = visibleSessions.filter((session) => this.selectedSessionIds.has(session.id)).length;
|
||||
return html`
|
||||
@@ -233,6 +232,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
|
||||
<button ?disabled=${visibleSessions.length === 0} @click=${() => { this.toggleVisibleSelection(visibleSessions, !allVisibleSelected); }}>${allVisibleSelected ? "Clear visible" : "Select visible"}</button>
|
||||
<small>${selectedSessions.length} selected${visibleSelectedCount !== selectedSessions.length ? html` · ${visibleSelectedCount} visible` : null}</small>
|
||||
<button ?disabled=${archivableSessions.length === 0} @click=${() => { this.archiveSelectedCurrent(); }}>Archive selected</button>
|
||||
<button ?disabled=${unreadSelectedSessions.length === 0} @click=${() => { this.markSelectedCurrentRead(); }}>Mark read</button>
|
||||
<button @click=${() => { this.clearSelection("current"); }}>Clear</button>
|
||||
<button @click=${() => { this.closeSelection("current"); }}>Done</button>
|
||||
</div>
|
||||
@@ -266,14 +266,15 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
|
||||
const bulkSelected = showsCheckbox && this.selectedSessionIds.has(session.id);
|
||||
const status = this.statuses[session.id];
|
||||
const activity = this.activities[session.id];
|
||||
const indicatorKind = sessionRowActivityKind(session, status, activity, this.sending[session.id] === true, this.unreadSessionIds.has(session.id));
|
||||
const indicatorKind = sessionRowActivityKind(session, status, activity, this.sending[session.id] === true);
|
||||
const unread = sessionRowUnread(session, this.unreadSessionIds);
|
||||
const persistenceOptions = this.sessionPersistenceOptions();
|
||||
const canArchive = isArchivableSessionInfo(session, status, persistenceOptions);
|
||||
const canDeleteTransient = isTransientNewSessionInfo(session, status, persistenceOptions);
|
||||
const canReloadSession = canArchive && this.canReload;
|
||||
return html`
|
||||
<div
|
||||
class="action-row ${this.selected?.id === session.id ? "selected" : ""} ${bulkSelected ? "bulk-selected" : ""} ${session.archived === true ? "archived" : ""} ${selectionActive ? "selecting" : ""} ${indicatorKind === "unread" ? "unread" : ""}"
|
||||
class="action-row ${this.selected?.id === session.id ? "selected" : ""} ${bulkSelected ? "bulk-selected" : ""} ${session.archived === true ? "archived" : ""} ${selectionActive ? "selecting" : ""} ${unread ? "unread" : ""}"
|
||||
style=${`--depth:${String(cappedDepth)}`}
|
||||
tabindex="0"
|
||||
title=${session.path}
|
||||
@@ -283,7 +284,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
|
||||
<div class="action-main ${selectionActive ? "selecting" : ""}">
|
||||
${showsCheckbox ? html`<input class="session-checkbox" type="checkbox" aria-label=${`Select ${sessionLabel(session)}`} .checked=${bulkSelected} @click=${(event: MouseEvent) => { event.stopPropagation(); }} @change=${() => { this.toggleSelected(session.id); }}>` : null}
|
||||
<span class="action-name-line"><span class="action-name" dir="auto">${row.depth > 0 ? html`<span class="tree-marker">↳</span>` : null}${sessionLabel(session)}${row.depth > 2 ? html` <span class="badge">depth ${row.depth}</span>` : null}${row.hasMissingParent ? html` <span class="badge">parent unavailable</span>` : null}</span></span><small>${this.renderSessionMetaPrefix(session, status, activity)}${String(session.messageCount)} messages</small>
|
||||
${this.renderActivity(indicatorKind)}
|
||||
${this.renderActivity(indicatorKind, unread)}
|
||||
</div>
|
||||
<div class="action-menu">
|
||||
<button class="action-menu-toggle" title="Session actions" @click=${(event: MouseEvent) => { event.stopPropagation(); this.toggleMenu(session.id, event.currentTarget); }}>⋯</button>
|
||||
@@ -297,6 +298,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
|
||||
: canDeleteTransient
|
||||
? html`<button title="Delete transient new session" @click=${() => { this.openMenuSessionId = undefined; this.onDelete?.(session); }}>Delete</button>`
|
||||
: html`
|
||||
${this.unreadSessionIds.has(session.id) ? html`<button title="Mark session as read" @click=${() => { this.openMenuSessionId = undefined; this.onMarkRead?.(session); }}>Mark as read</button>` : null}
|
||||
${canArchive ? html`
|
||||
<button title="Archive session" @click=${() => { this.openMenuSessionId = undefined; this.onArchive?.(session); }}>Archive</button>
|
||||
${descendantCount > 0 ? html`<button title="Archive this session and its descendants" @click=${() => { this.openMenuSessionId = undefined; this.confirmArchiveWithDescendants(session, descendantCount); }}>Archive with descendants (${descendantCount})</button>` : null}
|
||||
@@ -348,6 +350,12 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
|
||||
void this.onDeleteArchivedMany?.(archived);
|
||||
}
|
||||
|
||||
private markSelectedCurrentRead(): void {
|
||||
const unreadSelected = this.selectedSessions("current").filter((session) => this.unreadSessionIds.has(session.id));
|
||||
if (unreadSelected.length === 0) return;
|
||||
void this.onMarkReadMany?.(unreadSelected);
|
||||
}
|
||||
|
||||
private archiveSelectedCurrent(): void {
|
||||
const sessions = this.selectedSessions("current").filter((session) => isArchivableSessionInfo(session, this.statuses[session.id], this.sessionPersistenceOptions()));
|
||||
this.selectedSessionIds = removeSessionIds(this.selectedSessionIds, sessions.map((session) => session.id));
|
||||
@@ -442,13 +450,9 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
|
||||
return { authoritative: this.authoritativeSessionPersistence };
|
||||
}
|
||||
|
||||
private renderActivity(kind: ActivityIndicatorKind | undefined) {
|
||||
const label = kind === "sending"
|
||||
? "Sending message"
|
||||
: kind === "unread"
|
||||
? "Unread session activity"
|
||||
: "Session active";
|
||||
return renderActionActivityIndicator(kind, label);
|
||||
private renderActivity(kind: ActivityIndicatorKind | undefined, unread: boolean) {
|
||||
const label = kind === "sending" ? "Sending message" : "Session active";
|
||||
return renderActionActivityIndicator(kind, label, unread ? "Unread session activity" : undefined);
|
||||
}
|
||||
|
||||
static override styles = [listStyles, css`
|
||||
@@ -483,19 +487,8 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
|
||||
export function unreadSessionCount(
|
||||
sessions: readonly SessionInfo[],
|
||||
unreadSessionIds: ReadonlySet<string>,
|
||||
runtime: {
|
||||
statuses?: Record<string, SessionStatus> | undefined;
|
||||
activities?: Record<string, SessionActivity> | undefined;
|
||||
sending?: Record<string, true> | undefined;
|
||||
} = {},
|
||||
): number {
|
||||
return sessions.filter((session) => sessionRowActivityKind(
|
||||
session,
|
||||
runtime.statuses?.[session.id],
|
||||
runtime.activities?.[session.id],
|
||||
runtime.sending?.[session.id] === true,
|
||||
unreadSessionIds.has(session.id),
|
||||
) === "unread").length;
|
||||
return sessions.filter((session) => sessionRowUnread(session, unreadSessionIds)).length;
|
||||
}
|
||||
|
||||
function sessionSelectionScope(session: SessionInfo): SessionSelectionScope {
|
||||
@@ -534,24 +527,34 @@ function unarchivedDescendantCounts(sessions: SessionInfo[]): Map<string, number
|
||||
|
||||
/**
|
||||
* Resolve the activity indicator kind for a session row, or undefined when the
|
||||
* row should show no indicator. Pure so it can be unit-tested without rendering.
|
||||
* row should show no work dot. Pure so it can be unit-tested without rendering.
|
||||
*
|
||||
* "sending" (client-side upload in flight) is reported with its own kind, and
|
||||
* takes precedence over server activity, so it can be colored distinctly to
|
||||
* signal that it is not yet propagated to workspace/machine activity. Unread is
|
||||
* the idle fallback, so it never replaces an indicator for ongoing work.
|
||||
* signal that it is not yet propagated to workspace/machine activity. Unread
|
||||
* is not a kind: it is an attention flag resolved by `sessionRowUnread` and
|
||||
* rendered as a ring around this dot (or a filled dot when this is undefined).
|
||||
*/
|
||||
export function sessionRowActivityKind(
|
||||
session: SessionInfo,
|
||||
status: SessionStatus | undefined,
|
||||
activity: SessionActivity | undefined,
|
||||
sending: boolean,
|
||||
unread = false,
|
||||
): ActivityIndicatorKind | undefined {
|
||||
if (isCachedNewSessionInfo(session) || session.archived === true) return undefined;
|
||||
if (sending) return "sending";
|
||||
if (isSessionActive(status, activity)) return "session";
|
||||
return unread ? "unread" : undefined;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a session row carries the unread attention flag. Cached-new and
|
||||
* archived sessions can never be unread: they have no server-side unread
|
||||
* completions to acknowledge.
|
||||
*/
|
||||
export function sessionRowUnread(session: SessionInfo, unreadSessionIds: ReadonlySet<string>): boolean {
|
||||
if (isCachedNewSessionInfo(session) || session.archived === true) return false;
|
||||
return unreadSessionIds.has(session.id);
|
||||
}
|
||||
|
||||
export function sessionRowsForCurrentTree(sessions: SessionInfo[]): SessionRow[] {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { Workspace, WorkspaceActivity } from "../api";
|
||||
import { WorkspaceList } from "./WorkspaceList";
|
||||
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
describe("workspace unread indicator", () => {
|
||||
it("shows an unread dot only on workspaces tracked as unread", async () => {
|
||||
const list = await mountWorkspaceList([workspace("ws-a"), workspace("ws-b")], new Set(["ws-b"]));
|
||||
|
||||
expect(unreadDot(rowFor(list, "ws-a"))).toBeNull();
|
||||
const dot = unreadDot(rowFor(list, "ws-b"));
|
||||
expect(dot).not.toBeNull();
|
||||
expect(dot?.getAttribute("title")).toBe("Unread sessions in this workspace");
|
||||
});
|
||||
|
||||
it("clears the dot once the workspace is no longer tracked as unread", async () => {
|
||||
const list = await mountWorkspaceList([workspace("ws-a")], new Set(["ws-a"]));
|
||||
expect(list.shadowRoot?.querySelector(".activity-indicator.unread")).not.toBeNull();
|
||||
|
||||
list.unreadWorkspaceIds = new Set();
|
||||
await list.updateComplete;
|
||||
|
||||
expect(list.shadowRoot?.querySelector(".activity-indicator.unread")).toBeNull();
|
||||
});
|
||||
|
||||
it("wraps the work dot in an unread ring when the workspace is busy and unread", async () => {
|
||||
const list = await mountWorkspaceList([workspace("ws-a")], new Set(["ws-a"]));
|
||||
list.activities = { "/repo/ws-a": workspaceActivity("/repo/ws-a", false, true) };
|
||||
await list.updateComplete;
|
||||
|
||||
const row = rowFor(list, "ws-a");
|
||||
const ring = row.querySelector(".unread-ring");
|
||||
expect(ring?.querySelector(".activity-indicator.terminal")).not.toBeNull();
|
||||
expect(ring?.getAttribute("title")).toBe("Unread sessions in this workspace · Workspace terminal active");
|
||||
expect(row.querySelector(".activity-indicator.unread")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
async function mountWorkspaceList(workspaces: Workspace[], unreadWorkspaceIds: ReadonlySet<string>): Promise<WorkspaceList> {
|
||||
const list = new WorkspaceList();
|
||||
list.workspaces = workspaces;
|
||||
list.unreadWorkspaceIds = unreadWorkspaceIds;
|
||||
document.body.append(list);
|
||||
await list.updateComplete;
|
||||
return list;
|
||||
}
|
||||
|
||||
function rowFor(list: WorkspaceList, workspaceLabel: string): Element {
|
||||
const rows = [...(list.shadowRoot?.querySelectorAll(".workspace-row") ?? [])];
|
||||
const row = rows.find((candidate) => candidate.textContent.includes(workspaceLabel));
|
||||
if (row === undefined) throw new Error(`Expected a workspace row for ${workspaceLabel}`);
|
||||
return row;
|
||||
}
|
||||
|
||||
function unreadDot(row: Element): Element | null {
|
||||
return row.querySelector(".activity-indicator.unread");
|
||||
}
|
||||
|
||||
function workspaceActivity(cwd: string, hasSessionActivity: boolean, hasTerminalActivity: boolean): WorkspaceActivity {
|
||||
return { cwd, hasSessionActivity, hasTerminalActivity, updatedAt: "2026-06-04T00:00:00.000Z" };
|
||||
}
|
||||
|
||||
function workspace(id: string): Workspace {
|
||||
return { id, projectId: "project-1", path: `/repo/${id}`, label: id, isMain: true, isGitRepo: true, isGitWorktree: false };
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export class WorkspaceList extends LitElement implements KeyboardNavigableSectio
|
||||
@property({ attribute: false }) workspaceLabelItems: (workspace: Workspace) => WorkspaceLabelItem[] = () => [];
|
||||
@property({ attribute: false }) activities: Record<string, WorkspaceActivity> = {};
|
||||
@property({ attribute: false }) deletingWorkspaceIds: string[] = [];
|
||||
@property({ attribute: false }) unreadWorkspaceIds: ReadonlySet<string> = new Set();
|
||||
@property({ attribute: false }) onSelect?: (workspace: Workspace) => void;
|
||||
@property({ attribute: false }) onDelete?: (workspace: Workspace) => void;
|
||||
@property({ attribute: false }) onToggleCollapsed?: () => void;
|
||||
@@ -105,7 +106,8 @@ export class WorkspaceList extends LitElement implements KeyboardNavigableSectio
|
||||
|
||||
private renderActivity(workspace: Workspace): TemplateResult | undefined {
|
||||
const kind = workspaceActivityIndicator(workspaceActivityFor(workspace, this.activities));
|
||||
return renderActionActivityIndicator(kind, kind === "terminal" ? "Workspace terminal active" : "Workspace active");
|
||||
const unreadLabel = this.unreadWorkspaceIds.has(workspace.id) ? "Unread sessions in this workspace" : undefined;
|
||||
return renderActionActivityIndicator(kind, kind === "terminal" ? "Workspace terminal active" : "Workspace active", unreadLabel);
|
||||
}
|
||||
|
||||
private renderWorkspaceMain(label: string, items: WorkspaceLabelItem[], workspace: Workspace): TemplateResult {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { render, type TemplateResult } from "lit";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { renderActionActivityIndicator, renderActivityIndicator } from "./activityBadge";
|
||||
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
describe("renderActivityIndicator", () => {
|
||||
it("renders nothing when the row is idle and read", () => {
|
||||
const container = renderInto(renderActivityIndicator(undefined, "Machine active"));
|
||||
|
||||
expect(container.querySelector(".activity-indicator, .unread-ring")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders a bare work dot when the row is active and read", () => {
|
||||
const container = renderInto(renderActivityIndicator("session", "Machine active"));
|
||||
|
||||
const dot = container.querySelector(".activity-indicator.session");
|
||||
expect(dot?.getAttribute("aria-label")).toBe("Machine active");
|
||||
expect(container.querySelector(".unread-ring")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders a filled unread dot when the row is idle and unread", () => {
|
||||
const container = renderInto(renderActivityIndicator(undefined, "Machine active", "Unread sessions on this machine"));
|
||||
|
||||
const dot = container.querySelector(".activity-indicator.unread");
|
||||
expect(dot?.getAttribute("title")).toBe("Unread sessions on this machine");
|
||||
expect(container.querySelector(".unread-ring")).toBeNull();
|
||||
});
|
||||
|
||||
it("wraps the work dot in an unread ring when the row is active and unread", () => {
|
||||
const container = renderInto(renderActivityIndicator("terminal", "Machine terminal active", "Unread sessions on this machine"));
|
||||
|
||||
const ring = container.querySelector(".unread-ring");
|
||||
expect(ring?.getAttribute("role")).toBe("img");
|
||||
expect(ring?.getAttribute("aria-label")).toBe("Unread sessions on this machine · Machine terminal active");
|
||||
const dot = ring?.querySelector(".activity-indicator.terminal");
|
||||
expect(dot?.getAttribute("aria-hidden")).toBe("true");
|
||||
// One mark only: the ring replaces the standalone unread dot.
|
||||
expect(container.querySelector(".activity-indicator.unread")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderActionActivityIndicator", () => {
|
||||
it("slots the composite mark into the row corner", () => {
|
||||
const container = renderInto(renderActionActivityIndicator("session", "Session active", "Unread session activity"));
|
||||
|
||||
const slot = container.querySelector(".action-activity");
|
||||
expect(slot?.querySelector(".unread-ring .activity-indicator.session")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders no slot when there is nothing to show", () => {
|
||||
const container = renderInto(renderActionActivityIndicator(undefined));
|
||||
|
||||
expect(container.querySelector(".action-activity")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
function renderInto(template: TemplateResult | undefined): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
render(template ?? null, container);
|
||||
return container;
|
||||
}
|
||||
@@ -1,14 +1,34 @@
|
||||
import { html, type TemplateResult } from "lit";
|
||||
|
||||
export type ActivityIndicatorKind = "session" | "terminal" | "sending" | "unread";
|
||||
/**
|
||||
* Work signals a row can show. At most one kind renders at a time; call sites
|
||||
* resolve precedence (sending > session > terminal) before rendering.
|
||||
*/
|
||||
export type ActivityIndicatorKind = "session" | "terminal" | "sending";
|
||||
|
||||
export function renderActivityIndicator(kind: ActivityIndicatorKind | undefined, label = "Active"): TemplateResult | undefined {
|
||||
if (kind === undefined) return undefined;
|
||||
return html`<span class=${`activity-indicator ${kind}`} role="img" aria-label=${label} title=${label}></span>`;
|
||||
/**
|
||||
* Render the single indicator mark for a row.
|
||||
*
|
||||
* Unread is an attention flag, not a work signal, so it never competes with
|
||||
* the activity kinds for the slot: pass `unreadLabel` and it renders as a
|
||||
* static accent ring around the work dot (which keeps its own color, shape,
|
||||
* and pulse), or as a filled static accent dot when the row is idle. The label
|
||||
* is the flag — pass undefined when the row has nothing unread.
|
||||
*/
|
||||
export function renderActivityIndicator(kind: ActivityIndicatorKind | undefined, label = "Active", unreadLabel?: string): TemplateResult | undefined {
|
||||
if (kind === undefined) {
|
||||
if (unreadLabel === undefined) return undefined;
|
||||
return html`<span class="activity-indicator unread" role="img" aria-label=${unreadLabel} title=${unreadLabel}></span>`;
|
||||
}
|
||||
if (unreadLabel === undefined) {
|
||||
return html`<span class=${`activity-indicator ${kind}`} role="img" aria-label=${label} title=${label}></span>`;
|
||||
}
|
||||
const combinedLabel = `${unreadLabel} · ${label}`;
|
||||
return html`<span class="unread-ring" role="img" aria-label=${combinedLabel} title=${combinedLabel}><span class=${`activity-indicator ${kind}`} aria-hidden="true"></span></span>`;
|
||||
}
|
||||
|
||||
export function renderActionActivityIndicator(kind: ActivityIndicatorKind | undefined, label = "Active"): TemplateResult | undefined {
|
||||
const indicator = renderActivityIndicator(kind, label);
|
||||
export function renderActionActivityIndicator(kind: ActivityIndicatorKind | undefined, label = "Active", unreadLabel?: string): TemplateResult | undefined {
|
||||
const indicator = renderActivityIndicator(kind, label, unreadLabel);
|
||||
if (indicator === undefined) return undefined;
|
||||
return html`<span class="action-activity">${indicator}</span>`;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Machine } from "../../api";
|
||||
import { shouldShowMachinesSection } from "./AppNavigationPanel";
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { Machine, Project, Workspace } from "../../api";
|
||||
import type { UnreadPresence } from "../../unreadPresence";
|
||||
import { MachineList } from "../MachineList";
|
||||
import { MachineSwitcher } from "../MachineSwitcher";
|
||||
import { ProjectList } from "../ProjectList";
|
||||
import { WorkspaceList } from "../WorkspaceList";
|
||||
import { AppNavigationPanel, shouldShowMachinesSection } from "./AppNavigationPanel";
|
||||
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
describe("shouldShowMachinesSection", () => {
|
||||
it("hides machine navigation when there is no machine choice", () => {
|
||||
@@ -13,6 +24,39 @@ describe("shouldShowMachinesSection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("unread presence wiring", () => {
|
||||
it("feeds each unread presence slice to the matching navigation section", async () => {
|
||||
const unreadPresence: UnreadPresence = {
|
||||
machines: new Set(["remote-a"]),
|
||||
projects: new Set(["project-1"]),
|
||||
workspaces: new Set(["ws-1"]),
|
||||
};
|
||||
const panel = new AppNavigationPanel();
|
||||
panel.compact = true;
|
||||
panel.machines = [machine("local"), machine("remote-a")];
|
||||
panel.selectedMachine = machine("local");
|
||||
panel.projects = [project("project-1")];
|
||||
panel.workspaces = [workspace("ws-1", "project-1")];
|
||||
panel.unreadPresence = unreadPresence;
|
||||
document.body.append(panel);
|
||||
await panel.updateComplete;
|
||||
|
||||
const switcher = panel.shadowRoot?.querySelector("machine-switcher");
|
||||
const machineList = panel.shadowRoot?.querySelector("machine-list");
|
||||
const projectList = panel.shadowRoot?.querySelector("project-list");
|
||||
const workspaceList = panel.shadowRoot?.querySelector("workspace-list");
|
||||
if (!(switcher instanceof MachineSwitcher)) throw new Error("Expected machine-switcher section");
|
||||
if (!(machineList instanceof MachineList)) throw new Error("Expected machine-list section");
|
||||
if (!(projectList instanceof ProjectList)) throw new Error("Expected project-list section");
|
||||
if (!(workspaceList instanceof WorkspaceList)) throw new Error("Expected workspace-list section");
|
||||
|
||||
expect(switcher.unreadMachineIds).toBe(unreadPresence.machines);
|
||||
expect(machineList.unreadMachineIds).toBe(unreadPresence.machines);
|
||||
expect(projectList.unreadProjectIds).toBe(unreadPresence.projects);
|
||||
expect(workspaceList.unreadWorkspaceIds).toBe(unreadPresence.workspaces);
|
||||
});
|
||||
});
|
||||
|
||||
function machine(id: string): Machine {
|
||||
return {
|
||||
id,
|
||||
@@ -22,3 +66,11 @@ function machine(id: string): Machine {
|
||||
updatedAt: "2026-06-04T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
function project(id: string): Project {
|
||||
return { id, name: id, path: `/repo/${id}`, createdAt: "2026-06-04T00:00:00.000Z" };
|
||||
}
|
||||
|
||||
function workspace(id: string, projectId: string): Workspace {
|
||||
return { id, projectId, path: `/repo/${id}`, label: id, isMain: true, isGitRepo: true, isGitWorktree: false };
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Machine, MachineHealth, Project, SessionActivity, SessionInfo, Ses
|
||||
import type { WorkspaceLabelItem } from "../../plugins/types";
|
||||
import type { NavigationSection } from "../../appShell/navigationState";
|
||||
import { NAVIGATION_SECTION_ORDER } from "../../appShell/navigationState";
|
||||
import { EMPTY_UNREAD_PRESENCE, type UnreadPresence } from "../../unreadPresence";
|
||||
import type { KeyboardNavigableSection } from "../navigationFocus";
|
||||
import "../MachineList";
|
||||
import "../MachineSwitcher";
|
||||
@@ -30,6 +31,7 @@ export class AppNavigationPanel extends LitElement {
|
||||
@property({ attribute: false }) sessionStatuses: Record<string, SessionStatus> = {};
|
||||
@property({ attribute: false }) sendingPrompts: Record<string, true> = {};
|
||||
@property({ attribute: false }) unreadSessionIds: ReadonlySet<string> = new Set();
|
||||
@property({ attribute: false }) unreadPresence: UnreadPresence = EMPTY_UNREAD_PRESENCE;
|
||||
@property({ attribute: false }) workspacesByProjectId: Record<string, Workspace[]> = {};
|
||||
@property({ attribute: false }) deletingWorkspaceIds: string[] = [];
|
||||
@property({ attribute: false }) workspaceLabelItems: (workspace: Workspace) => WorkspaceLabelItem[] = () => [];
|
||||
@@ -67,6 +69,8 @@ export class AppNavigationPanel extends LitElement {
|
||||
@property({ attribute: false }) onDeleteArchivedSession?: (session: SessionInfo) => void | Promise<void>;
|
||||
@property({ attribute: false }) onDeleteArchivedSessions?: (sessions: SessionInfo[]) => void | Promise<void>;
|
||||
@property({ attribute: false }) onDetachParentSession?: (session: SessionInfo) => void | Promise<void>;
|
||||
@property({ attribute: false }) onMarkSessionRead?: (session: SessionInfo) => void | Promise<void>;
|
||||
@property({ attribute: false }) onMarkSessionsRead?: (sessions: SessionInfo[]) => void | Promise<void>;
|
||||
@property({ attribute: false }) onReloadSession?: (session: SessionInfo) => void | Promise<void>;
|
||||
@property({ attribute: false }) onCleanupSessions?: () => void | Promise<void>;
|
||||
@property({ attribute: false }) onArchivedCollapsed?: () => void | Promise<void>;
|
||||
@@ -101,6 +105,7 @@ export class AppNavigationPanel extends LitElement {
|
||||
.selected=${this.selectedMachine}
|
||||
.statuses=${this.machineStatuses}
|
||||
.activities=${this.machineActivities}
|
||||
.unreadMachineIds=${this.unreadPresence.machines}
|
||||
.onSelect=${(machine: Machine) => this.onSelectMachine?.(machine)}
|
||||
.onRemove=${(machine: Machine) => this.onRemoveMachine?.(machine)}
|
||||
.onFocusNextSection=${() => { this.focusNextFrom("machines"); }}
|
||||
@@ -118,6 +123,7 @@ export class AppNavigationPanel extends LitElement {
|
||||
.selected=${this.selectedMachine}
|
||||
.statuses=${this.machineStatuses}
|
||||
.activities=${this.machineActivities}
|
||||
.unreadMachineIds=${this.unreadPresence.machines}
|
||||
.collapsible=${this.collapsible}
|
||||
.collapsed=${this.machinesCollapsed}
|
||||
.onToggleCollapsed=${() => { this.onToggleMachines?.(); }}
|
||||
@@ -132,6 +138,7 @@ export class AppNavigationPanel extends LitElement {
|
||||
.selected=${this.selectedProject}
|
||||
.activities=${this.workspaceActivities}
|
||||
.workspacesByProjectId=${this.workspacesByProjectId}
|
||||
.unreadProjectIds=${this.unreadPresence.projects}
|
||||
.collapsible=${this.collapsible}
|
||||
.collapsed=${this.projectsCollapsed}
|
||||
.onToggleCollapsed=${() => { this.onToggleProjects?.(); }}
|
||||
@@ -146,6 +153,7 @@ export class AppNavigationPanel extends LitElement {
|
||||
.selected=${this.selectedWorkspace}
|
||||
.activities=${this.workspaceActivities}
|
||||
.deletingWorkspaceIds=${this.deletingWorkspaceIds}
|
||||
.unreadWorkspaceIds=${this.unreadPresence.workspaces}
|
||||
.collapsible=${this.collapsible}
|
||||
.collapsed=${this.workspacesCollapsed}
|
||||
.workspaceLabelItems=${this.workspaceLabelItems}
|
||||
@@ -185,6 +193,8 @@ export class AppNavigationPanel extends LitElement {
|
||||
.onDeleteArchived=${(session: SessionInfo) => this.onDeleteArchivedSession?.(session)}
|
||||
.onDeleteArchivedMany=${(sessions: SessionInfo[]) => this.onDeleteArchivedSessions?.(sessions)}
|
||||
.onDetachParent=${(session: SessionInfo) => this.onDetachParentSession?.(session)}
|
||||
.onMarkRead=${(session: SessionInfo) => this.onMarkSessionRead?.(session)}
|
||||
.onMarkReadMany=${(sessions: SessionInfo[]) => this.onMarkSessionsRead?.(sessions)}
|
||||
.onReload=${(session: SessionInfo) => this.onReloadSession?.(session)}
|
||||
.onCleanup=${() => this.onCleanupSessions?.()}
|
||||
.onFocusPreviousSection=${() => { this.focusPreviousFrom("sessions"); }}
|
||||
|
||||
@@ -277,13 +277,17 @@ export const listStyles = css`
|
||||
.badge { display: inline-block; margin-left: 5px; border: 1px solid var(--pi-border); border-radius: 999px; color: var(--pi-muted); padding: 0 5px; font-size: 11px; font-weight: 400; }
|
||||
.action-activity { position: absolute; top: 5px; right: 6px; z-index: 1; display: grid; place-items: center; width: 10px; height: 10px; }
|
||||
.action-activity .activity-indicator { margin: 0; vertical-align: 0; }
|
||||
.activity-indicator { display: inline-block; width: 7px; height: 7px; margin-right: 6px; background: var(--pi-success); animation: pulse 1s ease-in-out infinite; vertical-align: 1px; }
|
||||
.activity-indicator { flex: 0 0 auto; display: inline-block; width: 7px; height: 7px; margin-right: 6px; background: var(--pi-success); animation: pulse 1s ease-in-out infinite; vertical-align: 1px; }
|
||||
.activity-indicator.session { border-radius: 50%; background: var(--pi-success); }
|
||||
.activity-indicator.terminal { border-radius: 2px; background: var(--pi-accent); }
|
||||
/* Client-side sending (upload in flight); distinct from server activity, which propagates to workspace/machine rows. */
|
||||
.activity-indicator.sending { border-radius: 50%; background: var(--pi-warning); }
|
||||
/* Unread is a stable state, not ongoing work: keep it static and accent-colored. */
|
||||
.activity-indicator.unread { border-radius: 50%; background: var(--pi-accent); animation: none; box-shadow: 0 0 0 2px color-mix(in srgb, var(--pi-accent) 20%, transparent); }
|
||||
/* Unread + ongoing work: a static accent ring wraps the still-pulsing work dot. */
|
||||
.unread-ring { flex: 0 0 auto; box-sizing: border-box; display: inline-grid; place-items: center; width: 9px; height: 9px; margin-right: 6px; border: 1.5px solid var(--pi-accent); border-radius: 50%; vertical-align: 1px; }
|
||||
.unread-ring .activity-indicator { width: 5px; height: 5px; margin: 0; vertical-align: 0; }
|
||||
.action-activity .unread-ring { margin: 0; vertical-align: 0; }
|
||||
.action-menu { position: relative; align-self: stretch; }
|
||||
.action-menu-toggle { display: grid; place-items: center; height: 100%; min-width: 32px; padding: 0; color: var(--pi-muted); border-left: 0; border-top-left-radius: 0; border-bottom-left-radius: 0; }
|
||||
.action-menu-toggle:hover { color: var(--pi-text); background: var(--pi-surface-hover); }
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -588,6 +588,237 @@ describe("PiSessionService daemon-owned unread state", () => {
|
||||
await service.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("clears unread on bulk archive for listed, active, and already-archived sessions while busy failures keep unread", async () => {
|
||||
const unreadStore = new SessionUnreadStore({ createCatalogId: () => "catalog-test" });
|
||||
for (const sessionId of ["listed-idle", "active-idle", "busy", "already-archived"]) {
|
||||
completeStoreWork(unreadStore, sessionId, WORKSPACE_CWD);
|
||||
}
|
||||
const archivedRecord = { sessionId: "already-archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", archivePath: "/archive/already-archived.jsonl" };
|
||||
const activeIdle = fakeRuntime("active-idle");
|
||||
const busy = fakeRuntime("busy", { isStreaming: true });
|
||||
const runtimes = new Map([["active-idle", activeIdle.runtime], ["busy", busy.runtime]]);
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
createAgentRuntime: (_createRuntime, options) => {
|
||||
const runtime = runtimes.get(options.sessionManager.getSessionId());
|
||||
if (runtime === undefined) throw new Error("Unexpected runtime creation");
|
||||
return Promise.resolve(runtime);
|
||||
},
|
||||
sessionManager: {
|
||||
create: () => fakeSessionManager(),
|
||||
list: () => Promise.resolve([sessionRecord("listed-idle"), sessionRecord("active-idle"), sessionRecord("busy")]),
|
||||
open: (path) => fakeSessionManager("/workspace", { getSessionId: () => path.replace(/^\/sessions\/|\.jsonl$/g, "") }),
|
||||
},
|
||||
archiveStore: {
|
||||
list: () => Promise.resolve([archivedRecord]),
|
||||
get: (sessionId) => Promise.resolve(sessionId === "already-archived" ? archivedRecord : undefined),
|
||||
archive: () => Promise.reject(new Error("bulk archive should use archiveMany")),
|
||||
archiveMany: (inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }))),
|
||||
restore: () => Promise.resolve(),
|
||||
isArchived: (sessionId) => Promise.resolve(sessionId === "already-archived"),
|
||||
},
|
||||
heartbeatIntervalMs: 60_000,
|
||||
unreadStore,
|
||||
});
|
||||
|
||||
try {
|
||||
await service.status(sessionRef("active-idle"));
|
||||
await service.status(sessionRef("busy"));
|
||||
const result = await service.archiveMany([
|
||||
{ id: "already-archived", cwd: "/workspace" },
|
||||
{ id: "listed-idle", cwd: "/workspace" },
|
||||
{ id: "active-idle", cwd: "/workspace" },
|
||||
{ id: "busy", cwd: "/workspace" },
|
||||
]);
|
||||
|
||||
expect(result.archivedSessionIds).toEqual(["already-archived", "listed-idle", "active-idle"]);
|
||||
expect(result.failures).toEqual([{ sessionId: "busy", error: "Stop current session activity before archiving" }]);
|
||||
expect(activeIdle.calls.dispose).toBe(1);
|
||||
expect(busy.calls.abort).toBe(0);
|
||||
expect((await service.unreadCatalog()).sessions).toMatchObject([{ sessionId: "busy", cwd: WORKSPACE_CWD }]);
|
||||
} finally {
|
||||
await service.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("clears unread for the whole subtree when archiving with descendants", async () => {
|
||||
const unreadStore = new SessionUnreadStore({ createCatalogId: () => "catalog-test" });
|
||||
for (const sessionId of ["root", "direct-child", "archived-child", "grandchild"]) {
|
||||
completeStoreWork(unreadStore, sessionId, WORKSPACE_CWD);
|
||||
}
|
||||
const root = sessionRecord("root");
|
||||
const directChild = { ...sessionRecord("direct-child"), parentSessionPath: root.path };
|
||||
const archivedChild = { ...sessionRecord("archived-child"), parentSessionPath: root.path };
|
||||
const grandchild = { ...sessionRecord("grandchild"), parentSessionPath: archivedChild.path };
|
||||
const archivedChildRecord = {
|
||||
sessionId: "archived-child",
|
||||
cwd: "/workspace",
|
||||
archivedAt: "2026-01-02T00:00:00.000Z",
|
||||
originalPath: archivedChild.path,
|
||||
archivePath: "/archive/archived-child.jsonl",
|
||||
parentSessionPath: root.path,
|
||||
};
|
||||
const fake = fakeRuntime("root", { sessionFile: root.path });
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||
sessionManager: {
|
||||
create: () => fakeSessionManager(),
|
||||
list: () => Promise.resolve([root, directChild, archivedChild, grandchild]),
|
||||
open: () => fakeSessionManager(),
|
||||
},
|
||||
archiveStore: {
|
||||
list: () => Promise.resolve([archivedChildRecord]),
|
||||
get: () => Promise.resolve(undefined),
|
||||
archive: (input: { sessionId: string; cwd: string }) => Promise.resolve({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }),
|
||||
restore: () => Promise.resolve(),
|
||||
isArchived: () => Promise.resolve(false),
|
||||
},
|
||||
heartbeatIntervalMs: 60_000,
|
||||
unreadStore,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await service.archiveTree(sessionRef("root"));
|
||||
|
||||
expect(result.sessionIds).toEqual(["root", "direct-child", "grandchild"]);
|
||||
expect(result.skippedAlreadyArchivedCount).toBe(1);
|
||||
expect((await service.unreadCatalog()).sessions).toEqual([]);
|
||||
} finally {
|
||||
await service.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("clears unread for sessions archived and deleted by cleanup", async () => {
|
||||
const unreadStore = new SessionUnreadStore({ createCatalogId: () => "catalog-test" });
|
||||
completeStoreWork(unreadStore, "cleanup-archive", "/old-project");
|
||||
completeStoreWork(unreadStore, "cleanup-delete", "/old-project");
|
||||
const archivedRecord = { sessionId: "cleanup-delete", cwd: "/old-project", archivedAt: "2026-04-01T00:00:00.000Z", archivePath: "/archive/cleanup-delete.jsonl" };
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
now: () => new Date("2026-06-25T00:00:00.000Z"),
|
||||
createAgentRuntime: () => Promise.reject(new Error("cleanup should not open runtimes")),
|
||||
sessionManager: {
|
||||
create: () => fakeSessionManager(),
|
||||
list: () => Promise.resolve([]),
|
||||
listAll: () => Promise.resolve([sessionRecord("cleanup-archive", "/old-project")]),
|
||||
open: () => fakeSessionManager(),
|
||||
},
|
||||
archiveStore: {
|
||||
list: () => Promise.resolve([archivedRecord]),
|
||||
get: () => Promise.resolve(undefined),
|
||||
archive: () => Promise.reject(new Error("cleanup should use archiveMany")),
|
||||
archiveMany: (inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-06-25T00:00:00.000Z" }))),
|
||||
restore: () => Promise.resolve(),
|
||||
isArchived: () => Promise.resolve(false),
|
||||
deleteArchived: () => Promise.reject(new Error("cleanup should use deleteArchivedMany")),
|
||||
deleteArchivedMany: (sessionIds: readonly string[]) => Promise.resolve([...sessionIds]),
|
||||
},
|
||||
heartbeatIntervalMs: 60_000,
|
||||
unreadStore,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await service.cleanup({ thresholds: { archiveIdleDays: 30, deleteArchivedDays: 30 } });
|
||||
|
||||
expect(result.archivedSessionIds).toEqual(["cleanup-archive"]);
|
||||
expect(result.deletedSessionIds).toEqual(["cleanup-delete"]);
|
||||
expect((await service.unreadCatalog()).sessions).toEqual([]);
|
||||
} finally {
|
||||
await service.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("clears unread only for archived records actually removed by bulk delete", async () => {
|
||||
const unreadStore = new SessionUnreadStore({ createCatalogId: () => "catalog-test" });
|
||||
completeStoreWork(unreadStore, "busy-archived", WORKSPACE_CWD);
|
||||
completeStoreWork(unreadStore, "idle-archived", WORKSPACE_CWD);
|
||||
const busyRecord = { sessionId: "busy-archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", archivePath: "/archive/busy.jsonl" };
|
||||
const idleRecord = { sessionId: "idle-archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", archivePath: "/archive/idle.jsonl" };
|
||||
const busy = fakeRuntime("busy-archived", { isStreaming: true });
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
createAgentRuntime: runtimeCreator(busy.runtime),
|
||||
sessionManager: sessionGateway([]),
|
||||
archiveStore: {
|
||||
list: () => Promise.resolve([busyRecord, idleRecord]),
|
||||
get: (sessionId) => Promise.resolve(sessionId === "busy-archived" ? busyRecord : undefined),
|
||||
archive: () => Promise.reject(new Error("archive should not be called")),
|
||||
restore: () => Promise.resolve(),
|
||||
isArchived: () => Promise.resolve(false),
|
||||
deleteArchived: () => Promise.reject(new Error("bulk delete should use deleteArchivedMany")),
|
||||
deleteArchivedMany: (sessionIds: readonly string[]) => Promise.resolve([...sessionIds]),
|
||||
},
|
||||
heartbeatIntervalMs: 60_000,
|
||||
unreadStore,
|
||||
});
|
||||
|
||||
try {
|
||||
await service.status(sessionRef("busy-archived"));
|
||||
const result = await service.deleteArchivedMany([
|
||||
{ id: "busy-archived", cwd: "/workspace" },
|
||||
{ id: "idle-archived", cwd: "/workspace" },
|
||||
]);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
deletedSessionIds: ["idle-archived"],
|
||||
failures: [{ sessionId: "busy-archived", error: "Stop current session activity before deleting archived session" }],
|
||||
});
|
||||
expect((await service.unreadCatalog()).sessions).toMatchObject([{ sessionId: "busy-archived", cwd: WORKSPACE_CWD }]);
|
||||
} finally {
|
||||
await service.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not resurrect unread when archiving an active session with a pending activity latch", async () => {
|
||||
const unreadStore = new SessionUnreadStore({ createCatalogId: () => "catalog-test" });
|
||||
const hub = new CapturingSessionEventHub();
|
||||
const fake = fakeRuntime("archive-active");
|
||||
const service = new PiSessionService(hub, {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||
sessionManager: sessionGateway([sessionRecord("archive-active")]),
|
||||
archiveStore: {
|
||||
...emptyArchiveStore(),
|
||||
archive: (input: { sessionId: string; cwd: string }) => Promise.resolve({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }),
|
||||
},
|
||||
heartbeatIntervalMs: 60_000,
|
||||
unreadStore,
|
||||
});
|
||||
|
||||
try {
|
||||
await service.status(sessionRef("archive-active"));
|
||||
completeRuntimeWork(fake);
|
||||
expect((await service.unreadCatalog()).sessions).toMatchObject([{ sessionId: "archive-active", completionOrder: 1 }]);
|
||||
|
||||
// Leave a set activity latch behind: work started, then stopped without an
|
||||
// observable end event. The archive path must not let that latch record a
|
||||
// completion after the forget clears unread state.
|
||||
fake.session.isStreaming = true;
|
||||
fake.emit({ type: "agent_start" });
|
||||
fake.session.isStreaming = false;
|
||||
|
||||
await service.archive(sessionRef("archive-active"));
|
||||
expect((await service.unreadCatalog()).sessions).toEqual([]);
|
||||
expect(unreadEvents(hub).at(-1)).toMatchObject({ sessionId: "archive-active", unread: null });
|
||||
|
||||
// The disposed runtime is unsubscribed before the forget, so late events
|
||||
// cannot re-latch and manufacture a completion for the archived session.
|
||||
fake.emit({ type: "agent_start" });
|
||||
fake.emit({ type: "turn_end" });
|
||||
await drainMicrotasks();
|
||||
expect((await service.unreadCatalog()).sessions).toEqual([]);
|
||||
expect(unreadEvents(hub).at(-1)).toMatchObject({ sessionId: "archive-active", unread: null });
|
||||
} finally {
|
||||
await service.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function completeRuntimeWork(runtime: ReturnType<typeof fakeRuntime>): void {
|
||||
|
||||
Reference in New Issue
Block a user