From f76a9fabc8634df271a77a915032d70331b03098 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Mon, 27 Jul 2026 15:55:06 +0200 Subject: [PATCH] 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. --- .changeset/unread-bubble-up-indicators.md | 2 +- src/client/src/components/MachineList.test.ts | 31 ++++++++- src/client/src/components/MachineList.ts | 19 ++---- .../src/components/MachineSwitcher.test.ts | 19 +++++- src/client/src/components/MachineSwitcher.ts | 21 +++--- src/client/src/components/PiWebApp.ts | 6 +- src/client/src/components/ProjectList.test.ts | 18 ++++- src/client/src/components/ProjectList.ts | 12 ++-- src/client/src/components/SessionList.test.ts | 33 ++++----- src/client/src/components/SessionList.ts | 56 +++++++--------- .../src/components/WorkspaceList.test.ts | 18 ++++- src/client/src/components/WorkspaceList.ts | 11 +-- .../src/components/activityBadge.test.ts | 67 +++++++++++++++++++ src/client/src/components/activityBadge.ts | 32 +++++++-- src/client/src/components/shared.ts | 4 ++ 15 files changed, 246 insertions(+), 103 deletions(-) create mode 100644 src/client/src/components/activityBadge.test.ts diff --git a/.changeset/unread-bubble-up-indicators.md b/.changeset/unread-bubble-up-indicators.md index 4fdf95a..0d04a42 100644 --- a/.changeset/unread-bubble-up-indicators.md +++ b/.changeset/unread-bubble-up-indicators.md @@ -2,4 +2,4 @@ "@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. diff --git a/src/client/src/components/MachineList.test.ts b/src/client/src/components/MachineList.test.ts index 8f5abb6..c47115b 100644 --- a/src/client/src/components/MachineList.test.ts +++ b/src/client/src/components/MachineList.test.ts @@ -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, statuses: Record = {}, + activities: Record> = {}, ): Promise { 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, diff --git a/src/client/src/components/MachineList.ts b/src/client/src/components/MachineList.ts index 1a833a1..e87a807 100644 --- a/src/client/src/components/MachineList.ts +++ b/src/client/src/components/MachineList.ts @@ -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); }} >
- ${machine.name}${this.renderUnread(machine)}${machine.kind === "local" ? "Local Pi Web" : machine.baseUrl ?? "Remote Pi Web"} · ${statusLabel} + ${machine.name}${machine.kind === "local" ? "Local Pi Web" : machine.baseUrl ?? "Remote Pi Web"} · ${statusLabel} ${this.renderActivity(machine)}
${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) { diff --git a/src/client/src/components/MachineSwitcher.test.ts b/src/client/src/components/MachineSwitcher.test.ts index a5ed843..faf853c 100644 --- a/src/client/src/components/MachineSwitcher.test.ts +++ b/src/client/src/components/MachineSwitcher.test.ts @@ -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): Promise { @@ -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, diff --git a/src/client/src/components/MachineSwitcher.ts b/src/client/src/components/MachineSwitcher.ts index 5a04bd6..6eb77c7 100644 --- a/src/client/src/components/MachineSwitcher.ts +++ b/src/client/src/components/MachineSwitcher.ts @@ -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)} Machine ${label} @@ -98,7 +98,7 @@ export class MachineSwitcher extends LitElement implements KeyboardNavigableSect @click=${() => { this.select(machine); }} @keydown=${(event: KeyboardEvent) => { this.handleMachineOptionKeydown(event); }} > - ${this.renderActivity(machine)}${this.renderUnread(machine)}${machine.name} + ${this.renderActivity(machine)}${machine.name} ${machine.kind === "local" ? "Local Pi Web" : machine.baseUrl ?? "Remote Pi Web"} · ${machineStatusLabel(status)} ${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); } diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index eb44ecd..9793819 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -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", diff --git a/src/client/src/components/ProjectList.test.ts b/src/client/src/components/ProjectList.test.ts index e5366e6..50fd82b 100644 --- a/src/client/src/components/ProjectList.test.ts +++ b/src/client/src/components/ProjectList.test.ts @@ -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): Promise { @@ -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" }; } diff --git a/src/client/src/components/ProjectList.ts b/src/client/src/components/ProjectList.ts index 0554105..8ebb8bb 100644 --- a/src/client/src/components/ProjectList.ts +++ b/src/client/src/components/ProjectList.ts @@ -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); }} >
- ${project.name}${this.renderUnread(project)}${project.path} + ${project.name}${project.path} ${this.renderActivity(project)}
@@ -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) { diff --git a/src/client/src/components/SessionList.test.ts b/src/client/src/components/SessionList.test.ts index d0a5fa9..8943f9b 100644 --- a/src/client/src/components/SessionList.test.ts +++ b/src/client/src/components/SessionList.test.ts @@ -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); }); }); diff --git a/src/client/src/components/SessionList.ts b/src/client/src/components/SessionList.ts index a7b7bba..014224b 100644 --- a/src/client/src/components/SessionList.ts +++ b/src/client/src/components/SessionList.ts @@ -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`
${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`
${showsCheckbox ? html` { event.stopPropagation(); }} @change=${() => { this.toggleSelected(session.id); }}>` : null} ${row.depth > 0 ? html`` : null}${sessionLabel(session)}${row.depth > 2 ? html` depth ${row.depth}` : null}${row.hasMissingParent ? html` parent unavailable` : null}${this.renderSessionMetaPrefix(session, status, activity)}${String(session.messageCount)} messages - ${this.renderActivity(indicatorKind)} + ${this.renderActivity(indicatorKind, unread)}
@@ -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, - runtime: { - statuses?: Record | undefined; - activities?: Record | undefined; - sending?: Record | 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): boolean { + if (isCachedNewSessionInfo(session) || session.archived === true) return false; + return unreadSessionIds.has(session.id); } export function sessionRowsForCurrentTree(sessions: SessionInfo[]): SessionRow[] { diff --git a/src/client/src/components/WorkspaceList.test.ts b/src/client/src/components/WorkspaceList.test.ts index 1fed519..0649e91 100644 --- a/src/client/src/components/WorkspaceList.test.ts +++ b/src/client/src/components/WorkspaceList.test.ts @@ -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): Promise { @@ -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 }; } diff --git a/src/client/src/components/WorkspaceList.ts b/src/client/src/components/WorkspaceList.ts index 29b2dba..fc6161e 100644 --- a/src/client/src/components/WorkspaceList.ts +++ b/src/client/src/components/WorkspaceList.ts @@ -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` ${label} - ${this.renderUnread(workspace)} ${this.isDeleting(workspace) ? html`Deleting…` : null} ${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); diff --git a/src/client/src/components/activityBadge.test.ts b/src/client/src/components/activityBadge.test.ts new file mode 100644 index 0000000..8322e89 --- /dev/null +++ b/src/client/src/components/activityBadge.test.ts @@ -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; +} diff --git a/src/client/src/components/activityBadge.ts b/src/client/src/components/activityBadge.ts index 5cc0030..1509b0f 100644 --- a/src/client/src/components/activityBadge.ts +++ b/src/client/src/components/activityBadge.ts @@ -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``; +/** + * 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``; + } + if (unreadLabel === undefined) { + return html``; + } + const combinedLabel = `${unreadLabel} · ${label}`; + return html``; } -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`${indicator}`; } diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index 42fc34f..ea132fb 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -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); }