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:
@@ -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.
|
||||
|
||||
@@ -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