Files
kandev/apps/web/lib/session-unread-divider.ts
T

93 lines
4.2 KiB
TypeScript

import type { RenderItem } from "@/hooks/use-processed-messages";
/** Message ids carried by a render item — a "message" item carries its own
* id, a "turn_group" carries every message bundled into that visual group,
* and non-message items (prepare_progress, agent_error_notice) carry none.
*
* The frontend-only "task-description" placeholder (injected by
* use-processed-messages.ts when a session has no real messages yet) is
* excluded too: it never exists as a row in the backend messages table, so
* treating it as a real message id would make lastRenderedMessageId return
* "task-description" as the cursor target — which the backend's
* MarkSessionRead correctly rejects as an unknown message id (400), but
* only after a wasted round trip logged as a console error on every visit
* to a session with no messages yet. */
function renderItemMessageIds(item: RenderItem): string[] {
if (item.type === "message") {
return item.message.id === "task-description" ? [] : [item.message.id];
}
if (item.type === "turn_group") {
return item.messages.map((message) => message.id).filter((id) => id !== "task-description");
}
return [];
}
/**
* Locates the Slack-style unread ("New") divider boundary: the key of the
* render item the divider should appear immediately before.
*
* Finds the item containing lastReadMessageId, then returns the key of the
* next item that carries a real message. When lastReadMessageId falls inside
* a turn_group (several messages bundled into one visual group), the whole
* group counts as read and the divider lands after it — a group is never
* split into read/unread halves.
*
* When lastReadMessageId isn't found among the currently loaded items, the
* loaded window is treated as entirely unread — the divider goes before its
* first real message — rather than suppressed. The common cause is an older
* cursor pointing past the currently paginated window (the loaded messages
* are the transcript's tail; older history hasn't been fetched yet), which
* means everything visible really is unread. Returns null only when there is
* no cursor at all (a brand-new session has nothing to mark "New" against),
* or the cursor already points at the newest loaded message.
*
* The returned key mirrors message-list-shared.tsx's getItemKey format
* (bare message id, or the item's own id for non-message items) — duplicated
* inline rather than imported to avoid a circular import, since that module
* imports RenderItem from hooks/use-processed-messages.ts. Keep the two in
* sync.
*/
export function findUnreadDividerItemId(
items: RenderItem[],
lastReadMessageId: string | null | undefined,
): string | null {
if (!lastReadMessageId) return null;
const boundaryIndex = items.findIndex((item) =>
renderItemMessageIds(item).includes(lastReadMessageId),
);
const searchFrom = boundaryIndex === -1 ? 0 : boundaryIndex + 1;
for (let i = searchFrom; i < items.length; i++) {
const item = items[i];
if (renderItemMessageIds(item).length > 0) {
return item.type === "message" ? item.message.id : item.id;
}
}
return null;
}
/**
* Returns the id of the newest message actually represented among items —
* i.e. the last message that made it into the rendered transcript — or null
* if items carries no message at all.
*
* Callers deriving the Slack-style read-cursor's "latest message" must use
* this instead of the raw backend message list's last row: some backend
* rows never render (a pending `clarification_request`, a hidden "Session
* resumed" status, setup script output, a collapsed todo snapshot, ...), so
* the raw list's tail can be a message findUnreadDividerItemId will never
* find among items — which it then (correctly, per its own contract) treats
* as "the whole loaded window is unread", drawing the divider before
* everything even though nothing visible is actually new. Deriving the
* cursor from the same render-item universe findUnreadDividerItemId
* searches keeps both sides in sync.
*/
export function lastRenderedMessageId(items: RenderItem[]): string | null {
for (let i = items.length - 1; i >= 0; i--) {
const ids = renderItemMessageIds(items[i]);
if (ids.length > 0) return ids[ids.length - 1];
}
return null;
}