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.
This commit is contained in:
Federico Jaramillo Martinez
2026-07-27 14:22:17 +02:00
parent 0a080f4390
commit b4a7a9230b
5 changed files with 230 additions and 5 deletions
+7
View File
@@ -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<void> {
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)}
@@ -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<void>;
type RefreshUnread = (machineId: string) => Promise<void>;
type MarkSessionRead = (session: SessionInfo) => void;
type MarkSessionsRead = (sessions: SessionInfo[]) => void;
function createApp(storedValues: Record<string, string> = {}, mobileNavigation = false): PiWebApp {
const values = new Map(Object.entries(storedValues));
@@ -356,15 +404,31 @@ function mobileNavigationTab(app: PiWebApp): AppMobileMainTab {
}
function navigationUnreadSessionIds(app: PiWebApp): ReadonlySet<string> {
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";
}
+133 -2
View File
@@ -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<typeof sessionRowsForCurrentTree>) {
return rows.map((row) => ({ id: row.session.id, depth: row.depth, hasMissingParent: row.hasMissingParent }));
}
function sessionList(sessions: SessionInfo[], unreadSessionIds: ReadonlySet<string>): 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</button>");
const strings = templateStrings(host);
const values = templateValues(host);
for (let index = 0; index < values.length; index += 1) {
if (strings[index + 1]?.includes(">Mark read</button>") !== 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</button>");
}
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> = {}): SessionStatus {
return {
sessionId,
+11
View File
@@ -57,6 +57,8 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
@property({ attribute: false }) onDeleteArchived?: (session: SessionInfo) => void | Promise<void>;
@property({ attribute: false }) onDeleteArchivedMany?: (sessions: SessionInfo[]) => void | Promise<void>;
@property({ attribute: false }) onDetachParent?: (session: SessionInfo) => void;
@property({ attribute: false }) onMarkRead?: (session: SessionInfo) => void;
@property({ attribute: false }) onMarkReadMany?: (sessions: SessionInfo[]) => void | Promise<void>;
@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
<button ?disabled=${visibleSessions.length === 0} @click=${() => { this.toggleVisibleSelection(visibleSessions, !allVisibleSelected); }}>${allVisibleSelected ? "Clear visible" : "Select visible"}</button>
<small>${selectedSessions.length} selected${visibleSelectedCount !== selectedSessions.length ? html` · ${visibleSelectedCount} visible` : null}</small>
<button ?disabled=${archivableSessions.length === 0} @click=${() => { this.archiveSelectedCurrent(); }}>Archive selected</button>
<button ?disabled=${unreadSelectedSessions.length === 0} @click=${() => { this.markSelectedCurrentRead(); }}>Mark read</button>
<button @click=${() => { this.clearSelection("current"); }}>Clear</button>
<button @click=${() => { this.closeSelection("current"); }}>Done</button>
</div>
@@ -280,6 +284,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
: canDeleteTransient
? html`<button title="Delete transient new session" @click=${() => { this.openMenuSessionId = undefined; this.onDelete?.(session); }}>Delete</button>`
: html`
${this.unreadSessionIds.has(session.id) ? html`<button title="Mark session as read" @click=${() => { this.openMenuSessionId = undefined; this.onMarkRead?.(session); }}>Mark as read</button>` : null}
${canArchive ? html`
<button title="Archive session" @click=${() => { this.openMenuSessionId = undefined; this.onArchive?.(session); }}>Archive</button>
${descendantCount > 0 ? html`<button title="Archive this session and its descendants" @click=${() => { this.openMenuSessionId = undefined; this.confirmArchiveWithDescendants(session, descendantCount); }}>Archive with descendants (${descendantCount})</button>` : 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));
@@ -67,6 +67,8 @@ export class AppNavigationPanel extends LitElement {
@property({ attribute: false }) onDeleteArchivedSession?: (session: SessionInfo) => void | Promise<void>;
@property({ attribute: false }) onDeleteArchivedSessions?: (sessions: SessionInfo[]) => void | Promise<void>;
@property({ attribute: false }) onDetachParentSession?: (session: SessionInfo) => void | Promise<void>;
@property({ attribute: false }) onMarkSessionRead?: (session: SessionInfo) => void | Promise<void>;
@property({ attribute: false }) onMarkSessionsRead?: (sessions: SessionInfo[]) => void | Promise<void>;
@property({ attribute: false }) onReloadSession?: (session: SessionInfo) => void | Promise<void>;
@property({ attribute: false }) onCleanupSessions?: () => void | Promise<void>;
@property({ attribute: false }) onArchivedCollapsed?: () => void | Promise<void>;
@@ -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"); }}