feat(ui): unify row indicators into one mark with an unread ring

Unread is no longer a competing ActivityIndicatorKind or a separate
name-adjacent dot: every row renders a single indicator. An accent ring
wraps the still-pulsing work dot when a row is both busy and unread, a
filled accent dot shows while idle and unread, and activity kinds keep
their existing precedence (sending > session > terminal). Session rows
surface unread state even while busy, so the unread header count and
mobile Sessions badge now count busy unread sessions too.
This commit is contained in:
Federico Jaramillo Martinez
2026-07-27 15:55:06 +02:00
parent 8af637b00e
commit f76a9fabc8
15 changed files with 246 additions and 103 deletions
+1 -1
View File
@@ -2,4 +2,4 @@
"@jmfederico/pi-web": patch "@jmfederico/pi-web": patch
--- ---
Show a small unread dot on workspace, project, and machine rows in the navigation panel whenever a session beneath them has unread activity, so unread sessions stay visible without expanding each section. Machine dots reflect any unread session in that machine's catalog; existing per-session unread counts are unchanged. 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.
+30 -1
View File
@@ -1,7 +1,7 @@
// @vitest-environment happy-dom // @vitest-environment happy-dom
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import type { Machine, MachineHealth, MachineStatus } from "../api"; import type { Machine, MachineHealth, MachineStatus, WorkspaceActivity } from "../api";
import { canRemoveMachine, MachineList } from "./MachineList"; import { canRemoveMachine, MachineList } from "./MachineList";
afterEach(() => { afterEach(() => {
@@ -40,17 +40,42 @@ describe("machine unread indicator", () => {
expect(list.shadowRoot?.querySelector(".activity-indicator.unread")).toBeNull(); 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( async function mountMachineList(
machines: Machine[], machines: Machine[],
unreadMachineIds: ReadonlySet<string>, unreadMachineIds: ReadonlySet<string>,
statuses: Record<string, MachineHealth> = {}, statuses: Record<string, MachineHealth> = {},
activities: Record<string, Record<string, WorkspaceActivity>> = {},
): Promise<MachineList> { ): Promise<MachineList> {
const list = new MachineList(); const list = new MachineList();
list.machines = machines; list.machines = machines;
list.unreadMachineIds = unreadMachineIds; list.unreadMachineIds = unreadMachineIds;
list.statuses = statuses; list.statuses = statuses;
list.activities = activities;
document.body.append(list); document.body.append(list);
await list.updateComplete; await list.updateComplete;
return list; return list;
@@ -67,6 +92,10 @@ function unreadDot(row: Element): Element | null {
return row.querySelector(".activity-indicator.unread"); 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 { function machine(id: string, kind: Machine["kind"]): Machine {
return { return {
id, id,
+7 -12
View File
@@ -3,7 +3,7 @@ import { customElement, property, state } from "lit/decorators.js";
import type { Machine, MachineHealth, WorkspaceActivity } from "../api"; import type { Machine, MachineHealth, WorkspaceActivity } from "../api";
import { machineActivityIndicator } from "../workspaceActivity"; import { machineActivityIndicator } from "../workspaceActivity";
import { actionMenuPanelStyle } from "./actionMenu"; import { actionMenuPanelStyle } from "./actionMenu";
import { renderActionActivityIndicator, renderActivityIndicator } from "./activityBadge"; import { renderActionActivityIndicator } from "./activityBadge";
import type { KeyboardNavigableSection } from "./navigationFocus"; import type { KeyboardNavigableSection } from "./navigationFocus";
import { activateSelectableRow, focusSelectedOrFirstSelectableRow, handleSelectableRowKeyboard } from "./selectableRow"; import { activateSelectableRow, focusSelectedOrFirstSelectableRow, handleSelectableRowKeyboard } from "./selectableRow";
import { listStyles } from "./shared"; import { listStyles } from "./shared";
@@ -76,7 +76,7 @@ export class MachineList extends LitElement implements KeyboardNavigableSection
@keydown=${(event: KeyboardEvent) => { this.handleMachineKeydown(event, machine); }} @keydown=${(event: KeyboardEvent) => { this.handleMachineKeydown(event, machine); }}
> >
<div class="action-main"> <div class="action-main">
<span class="action-name machine-primary"><span class="machine-primary-label">${machine.name}</span>${this.renderUnread(machine)}</span><small>${machine.kind === "local" ? "Local Pi Web" : machine.baseUrl ?? "Remote Pi Web"} · ${statusLabel}</small> <span class="action-name machine-primary"><span class="machine-primary-label">${machine.name}</span></span><small>${machine.kind === "local" ? "Local Pi Web" : machine.baseUrl ?? "Remote Pi Web"} · ${statusLabel}</small>
${this.renderActivity(machine)} ${this.renderActivity(machine)}
</div> </div>
${hasRemoveAction ? this.renderMachineMenu(machine) : null} ${hasRemoveAction ? this.renderMachineMenu(machine) : null}
@@ -86,16 +86,11 @@ export class MachineList extends LitElement implements KeyboardNavigableSection
private renderActivity(machine: Machine) { private renderActivity(machine: Machine) {
const status = this.statuses[machine.id]?.status ?? machine.status; const status = this.statuses[machine.id]?.status ?? machine.status;
if (status === "offline" || status === "error") return undefined; // Unread survives offline: an offline machine keeps its last-known unread
const kind = machineActivityIndicator(this.activities[machine.id]); // state (stale-but-present still counts), so only the work dot is gated.
return renderActionActivityIndicator(kind, kind === "terminal" ? "Machine terminal active" : "Machine active"); 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 renderUnread(machine: Machine) {
// Unread is independent of machine activity: an offline machine keeps its
// last-known unread state (stale-but-present still counts).
if (!this.unreadMachineIds.has(machine.id)) return undefined;
return renderActivityIndicator("unread", "Unread sessions on this machine");
} }
private renderMachineMenu(machine: Machine) { private renderMachineMenu(machine: Machine) {
@@ -1,7 +1,7 @@
// @vitest-environment happy-dom // @vitest-environment happy-dom
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import type { Machine } from "../api"; import type { Machine, WorkspaceActivity } from "../api";
import { MachineSwitcher } from "./MachineSwitcher"; import { MachineSwitcher } from "./MachineSwitcher";
afterEach(() => { afterEach(() => {
@@ -35,6 +35,19 @@ describe("machine-switcher unread indicator", () => {
expect(unreadDot(optionFor(switcher, "remote-a"))).toBeNull(); expect(unreadDot(optionFor(switcher, "remote-a"))).toBeNull();
expect(unreadDot(optionFor(switcher, "remote-b"))).not.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> { async function mountSwitcher(machines: Machine[], unreadMachineIds: ReadonlySet<string>): Promise<MachineSwitcher> {
@@ -66,6 +79,10 @@ function unreadDot(option: Element): Element | null {
return option.querySelector(".activity-indicator.unread"); 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 { function machine(id: string, kind: Machine["kind"]): Machine {
return { return {
id, id,
+9 -12
View File
@@ -66,7 +66,7 @@ export class MachineSwitcher extends LitElement implements KeyboardNavigableSect
@click=${(event: MouseEvent) => { this.toggleMenu(event.currentTarget); }} @click=${(event: MouseEvent) => { this.toggleMenu(event.currentTarget); }}
@keydown=${(event: KeyboardEvent) => { this.handleSwitcherButtonKeydown(event); }} @keydown=${(event: KeyboardEvent) => { this.handleSwitcherButtonKeydown(event); }}
> >
${this.renderActivity(selected)}${this.renderUnread(selected)} ${this.renderActivity(selected)}
<span class="machine-switcher-text"> <span class="machine-switcher-text">
<span class="machine-switcher-kicker">Machine</span> <span class="machine-switcher-kicker">Machine</span>
<span class="machine-switcher-label">${label}</span> <span class="machine-switcher-label">${label}</span>
@@ -98,7 +98,7 @@ export class MachineSwitcher extends LitElement implements KeyboardNavigableSect
@click=${() => { this.select(machine); }} @click=${() => { this.select(machine); }}
@keydown=${(event: KeyboardEvent) => { this.handleMachineOptionKeydown(event); }} @keydown=${(event: KeyboardEvent) => { this.handleMachineOptionKeydown(event); }}
> >
<span class="machine-option-name">${this.renderActivity(machine)}${this.renderUnread(machine)}<span>${machine.name}</span></span> <span class="machine-option-name">${this.renderActivity(machine)}<span>${machine.name}</span></span>
<small>${machine.kind === "local" ? "Local Pi Web" : machine.baseUrl ?? "Remote Pi Web"} · ${machineStatusLabel(status)}</small> <small>${machine.kind === "local" ? "Local Pi Web" : machine.baseUrl ?? "Remote Pi Web"} · ${machineStatusLabel(status)}</small>
</button> </button>
${hasActions ? html` ${hasActions ? html`
@@ -124,16 +124,11 @@ export class MachineSwitcher extends LitElement implements KeyboardNavigableSect
private renderActivity(machine: Machine): TemplateResult | undefined { private renderActivity(machine: Machine): TemplateResult | undefined {
const status = machineStatus(machine, this.statuses); const status = machineStatus(machine, this.statuses);
if (status === "offline" || status === "error") return undefined; // Unread survives offline: an offline machine keeps its last-known unread
const kind = machineActivityIndicator(this.activities[machine.id]); // state (stale-but-present still counts), so only the work dot is gated.
return renderActivityIndicator(kind, kind === "terminal" ? "Machine terminal active" : "Machine active"); 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 renderUnread(machine: Machine): TemplateResult | undefined {
// Unread is independent of machine activity: an offline machine keeps its
// last-known unread state (stale-but-present still counts).
if (!this.unreadMachineIds.has(machine.id)) return undefined;
return renderActivityIndicator("unread", "Unread sessions on this machine");
} }
private selectedMachine(): Machine | undefined { private selectedMachine(): Machine | undefined {
@@ -296,6 +291,8 @@ export class MachineSwitcher extends LitElement implements KeyboardNavigableSect
.activity-indicator.terminal { border-radius: 2px; background: var(--pi-accent); } .activity-indicator.terminal { border-radius: 2px; background: var(--pi-accent); }
.activity-indicator.sending { border-radius: 50%; background: var(--pi-warning); } .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); } .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-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 { 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); } .machine-option.no-actions { grid-template-columns: minmax(0, 1fr); }
+1 -5
View File
@@ -2193,11 +2193,7 @@ export class PiWebApp extends LitElement {
} }
private mobileMainTabs(): AppMobileMainTab[] { private mobileMainTabs(): AppMobileMainTab[] {
const unreadCount = unreadSessionCount(this.state.sessions, this.unreadSessionIds, { const unreadCount = unreadSessionCount(this.state.sessions, this.unreadSessionIds);
statuses: this.state.sessionStatuses,
activities: this.state.sessionActivities,
sending: this.state.sendingPrompts,
});
return [ return [
{ {
id: "navigation", id: "navigation",
+17 -1
View File
@@ -1,7 +1,7 @@
// @vitest-environment happy-dom // @vitest-environment happy-dom
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import type { Project } from "../api"; import type { Project, WorkspaceActivity } from "../api";
import { ProjectList } from "./ProjectList"; import { ProjectList } from "./ProjectList";
afterEach(() => { afterEach(() => {
@@ -27,6 +27,18 @@ describe("project unread indicator", () => {
expect(list.shadowRoot?.querySelector(".activity-indicator.unread")).toBeNull(); 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> { async function mountProjectList(projects: Project[], unreadProjectIds: ReadonlySet<string>): Promise<ProjectList> {
@@ -49,6 +61,10 @@ function unreadDot(row: Element): Element | null {
return row.querySelector(".activity-indicator.unread"); 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 { function project(id: string): Project {
return { id, name: id, path: `/repo/${id}`, createdAt: "2026-06-04T00:00:00.000Z" }; return { id, name: id, path: `/repo/${id}`, createdAt: "2026-06-04T00:00:00.000Z" };
} }
+4 -8
View File
@@ -3,7 +3,7 @@ import { customElement, property, state } from "lit/decorators.js";
import type { Project, Workspace, WorkspaceActivity } from "../api"; import type { Project, Workspace, WorkspaceActivity } from "../api";
import { projectActivityIndicator } from "../workspaceActivity"; import { projectActivityIndicator } from "../workspaceActivity";
import { actionMenuPanelStyle } from "./actionMenu"; import { actionMenuPanelStyle } from "./actionMenu";
import { renderActionActivityIndicator, renderActivityIndicator } from "./activityBadge"; import { renderActionActivityIndicator } from "./activityBadge";
import type { KeyboardNavigableSection } from "./navigationFocus"; import type { KeyboardNavigableSection } from "./navigationFocus";
import { activateSelectableRow, focusSelectedOrFirstSelectableRow, handleSelectableRowKeyboard } from "./selectableRow"; import { activateSelectableRow, focusSelectedOrFirstSelectableRow, handleSelectableRowKeyboard } from "./selectableRow";
import { listStyles } from "./shared"; import { listStyles } from "./shared";
@@ -65,7 +65,7 @@ export class ProjectList extends LitElement implements KeyboardNavigableSection
@keydown=${(event: KeyboardEvent) => { this.handleProjectKeydown(event, project); }} @keydown=${(event: KeyboardEvent) => { this.handleProjectKeydown(event, project); }}
> >
<div class="action-main"> <div class="action-main">
<span class="workspace-primary"><span class="workspace-primary-label">${project.name}</span>${this.renderUnread(project)}</span><small>${project.path}</small> <span class="workspace-primary"><span class="workspace-primary-label">${project.name}</span></span><small>${project.path}</small>
${this.renderActivity(project)} ${this.renderActivity(project)}
</div> </div>
<div class="action-menu"> <div class="action-menu">
@@ -102,12 +102,8 @@ export class ProjectList extends LitElement implements KeyboardNavigableSection
private renderActivity(project: Project) { private renderActivity(project: Project) {
const kind = projectActivityIndicator(project, this.workspacesByProjectId[project.id] ?? [], this.activities); 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 renderUnread(project: Project) {
if (!this.unreadProjectIds.has(project.id)) return undefined;
return renderActivityIndicator("unread", "Unread sessions in this project");
} }
private toggleMenu(projectId: string, target: EventTarget | null) { private toggleMenu(projectId: string, target: EventTarget | null) {
+18 -15
View File
@@ -15,7 +15,7 @@ import {
templateValues, templateValues,
type TemplateEventHandler, type TemplateEventHandler,
} from "../templateInspection.testSupport"; } from "../templateInspection.testSupport";
import { SessionList, sessionRowActivityKind, sessionRowsForCurrentTree, unreadSessionCount } from "./SessionList"; import { SessionList, sessionRowActivityKind, sessionRowsForCurrentTree, sessionRowUnread, unreadSessionCount } from "./SessionList";
describe("sessionRowActivityKind", () => { describe("sessionRowActivityKind", () => {
const idle = sessionStatus("s"); const idle = sessionStatus("s");
@@ -37,33 +37,36 @@ describe("sessionRowActivityKind", () => {
expect(sessionRowActivityKind(session("s"), idle, { sessionId: "s", phase: "active", label: "running tool", at: "now" }, false)).toBe("session"); 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", () => { it("reports undefined when idle and not sending, even for an unread session", () => {
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", () => {
expect(sessionRowActivityKind(session("s"), idle, undefined, false)).toBeUndefined(); expect(sessionRowActivityKind(session("s"), idle, undefined, false)).toBeUndefined();
}); });
it("never shows an indicator for archived or cached-new sessions, even while sending or unread", () => { it("never shows an indicator for archived or cached-new sessions, even while sending", () => {
expect(sessionRowActivityKind({ ...session("s"), archived: true }, idle, undefined, true, true)).toBeUndefined(); expect(sessionRowActivityKind({ ...session("s"), archived: true }, idle, undefined, true)).toBeUndefined();
expect(sessionRowActivityKind(markCachedNewSessionInfo(session("s")), idle, undefined, true, 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", () => { describe("unreadSessionCount", () => {
it("counts only current persisted sessions", () => { it("counts only current persisted sessions, including busy ones", () => {
const current = session("current"); const current = session("current");
const archived = { ...session("archived"), archived: true, archivedAt: "2026-06-09T00:00:00.000Z" }; const archived = { ...session("archived"), archived: true, archivedAt: "2026-06-09T00:00:00.000Z" };
const cached = markCachedNewSessionInfo(session("cached")); const cached = markCachedNewSessionInfo(session("cached"));
const unreadIds = new Set([current.id, archived.id, cached.id]); const unreadIds = new Set([current.id, archived.id, cached.id]);
expect(unreadSessionCount([current, archived, cached], unreadIds)).toBe(1); expect(unreadSessionCount([current, archived, cached], unreadIds)).toBe(1);
expect(unreadSessionCount([current, archived, cached], unreadIds, {
statuses: { [current.id]: sessionStatus(current.id, { isStreaming: true }) },
})).toBe(0);
}); });
}); });
+24 -32
View File
@@ -110,11 +110,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
const currentSelectableSessions = currentRows.map((row) => row.session).filter((session) => sessionSelectionScope(session) === "current"); 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 archivedRows = sessionRows(this.sessions.filter((session) => session.archived === true && !currentRowIds.has(session.id)));
const descendantCounts = unarchivedDescendantCounts(this.sessions); const descendantCounts = unarchivedDescendantCounts(this.sessions);
const unreadCount = unreadSessionCount(currentSelectableSessions, this.unreadSessionIds, { const unreadCount = unreadSessionCount(currentSelectableSessions, this.unreadSessionIds);
statuses: this.statuses,
activities: this.activities,
sending: this.sending,
});
return html` return html`
<section> <section>
${this.renderHeading(currentRows.length + archivedRows.length, currentSelectableSessions, unreadCount)} ${this.renderHeading(currentRows.length + archivedRows.length, currentSelectableSessions, unreadCount)}
@@ -253,14 +249,15 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
const bulkSelected = showsCheckbox && this.selectedSessionIds.has(session.id); const bulkSelected = showsCheckbox && this.selectedSessionIds.has(session.id);
const status = this.statuses[session.id]; const status = this.statuses[session.id];
const activity = this.activities[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 persistenceOptions = this.sessionPersistenceOptions();
const canArchive = isArchivableSessionInfo(session, status, persistenceOptions); const canArchive = isArchivableSessionInfo(session, status, persistenceOptions);
const canDeleteTransient = isTransientNewSessionInfo(session, status, persistenceOptions); const canDeleteTransient = isTransientNewSessionInfo(session, status, persistenceOptions);
const canReloadSession = canArchive && this.canReload; const canReloadSession = canArchive && this.canReload;
return html` return html`
<div <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)}`} style=${`--depth:${String(cappedDepth)}`}
tabindex="0" tabindex="0"
title=${session.path} title=${session.path}
@@ -270,7 +267,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
<div class="action-main ${selectionActive ? "selecting" : ""}"> <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} ${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> <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>
<div class="action-menu"> <div class="action-menu">
<button class="action-menu-toggle" title="Session actions" @click=${(event: MouseEvent) => { event.stopPropagation(); this.toggleMenu(session.id, event.currentTarget); }}>⋯</button> <button class="action-menu-toggle" title="Session actions" @click=${(event: MouseEvent) => { event.stopPropagation(); this.toggleMenu(session.id, event.currentTarget); }}>⋯</button>
@@ -436,13 +433,9 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
return { authoritative: this.authoritativeSessionPersistence }; return { authoritative: this.authoritativeSessionPersistence };
} }
private renderActivity(kind: ActivityIndicatorKind | undefined) { private renderActivity(kind: ActivityIndicatorKind | undefined, unread: boolean) {
const label = kind === "sending" const label = kind === "sending" ? "Sending message" : "Session active";
? "Sending message" return renderActionActivityIndicator(kind, label, unread ? "Unread session activity" : undefined);
: kind === "unread"
? "Unread session activity"
: "Session active";
return renderActionActivityIndicator(kind, label);
} }
static override styles = [listStyles, css` static override styles = [listStyles, css`
@@ -477,19 +470,8 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
export function unreadSessionCount( export function unreadSessionCount(
sessions: readonly SessionInfo[], sessions: readonly SessionInfo[],
unreadSessionIds: ReadonlySet<string>, unreadSessionIds: ReadonlySet<string>,
runtime: {
statuses?: Record<string, SessionStatus> | undefined;
activities?: Record<string, SessionActivity> | undefined;
sending?: Record<string, true> | undefined;
} = {},
): number { ): number {
return sessions.filter((session) => sessionRowActivityKind( return sessions.filter((session) => sessionRowUnread(session, unreadSessionIds)).length;
session,
runtime.statuses?.[session.id],
runtime.activities?.[session.id],
runtime.sending?.[session.id] === true,
unreadSessionIds.has(session.id),
) === "unread").length;
} }
function sessionSelectionScope(session: SessionInfo): SessionSelectionScope { function sessionSelectionScope(session: SessionInfo): SessionSelectionScope {
@@ -528,24 +510,34 @@ function unarchivedDescendantCounts(sessions: SessionInfo[]): Map<string, number
/** /**
* Resolve the activity indicator kind for a session row, or undefined when the * 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 * "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 * 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 * signal that it is not yet propagated to workspace/machine activity. Unread
* the idle fallback, so it never replaces an indicator for ongoing work. * 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( export function sessionRowActivityKind(
session: SessionInfo, session: SessionInfo,
status: SessionStatus | undefined, status: SessionStatus | undefined,
activity: SessionActivity | undefined, activity: SessionActivity | undefined,
sending: boolean, sending: boolean,
unread = false,
): ActivityIndicatorKind | undefined { ): ActivityIndicatorKind | undefined {
if (isCachedNewSessionInfo(session) || session.archived === true) return undefined; if (isCachedNewSessionInfo(session) || session.archived === true) return undefined;
if (sending) return "sending"; if (sending) return "sending";
if (isSessionActive(status, activity)) return "session"; 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[] { export function sessionRowsForCurrentTree(sessions: SessionInfo[]): SessionRow[] {
@@ -1,7 +1,7 @@
// @vitest-environment happy-dom // @vitest-environment happy-dom
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import type { Workspace } from "../api"; import type { Workspace, WorkspaceActivity } from "../api";
import { WorkspaceList } from "./WorkspaceList"; import { WorkspaceList } from "./WorkspaceList";
afterEach(() => { afterEach(() => {
@@ -27,6 +27,18 @@ describe("workspace unread indicator", () => {
expect(list.shadowRoot?.querySelector(".activity-indicator.unread")).toBeNull(); 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> { async function mountWorkspaceList(workspaces: Workspace[], unreadWorkspaceIds: ReadonlySet<string>): Promise<WorkspaceList> {
@@ -49,6 +61,10 @@ function unreadDot(row: Element): Element | null {
return row.querySelector(".activity-indicator.unread"); 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 { function workspace(id: string): Workspace {
return { id, projectId: "project-1", path: `/repo/${id}`, label: id, isMain: true, isGitRepo: true, isGitWorktree: false }; return { id, projectId: "project-1", path: `/repo/${id}`, label: id, isMain: true, isGitRepo: true, isGitWorktree: false };
} }
+3 -8
View File
@@ -4,7 +4,7 @@ import type { Workspace, WorkspaceActivity } from "../api";
import type { WorkspaceLabelItem } from "../plugins/types"; import type { WorkspaceLabelItem } from "../plugins/types";
import { workspaceActivityFor, workspaceActivityIndicator } from "../workspaceActivity"; import { workspaceActivityFor, workspaceActivityIndicator } from "../workspaceActivity";
import { actionMenuPanelStyle } from "./actionMenu"; import { actionMenuPanelStyle } from "./actionMenu";
import { renderActionActivityIndicator, renderActivityIndicator } from "./activityBadge"; import { renderActionActivityIndicator } from "./activityBadge";
import type { KeyboardNavigableSection } from "./navigationFocus"; import type { KeyboardNavigableSection } from "./navigationFocus";
import { activateSelectableRow, focusSelectedOrFirstSelectableRow, handleSelectableRowKeyboard } from "./selectableRow"; import { activateSelectableRow, focusSelectedOrFirstSelectableRow, handleSelectableRowKeyboard } from "./selectableRow";
import { listStyles } from "./shared"; import { listStyles } from "./shared";
@@ -94,14 +94,14 @@ export class WorkspaceList extends LitElement implements KeyboardNavigableSectio
private renderActivity(workspace: Workspace): TemplateResult | undefined { private renderActivity(workspace: Workspace): TemplateResult | undefined {
const kind = workspaceActivityIndicator(workspaceActivityFor(workspace, this.activities)); 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 { private renderWorkspaceMain(label: string, items: WorkspaceLabelItem[], workspace: Workspace): TemplateResult {
return html` return html`
<span class="workspace-primary"> <span class="workspace-primary">
<span class="workspace-primary-label">${label}</span> <span class="workspace-primary-label">${label}</span>
${this.renderUnread(workspace)}
${this.isDeleting(workspace) ? html`<span class="workspace-status">Deleting…</span>` : null} ${this.isDeleting(workspace) ? html`<span class="workspace-status">Deleting…</span>` : null}
</span> </span>
${items.length === 0 ? null : html` ${items.length === 0 ? null : html`
@@ -113,11 +113,6 @@ export class WorkspaceList extends LitElement implements KeyboardNavigableSectio
`; `;
} }
private renderUnread(workspace: Workspace): TemplateResult | undefined {
if (!this.unreadWorkspaceIds.has(workspace.id)) return undefined;
return renderActivityIndicator("unread", "Unread sessions in this workspace");
}
private renderWorkspaceMenu(label: string, items: WorkspaceLabelItem[], workspace: Workspace): TemplateResult { private renderWorkspaceMenu(label: string, items: WorkspaceLabelItem[], workspace: Workspace): TemplateResult {
const open = this.openMenuWorkspaceId === workspace.id; const open = this.openMenuWorkspaceId === workspace.id;
const menuId = workspaceMenuId(workspace.id); const menuId = workspaceMenuId(workspace.id);
@@ -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;
}
+25 -5
View File
@@ -1,14 +1,34 @@
import { html, type TemplateResult } from "lit"; 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; * 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>`; 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 { export function renderActionActivityIndicator(kind: ActivityIndicatorKind | undefined, label = "Active", unreadLabel?: string): TemplateResult | undefined {
const indicator = renderActivityIndicator(kind, label); const indicator = renderActivityIndicator(kind, label, unreadLabel);
if (indicator === undefined) return undefined; if (indicator === undefined) return undefined;
return html`<span class="action-activity">${indicator}</span>`; return html`<span class="action-activity">${indicator}</span>`;
} }
+4
View File
@@ -284,6 +284,10 @@ export const listStyles = css`
.activity-indicator.sending { border-radius: 50%; background: var(--pi-warning); } .activity-indicator.sending { border-radius: 50%; background: var(--pi-warning); }
/* Unread is a stable state, not ongoing work: keep it static and accent-colored. */ /* 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); } .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 { 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 { 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); } .action-menu-toggle:hover { color: var(--pi-text); background: var(--pi-surface-hover); }