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