Files
kandev/apps/web/components/task/chat/use-session-read-tracking.ts
T

170 lines
7.2 KiB
TypeScript

"use client";
import { useEffect, useRef, useState } from "react";
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
import { markSessionRead } from "@/lib/api/domains/session-api";
type Anchor = { sessionId: string; messageId: string | null };
type Visit = {
sessionId: string;
priorCursor: string | null;
initialMessagesReady: boolean;
};
function visitAnchor(
sessionId: string,
priorCursor: string | null,
latestMessageId: string | null,
) {
return latestMessageId !== null && priorCursor !== null && priorCursor !== latestMessageId
? { sessionId, messageId: priorCursor }
: null;
}
function visibleAnchor(
unreadDividerEnabled: boolean,
isVisible: boolean,
anchor: Anchor | null,
sessionId: string | null,
): string | null {
if (!unreadDividerEnabled || !isVisible || anchor?.sessionId !== sessionId) return null;
return anchor.messageId;
}
/**
* Tracks a session's Slack-style read cursor and derives the frozen
* unread-divider anchor for the current "visit".
*
* - While the session is the visible chat panel, the cursor advances to
* latestMessageId immediately (no debounce) whenever it changes — mirrors
* Slack's continuous mark-as-read-while-open behavior, so a quick
* navigate-away-and-back still sees the correct boundary next time.
* - A visible visit captures its persisted cursor synchronously, but decides
* divider eligibility only once the initial message load settles. If that
* cursor is already the initial transcript tail, the visit is permanently
* divider-ineligible: later live messages cannot create a "New" boundary.
* A genuine initial unread boundary remains frozen until the next
* visibility transition (leaving and coming back, or switching sessions).
* - The visit state is captured during render (via the React "adjust state
* while rendering" pattern), not inside a useEffect. It retains the cursor
* from before live mark-read can overwrite it, while avoiding a divider
* decision from a partial transcript.
* - The capture is additionally gated on the session record already
* existing in the store (a reactive selector, not store.getState()). This
* prevents a host where isVisible is true from its first render (such as
* mobile) from treating an unloaded session as a real empty cursor.
*
* Returns the render-item key (see hooks/use-processed-messages.ts's
* findUnreadDividerItemId) the "New" divider should render immediately
* before, or null when there's nothing to mark.
*/
export function useSessionReadTracking(
sessionId: string | null,
isVisible: boolean,
latestMessageId: string | null,
initialMessagesLoading = false,
): string | null {
const store = useAppStoreApi();
const updateSessionReadCursor = useAppStore((state) => state.updateSessionReadCursor);
const unreadDividerEnabled = useAppStore((state) => state.userSettings.unreadDivider);
// Reactive (not store.getState()) so a session that hasn't loaded into the
// store yet at mount — e.g. mobile, where isVisible defaults to true from
// the very first render (see TaskChatPanelProps), unlike the dockview host
// path where usePanelActive's async lag incidentally gives the session
// fetch a head start — triggers a re-render once it does, instead of the
// capture below racing ahead of it and locking in undefined?.last_read_
// message_id ?? null as if that were a real "no prior cursor" answer.
const sessionLoaded = useAppStore(
(state) => sessionId !== null && sessionId in state.taskSessions.items,
);
const [anchor, setAnchor] = useState<Anchor | null>(null);
// A visit starts at a visibility transition but only becomes eligible for
// a divider after its initial message load settles. Its prior cursor stays
// immutable so live messages cannot create a new boundary mid-visit.
const [visit, setVisit] = useState<Visit | null>(null);
if (unreadDividerEnabled && isVisible && sessionId && sessionLoaded) {
if (visit?.sessionId !== sessionId) {
const priorCursor =
store.getState().taskSessions.items[sessionId]?.last_read_message_id ?? null;
const initialMessagesReady = !initialMessagesLoading;
setVisit({ sessionId, priorCursor, initialMessagesReady });
setAnchor(initialMessagesReady ? visitAnchor(sessionId, priorCursor, latestMessageId) : null);
} else if (!visit.initialMessagesReady && !initialMessagesLoading) {
setVisit({ ...visit, initialMessagesReady: true });
setAnchor(visitAnchor(sessionId, visit.priorCursor, latestMessageId));
}
}
// The *hide* transition is debounced rather than immediate: Dockview
// briefly disposes and re-registers a panel's underlying api during a
// layout-driven remount (see PanelPortalManager's class doc and
// usePanelActive), which can make isVisible read false for a render or
// two even though the user never actually left.
useEffect(() => {
if (visit === null) return;
if (!unreadDividerEnabled) {
setVisit(null);
setAnchor(null);
latestDispatchRef.current = null;
return;
}
if (isVisible) return;
const timer = setTimeout(() => {
setVisit(null);
setAnchor(null);
latestDispatchRef.current = null;
}, 300);
return () => clearTimeout(timer);
}, [isVisible, unreadDividerEnabled, visit]);
// Tracks the (sessionId, messageId) of the most recently *dispatched*
// mark-read request. The backend's cursor write is atomically monotonic
// (see UpdateTaskSessionLastReadMessageID), but that only protects the
// persisted row — not an in-flight HTTP response, which is a snapshot
// frozen at the moment its own GetTaskSession ran. Two overlapping
// requests (an older m2, then a newer m3) can still have their responses
// arrive in either order; if m2's (now-stale) response resolves after
// m3's, applying it verbatim would regress the local store back to m2
// even though the database is correctly at m3. Comparing against this
// ref when a response resolves discards any response that's no longer
// the latest dispatched request for this session.
const latestDispatchRef = useRef<{ sessionId: string; messageId: string } | null>(null);
useEffect(() => {
if (
!unreadDividerEnabled ||
!sessionId ||
!isVisible ||
!latestMessageId ||
visit?.sessionId !== sessionId ||
!visit.initialMessagesReady
)
return;
const currentCursor = store.getState().taskSessions.items[sessionId]?.last_read_message_id;
if (currentCursor === latestMessageId) return;
latestDispatchRef.current = { sessionId, messageId: latestMessageId };
void markSessionRead(sessionId, latestMessageId)
.then((response) => {
const dispatch = latestDispatchRef.current;
const isStale =
!dispatch || dispatch.sessionId !== sessionId || dispatch.messageId !== latestMessageId;
if (isStale) return;
updateSessionReadCursor(response.session_id, response.last_read_message_id);
})
.catch((err: unknown) => {
console.error("Failed to mark session read", err);
});
}, [
sessionId,
isVisible,
visit,
latestMessageId,
store,
unreadDividerEnabled,
updateSessionReadCursor,
]);
return visibleAnchor(unreadDividerEnabled, isVisible, anchor, sessionId);
}