From 8af637b00e4df76bc4add5342888963e3c41bc1a Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Mon, 27 Jul 2026 15:08:31 +0200 Subject: [PATCH] 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. --- .changeset/unread-bubble-up-indicators.md | 5 ++ .changeset/unread-mark-as-read-actions.md | 5 ++ src/client/src/components/MachineList.test.ts | 68 +++++++++++++++- src/client/src/components/MachineList.ts | 12 ++- .../src/components/MachineSwitcher.test.ts | 77 +++++++++++++++++++ src/client/src/components/MachineSwitcher.ts | 13 +++- src/client/src/components/PiWebApp.ts | 1 + .../src/components/PiWebApp.unread.test.ts | 22 ++++++ src/client/src/components/ProjectList.test.ts | 54 +++++++++++++ src/client/src/components/ProjectList.ts | 10 ++- .../src/components/WorkspaceList.test.ts | 54 +++++++++++++ src/client/src/components/WorkspaceList.ts | 9 ++- .../appShell/AppNavigationPanel.test.ts | 58 +++++++++++++- .../components/appShell/AppNavigationPanel.ts | 6 ++ src/client/src/components/shared.ts | 2 +- 15 files changed, 382 insertions(+), 14 deletions(-) create mode 100644 .changeset/unread-bubble-up-indicators.md create mode 100644 .changeset/unread-mark-as-read-actions.md create mode 100644 src/client/src/components/MachineSwitcher.test.ts create mode 100644 src/client/src/components/ProjectList.test.ts create mode 100644 src/client/src/components/WorkspaceList.test.ts diff --git a/.changeset/unread-bubble-up-indicators.md b/.changeset/unread-bubble-up-indicators.md new file mode 100644 index 0000000..4fdf95a --- /dev/null +++ b/.changeset/unread-bubble-up-indicators.md @@ -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. diff --git a/.changeset/unread-mark-as-read-actions.md b/.changeset/unread-mark-as-read-actions.md new file mode 100644 index 0000000..7f88c3c --- /dev/null +++ b/.changeset/unread-mark-as-read-actions.md @@ -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. diff --git a/src/client/src/components/MachineList.test.ts b/src/client/src/components/MachineList.test.ts index 3143589..8f5abb6 100644 --- a/src/client/src/components/MachineList.test.ts +++ b/src/client/src/components/MachineList.test.ts @@ -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, + statuses: Record = {}, +): Promise { + 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 }; +} diff --git a/src/client/src/components/MachineList.ts b/src/client/src/components/MachineList.ts index e642189..1a833a1 100644 --- a/src/client/src/components/MachineList.ts +++ b/src/client/src/components/MachineList.ts @@ -3,7 +3,7 @@ import { customElement, property, state } from "lit/decorators.js"; import type { Machine, MachineHealth, WorkspaceActivity } from "../api"; import { machineActivityIndicator } from "../workspaceActivity"; import { actionMenuPanelStyle } from "./actionMenu"; -import { renderActionActivityIndicator } 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 = {}; @property({ attribute: false }) activities: Record> = {}; + @property({ attribute: false }) unreadMachineIds: ReadonlySet = 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); }} >
- ${machine.name}${machine.kind === "local" ? "Local Pi Web" : machine.baseUrl ?? "Remote Pi Web"} · ${statusLabel} + ${machine.name}${this.renderUnread(machine)}${machine.kind === "local" ? "Local Pi Web" : machine.baseUrl ?? "Remote Pi Web"} · ${statusLabel} ${this.renderActivity(machine)}
${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); diff --git a/src/client/src/components/MachineSwitcher.test.ts b/src/client/src/components/MachineSwitcher.test.ts new file mode 100644 index 0000000..a5ed843 --- /dev/null +++ b/src/client/src/components/MachineSwitcher.test.ts @@ -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): Promise { + 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", + }; +} diff --git a/src/client/src/components/MachineSwitcher.ts b/src/client/src/components/MachineSwitcher.ts index b488305..5a04bd6 100644 --- a/src/client/src/components/MachineSwitcher.ts +++ b/src/client/src/components/MachineSwitcher.ts @@ -13,6 +13,7 @@ export class MachineSwitcher extends LitElement implements KeyboardNavigableSect @property({ attribute: false }) selected?: Machine; @property({ attribute: false }) statuses: Record = {}; @property({ attribute: false }) activities: Record> = {}; + @property({ attribute: false }) unreadMachineIds: ReadonlySet = new Set(); @property({ attribute: false }) onSelect?: (machine: Machine) => void | Promise; @property({ attribute: false }) onRemove?: (machine: Machine) => void | Promise; @property({ attribute: false }) onFocusNextSection?: () => void | Promise; @@ -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)} Machine ${label} @@ -97,7 +98,7 @@ export class MachineSwitcher extends LitElement implements KeyboardNavigableSect @click=${() => { this.select(machine); }} @keydown=${(event: KeyboardEvent) => { this.handleMachineOptionKeydown(event); }} > - ${this.renderActivity(machine)}${machine.name} + ${this.renderActivity(machine)}${this.renderUnread(machine)}${machine.name} ${machine.kind === "local" ? "Local Pi Web" : machine.baseUrl ?? "Remote Pi Web"} · ${machineStatusLabel(status)} ${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); } diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 4e4d01f..eb44ecd 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -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} diff --git a/src/client/src/components/PiWebApp.unread.test.ts b/src/client/src/components/PiWebApp.unread.test.ts index e6a746e..3011f9e 100644 --- a/src/client/src/components/PiWebApp.unread.test.ts +++ b/src/client/src/components/PiWebApp.unread.test.ts @@ -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; diff --git a/src/client/src/components/ProjectList.test.ts b/src/client/src/components/ProjectList.test.ts new file mode 100644 index 0000000..e5366e6 --- /dev/null +++ b/src/client/src/components/ProjectList.test.ts @@ -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): Promise { + 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" }; +} diff --git a/src/client/src/components/ProjectList.ts b/src/client/src/components/ProjectList.ts index 1867abb..0554105 100644 --- a/src/client/src/components/ProjectList.ts +++ b/src/client/src/components/ProjectList.ts @@ -3,7 +3,7 @@ import { customElement, property, state } from "lit/decorators.js"; import type { Project, Workspace, WorkspaceActivity } from "../api"; import { projectActivityIndicator } from "../workspaceActivity"; import { actionMenuPanelStyle } from "./actionMenu"; -import { renderActionActivityIndicator } 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 = {}; @property({ attribute: false }) workspacesByProjectId: Record = {}; + @property({ attribute: false }) unreadProjectIds: ReadonlySet = 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); }} >
- ${project.name}${project.path} + ${project.name}${this.renderUnread(project)}${project.path} ${this.renderActivity(project)}
@@ -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; diff --git a/src/client/src/components/WorkspaceList.test.ts b/src/client/src/components/WorkspaceList.test.ts new file mode 100644 index 0000000..1fed519 --- /dev/null +++ b/src/client/src/components/WorkspaceList.test.ts @@ -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): Promise { + 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 }; +} diff --git a/src/client/src/components/WorkspaceList.ts b/src/client/src/components/WorkspaceList.ts index d901bf9..29b2dba 100644 --- a/src/client/src/components/WorkspaceList.ts +++ b/src/client/src/components/WorkspaceList.ts @@ -4,7 +4,7 @@ import type { Workspace, WorkspaceActivity } from "../api"; import type { WorkspaceLabelItem } from "../plugins/types"; import { workspaceActivityFor, workspaceActivityIndicator } from "../workspaceActivity"; import { actionMenuPanelStyle } from "./actionMenu"; -import { renderActionActivityIndicator } 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 = {}; @property({ attribute: false }) deletingWorkspaceIds: string[] = []; + @property({ attribute: false }) unreadWorkspaceIds: ReadonlySet = 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` ${label} + ${this.renderUnread(workspace)} ${this.isDeleting(workspace) ? html`Deleting…` : null} ${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); diff --git a/src/client/src/components/appShell/AppNavigationPanel.test.ts b/src/client/src/components/appShell/AppNavigationPanel.test.ts index 51defa1..023a38b 100644 --- a/src/client/src/components/appShell/AppNavigationPanel.test.ts +++ b/src/client/src/components/appShell/AppNavigationPanel.test.ts @@ -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 }; +} diff --git a/src/client/src/components/appShell/AppNavigationPanel.ts b/src/client/src/components/appShell/AppNavigationPanel.ts index 1a442ca..d1817b0 100644 --- a/src/client/src/components/appShell/AppNavigationPanel.ts +++ b/src/client/src/components/appShell/AppNavigationPanel.ts @@ -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 = {}; @property({ attribute: false }) sendingPrompts: Record = {}; @property({ attribute: false }) unreadSessionIds: ReadonlySet = new Set(); + @property({ attribute: false }) unreadPresence: UnreadPresence = EMPTY_UNREAD_PRESENCE; @property({ attribute: false }) workspacesByProjectId: Record = {}; @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} diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index 7213218..42fc34f 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -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. */