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"); }}