feat(ui): bubble unread presence up to workspace, project, and machine rows

Wire the derived UnreadPresence into dot indicators (no counts) across the
navigation panel: workspace, project, and machine rows (machine-switcher and
machine-list) now show a static accent dot whenever a session beneath them is
unread, including offline machines with stale-but-present state. Presence
flows PiWebApp -> AppNavigationPanel -> leaf id sets, mirroring the
unreadSessionIds chain, and is covered by happy-dom component tests per list
plus panel- and app-level wiring tests. Adds changesets for the mark-as-read
actions and the bubble-up indicators.
This commit is contained in:
Federico Jaramillo Martinez
2026-07-27 15:08:31 +02:00
parent 9267174736
commit 8af637b00e
15 changed files with 382 additions and 14 deletions
@@ -0,0 +1,5 @@
---
"@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.
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Add "Mark as read" actions for unread sessions: a per-session item in the session row ⋯ menu (shown only for unread sessions) and a bulk "Mark read" button in the multi-select bar that marks every unread selected session as read.
+65 -3
View File
@@ -1,6 +1,12 @@
import { describe, expect, it } from "vitest";
import type { Machine } from "../api";
import { canRemoveMachine } from "./MachineList";
// @vitest-environment happy-dom
import { afterEach, describe, expect, it } from "vitest";
import type { Machine, MachineHealth, MachineStatus } from "../api";
import { canRemoveMachine, MachineList } from "./MachineList";
afterEach(() => {
document.body.replaceChildren();
});
describe("canRemoveMachine", () => {
it("only allows remote machines to be removed from the machine list", () => {
@@ -9,6 +15,58 @@ describe("canRemoveMachine", () => {
});
});
describe("machine unread indicator", () => {
it("shows an unread dot only on machines tracked as unread, including offline ones", async () => {
const list = await mountMachineList(
[machine("local", "local"), machine("remote-a", "remote"), machine("remote-b", "remote")],
new Set(["remote-a", "remote-b"]),
{ "remote-b": machineHealth("remote-b", "offline") },
);
expect(unreadDot(rowFor(list, "local"))).toBeNull();
const remoteDot = unreadDot(rowFor(list, "remote-a"));
expect(remoteDot).not.toBeNull();
expect(remoteDot?.getAttribute("title")).toBe("Unread sessions on this machine");
// Stale-but-present counts: an offline machine keeps its last-known unread state.
expect(unreadDot(rowFor(list, "remote-b"))).not.toBeNull();
});
it("clears the dot once the machine is no longer tracked as unread", async () => {
const list = await mountMachineList([machine("local", "local")], new Set(["local"]));
expect(list.shadowRoot?.querySelector(".activity-indicator.unread")).not.toBeNull();
list.unreadMachineIds = new Set();
await list.updateComplete;
expect(list.shadowRoot?.querySelector(".activity-indicator.unread")).toBeNull();
});
});
async function mountMachineList(
machines: Machine[],
unreadMachineIds: ReadonlySet<string>,
statuses: Record<string, MachineHealth> = {},
): Promise<MachineList> {
const list = new MachineList();
list.machines = machines;
list.unreadMachineIds = unreadMachineIds;
list.statuses = statuses;
document.body.append(list);
await list.updateComplete;
return list;
}
function rowFor(list: MachineList, machineName: string): Element {
const rows = [...(list.shadowRoot?.querySelectorAll(".machine-row") ?? [])];
const row = rows.find((candidate) => candidate.textContent.includes(machineName));
if (row === undefined) throw new Error(`Expected a machine row for ${machineName}`);
return row;
}
function unreadDot(row: Element): Element | null {
return row.querySelector(".activity-indicator.unread");
}
function machine(id: string, kind: Machine["kind"]): Machine {
return {
id,
@@ -18,3 +76,7 @@ function machine(id: string, kind: Machine["kind"]): Machine {
updatedAt: "2026-06-04T00:00:00.000Z",
};
}
function machineHealth(machineId: string, status: MachineStatus): MachineHealth {
return { machineId, ok: status === "online", checkedAt: "2026-06-04T00:00:00.000Z", status };
}
+10 -2
View File
@@ -3,7 +3,7 @@ import { customElement, property, state } from "lit/decorators.js";
import type { Machine, MachineHealth, WorkspaceActivity } from "../api";
import { machineActivityIndicator } from "../workspaceActivity";
import { actionMenuPanelStyle } from "./actionMenu";
import { renderActionActivityIndicator } from "./activityBadge";
import { renderActionActivityIndicator, renderActivityIndicator } from "./activityBadge";
import type { KeyboardNavigableSection } from "./navigationFocus";
import { activateSelectableRow, focusSelectedOrFirstSelectableRow, handleSelectableRowKeyboard } from "./selectableRow";
import { listStyles } from "./shared";
@@ -14,6 +14,7 @@ export class MachineList extends LitElement implements KeyboardNavigableSection
@property({ attribute: false }) selected?: Machine;
@property({ attribute: false }) statuses: Record<string, MachineHealth> = {};
@property({ attribute: false }) activities: Record<string, Record<string, WorkspaceActivity>> = {};
@property({ attribute: false }) unreadMachineIds: ReadonlySet<string> = new Set();
@property({ type: Boolean, reflect: true }) collapsible = false;
@property({ type: Boolean, reflect: true }) collapsed = false;
@property({ attribute: false }) onSelect?: (machine: Machine) => void;
@@ -75,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></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>${this.renderUnread(machine)}</span><small>${machine.kind === "local" ? "Local Pi Web" : machine.baseUrl ?? "Remote Pi Web"} · ${statusLabel}</small>
${this.renderActivity(machine)}
</div>
${hasRemoveAction ? this.renderMachineMenu(machine) : null}
@@ -90,6 +91,13 @@ export class MachineList extends LitElement implements KeyboardNavigableSection
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");
}
private renderMachineMenu(machine: Machine) {
const open = this.openMenuMachineId === machine.id;
const menuId = machineMenuId(machine.id);
@@ -0,0 +1,77 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it } from "vitest";
import type { Machine } from "../api";
import { MachineSwitcher } from "./MachineSwitcher";
afterEach(() => {
document.body.replaceChildren();
});
describe("machine-switcher unread indicator", () => {
it("shows an unread dot on the switcher button while the selected machine has unread sessions", async () => {
const switcher = await mountSwitcher([machine("local", "local")], new Set(["local"]));
const button = switcherButton(switcher);
const dot = button.querySelector(".activity-indicator.unread");
expect(dot).not.toBeNull();
expect(dot?.getAttribute("title")).toBe("Unread sessions on this machine");
switcher.unreadMachineIds = new Set();
await switcher.updateComplete;
expect(switcherButton(switcher).querySelector(".activity-indicator.unread")).toBeNull();
});
it("marks only the unread machines among the dropdown options", async () => {
const switcher = await mountSwitcher(
[machine("local", "local"), machine("remote-a", "remote"), machine("remote-b", "remote")],
new Set(["remote-b"]),
);
switcherButton(switcher).click();
await switcher.updateComplete;
expect(unreadDot(optionFor(switcher, "local"))).toBeNull();
expect(unreadDot(optionFor(switcher, "remote-a"))).toBeNull();
expect(unreadDot(optionFor(switcher, "remote-b"))).not.toBeNull();
});
});
async function mountSwitcher(machines: Machine[], unreadMachineIds: ReadonlySet<string>): Promise<MachineSwitcher> {
const switcher = new MachineSwitcher();
switcher.machines = machines;
const selected = machines[0];
if (selected === undefined) throw new Error("Expected at least one machine");
switcher.selected = selected;
switcher.unreadMachineIds = unreadMachineIds;
document.body.append(switcher);
await switcher.updateComplete;
return switcher;
}
function switcherButton(switcher: MachineSwitcher): HTMLElement {
const button = switcher.shadowRoot?.querySelector(".machine-switcher-button");
if (!(button instanceof HTMLElement)) throw new Error("Expected the machine switcher button");
return button;
}
function optionFor(switcher: MachineSwitcher, machineName: string): Element {
const options = [...(switcher.shadowRoot?.querySelectorAll(".machine-option") ?? [])];
const option = options.find((candidate) => candidate.textContent.includes(machineName));
if (option === undefined) throw new Error(`Expected a machine option for ${machineName}`);
return option;
}
function unreadDot(option: Element): Element | null {
return option.querySelector(".activity-indicator.unread");
}
function machine(id: string, kind: Machine["kind"]): Machine {
return {
id,
name: id,
kind,
createdAt: "2026-06-04T00:00:00.000Z",
updatedAt: "2026-06-04T00:00:00.000Z",
};
}
+11 -2
View File
@@ -13,6 +13,7 @@ export class MachineSwitcher extends LitElement implements KeyboardNavigableSect
@property({ attribute: false }) selected?: Machine;
@property({ attribute: false }) statuses: Record<string, MachineHealth> = {};
@property({ attribute: false }) activities: Record<string, Record<string, WorkspaceActivity>> = {};
@property({ attribute: false }) unreadMachineIds: ReadonlySet<string> = new Set();
@property({ attribute: false }) onSelect?: (machine: Machine) => void | Promise<void>;
@property({ attribute: false }) onRemove?: (machine: Machine) => void | Promise<void>;
@property({ attribute: false }) onFocusNextSection?: () => void | Promise<void>;
@@ -65,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.renderActivity(selected)}${this.renderUnread(selected)}
<span class="machine-switcher-text">
<span class="machine-switcher-kicker">Machine</span>
<span class="machine-switcher-label">${label}</span>
@@ -97,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)}<span>${machine.name}</span></span>
<span class="machine-option-name">${this.renderActivity(machine)}${this.renderUnread(machine)}<span>${machine.name}</span></span>
<small>${machine.kind === "local" ? "Local Pi Web" : machine.baseUrl ?? "Remote Pi Web"} · ${machineStatusLabel(status)}</small>
</button>
${hasActions ? html`
@@ -128,6 +129,13 @@ export class MachineSwitcher extends LitElement implements KeyboardNavigableSect
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");
}
private selectedMachine(): Machine | undefined {
return this.selected ?? this.machines.find((machine) => machine.id === "local") ?? this.machines[0];
}
@@ -287,6 +295,7 @@ export class MachineSwitcher extends LitElement implements KeyboardNavigableSect
.activity-indicator.session { border-radius: 50%; background: var(--pi-success); }
.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); }
.machine-switcher-menu { position: fixed; z-index: 10000; box-sizing: border-box; min-width: min(280px, calc(100vw - 16px)); overflow: auto; padding: 4px; border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); box-shadow: 0 8px 24px var(--pi-shadow); }
.machine-option { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 2px; align-items: stretch; margin: 2px 0; }
.machine-option.no-actions { grid-template-columns: minmax(0, 1fr); }
+1
View File
@@ -1342,6 +1342,7 @@ export class PiWebApp extends LitElement {
.sessionActivities=${this.state.sessionActivities}
.sendingPrompts=${this.state.sendingPrompts}
.unreadSessionIds=${this.unreadSessionIds}
.unreadPresence=${this.unreadPresence}
.selectedSession=${this.state.selectedSession}
.startingSessionCount=${this.state.startingSessionCount}
.canStartSession=${!!this.state.selectedWorkspace}
@@ -337,6 +337,28 @@ describe("PiWebApp session unread wiring", () => {
expect([...unreadPresence(app).projects]).toEqual(["project-1"]);
expect([...unreadPresence(app).workspaces]).toEqual(["ws-1"]);
});
it("binds the derived unread presence into the navigation panel", () => {
stubJsonFetch({ catalogId: "catalog-a", catalogRevision: 2, sessions: [] });
const app = createApp();
enableUnread(app);
const selected = session("selected");
setAppState(app, {
...initialAppState(),
machines: [machine("local")],
selectedMachine: machine("local"),
sessions: [selected],
selectedSession: selected,
mainView: "chat",
});
handleRealtimeEvent(app, unreadEvent(1, unreadSummary(selected, 1)));
const bound = navigationPanelValue(app, ".unreadPresence=");
if (!isUnreadPresence(bound)) throw new Error("Expected unread presence in navigation");
expect(bound).toBe(unreadPresence(app));
expect([...bound.machines]).toEqual(["local"]);
});
});
type RenderNavigationPanel = (this: PiWebApp) => TemplateResult;
@@ -0,0 +1,54 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it } from "vitest";
import type { Project } from "../api";
import { ProjectList } from "./ProjectList";
afterEach(() => {
document.body.replaceChildren();
});
describe("project unread indicator", () => {
it("shows an unread dot only on projects tracked as unread", async () => {
const list = await mountProjectList([project("project-a"), project("project-b")], new Set(["project-b"]));
expect(unreadDot(rowFor(list, "project-a"))).toBeNull();
const dot = unreadDot(rowFor(list, "project-b"));
expect(dot).not.toBeNull();
expect(dot?.getAttribute("title")).toBe("Unread sessions in this project");
});
it("clears the dot once the project is no longer tracked as unread", async () => {
const list = await mountProjectList([project("project-a")], new Set(["project-a"]));
expect(list.shadowRoot?.querySelector(".activity-indicator.unread")).not.toBeNull();
list.unreadProjectIds = new Set();
await list.updateComplete;
expect(list.shadowRoot?.querySelector(".activity-indicator.unread")).toBeNull();
});
});
async function mountProjectList(projects: Project[], unreadProjectIds: ReadonlySet<string>): Promise<ProjectList> {
const list = new ProjectList();
list.projects = projects;
list.unreadProjectIds = unreadProjectIds;
document.body.append(list);
await list.updateComplete;
return list;
}
function rowFor(list: ProjectList, projectName: string): Element {
const rows = [...(list.shadowRoot?.querySelectorAll(".action-row") ?? [])];
const row = rows.find((candidate) => candidate.textContent.includes(projectName));
if (row === undefined) throw new Error(`Expected a project row for ${projectName}`);
return row;
}
function unreadDot(row: Element): Element | null {
return row.querySelector(".activity-indicator.unread");
}
function project(id: string): Project {
return { id, name: id, path: `/repo/${id}`, createdAt: "2026-06-04T00:00:00.000Z" };
}
+8 -2
View File
@@ -3,7 +3,7 @@ import { customElement, property, state } from "lit/decorators.js";
import type { Project, Workspace, WorkspaceActivity } from "../api";
import { projectActivityIndicator } from "../workspaceActivity";
import { actionMenuPanelStyle } from "./actionMenu";
import { renderActionActivityIndicator } from "./activityBadge";
import { renderActionActivityIndicator, renderActivityIndicator } from "./activityBadge";
import type { KeyboardNavigableSection } from "./navigationFocus";
import { activateSelectableRow, focusSelectedOrFirstSelectableRow, handleSelectableRowKeyboard } from "./selectableRow";
import { listStyles } from "./shared";
@@ -14,6 +14,7 @@ export class ProjectList extends LitElement implements KeyboardNavigableSection
@property({ attribute: false }) selected?: Project;
@property({ attribute: false }) activities: Record<string, WorkspaceActivity> = {};
@property({ attribute: false }) workspacesByProjectId: Record<string, Workspace[]> = {};
@property({ attribute: false }) unreadProjectIds: ReadonlySet<string> = new Set();
@property({ type: Boolean, reflect: true }) collapsible = false;
@property({ type: Boolean, reflect: true }) collapsed = false;
@property({ attribute: false }) onSelect?: (project: Project) => void;
@@ -64,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></span><small>${project.path}</small>
<span class="workspace-primary"><span class="workspace-primary-label">${project.name}</span>${this.renderUnread(project)}</span><small>${project.path}</small>
${this.renderActivity(project)}
</div>
<div class="action-menu">
@@ -104,6 +105,11 @@ export class ProjectList extends LitElement implements KeyboardNavigableSection
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");
}
private toggleMenu(projectId: string, target: EventTarget | null) {
if (this.openMenuProjectId === projectId) {
this.openMenuProjectId = undefined;
@@ -0,0 +1,54 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it } from "vitest";
import type { Workspace } from "../api";
import { WorkspaceList } from "./WorkspaceList";
afterEach(() => {
document.body.replaceChildren();
});
describe("workspace unread indicator", () => {
it("shows an unread dot only on workspaces tracked as unread", async () => {
const list = await mountWorkspaceList([workspace("ws-a"), workspace("ws-b")], new Set(["ws-b"]));
expect(unreadDot(rowFor(list, "ws-a"))).toBeNull();
const dot = unreadDot(rowFor(list, "ws-b"));
expect(dot).not.toBeNull();
expect(dot?.getAttribute("title")).toBe("Unread sessions in this workspace");
});
it("clears the dot once the workspace is no longer tracked as unread", async () => {
const list = await mountWorkspaceList([workspace("ws-a")], new Set(["ws-a"]));
expect(list.shadowRoot?.querySelector(".activity-indicator.unread")).not.toBeNull();
list.unreadWorkspaceIds = new Set();
await list.updateComplete;
expect(list.shadowRoot?.querySelector(".activity-indicator.unread")).toBeNull();
});
});
async function mountWorkspaceList(workspaces: Workspace[], unreadWorkspaceIds: ReadonlySet<string>): Promise<WorkspaceList> {
const list = new WorkspaceList();
list.workspaces = workspaces;
list.unreadWorkspaceIds = unreadWorkspaceIds;
document.body.append(list);
await list.updateComplete;
return list;
}
function rowFor(list: WorkspaceList, workspaceLabel: string): Element {
const rows = [...(list.shadowRoot?.querySelectorAll(".workspace-row") ?? [])];
const row = rows.find((candidate) => candidate.textContent.includes(workspaceLabel));
if (row === undefined) throw new Error(`Expected a workspace row for ${workspaceLabel}`);
return row;
}
function unreadDot(row: Element): Element | null {
return row.querySelector(".activity-indicator.unread");
}
function workspace(id: string): Workspace {
return { id, projectId: "project-1", path: `/repo/${id}`, label: id, isMain: true, isGitRepo: true, isGitWorktree: false };
}
+8 -1
View File
@@ -4,7 +4,7 @@ import type { Workspace, WorkspaceActivity } from "../api";
import type { WorkspaceLabelItem } from "../plugins/types";
import { workspaceActivityFor, workspaceActivityIndicator } from "../workspaceActivity";
import { actionMenuPanelStyle } from "./actionMenu";
import { renderActionActivityIndicator } from "./activityBadge";
import { renderActionActivityIndicator, renderActivityIndicator } from "./activityBadge";
import type { KeyboardNavigableSection } from "./navigationFocus";
import { activateSelectableRow, focusSelectedOrFirstSelectableRow, handleSelectableRowKeyboard } from "./selectableRow";
import { listStyles } from "./shared";
@@ -19,6 +19,7 @@ export class WorkspaceList extends LitElement implements KeyboardNavigableSectio
@property({ attribute: false }) workspaceLabelItems: (workspace: Workspace) => WorkspaceLabelItem[] = () => [];
@property({ attribute: false }) activities: Record<string, WorkspaceActivity> = {};
@property({ attribute: false }) deletingWorkspaceIds: string[] = [];
@property({ attribute: false }) unreadWorkspaceIds: ReadonlySet<string> = new Set();
@property({ attribute: false }) onSelect?: (workspace: Workspace) => void;
@property({ attribute: false }) onDelete?: (workspace: Workspace) => void;
@property({ attribute: false }) onToggleCollapsed?: () => void;
@@ -100,6 +101,7 @@ export class WorkspaceList extends LitElement implements KeyboardNavigableSectio
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`
@@ -111,6 +113,11 @@ 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);
@@ -1,6 +1,17 @@
import { describe, expect, it } from "vitest";
import type { Machine } from "../../api";
import { shouldShowMachinesSection } from "./AppNavigationPanel";
// @vitest-environment happy-dom
import { afterEach, describe, expect, it } from "vitest";
import type { Machine, Project, Workspace } from "../../api";
import type { UnreadPresence } from "../../unreadPresence";
import { MachineList } from "../MachineList";
import { MachineSwitcher } from "../MachineSwitcher";
import { ProjectList } from "../ProjectList";
import { WorkspaceList } from "../WorkspaceList";
import { AppNavigationPanel, shouldShowMachinesSection } from "./AppNavigationPanel";
afterEach(() => {
document.body.replaceChildren();
});
describe("shouldShowMachinesSection", () => {
it("hides machine navigation when there is no machine choice", () => {
@@ -13,6 +24,39 @@ describe("shouldShowMachinesSection", () => {
});
});
describe("unread presence wiring", () => {
it("feeds each unread presence slice to the matching navigation section", async () => {
const unreadPresence: UnreadPresence = {
machines: new Set(["remote-a"]),
projects: new Set(["project-1"]),
workspaces: new Set(["ws-1"]),
};
const panel = new AppNavigationPanel();
panel.compact = true;
panel.machines = [machine("local"), machine("remote-a")];
panel.selectedMachine = machine("local");
panel.projects = [project("project-1")];
panel.workspaces = [workspace("ws-1", "project-1")];
panel.unreadPresence = unreadPresence;
document.body.append(panel);
await panel.updateComplete;
const switcher = panel.shadowRoot?.querySelector("machine-switcher");
const machineList = panel.shadowRoot?.querySelector("machine-list");
const projectList = panel.shadowRoot?.querySelector("project-list");
const workspaceList = panel.shadowRoot?.querySelector("workspace-list");
if (!(switcher instanceof MachineSwitcher)) throw new Error("Expected machine-switcher section");
if (!(machineList instanceof MachineList)) throw new Error("Expected machine-list section");
if (!(projectList instanceof ProjectList)) throw new Error("Expected project-list section");
if (!(workspaceList instanceof WorkspaceList)) throw new Error("Expected workspace-list section");
expect(switcher.unreadMachineIds).toBe(unreadPresence.machines);
expect(machineList.unreadMachineIds).toBe(unreadPresence.machines);
expect(projectList.unreadProjectIds).toBe(unreadPresence.projects);
expect(workspaceList.unreadWorkspaceIds).toBe(unreadPresence.workspaces);
});
});
function machine(id: string): Machine {
return {
id,
@@ -22,3 +66,11 @@ function machine(id: string): Machine {
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" };
}
function workspace(id: string, projectId: string): Workspace {
return { id, projectId, path: `/repo/${id}`, label: id, isMain: true, isGitRepo: true, isGitWorktree: false };
}
@@ -4,6 +4,7 @@ import type { Machine, MachineHealth, Project, SessionActivity, SessionInfo, Ses
import type { WorkspaceLabelItem } from "../../plugins/types";
import type { NavigationSection } from "../../appShell/navigationState";
import { NAVIGATION_SECTION_ORDER } from "../../appShell/navigationState";
import { EMPTY_UNREAD_PRESENCE, type UnreadPresence } from "../../unreadPresence";
import type { KeyboardNavigableSection } from "../navigationFocus";
import "../MachineList";
import "../MachineSwitcher";
@@ -30,6 +31,7 @@ export class AppNavigationPanel extends LitElement {
@property({ attribute: false }) sessionStatuses: Record<string, SessionStatus> = {};
@property({ attribute: false }) sendingPrompts: Record<string, true> = {};
@property({ attribute: false }) unreadSessionIds: ReadonlySet<string> = new Set();
@property({ attribute: false }) unreadPresence: UnreadPresence = EMPTY_UNREAD_PRESENCE;
@property({ attribute: false }) workspacesByProjectId: Record<string, Workspace[]> = {};
@property({ attribute: false }) deletingWorkspaceIds: string[] = [];
@property({ attribute: false }) workspaceLabelItems: (workspace: Workspace) => WorkspaceLabelItem[] = () => [];
@@ -103,6 +105,7 @@ export class AppNavigationPanel extends LitElement {
.selected=${this.selectedMachine}
.statuses=${this.machineStatuses}
.activities=${this.machineActivities}
.unreadMachineIds=${this.unreadPresence.machines}
.onSelect=${(machine: Machine) => this.onSelectMachine?.(machine)}
.onRemove=${(machine: Machine) => this.onRemoveMachine?.(machine)}
.onFocusNextSection=${() => { this.focusNextFrom("machines"); }}
@@ -120,6 +123,7 @@ export class AppNavigationPanel extends LitElement {
.selected=${this.selectedMachine}
.statuses=${this.machineStatuses}
.activities=${this.machineActivities}
.unreadMachineIds=${this.unreadPresence.machines}
.collapsible=${this.collapsible}
.collapsed=${this.machinesCollapsed}
.onToggleCollapsed=${() => { this.onToggleMachines?.(); }}
@@ -134,6 +138,7 @@ export class AppNavigationPanel extends LitElement {
.selected=${this.selectedProject}
.activities=${this.workspaceActivities}
.workspacesByProjectId=${this.workspacesByProjectId}
.unreadProjectIds=${this.unreadPresence.projects}
.collapsible=${this.collapsible}
.collapsed=${this.projectsCollapsed}
.onToggleCollapsed=${() => { this.onToggleProjects?.(); }}
@@ -148,6 +153,7 @@ export class AppNavigationPanel extends LitElement {
.selected=${this.selectedWorkspace}
.activities=${this.workspaceActivities}
.deletingWorkspaceIds=${this.deletingWorkspaceIds}
.unreadWorkspaceIds=${this.unreadPresence.workspaces}
.collapsible=${this.collapsible}
.collapsed=${this.workspacesCollapsed}
.workspaceLabelItems=${this.workspaceLabelItems}
+1 -1
View File
@@ -277,7 +277,7 @@ export const listStyles = css`
.badge { display: inline-block; margin-left: 5px; border: 1px solid var(--pi-border); border-radius: 999px; color: var(--pi-muted); padding: 0 5px; font-size: 11px; font-weight: 400; }
.action-activity { position: absolute; top: 5px; right: 6px; z-index: 1; display: grid; place-items: center; width: 10px; height: 10px; }
.action-activity .activity-indicator { margin: 0; vertical-align: 0; }
.activity-indicator { display: inline-block; width: 7px; height: 7px; margin-right: 6px; background: var(--pi-success); animation: pulse 1s ease-in-out infinite; vertical-align: 1px; }
.activity-indicator { flex: 0 0 auto; display: inline-block; width: 7px; height: 7px; margin-right: 6px; background: var(--pi-success); animation: pulse 1s ease-in-out infinite; vertical-align: 1px; }
.activity-indicator.session { border-radius: 50%; background: var(--pi-success); }
.activity-indicator.terminal { border-radius: 2px; background: var(--pi-accent); }
/* Client-side sending (upload in flight); distinct from server activity, which propagates to workspace/machine rows. */