From 0a080f4390160962ac3cc675cdf1fb674b2b24f5 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Mon, 27 Jul 2026 13:59:29 +0200 Subject: [PATCH 1/5] test(sessions): cover unread cleanup across all archive paths Relay unread-ux leg 1: audit verified every archive path (single, bulk, tree, cleanup, restore, delete, rebind) clears unread state in the right order with no resurrection vector. Add regression tests for the uncovered paths: bulk archive, archive with descendants, cleanup, bulk delete, and archiving an active session with a pending activity latch. --- .../sessions/piSessionService.unread.test.ts | 231 ++++++++++++++++++ 1 file changed, 231 insertions(+) diff --git a/src/server/sessions/piSessionService.unread.test.ts b/src/server/sessions/piSessionService.unread.test.ts index c5a432a..4268136 100644 --- a/src/server/sessions/piSessionService.unread.test.ts +++ b/src/server/sessions/piSessionService.unread.test.ts @@ -588,6 +588,237 @@ describe("PiSessionService daemon-owned unread state", () => { await service.dispose(); } }); + + it("clears unread on bulk archive for listed, active, and already-archived sessions while busy failures keep unread", async () => { + const unreadStore = new SessionUnreadStore({ createCatalogId: () => "catalog-test" }); + for (const sessionId of ["listed-idle", "active-idle", "busy", "already-archived"]) { + completeStoreWork(unreadStore, sessionId, WORKSPACE_CWD); + } + const archivedRecord = { sessionId: "already-archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", archivePath: "/archive/already-archived.jsonl" }; + const activeIdle = fakeRuntime("active-idle"); + const busy = fakeRuntime("busy", { isStreaming: true }); + const runtimes = new Map([["active-idle", activeIdle.runtime], ["busy", busy.runtime]]); + const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, + createAgentRuntime: (_createRuntime, options) => { + const runtime = runtimes.get(options.sessionManager.getSessionId()); + if (runtime === undefined) throw new Error("Unexpected runtime creation"); + return Promise.resolve(runtime); + }, + sessionManager: { + create: () => fakeSessionManager(), + list: () => Promise.resolve([sessionRecord("listed-idle"), sessionRecord("active-idle"), sessionRecord("busy")]), + open: (path) => fakeSessionManager("/workspace", { getSessionId: () => path.replace(/^\/sessions\/|\.jsonl$/g, "") }), + }, + archiveStore: { + list: () => Promise.resolve([archivedRecord]), + get: (sessionId) => Promise.resolve(sessionId === "already-archived" ? archivedRecord : undefined), + archive: () => Promise.reject(new Error("bulk archive should use archiveMany")), + archiveMany: (inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }))), + restore: () => Promise.resolve(), + isArchived: (sessionId) => Promise.resolve(sessionId === "already-archived"), + }, + heartbeatIntervalMs: 60_000, + unreadStore, + }); + + try { + await service.status(sessionRef("active-idle")); + await service.status(sessionRef("busy")); + const result = await service.archiveMany([ + { id: "already-archived", cwd: "/workspace" }, + { id: "listed-idle", cwd: "/workspace" }, + { id: "active-idle", cwd: "/workspace" }, + { id: "busy", cwd: "/workspace" }, + ]); + + expect(result.archivedSessionIds).toEqual(["already-archived", "listed-idle", "active-idle"]); + expect(result.failures).toEqual([{ sessionId: "busy", error: "Stop current session activity before archiving" }]); + expect(activeIdle.calls.dispose).toBe(1); + expect(busy.calls.abort).toBe(0); + expect((await service.unreadCatalog()).sessions).toMatchObject([{ sessionId: "busy", cwd: WORKSPACE_CWD }]); + } finally { + await service.dispose(); + } + }); + + it("clears unread for the whole subtree when archiving with descendants", async () => { + const unreadStore = new SessionUnreadStore({ createCatalogId: () => "catalog-test" }); + for (const sessionId of ["root", "direct-child", "archived-child", "grandchild"]) { + completeStoreWork(unreadStore, sessionId, WORKSPACE_CWD); + } + const root = sessionRecord("root"); + const directChild = { ...sessionRecord("direct-child"), parentSessionPath: root.path }; + const archivedChild = { ...sessionRecord("archived-child"), parentSessionPath: root.path }; + const grandchild = { ...sessionRecord("grandchild"), parentSessionPath: archivedChild.path }; + const archivedChildRecord = { + sessionId: "archived-child", + cwd: "/workspace", + archivedAt: "2026-01-02T00:00:00.000Z", + originalPath: archivedChild.path, + archivePath: "/archive/archived-child.jsonl", + parentSessionPath: root.path, + }; + const fake = fakeRuntime("root", { sessionFile: root.path }); + const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, + createAgentRuntime: runtimeCreator(fake.runtime), + sessionManager: { + create: () => fakeSessionManager(), + list: () => Promise.resolve([root, directChild, archivedChild, grandchild]), + open: () => fakeSessionManager(), + }, + archiveStore: { + list: () => Promise.resolve([archivedChildRecord]), + get: () => Promise.resolve(undefined), + archive: (input: { sessionId: string; cwd: string }) => Promise.resolve({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }), + restore: () => Promise.resolve(), + isArchived: () => Promise.resolve(false), + }, + heartbeatIntervalMs: 60_000, + unreadStore, + }); + + try { + const result = await service.archiveTree(sessionRef("root")); + + expect(result.sessionIds).toEqual(["root", "direct-child", "grandchild"]); + expect(result.skippedAlreadyArchivedCount).toBe(1); + expect((await service.unreadCatalog()).sessions).toEqual([]); + } finally { + await service.dispose(); + } + }); + + it("clears unread for sessions archived and deleted by cleanup", async () => { + const unreadStore = new SessionUnreadStore({ createCatalogId: () => "catalog-test" }); + completeStoreWork(unreadStore, "cleanup-archive", "/old-project"); + completeStoreWork(unreadStore, "cleanup-delete", "/old-project"); + const archivedRecord = { sessionId: "cleanup-delete", cwd: "/old-project", archivedAt: "2026-04-01T00:00:00.000Z", archivePath: "/archive/cleanup-delete.jsonl" }; + const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, + now: () => new Date("2026-06-25T00:00:00.000Z"), + createAgentRuntime: () => Promise.reject(new Error("cleanup should not open runtimes")), + sessionManager: { + create: () => fakeSessionManager(), + list: () => Promise.resolve([]), + listAll: () => Promise.resolve([sessionRecord("cleanup-archive", "/old-project")]), + open: () => fakeSessionManager(), + }, + archiveStore: { + list: () => Promise.resolve([archivedRecord]), + get: () => Promise.resolve(undefined), + archive: () => Promise.reject(new Error("cleanup should use archiveMany")), + archiveMany: (inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-06-25T00:00:00.000Z" }))), + restore: () => Promise.resolve(), + isArchived: () => Promise.resolve(false), + deleteArchived: () => Promise.reject(new Error("cleanup should use deleteArchivedMany")), + deleteArchivedMany: (sessionIds: readonly string[]) => Promise.resolve([...sessionIds]), + }, + heartbeatIntervalMs: 60_000, + unreadStore, + }); + + try { + const result = await service.cleanup({ thresholds: { archiveIdleDays: 30, deleteArchivedDays: 30 } }); + + expect(result.archivedSessionIds).toEqual(["cleanup-archive"]); + expect(result.deletedSessionIds).toEqual(["cleanup-delete"]); + expect((await service.unreadCatalog()).sessions).toEqual([]); + } finally { + await service.dispose(); + } + }); + + it("clears unread only for archived records actually removed by bulk delete", async () => { + const unreadStore = new SessionUnreadStore({ createCatalogId: () => "catalog-test" }); + completeStoreWork(unreadStore, "busy-archived", WORKSPACE_CWD); + completeStoreWork(unreadStore, "idle-archived", WORKSPACE_CWD); + const busyRecord = { sessionId: "busy-archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", archivePath: "/archive/busy.jsonl" }; + const idleRecord = { sessionId: "idle-archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", archivePath: "/archive/idle.jsonl" }; + const busy = fakeRuntime("busy-archived", { isStreaming: true }); + const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, + createAgentRuntime: runtimeCreator(busy.runtime), + sessionManager: sessionGateway([]), + archiveStore: { + list: () => Promise.resolve([busyRecord, idleRecord]), + get: (sessionId) => Promise.resolve(sessionId === "busy-archived" ? busyRecord : undefined), + archive: () => Promise.reject(new Error("archive should not be called")), + restore: () => Promise.resolve(), + isArchived: () => Promise.resolve(false), + deleteArchived: () => Promise.reject(new Error("bulk delete should use deleteArchivedMany")), + deleteArchivedMany: (sessionIds: readonly string[]) => Promise.resolve([...sessionIds]), + }, + heartbeatIntervalMs: 60_000, + unreadStore, + }); + + try { + await service.status(sessionRef("busy-archived")); + const result = await service.deleteArchivedMany([ + { id: "busy-archived", cwd: "/workspace" }, + { id: "idle-archived", cwd: "/workspace" }, + ]); + + expect(result).toMatchObject({ + deletedSessionIds: ["idle-archived"], + failures: [{ sessionId: "busy-archived", error: "Stop current session activity before deleting archived session" }], + }); + expect((await service.unreadCatalog()).sessions).toMatchObject([{ sessionId: "busy-archived", cwd: WORKSPACE_CWD }]); + } finally { + await service.dispose(); + } + }); + + it("does not resurrect unread when archiving an active session with a pending activity latch", async () => { + const unreadStore = new SessionUnreadStore({ createCatalogId: () => "catalog-test" }); + const hub = new CapturingSessionEventHub(); + const fake = fakeRuntime("archive-active"); + const service = new PiSessionService(hub, { + agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, + createAgentRuntime: runtimeCreator(fake.runtime), + sessionManager: sessionGateway([sessionRecord("archive-active")]), + archiveStore: { + ...emptyArchiveStore(), + archive: (input: { sessionId: string; cwd: string }) => Promise.resolve({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }), + }, + heartbeatIntervalMs: 60_000, + unreadStore, + }); + + try { + await service.status(sessionRef("archive-active")); + completeRuntimeWork(fake); + expect((await service.unreadCatalog()).sessions).toMatchObject([{ sessionId: "archive-active", completionOrder: 1 }]); + + // Leave a set activity latch behind: work started, then stopped without an + // observable end event. The archive path must not let that latch record a + // completion after the forget clears unread state. + fake.session.isStreaming = true; + fake.emit({ type: "agent_start" }); + fake.session.isStreaming = false; + + await service.archive(sessionRef("archive-active")); + expect((await service.unreadCatalog()).sessions).toEqual([]); + expect(unreadEvents(hub).at(-1)).toMatchObject({ sessionId: "archive-active", unread: null }); + + // The disposed runtime is unsubscribed before the forget, so late events + // cannot re-latch and manufacture a completion for the archived session. + fake.emit({ type: "agent_start" }); + fake.emit({ type: "turn_end" }); + await drainMicrotasks(); + expect((await service.unreadCatalog()).sessions).toEqual([]); + expect(unreadEvents(hub).at(-1)).toMatchObject({ sessionId: "archive-active", unread: null }); + } finally { + await service.dispose(); + } + }); }); function completeRuntimeWork(runtime: ReturnType): void { From b4a7a9230b45afb1100d4ee34f4f2a0d8a60d76c Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Mon, 27 Jul 2026 14:22:17 +0200 Subject: [PATCH 2/5] feat(ui): add mark-as-read actions for unread sessions Add a "Mark as read" item to the session row menu (shown only for unread, non-archived, non-transient sessions) and a bulk "Mark read" action to the current-selection toolbar (enabled when any selected session is unread). Both flow through AppNavigationPanel to PiWebApp, which acknowledges via SessionUnreadController with the exact observed completion order. --- src/client/src/components/PiWebApp.ts | 7 + .../src/components/PiWebApp.unread.test.ts | 78 +++++++++- src/client/src/components/SessionList.test.ts | 135 +++++++++++++++++- src/client/src/components/SessionList.ts | 11 ++ .../components/appShell/AppNavigationPanel.ts | 4 + 5 files changed, 230 insertions(+), 5 deletions(-) diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 0a85cc9..b1d6394 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -301,6 +301,11 @@ export class PiWebApp extends LitElement { void this.sessionUnread.acknowledge(machineId, session); } + private markSessionsRead(sessions: readonly SessionInfo[]): void { + const machineId = selectedMachineId(this.state); + for (const session of sessions) void this.sessionUnread.acknowledge(machineId, session); + } + private async commitReadyChatAfterRender(machineId: string, session: SessionInfo): Promise { const identity = unreadChatIdentity(machineId, session); await this.updateComplete; @@ -1348,6 +1353,8 @@ export class PiWebApp extends LitElement { .onArchivedCollapsed=${() => { this.sessions.clearSelectionAfterArchivedCollapse(); }} .onStartSession=${() => this.startSessionFromNavigation()} .onSelectSession=${(session: SessionInfo) => this.selectNavigationItem("sessions", "chat", () => this.sessions.selectSession(session))} + .onMarkSessionRead=${(session: SessionInfo) => { this.markSessionsRead([session]); }} + .onMarkSessionsRead=${(sessions: SessionInfo[]) => { this.markSessionsRead(sessions); }} .onArchiveSession=${(session: SessionInfo) => this.sessions.archiveSession(session)} .onArchiveSessionWithDescendants=${(session: SessionInfo) => this.sessions.archiveSessionWithDescendants(session)} .onArchiveSessions=${(sessions: SessionInfo[]) => this.sessions.archiveSessions(sessions)} diff --git a/src/client/src/components/PiWebApp.unread.test.ts b/src/client/src/components/PiWebApp.unread.test.ts index c5a9825..208aff1 100644 --- a/src/client/src/components/PiWebApp.unread.test.ts +++ b/src/client/src/components/PiWebApp.unread.test.ts @@ -231,6 +231,52 @@ describe("PiWebApp session unread wiring", () => { expect(navigationUnreadSessionIds(app).size).toBe(0); }); + + it("acknowledges a session explicitly marked as read from navigation", async () => { + const fetchMock = stubJsonFetch({ catalogId: "catalog-a", catalogRevision: 2, sessions: [] }); + const app = createApp(); + enableUnread(app); + const selected = session("selected"); + const alpha = session("alpha"); + setAppState(app, { ...initialAppState(), sessions: [selected, alpha], selectedSession: selected, mainView: "chat" }); + + handleRealtimeEvent(app, unreadEvent(1, unreadSummary(alpha, 1))); + expect([...navigationUnreadSessionIds(app)]).toEqual([alpha.id]); + expect(fetchMock).not.toHaveBeenCalled(); + + navigationMarkSessionRead(app)(alpha); + await vi.waitFor(() => { expect(fetchMock).toHaveBeenCalledOnce(); }); + expect(fetchMock.mock.calls[0]?.[0]).toBe("https://pi.example.test/api/machines/local/sessions/alpha/unread/acknowledge"); + const init = fetchMock.mock.calls[0]?.[1]; + expect(JSON.parse(typeof init?.body === "string" ? init.body : "{}")).toEqual({ + cwd: "/repo", + catalogId: "catalog-a", + throughCompletionOrder: 1, + }); + await vi.waitFor(() => { expect(navigationUnreadSessionIds(app).size).toBe(0); }); + }); + + it("acknowledges every session in a bulk mark-read request from navigation", async () => { + const fetchMock = stubJsonFetch({ catalogId: "catalog-a", catalogRevision: 2, sessions: [] }); + const app = createApp(); + enableUnread(app); + const selected = session("selected"); + const alpha = session("alpha"); + const beta = session("beta"); + setAppState(app, { ...initialAppState(), sessions: [selected, alpha, beta], selectedSession: selected, mainView: "chat" }); + + handleRealtimeEvent(app, unreadEvent(1, unreadSummary(alpha, 1))); + handleRealtimeEvent(app, unreadEvent(2, unreadSummary(beta, 2))); + expect([...navigationUnreadSessionIds(app)]).toEqual([alpha.id, beta.id]); + expect(fetchMock).not.toHaveBeenCalled(); + + navigationMarkSessionsRead(app)([alpha, beta]); + await vi.waitFor(() => { expect(fetchMock).toHaveBeenCalledTimes(2); }); + const requests = fetchMock.mock.calls.map((call) => call[0]); + expect(requests).toContain("https://pi.example.test/api/machines/local/sessions/alpha/unread/acknowledge"); + expect(requests).toContain("https://pi.example.test/api/machines/local/sessions/beta/unread/acknowledge"); + await vi.waitFor(() => { expect(navigationUnreadSessionIds(app).size).toBe(0); }); + }); }); type RenderNavigationPanel = (this: PiWebApp) => TemplateResult; @@ -241,6 +287,8 @@ type UpdatedHook = (this: PiWebApp) => void; type DisconnectedHook = (this: PiWebApp) => void; type RenegotiateUnreadMachine = (this: PiWebApp, machineId: string) => Promise; type RefreshUnread = (machineId: string) => Promise; +type MarkSessionRead = (session: SessionInfo) => void; +type MarkSessionsRead = (sessions: SessionInfo[]) => void; function createApp(storedValues: Record = {}, mobileNavigation = false): PiWebApp { const values = new Map(Object.entries(storedValues)); @@ -356,15 +404,31 @@ function mobileNavigationTab(app: PiWebApp): AppMobileMainTab { } function navigationUnreadSessionIds(app: PiWebApp): ReadonlySet { - const method: unknown = Reflect.get(app, "renderNavigationPanel"); - if (!isRenderNavigationPanel(method)) throw new Error("PiWebApp.renderNavigationPanel is not callable"); - const value = templateValueAfterMarker(method.call(app), ".unreadSessionIds="); + const value = navigationPanelValue(app, ".unreadSessionIds="); if (!(value instanceof Set) || ![...value].every((entry: unknown) => typeof entry === "string")) { throw new Error("Expected unread session ids in navigation"); } return value; } +function navigationMarkSessionRead(app: PiWebApp): MarkSessionRead { + const value = navigationPanelValue(app, ".onMarkSessionRead="); + if (!isMarkSessionRead(value)) throw new Error("Expected mark-session-read callback in navigation"); + return value; +} + +function navigationMarkSessionsRead(app: PiWebApp): MarkSessionsRead { + const value = navigationPanelValue(app, ".onMarkSessionsRead="); + if (!isMarkSessionsRead(value)) throw new Error("Expected mark-sessions-read callback in navigation"); + return value; +} + +function navigationPanelValue(app: PiWebApp, marker: string): unknown { + const method: unknown = Reflect.get(app, "renderNavigationPanel"); + if (!isRenderNavigationPanel(method)) throw new Error("PiWebApp.renderNavigationPanel is not callable"); + return templateValueAfterMarker(method.call(app), marker); +} + function session(id: string): SessionInfo { return { id, @@ -457,3 +521,11 @@ function isRenegotiateUnreadMachine(value: unknown): value is RenegotiateUnreadM function isRefreshUnread(value: unknown): value is RefreshUnread { return typeof value === "function"; } + +function isMarkSessionRead(value: unknown): value is MarkSessionRead { + return typeof value === "function"; +} + +function isMarkSessionsRead(value: unknown): value is MarkSessionsRead { + return typeof value === "function"; +} diff --git a/src/client/src/components/SessionList.test.ts b/src/client/src/components/SessionList.test.ts index fcda1d7..d0a5fa9 100644 --- a/src/client/src/components/SessionList.test.ts +++ b/src/client/src/components/SessionList.test.ts @@ -1,8 +1,21 @@ -import { describe, expect, it } from "vitest"; +import type { TemplateResult } from "lit"; +import { describe, expect, it, vi } from "vitest"; import type { SessionInfo, SessionStatus } from "../api"; import { markCachedNewSessionInfo } from "../cachedNewSessions"; import { isArchivableSessionInfo, isTransientNewSessionInfo } from "../sessionPersistence"; -import { sessionRowActivityKind, sessionRowsForCurrentTree, unreadSessionCount } from "./SessionList"; +// Vitest runs in the node environment with no DOM, so menu/bulk-bar wiring is +// verified through the shared TemplateResult inspection escape hatch: handler +// lookups stay anchored to the buttons' own user-facing text. +import { + findOptionalTemplateClickHandlerForText, + isTemplateEventHandler, + isTemplateResult, + templateClickHandlerForText, + templateStrings, + templateValues, + type TemplateEventHandler, +} from "../templateInspection.testSupport"; +import { SessionList, sessionRowActivityKind, sessionRowsForCurrentTree, unreadSessionCount } from "./SessionList"; describe("sessionRowActivityKind", () => { const idle = sessionStatus("s"); @@ -88,6 +101,60 @@ describe("session action eligibility", () => { }); }); +describe("mark-as-read actions", () => { + it("offers Mark as read in the menu of an unread current session and forwards it", () => { + const unread = session("unread"); + const list = sessionList([unread, session("read")], new Set([unread.id])); + const onMarkRead = vi.fn<(session: SessionInfo) => void>(); + list.onMarkRead = onMarkRead; + + openSessionMenu(list, unread.id); + templateClickHandlerForText(renderList(list), "Mark as read")(new Event("click")); + + expect(onMarkRead).toHaveBeenCalledWith(unread); + expect(componentState(list, "openMenuSessionId")).toBeUndefined(); + }); + + it("hides Mark as read for read, transient, and archived sessions even when tracked as unread", () => { + const read = session("read"); + const cached = markCachedNewSessionInfo(session("cached")); + const archived = { ...session("archived"), archived: true, archivedAt: "2026-06-09T00:00:00.000Z" }; + const list = sessionList([read, cached, archived], new Set([cached.id, archived.id])); + + openSessionMenu(list, read.id); + expect(findOptionalTemplateClickHandlerForText(renderList(list), "Mark as read")).toBeUndefined(); + + openSessionMenu(list, cached.id); + expect(findOptionalTemplateClickHandlerForText(renderList(list), "Mark as read")).toBeUndefined(); + + setComponentState(list, "archivedExpanded", true); + openSessionMenu(list, archived.id); + expect(findOptionalTemplateClickHandlerForText(renderList(list), "Mark as read")).toBeUndefined(); + }); + + it("enables bulk Mark read only when a selected session is unread and forwards only the unread selection", () => { + const unreadA = session("unread-a"); + const readB = session("read-b"); + const unreadC = session("unread-c"); + const list = sessionList([unreadA, readB, unreadC], new Set([unreadA.id, unreadC.id])); + const onMarkReadMany = vi.fn<(sessions: SessionInfo[]) => void>(); + list.onMarkReadMany = onMarkReadMany; + setComponentState(list, "selectionScopes", new Set(["current"])); + + setComponentState(list, "selectedSessionIds", new Set([readB.id])); + const disabledButton = markReadButton(renderList(list)); + expect(disabledButton.disabled).toBe(true); + disabledButton.click(new Event("click")); + expect(onMarkReadMany).not.toHaveBeenCalled(); + + setComponentState(list, "selectedSessionIds", new Set([unreadA.id, readB.id, unreadC.id])); + const enabledButton = markReadButton(renderList(list)); + expect(enabledButton.disabled).toBe(false); + enabledButton.click(new Event("click")); + expect(onMarkReadMany).toHaveBeenCalledWith([unreadA, unreadC]); + }); +}); + describe("sessionRowsForCurrentTree", () => { it("keeps archived ancestors visible while they have unarchived descendants", () => { const parent = { ...session("parent"), archived: true, archivedAt: "2026-06-09T00:00:00.000Z" }; @@ -121,6 +188,70 @@ function rowSummaries(rows: ReturnType) { return rows.map((row) => ({ id: row.session.id, depth: row.depth, hasMissingParent: row.hasMissingParent })); } +function sessionList(sessions: SessionInfo[], unreadSessionIds: ReadonlySet): SessionList { + const list = new SessionList(); + list.sessions = sessions; + list.unreadSessionIds = unreadSessionIds; + return list; +} + +function renderList(list: SessionList): TemplateResult { + return list.render(); +} + +function openSessionMenu(list: SessionList, sessionId: string): void { + setComponentState(list, "openMenuSessionId", sessionId); +} + +function componentState(list: SessionList, property: string): unknown { + return Reflect.get(list, property); +} + +function setComponentState(list: SessionList, property: string, value: unknown): void { + if (!Reflect.set(list, property, value)) throw new Error(`Could not set session list property ${property}`); +} + +// Locates the bulk "Mark read" button inside the selection toolbar template, +// anchored to the button's own static text so unrelated toolbar changes do not +// break the lookup. The disabled binding sits immediately before its @click. +function markReadButton(template: TemplateResult): { disabled: boolean; click: TemplateEventHandler } { + const host = findTemplateWithStaticText(template, ">Mark read"); + const strings = templateStrings(host); + const values = templateValues(host); + for (let index = 0; index < values.length; index += 1) { + if (strings[index + 1]?.includes(">Mark read") !== true) continue; + const click = values[index]; + const disabled = values[index - 1]; + if (!isTemplateEventHandler(click) || typeof disabled !== "boolean") throw new Error("Mark read button wiring is unavailable"); + return { disabled, click }; + } + throw new Error("Expected a click handler before >Mark read"); +} + +function findTemplateWithStaticText(value: unknown, text: string): TemplateResult { + const found = findOptionalTemplateWithStaticText(value, text); + if (found === undefined) throw new Error(`Expected template containing ${text}`); + return found; +} + +function findOptionalTemplateWithStaticText(value: unknown, text: string): TemplateResult | undefined { + if (Array.isArray(value)) { + for (const item of value) { + const found = findOptionalTemplateWithStaticText(item, text); + if (found !== undefined) return found; + } + return undefined; + } + if (!isTemplateResult(value)) return undefined; + if (templateStrings(value).some((chunk) => chunk.includes(text))) return value; + for (const item of templateValues(value)) { + const found = findOptionalTemplateWithStaticText(item, text); + if (found !== undefined) return found; + } + return undefined; +} + + function sessionStatus(sessionId: string, overrides: Partial = {}): SessionStatus { return { sessionId, diff --git a/src/client/src/components/SessionList.ts b/src/client/src/components/SessionList.ts index d126260..a7b7bba 100644 --- a/src/client/src/components/SessionList.ts +++ b/src/client/src/components/SessionList.ts @@ -57,6 +57,8 @@ export class SessionList extends LitElement implements KeyboardNavigableSection @property({ attribute: false }) onDeleteArchived?: (session: SessionInfo) => void | Promise; @property({ attribute: false }) onDeleteArchivedMany?: (sessions: SessionInfo[]) => void | Promise; @property({ attribute: false }) onDetachParent?: (session: SessionInfo) => void; + @property({ attribute: false }) onMarkRead?: (session: SessionInfo) => void; + @property({ attribute: false }) onMarkReadMany?: (sessions: SessionInfo[]) => void | Promise; @property({ attribute: false }) onReload?: (session: SessionInfo) => void; @property({ attribute: false }) onCleanup?: () => void; @@ -209,6 +211,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection const selectedSessions = this.selectedSessions("current"); const archivableSessions = selectedSessions.filter((session) => isArchivableSessionInfo(session, this.statuses[session.id], this.sessionPersistenceOptions())); + const unreadSelectedSessions = selectedSessions.filter((session) => this.unreadSessionIds.has(session.id)); const allVisibleSelected = visibleSessions.length > 0 && visibleSessions.every((session) => this.selectedSessionIds.has(session.id)); const visibleSelectedCount = visibleSessions.filter((session) => this.selectedSessionIds.has(session.id)).length; return html` @@ -216,6 +219,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection ${selectedSessions.length} selected${visibleSelectedCount !== selectedSessions.length ? html` · ${visibleSelectedCount} visible` : null} + @@ -280,6 +284,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection : canDeleteTransient ? html`` : html` + ${this.unreadSessionIds.has(session.id) ? html`` : null} ${canArchive ? html` ${descendantCount > 0 ? html`` : null} @@ -331,6 +336,12 @@ export class SessionList extends LitElement implements KeyboardNavigableSection void this.onDeleteArchivedMany?.(archived); } + private markSelectedCurrentRead(): void { + const unreadSelected = this.selectedSessions("current").filter((session) => this.unreadSessionIds.has(session.id)); + if (unreadSelected.length === 0) return; + void this.onMarkReadMany?.(unreadSelected); + } + private archiveSelectedCurrent(): void { const sessions = this.selectedSessions("current").filter((session) => isArchivableSessionInfo(session, this.statuses[session.id], this.sessionPersistenceOptions())); this.selectedSessionIds = removeSessionIds(this.selectedSessionIds, sessions.map((session) => session.id)); diff --git a/src/client/src/components/appShell/AppNavigationPanel.ts b/src/client/src/components/appShell/AppNavigationPanel.ts index 49956cd..1a442ca 100644 --- a/src/client/src/components/appShell/AppNavigationPanel.ts +++ b/src/client/src/components/appShell/AppNavigationPanel.ts @@ -67,6 +67,8 @@ export class AppNavigationPanel extends LitElement { @property({ attribute: false }) onDeleteArchivedSession?: (session: SessionInfo) => void | Promise; @property({ attribute: false }) onDeleteArchivedSessions?: (sessions: SessionInfo[]) => void | Promise; @property({ attribute: false }) onDetachParentSession?: (session: SessionInfo) => void | Promise; + @property({ attribute: false }) onMarkSessionRead?: (session: SessionInfo) => void | Promise; + @property({ attribute: false }) onMarkSessionsRead?: (sessions: SessionInfo[]) => void | Promise; @property({ attribute: false }) onReloadSession?: (session: SessionInfo) => void | Promise; @property({ attribute: false }) onCleanupSessions?: () => void | Promise; @property({ attribute: false }) onArchivedCollapsed?: () => void | Promise; @@ -185,6 +187,8 @@ export class AppNavigationPanel extends LitElement { .onDeleteArchived=${(session: SessionInfo) => this.onDeleteArchivedSession?.(session)} .onDeleteArchivedMany=${(sessions: SessionInfo[]) => this.onDeleteArchivedSessions?.(sessions)} .onDetachParent=${(session: SessionInfo) => this.onDetachParentSession?.(session)} + .onMarkRead=${(session: SessionInfo) => this.onMarkSessionRead?.(session)} + .onMarkReadMany=${(sessions: SessionInfo[]) => this.onMarkSessionsRead?.(sessions)} .onReload=${(session: SessionInfo) => this.onReloadSession?.(session)} .onCleanup=${() => this.onCleanupSessions?.()} .onFocusPreviousSection=${() => { this.focusPreviousFrom("sessions"); }} From 9267174736154ec11a8a821721f006bb4eca3ba7 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Mon, 27 Jul 2026 14:41:40 +0200 Subject: [PATCH 3/5] feat(ui): derive bubble-up unread presence from unread projections Add pure unreadPresence helpers mapping unread summary cwds to machine, project, and workspace presence (machine = any summary, workspace = exact path match, project = known workspace ownership), plus a PiWebApp seam that keeps the derived UnreadPresence state current on any machine's projection change and on app-state transitions. No UI consumption yet. --- src/client/src/components/PiWebApp.ts | 16 ++ .../src/components/PiWebApp.unread.test.ts | 110 ++++++++++- src/client/src/unreadPresence.test.ts | 184 ++++++++++++++++++ src/client/src/unreadPresence.ts | 106 ++++++++++ 4 files changed, 414 insertions(+), 2 deletions(-) create mode 100644 src/client/src/unreadPresence.test.ts create mode 100644 src/client/src/unreadPresence.ts diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index b1d6394..4e4d01f 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -27,6 +27,7 @@ import { sessionCleanupRequestKey, sessionCleanupUnavailableMessage } from "../s import { selectedNotificationView } from "../sessionNotifications"; import { hasAuthoritativeSessionPersistence as runtimeHasAuthoritativeSessionPersistence } from "../sessionPersistence"; import { SessionUnreadController } from "../sessionUnread"; +import { deriveUnreadPresence, EMPTY_UNREAD_PRESENCE, sameUnreadPresence, type UnreadPresence } from "../unreadPresence"; import { initialSessionWarningVisibilityState, reconcileSessionWarningVisibility, toggleSessionWarnings } from "../sessionWarningVisibility"; import { RealtimeSocket, type BrowserRealtimeEvent } from "../sessionSocket"; import type { PiWebPluginRegistration, PluginMachine, PluginPromptEditor, QualifiedContributionId, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspacePanelContribution, PluginRuntimeContext, TerminalCommandRunsInternalRuntime, WorkspaceFiles, WorkspaceHost, WorkspaceLabelContext, WorkspaceLabelItem, WorkspacePanelContext } from "../plugins/types"; @@ -109,6 +110,7 @@ export class PiWebApp extends LitElement { private readonly sessionUnread = new SessionUnreadController({ onChange: (machineId) => { + this.syncUnreadPresence(); if (selectedMachineId(this.state) !== machineId) return; this.syncUnreadSessionIds(); this.syncSelectedSessionReadState(); @@ -118,6 +120,7 @@ export class PiWebApp extends LitElement { }, }); @state() private unreadSessionIds: ReadonlySet = this.sessionUnread.unreadSessionIds(selectedMachineId(this.state), this.state.sessions); + @state() private unreadPresence: UnreadPresence = EMPTY_UNREAD_PRESENCE; private unreadConnected = false; private committedChatIdentity: string | undefined; private readyChatIdentity: string | undefined; @@ -319,6 +322,18 @@ export class PiWebApp extends LitElement { if (!sameStringSet(next, this.unreadSessionIds)) this.unreadSessionIds = next; } + private syncUnreadPresence(): void { + const next = deriveUnreadPresence({ + machineIds: this.state.machines.map((machine) => machine.id), + projectionFor: (machineId) => this.sessionUnread.projection(machineId), + selectedMachineId: selectedMachineId(this.state), + projects: this.state.projects, + workspaces: this.state.workspaces, + workspacesByProjectId: this.state.workspacesByProjectId, + }); + if (!sameUnreadPresence(next, this.unreadPresence)) this.unreadPresence = next; + } + private isSessionSeen(machineId: string, session: SessionInfo): boolean { if (!this.unreadConnected) return false; const identity = unreadChatIdentity(machineId, session); @@ -404,6 +419,7 @@ export class PiWebApp extends LitElement { } if (machineUnreadInputsChanged(previous, this.state)) this.syncSessionUnreadMachines(); this.syncUnreadSessionIds(); + this.syncUnreadPresence(); this.handleActivityTransition(previous, this.state); this.handleWorkspaceChange(previous, this.state); this.handleMachineChange(previous, this.state); diff --git a/src/client/src/components/PiWebApp.unread.test.ts b/src/client/src/components/PiWebApp.unread.test.ts index 208aff1..e6a746e 100644 --- a/src/client/src/components/PiWebApp.unread.test.ts +++ b/src/client/src/components/PiWebApp.unread.test.ts @@ -1,9 +1,10 @@ import type { TemplateResult } from "lit"; import { afterEach, describe, expect, it, vi } from "vitest"; import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities"; -import type { SessionInfo, SessionUnreadEvent, SessionUnreadSummary } from "../api"; +import type { Machine, Project, SessionInfo, SessionUnreadEvent, SessionUnreadSummary, Workspace } from "../api"; import { initialAppState, type AppState } from "../appState"; import type { BrowserRealtimeEvent } from "../sessionSocket"; +import type { UnreadPresence } from "../unreadPresence"; import type { AppMobileMainTab } from "./appShell/AppMobileMainTabs"; // Template inspection is proportionate here because this node-environment test // verifies only PiWebApp's unread-state property wiring into navigation. @@ -277,11 +278,71 @@ describe("PiWebApp session unread wiring", () => { expect(requests).toContain("https://pi.example.test/api/machines/local/sessions/beta/unread/acknowledge"); await vi.waitFor(() => { expect(navigationUnreadSessionIds(app).size).toBe(0); }); }); + + it("derives bubble-up unread presence for selected and background machines", () => { + stubJsonFetch({ catalogId: "catalog-a", catalogRevision: 2, sessions: [] }); + const app = createApp(); + enableUnread(app); + enableUnreadMachine(app, "remote"); + const selected = session("selected"); + setAppState(app, { + ...initialAppState(), + machines: [machine("local"), machine("remote")], + selectedMachine: machine("local"), + projects: [project("project-1")], + workspaces: [workspace("ws-1", "project-1", "/repo")], + workspacesByProjectId: { "project-1": [workspace("ws-1", "project-1", "/repo")] }, + sessions: [selected], + selectedSession: selected, + mainView: "chat", + }); + + handleRealtimeEvent(app, unreadEvent(1, unreadSummary(selected, 1))); + expect([...unreadPresence(app).machines]).toEqual(["local"]); + expect([...unreadPresence(app).projects]).toEqual(["project-1"]); + expect([...unreadPresence(app).workspaces]).toEqual(["ws-1"]); + + const remoteSession = { ...session("remote-session"), cwd: "/unmapped" }; + handleMachineActivityEvent(app, "remote", unreadEvent(1, unreadSummary(remoteSession, 1))); + expect([...unreadPresence(app).machines].sort()).toEqual(["local", "remote"]); + // Background cwds never leak into the selected machine's workspace/project rows. + expect([...unreadPresence(app).projects]).toEqual(["project-1"]); + expect([...unreadPresence(app).workspaces]).toEqual(["ws-1"]); + }); + + it("recomputes bubble-up presence when workspace data loads after the unread event", () => { + 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))); + expect([...unreadPresence(app).machines]).toEqual(["local"]); + expect(unreadPresence(app).projects.size).toBe(0); + expect(unreadPresence(app).workspaces.size).toBe(0); + + setState(app, { + projects: [project("project-1")], + workspaces: [workspace("ws-1", "project-1", "/repo")], + workspacesByProjectId: { "project-1": [workspace("ws-1", "project-1", "/repo")] }, + }); + expect([...unreadPresence(app).projects]).toEqual(["project-1"]); + expect([...unreadPresence(app).workspaces]).toEqual(["ws-1"]); + }); }); type RenderNavigationPanel = (this: PiWebApp) => TemplateResult; type SetAppState = (this: PiWebApp, patch: Partial) => void; type HandleRealtimeEvent = (this: PiWebApp, machineId: string, event: BrowserRealtimeEvent) => void; +type HandleMachineActivityEvent = (this: PiWebApp, machineId: string, event: BrowserRealtimeEvent) => void; type MobileMainTabs = (this: PiWebApp) => AppMobileMainTab[]; type UpdatedHook = (this: PiWebApp) => void; type DisconnectedHook = (this: PiWebApp) => void; @@ -335,13 +396,23 @@ function handleRealtimeEvent(app: PiWebApp, event: BrowserRealtimeEvent): void { method.call(app, "local", event); } +function handleMachineActivityEvent(app: PiWebApp, machineId: string, event: BrowserRealtimeEvent): void { + const method: unknown = Reflect.get(app, "handleMachineActivityEvent"); + if (!isHandleMachineActivityEvent(method)) throw new Error("PiWebApp.handleMachineActivityEvent is not callable"); + method.call(app, machineId, event); +} + function enableUnread(app: PiWebApp): void { if (!Reflect.set(app, "unreadConnected", true)) throw new Error("Could not connect PiWebApp unread state"); + enableUnreadMachine(app, "local"); +} + +function enableUnreadMachine(app: PiWebApp, machineId: string): void { const controller: unknown = Reflect.get(app, "sessionUnread"); if (typeof controller !== "object" || controller === null) throw new Error("PiWebApp unread controller is unavailable"); const setCapability: unknown = Reflect.get(controller, "setCapability"); if (typeof setCapability !== "function") throw new Error("PiWebApp unread capability setter is unavailable"); - Reflect.apply(setCapability, controller, ["local", "supported"]); + Reflect.apply(setCapability, controller, [machineId, "supported"]); } function exposeSelectedChat(app: PiWebApp): void { @@ -429,6 +500,37 @@ function navigationPanelValue(app: PiWebApp, marker: string): unknown { return templateValueAfterMarker(method.call(app), marker); } +function unreadPresence(app: PiWebApp): UnreadPresence { + const value: unknown = Reflect.get(app, "unreadPresence"); + if (!isUnreadPresence(value)) throw new Error("Expected derived unread presence on PiWebApp"); + return value; +} + +function isUnreadPresence(value: unknown): value is UnreadPresence { + if (typeof value !== "object" || value === null) return false; + return Reflect.get(value, "machines") instanceof Set + && Reflect.get(value, "projects") instanceof Set + && Reflect.get(value, "workspaces") instanceof Set; +} + +function machine(id: string): Machine { + return { + id, + name: id, + kind: id === "local" ? "local" : "remote", + createdAt: "2026-07-20T00:00:00.000Z", + updatedAt: "2026-07-20T00:00:00.000Z", + }; +} + +function project(id: string): Project { + return { id, name: id, path: "/repo", createdAt: "2026-07-20T00:00:00.000Z" }; +} + +function workspace(id: string, projectId: string, path: string): Workspace { + return { id, projectId, path, label: id, isMain: true, isGitRepo: true, isGitWorktree: false }; +} + function session(id: string): SessionInfo { return { id, @@ -502,6 +604,10 @@ function isHandleRealtimeEvent(value: unknown): value is HandleRealtimeEvent { return typeof value === "function"; } +function isHandleMachineActivityEvent(value: unknown): value is HandleMachineActivityEvent { + return typeof value === "function"; +} + function isMobileMainTabs(value: unknown): value is MobileMainTabs { return typeof value === "function"; } diff --git a/src/client/src/unreadPresence.test.ts b/src/client/src/unreadPresence.test.ts new file mode 100644 index 0000000..4298da8 --- /dev/null +++ b/src/client/src/unreadPresence.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from "vitest"; +import type { Project, SessionUnreadSummary, Workspace } from "../../shared/apiTypes"; +import type { SessionUnreadProjectionView } from "./sessionUnread"; +import { + deriveUnreadPresence, + EMPTY_UNREAD_PRESENCE, + hasUnreadSessions, + machineUnreadPresence, + projectUnreadPresence, + sameUnreadPresence, + unreadCwds, + workspaceUnreadPresence, + type UnreadPresenceInputs, +} from "./unreadPresence"; + +describe("hasUnreadSessions", () => { + it("treats an unavailable projection as no presence", () => { + expect(hasUnreadSessions(undefined)).toBe(false); + }); + + it("treats an empty projection as no presence", () => { + expect(hasUnreadSessions(projection())).toBe(false); + }); + + it("treats any summary as presence, even when the projection is stale", () => { + expect(hasUnreadSessions(projection([summary("session-1", "/repo")]))).toBe(true); + expect(hasUnreadSessions(projection([summary("session-1", "/repo")], "stale"))).toBe(true); + }); +}); + +describe("unreadCwds", () => { + it("is empty without a projection or without sessions", () => { + expect([...unreadCwds(undefined)]).toEqual([]); + expect([...unreadCwds(projection())]).toEqual([]); + }); + + it("collects the distinct cwds of unread summaries", () => { + const cwds = unreadCwds(projection([ + summary("session-1", "/repo", 2), + summary("session-2", "/repo", 1), + summary("session-3", "/other", 3), + ])); + expect([...cwds].sort()).toEqual(["/other", "/repo"]); + }); +}); + +describe("machineUnreadPresence", () => { + it("flags machines with any unread summary, including cwds mapped to no known workspace", () => { + const projections = new Map([ + ["local", projection([summary("session-1", "/unmapped")])], + ["empty", projection()], + ["unsupported", undefined], + ]); + const present = machineUnreadPresence(["local", "empty", "unsupported"], (machineId) => projections.get(machineId)); + expect([...present]).toEqual(["local"]); + }); + + it("only considers the listed machine ids", () => { + const present = machineUnreadPresence(["local"], () => projection([summary("session-1", "/repo")])); + expect([...present]).toEqual(["local"]); + }); +}); + +describe("workspaceUnreadPresence", () => { + it("flags the workspace whose path exactly matches an unread cwd", () => { + const workspaces = [workspace("ws-1", "project-1", "/repo"), workspace("ws-2", "project-1", "/repo/branch")]; + expect([...workspaceUnreadPresence(workspaces, new Set(["/repo/branch"]))]).toEqual(["ws-2"]); + }); + + it("flags nothing when no unread cwd maps to a workspace path", () => { + const workspaces = [workspace("ws-1", "project-1", "/repo")]; + expect([...workspaceUnreadPresence(workspaces, new Set(["/unmapped"]))]).toEqual([]); + expect([...workspaceUnreadPresence(workspaces, new Set())]).toEqual([]); + }); +}); + +describe("projectUnreadPresence", () => { + it("flags the project owning a workspace with an unread cwd", () => { + const projects = [project("project-1"), project("project-2")]; + const workspacesByProjectId = { + "project-1": [workspace("ws-1", "project-1", "/repo")], + "project-2": [workspace("ws-2", "project-2", "/other")], + }; + expect([...projectUnreadPresence(projects, workspacesByProjectId, new Set(["/other"]))]).toEqual(["project-2"]); + }); + + it("flags the project through its main workspace at the project path", () => { + const projects = [project("project-1", "/repo")]; + const workspacesByProjectId = { "project-1": [workspace("ws-1", "project-1", "/repo")] }; + expect([...projectUnreadPresence(projects, workspacesByProjectId, new Set(["/repo"]))]).toEqual(["project-1"]); + }); + + it("does not flag a project whose workspaces are not loaded", () => { + const projects = [project("project-1")]; + expect([...projectUnreadPresence(projects, {}, new Set(["/repo"]))]).toEqual([]); + }); + + it("leaves a cwd under the project path but matching no known workspace to the machine dot only", () => { + const projects = [project("project-1", "/repo")]; + const workspacesByProjectId = { "project-1": [workspace("ws-1", "project-1", "/repo/main")] }; + expect([...projectUnreadPresence(projects, workspacesByProjectId, new Set(["/repo/unmapped-subdir"]))]).toEqual([]); + }); +}); + +describe("deriveUnreadPresence", () => { + it("maps the selected machine's unread cwds to workspace and project presence", () => { + const inputs = presenceInputs({ + projections: new Map([["local", projection([summary("session-1", "/repo")])]]), + }); + + const presence = deriveUnreadPresence(inputs); + + expect([...presence.machines]).toEqual(["local"]); + expect([...presence.workspaces]).toEqual(["ws-1"]); + expect([...presence.projects]).toEqual(["project-1"]); + }); + + it("reflects background machines at machine level without leaking their cwds into workspace or project rows", () => { + const inputs = presenceInputs({ + machineIds: ["local", "remote"], + projections: new Map([ + ["local", projection()], + ["remote", projection([summary("session-1", "/repo")])], + ]), + }); + + const presence = deriveUnreadPresence(inputs); + + expect([...presence.machines]).toEqual(["remote"]); + expect([...presence.workspaces]).toEqual([]); + expect([...presence.projects]).toEqual([]); + }); + + it("is empty when no machine has a usable projection", () => { + const presence = deriveUnreadPresence(presenceInputs({ projections: new Map([["local", undefined]]) })); + expect(sameUnreadPresence(presence, EMPTY_UNREAD_PRESENCE)).toBe(true); + }); +}); + +describe("sameUnreadPresence", () => { + it("compares presence by set contents", () => { + const left = { machines: new Set(["local"]), projects: new Set(["project-1"]), workspaces: new Set(["ws-1"]) }; + const matching = { machines: new Set(["local"]), projects: new Set(["project-1"]), workspaces: new Set(["ws-1"]) }; + const different = { machines: new Set(["remote"]), projects: new Set(["project-1"]), workspaces: new Set(["ws-1"]) }; + expect(sameUnreadPresence(left, matching)).toBe(true); + expect(sameUnreadPresence(left, different)).toBe(false); + expect(sameUnreadPresence(EMPTY_UNREAD_PRESENCE, { machines: new Set(), projects: new Set(), workspaces: new Set() })).toBe(true); + }); +}); + +function presenceInputs(options: { + machineIds?: string[]; + projections: Map; +}): UnreadPresenceInputs { + return { + machineIds: options.machineIds ?? ["local"], + projectionFor: (machineId) => options.projections.get(machineId), + selectedMachineId: "local", + projects: [project("project-1", "/repo")], + workspaces: [workspace("ws-1", "project-1", "/repo")], + workspacesByProjectId: { "project-1": [workspace("ws-1", "project-1", "/repo")] }, + }; +} + +function summary(sessionId: string, cwd: string, completionOrder = 1): SessionUnreadSummary { + return { sessionId, cwd, completionOrder, completedAt: "2026-07-20T00:00:00.000Z" }; +} + +function projection(summaries: SessionUnreadSummary[] = [], status: "fresh" | "stale" = "fresh"): SessionUnreadProjectionView { + return { + status, + catalogId: "catalog-a", + catalogRevision: summaries.reduce((revision, entry) => Math.max(revision, entry.completionOrder), 0), + sessions: summaries, + }; +} + +function workspace(id: string, projectId: string, path: string): Workspace { + return { id, projectId, path, label: id, isMain: false, isGitRepo: true, isGitWorktree: false }; +} + +function project(id: string, path = `/${id}`): Project { + return { id, name: id, path, createdAt: "2026-07-20T00:00:00.000Z" }; +} diff --git a/src/client/src/unreadPresence.ts b/src/client/src/unreadPresence.ts new file mode 100644 index 0000000..20b48e5 --- /dev/null +++ b/src/client/src/unreadPresence.ts @@ -0,0 +1,106 @@ +import type { Project, Workspace } from "./api"; +import type { SessionUnreadProjectionView } from "./sessionUnread"; + +/** + * Bubble-up unread *presence* (booleans, never counts) derived from + * `SessionUnreadController` projections, following the charter mapping chain + * cwd → workspace → project → machine: + * + * - A machine has presence when its projection carries ANY unread summary, + * including cwds that map to no known workspace — the honest catch-all. + * - A workspace has presence when an unread cwd equals its path exactly. + * - A project has presence only when one of its known workspaces has presence; + * a cwd matching no known workspace lights the machine dot only, never a + * project or workspace row (even when it sits under the project path). + * - An undefined projection (unsupported/unknown capability or not yet + * loaded) yields no presence. Stale-but-present data still counts, the same + * tolerance `SessionUnreadController.unreadSessionIds` applies. + * + * Workspace/project presence can only be derived for the machine whose + * projects and workspaces are loaded — the selected one. + */ + +export interface UnreadPresence { + readonly machines: ReadonlySet; + readonly projects: ReadonlySet; + readonly workspaces: ReadonlySet; +} + +/** Shared empty value; consumers must treat it as immutable. */ +export const EMPTY_UNREAD_PRESENCE: UnreadPresence = { + machines: new Set(), + projects: new Set(), + workspaces: new Set(), +}; + +export interface UnreadPresenceInputs { + readonly machineIds: readonly string[]; + readonly projectionFor: (machineId: string) => SessionUnreadProjectionView | undefined; + readonly selectedMachineId: string; + readonly projects: readonly Project[]; + /** Visible workspace rows (the selected project's workspaces). */ + readonly workspaces: readonly Workspace[]; + readonly workspacesByProjectId: Record; +} + +export function deriveUnreadPresence(input: UnreadPresenceInputs): UnreadPresence { + const cwds = unreadCwds(input.projectionFor(input.selectedMachineId)); + return { + machines: machineUnreadPresence(input.machineIds, input.projectionFor), + projects: projectUnreadPresence(input.projects, input.workspacesByProjectId, cwds), + workspaces: workspaceUnreadPresence(input.workspaces, cwds), + }; +} + +export function hasUnreadSessions(projection: Pick | undefined): boolean { + return projection !== undefined && projection.sessions.length > 0; +} + +export function unreadCwds(projection: Pick | undefined): ReadonlySet { + if (projection === undefined || projection.sessions.length === 0) return EMPTY_CWDS; + return new Set(projection.sessions.map((summary) => summary.cwd)); +} + +export function machineUnreadPresence( + machineIds: readonly string[], + projectionFor: (machineId: string) => SessionUnreadProjectionView | undefined, +): ReadonlySet { + const present = new Set(); + for (const machineId of machineIds) { + if (hasUnreadSessions(projectionFor(machineId))) present.add(machineId); + } + return present; +} + +export function workspaceUnreadPresence(workspaces: readonly Workspace[], cwds: ReadonlySet): ReadonlySet { + const present = new Set(); + for (const workspace of workspaces) { + if (cwds.has(workspace.path)) present.add(workspace.id); + } + return present; +} + +export function projectUnreadPresence( + projects: readonly Project[], + workspacesByProjectId: Record, + cwds: ReadonlySet, +): ReadonlySet { + const present = new Set(); + for (const project of projects) { + const workspaces = workspacesByProjectId[project.id] ?? []; + if (workspaces.some((workspace) => cwds.has(workspace.path))) present.add(project.id); + } + return present; +} + +export function sameUnreadPresence(left: UnreadPresence, right: UnreadPresence): boolean { + return sameStringSet(left.machines, right.machines) + && sameStringSet(left.projects, right.projects) + && sameStringSet(left.workspaces, right.workspaces); +} + +const EMPTY_CWDS: ReadonlySet = new Set(); + +function sameStringSet(left: ReadonlySet, right: ReadonlySet): boolean { + return left.size === right.size && [...left].every((value) => right.has(value)); +} From 8af637b00e4df76bc4add5342888963e3c41bc1a Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Mon, 27 Jul 2026 15:08:31 +0200 Subject: [PATCH 4/5] 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. */ From f76a9fabc8634df271a77a915032d70331b03098 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Mon, 27 Jul 2026 15:55:06 +0200 Subject: [PATCH 5/5] feat(ui): unify row indicators into one mark with an unread ring Unread is no longer a competing ActivityIndicatorKind or a separate name-adjacent dot: every row renders a single indicator. An accent ring wraps the still-pulsing work dot when a row is both busy and unread, a filled accent dot shows while idle and unread, and activity kinds keep their existing precedence (sending > session > terminal). Session rows surface unread state even while busy, so the unread header count and mobile Sessions badge now count busy unread sessions too. --- .changeset/unread-bubble-up-indicators.md | 2 +- src/client/src/components/MachineList.test.ts | 31 ++++++++- src/client/src/components/MachineList.ts | 19 ++---- .../src/components/MachineSwitcher.test.ts | 19 +++++- src/client/src/components/MachineSwitcher.ts | 21 +++--- src/client/src/components/PiWebApp.ts | 6 +- src/client/src/components/ProjectList.test.ts | 18 ++++- src/client/src/components/ProjectList.ts | 12 ++-- src/client/src/components/SessionList.test.ts | 33 ++++----- src/client/src/components/SessionList.ts | 56 +++++++--------- .../src/components/WorkspaceList.test.ts | 18 ++++- src/client/src/components/WorkspaceList.ts | 11 +-- .../src/components/activityBadge.test.ts | 67 +++++++++++++++++++ src/client/src/components/activityBadge.ts | 32 +++++++-- src/client/src/components/shared.ts | 4 ++ 15 files changed, 246 insertions(+), 103 deletions(-) create mode 100644 src/client/src/components/activityBadge.test.ts diff --git a/.changeset/unread-bubble-up-indicators.md b/.changeset/unread-bubble-up-indicators.md index 4fdf95a..0d04a42 100644 --- a/.changeset/unread-bubble-up-indicators.md +++ b/.changeset/unread-bubble-up-indicators.md @@ -2,4 +2,4 @@ "@jmfederico/pi-web": patch --- -Show a small unread dot on workspace, project, and machine rows in the navigation panel whenever a session beneath them has unread activity, so unread sessions stay visible without expanding each section. Machine dots reflect any unread session in that machine's catalog; existing per-session unread counts are unchanged. +Give every navigation row a single activity indicator that also carries unread state. When sessions beneath a workspace, project, or machine row have unread completions, the row's indicator becomes a static accent ring around the activity dot — or a filled accent dot while idle — instead of a separate dot next to the name. Session rows now surface unread state even while busy or sending, and the "N unread" header and mobile Sessions badge count busy unread sessions too. diff --git a/src/client/src/components/MachineList.test.ts b/src/client/src/components/MachineList.test.ts index 8f5abb6..c47115b 100644 --- a/src/client/src/components/MachineList.test.ts +++ b/src/client/src/components/MachineList.test.ts @@ -1,7 +1,7 @@ // @vitest-environment happy-dom import { afterEach, describe, expect, it } from "vitest"; -import type { Machine, MachineHealth, MachineStatus } from "../api"; +import type { Machine, MachineHealth, MachineStatus, WorkspaceActivity } from "../api"; import { canRemoveMachine, MachineList } from "./MachineList"; afterEach(() => { @@ -40,17 +40,42 @@ describe("machine unread indicator", () => { expect(list.shadowRoot?.querySelector(".activity-indicator.unread")).toBeNull(); }); + + it("wraps the work dot in an unread ring when a machine is busy and unread", async () => { + const list = await mountMachineList( + [machine("local", "local"), machine("remote-a", "remote")], + new Set(["local", "remote-a"]), + {}, + { + local: { "/repo": workspaceActivity("/repo", true, false) }, + "remote-a": { "/repo": workspaceActivity("/repo", false, true) }, + }, + ); + + const localRing = rowFor(list, "local").querySelector(".unread-ring"); + expect(localRing?.querySelector(".activity-indicator.session")).not.toBeNull(); + expect(localRing?.getAttribute("title")).toBe("Unread sessions on this machine · Machine active"); + + const remoteRing = rowFor(list, "remote-a").querySelector(".unread-ring"); + expect(remoteRing?.querySelector(".activity-indicator.terminal")).not.toBeNull(); + expect(remoteRing?.getAttribute("title")).toBe("Unread sessions on this machine · Machine terminal active"); + + // One mark per row: the ring replaces the standalone unread dot. + expect(rowFor(list, "local").querySelector(".activity-indicator.unread")).toBeNull(); + }); }); async function mountMachineList( machines: Machine[], unreadMachineIds: ReadonlySet, statuses: Record = {}, + activities: Record> = {}, ): Promise { const list = new MachineList(); list.machines = machines; list.unreadMachineIds = unreadMachineIds; list.statuses = statuses; + list.activities = activities; document.body.append(list); await list.updateComplete; return list; @@ -67,6 +92,10 @@ function unreadDot(row: Element): Element | null { return row.querySelector(".activity-indicator.unread"); } +function workspaceActivity(cwd: string, hasSessionActivity: boolean, hasTerminalActivity: boolean): WorkspaceActivity { + return { cwd, hasSessionActivity, hasTerminalActivity, updatedAt: "2026-06-04T00:00:00.000Z" }; +} + function machine(id: string, kind: Machine["kind"]): Machine { return { id, diff --git a/src/client/src/components/MachineList.ts b/src/client/src/components/MachineList.ts index 1a833a1..e87a807 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, renderActivityIndicator } from "./activityBadge"; +import { renderActionActivityIndicator } from "./activityBadge"; import type { KeyboardNavigableSection } from "./navigationFocus"; import { activateSelectableRow, focusSelectedOrFirstSelectableRow, handleSelectableRowKeyboard } from "./selectableRow"; import { listStyles } from "./shared"; @@ -76,7 +76,7 @@ export class MachineList extends LitElement implements KeyboardNavigableSection @keydown=${(event: KeyboardEvent) => { this.handleMachineKeydown(event, machine); }} >
- ${machine.name}${this.renderUnread(machine)}${machine.kind === "local" ? "Local Pi Web" : machine.baseUrl ?? "Remote Pi Web"} · ${statusLabel} + ${machine.name}${machine.kind === "local" ? "Local Pi Web" : machine.baseUrl ?? "Remote Pi Web"} · ${statusLabel} ${this.renderActivity(machine)}
${hasRemoveAction ? this.renderMachineMenu(machine) : null} @@ -86,16 +86,11 @@ export class MachineList extends LitElement implements KeyboardNavigableSection private renderActivity(machine: Machine) { const status = this.statuses[machine.id]?.status ?? machine.status; - if (status === "offline" || status === "error") return undefined; - const kind = machineActivityIndicator(this.activities[machine.id]); - return renderActionActivityIndicator(kind, kind === "terminal" ? "Machine terminal active" : "Machine active"); - } - - private renderUnread(machine: Machine) { - // Unread is independent of machine activity: an offline machine keeps its - // last-known unread state (stale-but-present still counts). - if (!this.unreadMachineIds.has(machine.id)) return undefined; - return renderActivityIndicator("unread", "Unread sessions on this machine"); + // Unread survives offline: an offline machine keeps its last-known unread + // state (stale-but-present still counts), so only the work dot is gated. + const kind = status === "offline" || status === "error" ? undefined : machineActivityIndicator(this.activities[machine.id]); + const unreadLabel = this.unreadMachineIds.has(machine.id) ? "Unread sessions on this machine" : undefined; + return renderActionActivityIndicator(kind, kind === "terminal" ? "Machine terminal active" : "Machine active", unreadLabel); } private renderMachineMenu(machine: Machine) { diff --git a/src/client/src/components/MachineSwitcher.test.ts b/src/client/src/components/MachineSwitcher.test.ts index a5ed843..faf853c 100644 --- a/src/client/src/components/MachineSwitcher.test.ts +++ b/src/client/src/components/MachineSwitcher.test.ts @@ -1,7 +1,7 @@ // @vitest-environment happy-dom import { afterEach, describe, expect, it } from "vitest"; -import type { Machine } from "../api"; +import type { Machine, WorkspaceActivity } from "../api"; import { MachineSwitcher } from "./MachineSwitcher"; afterEach(() => { @@ -35,6 +35,19 @@ describe("machine-switcher unread indicator", () => { expect(unreadDot(optionFor(switcher, "remote-a"))).toBeNull(); expect(unreadDot(optionFor(switcher, "remote-b"))).not.toBeNull(); }); + + it("wraps the work dot in an unread ring when the machine is busy and unread", async () => { + const switcher = await mountSwitcher([machine("local", "local")], new Set(["local"])); + switcher.activities = { local: { "/repo": workspaceActivity("/repo", true, false) } }; + await switcher.updateComplete; + + const button = switcherButton(switcher); + const ring = button.querySelector(".unread-ring"); + expect(ring?.querySelector(".activity-indicator.session")).not.toBeNull(); + expect(ring?.getAttribute("title")).toBe("Unread sessions on this machine · Machine active"); + // One mark only: the ring replaces the standalone unread dot. + expect(button.querySelector(".activity-indicator.unread")).toBeNull(); + }); }); async function mountSwitcher(machines: Machine[], unreadMachineIds: ReadonlySet): Promise { @@ -66,6 +79,10 @@ function unreadDot(option: Element): Element | null { return option.querySelector(".activity-indicator.unread"); } +function workspaceActivity(cwd: string, hasSessionActivity: boolean, hasTerminalActivity: boolean): WorkspaceActivity { + return { cwd, hasSessionActivity, hasTerminalActivity, updatedAt: "2026-06-04T00:00:00.000Z" }; +} + function machine(id: string, kind: Machine["kind"]): Machine { return { id, diff --git a/src/client/src/components/MachineSwitcher.ts b/src/client/src/components/MachineSwitcher.ts index 5a04bd6..6eb77c7 100644 --- a/src/client/src/components/MachineSwitcher.ts +++ b/src/client/src/components/MachineSwitcher.ts @@ -66,7 +66,7 @@ export class MachineSwitcher extends LitElement implements KeyboardNavigableSect @click=${(event: MouseEvent) => { this.toggleMenu(event.currentTarget); }} @keydown=${(event: KeyboardEvent) => { this.handleSwitcherButtonKeydown(event); }} > - ${this.renderActivity(selected)}${this.renderUnread(selected)} + ${this.renderActivity(selected)} Machine ${label} @@ -98,7 +98,7 @@ export class MachineSwitcher extends LitElement implements KeyboardNavigableSect @click=${() => { this.select(machine); }} @keydown=${(event: KeyboardEvent) => { this.handleMachineOptionKeydown(event); }} > - ${this.renderActivity(machine)}${this.renderUnread(machine)}${machine.name} + ${this.renderActivity(machine)}${machine.name} ${machine.kind === "local" ? "Local Pi Web" : machine.baseUrl ?? "Remote Pi Web"} · ${machineStatusLabel(status)} ${hasActions ? html` @@ -124,16 +124,11 @@ export class MachineSwitcher extends LitElement implements KeyboardNavigableSect private renderActivity(machine: Machine): TemplateResult | undefined { const status = machineStatus(machine, this.statuses); - if (status === "offline" || status === "error") return undefined; - const kind = machineActivityIndicator(this.activities[machine.id]); - return renderActivityIndicator(kind, kind === "terminal" ? "Machine terminal active" : "Machine active"); - } - - private renderUnread(machine: Machine): TemplateResult | undefined { - // Unread is independent of machine activity: an offline machine keeps its - // last-known unread state (stale-but-present still counts). - if (!this.unreadMachineIds.has(machine.id)) return undefined; - return renderActivityIndicator("unread", "Unread sessions on this machine"); + // Unread survives offline: an offline machine keeps its last-known unread + // state (stale-but-present still counts), so only the work dot is gated. + const kind = status === "offline" || status === "error" ? undefined : machineActivityIndicator(this.activities[machine.id]); + const unreadLabel = this.unreadMachineIds.has(machine.id) ? "Unread sessions on this machine" : undefined; + return renderActivityIndicator(kind, kind === "terminal" ? "Machine terminal active" : "Machine active", unreadLabel); } private selectedMachine(): Machine | undefined { @@ -296,6 +291,8 @@ export class MachineSwitcher extends LitElement implements KeyboardNavigableSect .activity-indicator.terminal { border-radius: 2px; background: var(--pi-accent); } .activity-indicator.sending { border-radius: 50%; background: var(--pi-warning); } .activity-indicator.unread { border-radius: 50%; background: var(--pi-accent); animation: none; box-shadow: 0 0 0 2px color-mix(in srgb, var(--pi-accent) 20%, transparent); } + .unread-ring { flex: 0 0 auto; box-sizing: border-box; display: inline-grid; place-items: center; width: 9px; height: 9px; border: 1.5px solid var(--pi-accent); border-radius: 50%; } + .unread-ring .activity-indicator { width: 5px; height: 5px; } .machine-switcher-menu { position: fixed; z-index: 10000; box-sizing: border-box; min-width: min(280px, calc(100vw - 16px)); overflow: auto; padding: 4px; border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); box-shadow: 0 8px 24px var(--pi-shadow); } .machine-option { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 2px; align-items: stretch; margin: 2px 0; } .machine-option.no-actions { grid-template-columns: minmax(0, 1fr); } diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index eb44ecd..9793819 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -2193,11 +2193,7 @@ export class PiWebApp extends LitElement { } private mobileMainTabs(): AppMobileMainTab[] { - const unreadCount = unreadSessionCount(this.state.sessions, this.unreadSessionIds, { - statuses: this.state.sessionStatuses, - activities: this.state.sessionActivities, - sending: this.state.sendingPrompts, - }); + const unreadCount = unreadSessionCount(this.state.sessions, this.unreadSessionIds); return [ { id: "navigation", diff --git a/src/client/src/components/ProjectList.test.ts b/src/client/src/components/ProjectList.test.ts index e5366e6..50fd82b 100644 --- a/src/client/src/components/ProjectList.test.ts +++ b/src/client/src/components/ProjectList.test.ts @@ -1,7 +1,7 @@ // @vitest-environment happy-dom import { afterEach, describe, expect, it } from "vitest"; -import type { Project } from "../api"; +import type { Project, WorkspaceActivity } from "../api"; import { ProjectList } from "./ProjectList"; afterEach(() => { @@ -27,6 +27,18 @@ describe("project unread indicator", () => { expect(list.shadowRoot?.querySelector(".activity-indicator.unread")).toBeNull(); }); + + it("wraps the work dot in an unread ring when the project is busy and unread", async () => { + const list = await mountProjectList([project("project-a")], new Set(["project-a"])); + list.activities = { "/repo/project-a": workspaceActivity("/repo/project-a", true, false) }; + await list.updateComplete; + + const row = rowFor(list, "project-a"); + const ring = row.querySelector(".unread-ring"); + expect(ring?.querySelector(".activity-indicator.session")).not.toBeNull(); + expect(ring?.getAttribute("title")).toBe("Unread sessions in this project · Project active"); + expect(row.querySelector(".activity-indicator.unread")).toBeNull(); + }); }); async function mountProjectList(projects: Project[], unreadProjectIds: ReadonlySet): Promise { @@ -49,6 +61,10 @@ function unreadDot(row: Element): Element | null { return row.querySelector(".activity-indicator.unread"); } +function workspaceActivity(cwd: string, hasSessionActivity: boolean, hasTerminalActivity: boolean): WorkspaceActivity { + return { cwd, hasSessionActivity, hasTerminalActivity, updatedAt: "2026-06-04T00:00:00.000Z" }; +} + function project(id: string): Project { return { id, name: id, path: `/repo/${id}`, createdAt: "2026-06-04T00:00:00.000Z" }; } diff --git a/src/client/src/components/ProjectList.ts b/src/client/src/components/ProjectList.ts index 0554105..8ebb8bb 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, renderActivityIndicator } from "./activityBadge"; +import { renderActionActivityIndicator } from "./activityBadge"; import type { KeyboardNavigableSection } from "./navigationFocus"; import { activateSelectableRow, focusSelectedOrFirstSelectableRow, handleSelectableRowKeyboard } from "./selectableRow"; import { listStyles } from "./shared"; @@ -65,7 +65,7 @@ export class ProjectList extends LitElement implements KeyboardNavigableSection @keydown=${(event: KeyboardEvent) => { this.handleProjectKeydown(event, project); }} >
- ${project.name}${this.renderUnread(project)}${project.path} + ${project.name}${project.path} ${this.renderActivity(project)}
@@ -102,12 +102,8 @@ export class ProjectList extends LitElement implements KeyboardNavigableSection private renderActivity(project: Project) { const kind = projectActivityIndicator(project, this.workspacesByProjectId[project.id] ?? [], this.activities); - return renderActionActivityIndicator(kind, kind === "terminal" ? "Project terminal active" : "Project active"); - } - - private renderUnread(project: Project) { - if (!this.unreadProjectIds.has(project.id)) return undefined; - return renderActivityIndicator("unread", "Unread sessions in this project"); + const unreadLabel = this.unreadProjectIds.has(project.id) ? "Unread sessions in this project" : undefined; + return renderActionActivityIndicator(kind, kind === "terminal" ? "Project terminal active" : "Project active", unreadLabel); } private toggleMenu(projectId: string, target: EventTarget | null) { diff --git a/src/client/src/components/SessionList.test.ts b/src/client/src/components/SessionList.test.ts index d0a5fa9..8943f9b 100644 --- a/src/client/src/components/SessionList.test.ts +++ b/src/client/src/components/SessionList.test.ts @@ -15,7 +15,7 @@ import { templateValues, type TemplateEventHandler, } from "../templateInspection.testSupport"; -import { SessionList, sessionRowActivityKind, sessionRowsForCurrentTree, unreadSessionCount } from "./SessionList"; +import { SessionList, sessionRowActivityKind, sessionRowsForCurrentTree, sessionRowUnread, unreadSessionCount } from "./SessionList"; describe("sessionRowActivityKind", () => { const idle = sessionStatus("s"); @@ -37,33 +37,36 @@ describe("sessionRowActivityKind", () => { expect(sessionRowActivityKind(session("s"), idle, { sessionId: "s", phase: "active", label: "running tool", at: "now" }, false)).toBe("session"); }); - it("reports unread only while the session is idle", () => { - expect(sessionRowActivityKind(session("s"), idle, undefined, false, true)).toBe("unread"); - expect(sessionRowActivityKind(session("s"), { ...idle, isStreaming: true }, undefined, false, true)).toBe("session"); - expect(sessionRowActivityKind(session("s"), idle, undefined, true, true)).toBe("sending"); - }); - - it("reports undefined when idle, read, and not sending", () => { + it("reports undefined when idle and not sending, even for an unread session", () => { expect(sessionRowActivityKind(session("s"), idle, undefined, false)).toBeUndefined(); }); - it("never shows an indicator for archived or cached-new sessions, even while sending or unread", () => { - expect(sessionRowActivityKind({ ...session("s"), archived: true }, idle, undefined, true, true)).toBeUndefined(); - expect(sessionRowActivityKind(markCachedNewSessionInfo(session("s")), idle, undefined, true, true)).toBeUndefined(); + it("never shows an indicator for archived or cached-new sessions, even while sending", () => { + expect(sessionRowActivityKind({ ...session("s"), archived: true }, idle, undefined, true)).toBeUndefined(); + expect(sessionRowActivityKind(markCachedNewSessionInfo(session("s")), idle, undefined, true)).toBeUndefined(); + }); +}); + +describe("sessionRowUnread", () => { + it("flags tracked current sessions regardless of activity state", () => { + expect(sessionRowUnread(session("s"), new Set(["s"]))).toBe(true); + expect(sessionRowUnread(session("s"), new Set())).toBe(false); + }); + + it("never flags archived or cached-new sessions, even when tracked as unread", () => { + expect(sessionRowUnread({ ...session("s"), archived: true }, new Set(["s"]))).toBe(false); + expect(sessionRowUnread(markCachedNewSessionInfo(session("s")), new Set(["s"]))).toBe(false); }); }); describe("unreadSessionCount", () => { - it("counts only current persisted sessions", () => { + it("counts only current persisted sessions, including busy ones", () => { const current = session("current"); const archived = { ...session("archived"), archived: true, archivedAt: "2026-06-09T00:00:00.000Z" }; const cached = markCachedNewSessionInfo(session("cached")); const unreadIds = new Set([current.id, archived.id, cached.id]); expect(unreadSessionCount([current, archived, cached], unreadIds)).toBe(1); - expect(unreadSessionCount([current, archived, cached], unreadIds, { - statuses: { [current.id]: sessionStatus(current.id, { isStreaming: true }) }, - })).toBe(0); }); }); diff --git a/src/client/src/components/SessionList.ts b/src/client/src/components/SessionList.ts index a7b7bba..014224b 100644 --- a/src/client/src/components/SessionList.ts +++ b/src/client/src/components/SessionList.ts @@ -110,11 +110,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection const currentSelectableSessions = currentRows.map((row) => row.session).filter((session) => sessionSelectionScope(session) === "current"); const archivedRows = sessionRows(this.sessions.filter((session) => session.archived === true && !currentRowIds.has(session.id))); const descendantCounts = unarchivedDescendantCounts(this.sessions); - const unreadCount = unreadSessionCount(currentSelectableSessions, this.unreadSessionIds, { - statuses: this.statuses, - activities: this.activities, - sending: this.sending, - }); + const unreadCount = unreadSessionCount(currentSelectableSessions, this.unreadSessionIds); return html`
${this.renderHeading(currentRows.length + archivedRows.length, currentSelectableSessions, unreadCount)} @@ -253,14 +249,15 @@ export class SessionList extends LitElement implements KeyboardNavigableSection const bulkSelected = showsCheckbox && this.selectedSessionIds.has(session.id); const status = this.statuses[session.id]; const activity = this.activities[session.id]; - const indicatorKind = sessionRowActivityKind(session, status, activity, this.sending[session.id] === true, this.unreadSessionIds.has(session.id)); + const indicatorKind = sessionRowActivityKind(session, status, activity, this.sending[session.id] === true); + const unread = sessionRowUnread(session, this.unreadSessionIds); const persistenceOptions = this.sessionPersistenceOptions(); const canArchive = isArchivableSessionInfo(session, status, persistenceOptions); const canDeleteTransient = isTransientNewSessionInfo(session, status, persistenceOptions); const canReloadSession = canArchive && this.canReload; return html`
${showsCheckbox ? html` { event.stopPropagation(); }} @change=${() => { this.toggleSelected(session.id); }}>` : null} ${row.depth > 0 ? html`` : null}${sessionLabel(session)}${row.depth > 2 ? html` depth ${row.depth}` : null}${row.hasMissingParent ? html` parent unavailable` : null}${this.renderSessionMetaPrefix(session, status, activity)}${String(session.messageCount)} messages - ${this.renderActivity(indicatorKind)} + ${this.renderActivity(indicatorKind, unread)}
@@ -436,13 +433,9 @@ export class SessionList extends LitElement implements KeyboardNavigableSection return { authoritative: this.authoritativeSessionPersistence }; } - private renderActivity(kind: ActivityIndicatorKind | undefined) { - const label = kind === "sending" - ? "Sending message" - : kind === "unread" - ? "Unread session activity" - : "Session active"; - return renderActionActivityIndicator(kind, label); + private renderActivity(kind: ActivityIndicatorKind | undefined, unread: boolean) { + const label = kind === "sending" ? "Sending message" : "Session active"; + return renderActionActivityIndicator(kind, label, unread ? "Unread session activity" : undefined); } static override styles = [listStyles, css` @@ -477,19 +470,8 @@ export class SessionList extends LitElement implements KeyboardNavigableSection export function unreadSessionCount( sessions: readonly SessionInfo[], unreadSessionIds: ReadonlySet, - runtime: { - statuses?: Record | undefined; - activities?: Record | undefined; - sending?: Record | undefined; - } = {}, ): number { - return sessions.filter((session) => sessionRowActivityKind( - session, - runtime.statuses?.[session.id], - runtime.activities?.[session.id], - runtime.sending?.[session.id] === true, - unreadSessionIds.has(session.id), - ) === "unread").length; + return sessions.filter((session) => sessionRowUnread(session, unreadSessionIds)).length; } function sessionSelectionScope(session: SessionInfo): SessionSelectionScope { @@ -528,24 +510,34 @@ function unarchivedDescendantCounts(sessions: SessionInfo[]): Map): boolean { + if (isCachedNewSessionInfo(session) || session.archived === true) return false; + return unreadSessionIds.has(session.id); } export function sessionRowsForCurrentTree(sessions: SessionInfo[]): SessionRow[] { diff --git a/src/client/src/components/WorkspaceList.test.ts b/src/client/src/components/WorkspaceList.test.ts index 1fed519..0649e91 100644 --- a/src/client/src/components/WorkspaceList.test.ts +++ b/src/client/src/components/WorkspaceList.test.ts @@ -1,7 +1,7 @@ // @vitest-environment happy-dom import { afterEach, describe, expect, it } from "vitest"; -import type { Workspace } from "../api"; +import type { Workspace, WorkspaceActivity } from "../api"; import { WorkspaceList } from "./WorkspaceList"; afterEach(() => { @@ -27,6 +27,18 @@ describe("workspace unread indicator", () => { expect(list.shadowRoot?.querySelector(".activity-indicator.unread")).toBeNull(); }); + + it("wraps the work dot in an unread ring when the workspace is busy and unread", async () => { + const list = await mountWorkspaceList([workspace("ws-a")], new Set(["ws-a"])); + list.activities = { "/repo/ws-a": workspaceActivity("/repo/ws-a", false, true) }; + await list.updateComplete; + + const row = rowFor(list, "ws-a"); + const ring = row.querySelector(".unread-ring"); + expect(ring?.querySelector(".activity-indicator.terminal")).not.toBeNull(); + expect(ring?.getAttribute("title")).toBe("Unread sessions in this workspace · Workspace terminal active"); + expect(row.querySelector(".activity-indicator.unread")).toBeNull(); + }); }); async function mountWorkspaceList(workspaces: Workspace[], unreadWorkspaceIds: ReadonlySet): Promise { @@ -49,6 +61,10 @@ function unreadDot(row: Element): Element | null { return row.querySelector(".activity-indicator.unread"); } +function workspaceActivity(cwd: string, hasSessionActivity: boolean, hasTerminalActivity: boolean): WorkspaceActivity { + return { cwd, hasSessionActivity, hasTerminalActivity, updatedAt: "2026-06-04T00:00:00.000Z" }; +} + function workspace(id: string): Workspace { return { id, projectId: "project-1", path: `/repo/${id}`, label: id, isMain: true, isGitRepo: true, isGitWorktree: false }; } diff --git a/src/client/src/components/WorkspaceList.ts b/src/client/src/components/WorkspaceList.ts index 29b2dba..fc6161e 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, renderActivityIndicator } from "./activityBadge"; +import { renderActionActivityIndicator } from "./activityBadge"; import type { KeyboardNavigableSection } from "./navigationFocus"; import { activateSelectableRow, focusSelectedOrFirstSelectableRow, handleSelectableRowKeyboard } from "./selectableRow"; import { listStyles } from "./shared"; @@ -94,14 +94,14 @@ export class WorkspaceList extends LitElement implements KeyboardNavigableSectio private renderActivity(workspace: Workspace): TemplateResult | undefined { const kind = workspaceActivityIndicator(workspaceActivityFor(workspace, this.activities)); - return renderActionActivityIndicator(kind, kind === "terminal" ? "Workspace terminal active" : "Workspace active"); + const unreadLabel = this.unreadWorkspaceIds.has(workspace.id) ? "Unread sessions in this workspace" : undefined; + return renderActionActivityIndicator(kind, kind === "terminal" ? "Workspace terminal active" : "Workspace active", unreadLabel); } private renderWorkspaceMain(label: string, items: WorkspaceLabelItem[], workspace: Workspace): TemplateResult { return html` ${label} - ${this.renderUnread(workspace)} ${this.isDeleting(workspace) ? html`Deleting…` : null} ${items.length === 0 ? null : html` @@ -113,11 +113,6 @@ export class WorkspaceList extends LitElement implements KeyboardNavigableSectio `; } - private renderUnread(workspace: Workspace): TemplateResult | undefined { - if (!this.unreadWorkspaceIds.has(workspace.id)) return undefined; - return renderActivityIndicator("unread", "Unread sessions in this workspace"); - } - private renderWorkspaceMenu(label: string, items: WorkspaceLabelItem[], workspace: Workspace): TemplateResult { const open = this.openMenuWorkspaceId === workspace.id; const menuId = workspaceMenuId(workspace.id); diff --git a/src/client/src/components/activityBadge.test.ts b/src/client/src/components/activityBadge.test.ts new file mode 100644 index 0000000..8322e89 --- /dev/null +++ b/src/client/src/components/activityBadge.test.ts @@ -0,0 +1,67 @@ +// @vitest-environment happy-dom + +import { render, type TemplateResult } from "lit"; +import { afterEach, describe, expect, it } from "vitest"; +import { renderActionActivityIndicator, renderActivityIndicator } from "./activityBadge"; + +afterEach(() => { + document.body.replaceChildren(); +}); + +describe("renderActivityIndicator", () => { + it("renders nothing when the row is idle and read", () => { + const container = renderInto(renderActivityIndicator(undefined, "Machine active")); + + expect(container.querySelector(".activity-indicator, .unread-ring")).toBeNull(); + }); + + it("renders a bare work dot when the row is active and read", () => { + const container = renderInto(renderActivityIndicator("session", "Machine active")); + + const dot = container.querySelector(".activity-indicator.session"); + expect(dot?.getAttribute("aria-label")).toBe("Machine active"); + expect(container.querySelector(".unread-ring")).toBeNull(); + }); + + it("renders a filled unread dot when the row is idle and unread", () => { + const container = renderInto(renderActivityIndicator(undefined, "Machine active", "Unread sessions on this machine")); + + const dot = container.querySelector(".activity-indicator.unread"); + expect(dot?.getAttribute("title")).toBe("Unread sessions on this machine"); + expect(container.querySelector(".unread-ring")).toBeNull(); + }); + + it("wraps the work dot in an unread ring when the row is active and unread", () => { + const container = renderInto(renderActivityIndicator("terminal", "Machine terminal active", "Unread sessions on this machine")); + + const ring = container.querySelector(".unread-ring"); + expect(ring?.getAttribute("role")).toBe("img"); + expect(ring?.getAttribute("aria-label")).toBe("Unread sessions on this machine · Machine terminal active"); + const dot = ring?.querySelector(".activity-indicator.terminal"); + expect(dot?.getAttribute("aria-hidden")).toBe("true"); + // One mark only: the ring replaces the standalone unread dot. + expect(container.querySelector(".activity-indicator.unread")).toBeNull(); + }); +}); + +describe("renderActionActivityIndicator", () => { + it("slots the composite mark into the row corner", () => { + const container = renderInto(renderActionActivityIndicator("session", "Session active", "Unread session activity")); + + const slot = container.querySelector(".action-activity"); + expect(slot?.querySelector(".unread-ring .activity-indicator.session")).not.toBeNull(); + }); + + it("renders no slot when there is nothing to show", () => { + const container = renderInto(renderActionActivityIndicator(undefined)); + + expect(container.querySelector(".action-activity")).toBeNull(); + }); +}); + +function renderInto(template: TemplateResult | undefined): HTMLElement { + const container = document.createElement("div"); + document.body.append(container); + render(template ?? null, container); + return container; +} diff --git a/src/client/src/components/activityBadge.ts b/src/client/src/components/activityBadge.ts index 5cc0030..1509b0f 100644 --- a/src/client/src/components/activityBadge.ts +++ b/src/client/src/components/activityBadge.ts @@ -1,14 +1,34 @@ import { html, type TemplateResult } from "lit"; -export type ActivityIndicatorKind = "session" | "terminal" | "sending" | "unread"; +/** + * Work signals a row can show. At most one kind renders at a time; call sites + * resolve precedence (sending > session > terminal) before rendering. + */ +export type ActivityIndicatorKind = "session" | "terminal" | "sending"; -export function renderActivityIndicator(kind: ActivityIndicatorKind | undefined, label = "Active"): TemplateResult | undefined { - if (kind === undefined) return undefined; - return html``; +/** + * Render the single indicator mark for a row. + * + * Unread is an attention flag, not a work signal, so it never competes with + * the activity kinds for the slot: pass `unreadLabel` and it renders as a + * static accent ring around the work dot (which keeps its own color, shape, + * and pulse), or as a filled static accent dot when the row is idle. The label + * is the flag — pass undefined when the row has nothing unread. + */ +export function renderActivityIndicator(kind: ActivityIndicatorKind | undefined, label = "Active", unreadLabel?: string): TemplateResult | undefined { + if (kind === undefined) { + if (unreadLabel === undefined) return undefined; + return html``; + } + if (unreadLabel === undefined) { + return html``; + } + const combinedLabel = `${unreadLabel} · ${label}`; + return html``; } -export function renderActionActivityIndicator(kind: ActivityIndicatorKind | undefined, label = "Active"): TemplateResult | undefined { - const indicator = renderActivityIndicator(kind, label); +export function renderActionActivityIndicator(kind: ActivityIndicatorKind | undefined, label = "Active", unreadLabel?: string): TemplateResult | undefined { + const indicator = renderActivityIndicator(kind, label, unreadLabel); if (indicator === undefined) return undefined; return html`${indicator}`; } diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index 42fc34f..ea132fb 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -284,6 +284,10 @@ export const listStyles = css` .activity-indicator.sending { border-radius: 50%; background: var(--pi-warning); } /* Unread is a stable state, not ongoing work: keep it static and accent-colored. */ .activity-indicator.unread { border-radius: 50%; background: var(--pi-accent); animation: none; box-shadow: 0 0 0 2px color-mix(in srgb, var(--pi-accent) 20%, transparent); } + /* Unread + ongoing work: a static accent ring wraps the still-pulsing work dot. */ + .unread-ring { flex: 0 0 auto; box-sizing: border-box; display: inline-grid; place-items: center; width: 9px; height: 9px; margin-right: 6px; border: 1.5px solid var(--pi-accent); border-radius: 50%; vertical-align: 1px; } + .unread-ring .activity-indicator { width: 5px; height: 5px; margin: 0; vertical-align: 0; } + .action-activity .unread-ring { margin: 0; vertical-align: 0; } .action-menu { position: relative; align-self: stretch; } .action-menu-toggle { display: grid; place-items: center; height: 100%; min-width: 32px; padding: 0; color: var(--pi-muted); border-left: 0; border-top-left-radius: 0; border-bottom-left-radius: 0; } .action-menu-toggle:hover { color: var(--pi-text); background: var(--pi-surface-hover); }