Archived
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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user