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
+30 -1
View File
@@ -1,7 +1,7 @@
// @vitest-environment happy-dom
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";
afterEach(() => {
@@ -40,17 +40,42 @@ describe("machine unread indicator", () => {
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;
@@ -67,6 +92,10 @@ 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,
+7 -12
View File
@@ -3,7 +3,7 @@ import { customElement, property, state } from "lit/decorators.js";
import type { Machine, MachineHealth, WorkspaceActivity } from "../api";
import { machineActivityIndicator } from "../workspaceActivity";
import { actionMenuPanelStyle } from "./actionMenu";
import { renderActionActivityIndicator, renderActivityIndicator } from "./activityBadge";
import { renderActionActivityIndicator } from "./activityBadge";
import type { KeyboardNavigableSection } from "./navigationFocus";
import { activateSelectableRow, focusSelectedOrFirstSelectableRow, handleSelectableRowKeyboard } from "./selectableRow";
import { listStyles } from "./shared";
@@ -76,7 +76,7 @@ export class MachineList extends LitElement implements KeyboardNavigableSection
@keydown=${(event: KeyboardEvent) => { this.handleMachineKeydown(event, machine); }}
>
<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)}
</div>
${hasRemoveAction ? this.renderMachineMenu(machine) : null}
@@ -86,16 +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");
}
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");
// 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) {
@@ -1,7 +1,7 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it } from "vitest";
import type { Machine } from "../api";
import type { Machine, WorkspaceActivity } from "../api";
import { MachineSwitcher } from "./MachineSwitcher";
afterEach(() => {
@@ -35,6 +35,19 @@ describe("machine-switcher unread indicator", () => {
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> {
@@ -66,6 +79,10 @@ 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,
+9 -12
View File
@@ -66,7 +66,7 @@ export class MachineSwitcher extends LitElement implements KeyboardNavigableSect
@click=${(event: MouseEvent) => { this.toggleMenu(event.currentTarget); }}
@keydown=${(event: KeyboardEvent) => { this.handleSwitcherButtonKeydown(event); }}
>
${this.renderActivity(selected)}${this.renderUnread(selected)}
${this.renderActivity(selected)}
<span class="machine-switcher-text">
<span class="machine-switcher-kicker">Machine</span>
<span class="machine-switcher-label">${label}</span>
@@ -98,7 +98,7 @@ export class MachineSwitcher extends LitElement implements KeyboardNavigableSect
@click=${() => { this.select(machine); }}
@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>
</button>
${hasActions ? html`
@@ -124,16 +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");
}
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");
// 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 {
@@ -296,6 +291,8 @@ export class MachineSwitcher extends LitElement implements KeyboardNavigableSect
.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); }
+1 -5
View File
@@ -2193,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",
+17 -1
View File
@@ -1,7 +1,7 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it } from "vitest";
import type { Project } from "../api";
import type { Project, WorkspaceActivity } from "../api";
import { ProjectList } from "./ProjectList";
afterEach(() => {
@@ -27,6 +27,18 @@ describe("project unread indicator", () => {
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> {
@@ -49,6 +61,10 @@ 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" };
}
+4 -8
View File
@@ -3,7 +3,7 @@ import { customElement, property, state } from "lit/decorators.js";
import type { Project, Workspace, WorkspaceActivity } from "../api";
import { projectActivityIndicator } from "../workspaceActivity";
import { actionMenuPanelStyle } from "./actionMenu";
import { renderActionActivityIndicator, renderActivityIndicator } from "./activityBadge";
import { renderActionActivityIndicator } from "./activityBadge";
import type { KeyboardNavigableSection } from "./navigationFocus";
import { activateSelectableRow, focusSelectedOrFirstSelectableRow, handleSelectableRowKeyboard } from "./selectableRow";
import { listStyles } from "./shared";
@@ -65,7 +65,7 @@ export class ProjectList extends LitElement implements KeyboardNavigableSection
@keydown=${(event: KeyboardEvent) => { this.handleProjectKeydown(event, project); }}
>
<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)}
</div>
<div class="action-menu">
@@ -102,12 +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");
}
private renderUnread(project: Project) {
if (!this.unreadProjectIds.has(project.id)) return undefined;
return renderActivityIndicator("unread", "Unread sessions in this project");
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) {
+18 -15
View File
@@ -15,7 +15,7 @@ import {
templateValues,
type TemplateEventHandler,
} from "../templateInspection.testSupport";
import { SessionList, sessionRowActivityKind, sessionRowsForCurrentTree, unreadSessionCount } from "./SessionList";
import { SessionList, sessionRowActivityKind, sessionRowsForCurrentTree, sessionRowUnread, unreadSessionCount } from "./SessionList";
describe("sessionRowActivityKind", () => {
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");
});
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);
});
});
+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 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)}
@@ -253,14 +249,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}
@@ -270,7 +267,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>
@@ -436,13 +433,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`
@@ -477,19 +470,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 {
@@ -528,24 +510,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[] {
@@ -1,7 +1,7 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it } from "vitest";
import type { Workspace } from "../api";
import type { Workspace, WorkspaceActivity } from "../api";
import { WorkspaceList } from "./WorkspaceList";
afterEach(() => {
@@ -27,6 +27,18 @@ describe("workspace unread indicator", () => {
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> {
@@ -49,6 +61,10 @@ 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 };
}
+3 -8
View File
@@ -4,7 +4,7 @@ import type { Workspace, WorkspaceActivity } from "../api";
import type { WorkspaceLabelItem } from "../plugins/types";
import { workspaceActivityFor, workspaceActivityIndicator } from "../workspaceActivity";
import { actionMenuPanelStyle } from "./actionMenu";
import { renderActionActivityIndicator, renderActivityIndicator } from "./activityBadge";
import { renderActionActivityIndicator } from "./activityBadge";
import type { KeyboardNavigableSection } from "./navigationFocus";
import { activateSelectableRow, focusSelectedOrFirstSelectableRow, handleSelectableRowKeyboard } from "./selectableRow";
import { listStyles } from "./shared";
@@ -94,14 +94,14 @@ 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 {
return html`
<span class="workspace-primary">
<span class="workspace-primary-label">${label}</span>
${this.renderUnread(workspace)}
${this.isDeleting(workspace) ? html`<span class="workspace-status">Deleting…</span>` : null}
</span>
${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 {
const open = this.openMenuWorkspaceId === 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;
}
+26 -6
View File
@@ -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>`;
}
+4
View File
@@ -284,6 +284,10 @@ export const listStyles = css`
.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); }