Archived
Merge pull request #105 from jmfederico/feat/unread-ux
feat(ui): improve unread-messages experience
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Give every navigation row a single activity indicator that also carries unread state. When sessions beneath a workspace, project, or machine row have unread completions, the row's indicator becomes a static accent ring around the activity dot — or a filled accent dot while idle — instead of a separate dot next to the name. Session rows now surface unread state even while busy or sending, and the "N unread" header and mobile Sessions badge count busy unread sessions too.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Add "Mark as read" actions for unread sessions: a per-session item in the session row ⋯ menu (shown only for unread sessions) and a bulk "Mark read" button in the multi-select bar that marks every unread selected session as read.
|
||||
@@ -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