Files
openchamber/packages/ui/src/hooks/useSessionAssist.ts
T
Bohdan Triapitsyn d8e223bf49 fix(chat): open a session already at its end, and keep it there
A session opened from the sidebar could land above the bottom, or show a
frame sitting lower and then snap up. The viewport was pinned before the
content was final: the recap note renders once the session record arrives
and grew the footer under the pinned viewport, and on large sessions
subagent task cards grow when their child sessions load, moving everything
above the viewport.

- The recap note holds the timeline reveal until the session record is in
  memory, so it is part of the first finished picture.
- The scroll hook holds the reveal until the viewport is pinned; the reveal
  itself runs once the content height has held still for two frames, with
  one exact pin against the final height (bounded at 300ms).
- Sitting on the end of a session that is not producing output is an
  invariant: content growth re-pins from a MutationObserver in the same
  frame the list writes its layout, so no frame paints with the end out of
  view. Output growth keeps gliding through followEnd, which now glides only
  while the session is working.
2026-08-29 21:17:28 +03:00

101 lines
4.1 KiB
TypeScript

import React from 'react';
import { useDirectoryStore, useSession, useSessionStatus } from '@/sync/sync-context';
import { getSessionAssist, type SessionAssistPayload } from '@/lib/sessionAssistMetadata';
import { useUIStore } from '@/stores/useUIStore';
// How long the chat must sit untouched before the recap becomes visible.
// The suggestion has no such delay — it shows as soon as it arrives.
const RECAP_VISIBILITY_DELAY_MS = 60 * 1000;
interface LastMessageSnapshot {
id: string;
role: string;
timestamp: number;
}
/** Narrow subscription to the last message of a session (id/role/time only). */
function useLastMessageSnapshot(sessionId: string, directory?: string): LastMessageSnapshot | null {
const store = useDirectoryStore(directory);
const cacheRef = React.useRef<LastMessageSnapshot | null>(null);
const getSnapshot = React.useCallback((): LastMessageSnapshot | null => {
if (!sessionId) return null;
const messages = store.getState().message[sessionId];
const last = messages && messages.length > 0 ? messages[messages.length - 1] : null;
const info = last as { id?: string; role?: string; time?: { completed?: number; created?: number } } | null;
if (!info?.id) {
cacheRef.current = null;
return null;
}
const next: LastMessageSnapshot = {
id: info.id,
role: typeof info.role === 'string' ? info.role : '',
timestamp: info.time?.completed ?? info.time?.created ?? 0,
};
const cached = cacheRef.current;
if (cached && cached.id === next.id && cached.role === next.role && cached.timestamp === next.timestamp) {
return cached;
}
cacheRef.current = next;
return next;
}, [sessionId, store]);
const subscribe = React.useCallback((notify: () => void) => {
if (!sessionId) return () => undefined;
return store.subscribe(notify);
}, [sessionId, store]);
return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}
export interface SessionAssistState {
/** Valid (fresh) assist payload, or null. */
assist: SessionAssistPayload | null;
/** Recap text, only when the 1-minute quiet window has elapsed. */
visibleRecap: string | null;
/** Suggestion text — fresh payload, session idle; caller still gates on input emptiness. */
suggestion: string | null;
/** False until the session record is in memory; the recap cannot be decided before that. */
sessionKnown: boolean;
}
export function useSessionAssistState(sessionId: string, directory?: string): SessionAssistState {
const session = useSession(sessionId, directory);
const status = useSessionStatus(sessionId, directory);
const lastMessage = useLastMessageSnapshot(sessionId, directory);
const sessionRecapEnabled = useUIStore((state) => state.sessionRecapEnabled);
const sessionSuggestionEnabled = useUIStore((state) => state.sessionSuggestionEnabled);
const isIdle = !status || status.type === 'idle';
const payload = getSessionAssist(session);
// Fresh = the payload's target message is still the session's last message.
const assist = payload
&& lastMessage
&& lastMessage.role === 'assistant'
&& lastMessage.id === payload.forMessageID
&& isIdle
? payload
: null;
// Recap waits out the quiet window; re-render once when the boundary passes.
const lastTimestamp = lastMessage?.timestamp ?? 0;
const [, forceTick] = React.useReducer((tick: number) => tick + 1, 0);
const quietElapsed = assist ? Date.now() - lastTimestamp >= RECAP_VISIBILITY_DELAY_MS : false;
React.useEffect(() => {
if (!assist || quietElapsed || !lastTimestamp) return undefined;
const remaining = RECAP_VISIBILITY_DELAY_MS - (Date.now() - lastTimestamp);
if (remaining <= 0) return undefined;
const timer = setTimeout(forceTick, remaining + 250);
return () => clearTimeout(timer);
}, [assist, quietElapsed, lastTimestamp]);
return {
assist,
visibleRecap: sessionRecapEnabled && assist && assist.recap && quietElapsed ? assist.recap : null,
suggestion: sessionSuggestionEnabled && assist && assist.suggestion ? assist.suggestion : null,
sessionKnown: session !== undefined && session !== null,
};
}