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