fix(chat): ignore Ctrl/Cmd+digit surface switching while typing in an input
The Ctrl/Cmd+digit shortcut for switching context surfaces fired even while the user was typing in an editable target (input, textarea, select, or contenteditable element), hijacking the keystroke. Guard the switchSurfaceDigit branch in useKeyboardShortcuts with the existing isEditableEventTarget helper, matching how other shortcuts in the same hook already bail out of editable targets. Closes #2503
This commit is contained in:
@@ -1,6 +1,10 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { Window } from 'happy-dom';
|
||||
|
||||
import { hasOpenDropdown, isTypingInEditableTarget } from './keyboard-shortcut-dom';
|
||||
import { hasOpenDropdown, isEditableEventTarget, shouldStopDropdownImeEscape } from './keyboard-shortcut-dom';
|
||||
|
||||
const domWindow = new Window();
|
||||
Object.assign(globalThis, { document: domWindow.document, HTMLElement: domWindow.HTMLElement });
|
||||
|
||||
test('does not treat an unrelated visible listbox as an open dropdown', () => {
|
||||
const promptNavigator = {} as Element;
|
||||
@@ -29,26 +33,25 @@ test('detects an open select popup', () => {
|
||||
expect(hasOpenDropdown(root)).toBe(true);
|
||||
});
|
||||
|
||||
// isTypingInEditableTarget — the mod+digit surface switcher guard (issue
|
||||
// #2503): while the user is typing in an editable target, ctrl/cmd+digit
|
||||
// must keep its normal meaning (browser tab switching, in-input chords)
|
||||
// instead of switching the context panel surface.
|
||||
const targetWithClosest = (result: Element | null): EventTarget =>
|
||||
({ closest: (selector: string) => (selector === 'input, textarea, [contenteditable="true"]' ? result : null) }) as unknown as EventTarget;
|
||||
|
||||
test('editable guard is false for a null target', () => {
|
||||
expect(isTypingInEditableTarget(null)).toBe(false);
|
||||
test('stops IME Escape before an open dropdown dismiss listener', () => {
|
||||
expect(shouldStopDropdownImeEscape({ key: 'Escape', isComposing: true, keyCode: 0 }, true)).toBe(true);
|
||||
expect(shouldStopDropdownImeEscape({ key: 'Escape', isComposing: false, keyCode: 229 }, true)).toBe(true);
|
||||
expect(shouldStopDropdownImeEscape({ key: 'Escape', isComposing: false, keyCode: 27 }, true)).toBe(false);
|
||||
expect(shouldStopDropdownImeEscape({ key: 'Escape', isComposing: true, keyCode: 0 }, false)).toBe(false);
|
||||
});
|
||||
|
||||
test('editable guard is false for a target without closest', () => {
|
||||
expect(isTypingInEditableTarget({} as EventTarget)).toBe(false);
|
||||
test('treats inputs, textareas, selects, and contenteditable elements as editable targets', () => {
|
||||
expect(isEditableEventTarget(document.createElement('input'))).toBe(true);
|
||||
expect(isEditableEventTarget(document.createElement('textarea'))).toBe(true);
|
||||
expect(isEditableEventTarget(document.createElement('select'))).toBe(true);
|
||||
|
||||
const editableDiv = document.createElement('div');
|
||||
Object.defineProperty(editableDiv, 'isContentEditable', { value: true });
|
||||
expect(isEditableEventTarget(editableDiv)).toBe(true);
|
||||
});
|
||||
|
||||
test('editable guard is true inside an input, textarea or contenteditable', () => {
|
||||
const editable = {} as Element;
|
||||
expect(isTypingInEditableTarget(targetWithClosest(editable))).toBe(true);
|
||||
});
|
||||
|
||||
test('editable guard is false outside editable surfaces', () => {
|
||||
expect(isTypingInEditableTarget(targetWithClosest(null))).toBe(false);
|
||||
test('does not treat a plain element or non-element target as editable', () => {
|
||||
expect(isEditableEventTarget(document.createElement('div'))).toBe(false);
|
||||
expect(isEditableEventTarget(document.createElement('button'))).toBe(false);
|
||||
expect(isEditableEventTarget(null)).toBe(false);
|
||||
});
|
||||
|
||||
@@ -7,16 +7,18 @@ export function hasOpenDropdown(root: ParentNode = document): boolean {
|
||||
return Boolean(root.querySelector(OPEN_DROPDOWN_SELECTOR));
|
||||
}
|
||||
|
||||
// Editable surfaces (the chat composer is a contenteditable CodeMirror view;
|
||||
// CommitInput, searches and dialogs use textareas/inputs). Global shortcuts
|
||||
// must not hijack keystrokes while the user is typing in one of these —
|
||||
// mod+digit in particular is the browser's own tab-switching chord.
|
||||
const EDITABLE_TARGET_SELECTOR = 'input, textarea, [contenteditable="true"]';
|
||||
|
||||
export function isTypingInEditableTarget(target: EventTarget | null): boolean {
|
||||
const element = target as Element | null;
|
||||
if (!element || typeof element.closest !== 'function') {
|
||||
return false;
|
||||
}
|
||||
return Boolean(element.closest(EDITABLE_TARGET_SELECTOR));
|
||||
export function shouldStopDropdownImeEscape(
|
||||
event: Pick<KeyboardEvent, 'isComposing' | 'key' | 'keyCode'>,
|
||||
dropdownOpen: boolean,
|
||||
): boolean {
|
||||
return dropdownOpen
|
||||
&& event.key === 'Escape'
|
||||
&& (event.isComposing || event.keyCode === 229);
|
||||
}
|
||||
|
||||
export function isEditableEventTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
if (target.isContentEditable) return true;
|
||||
const tagName = target.tagName;
|
||||
return tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Keeps agent memory loaded for whatever project the session belongs to.
|
||||
*
|
||||
* This does not belong to the Memory tab. The session index is built from the
|
||||
* loaded snapshot, so leaving the load to the panel meant a user who never
|
||||
* opened Project notes sent every message with no memory index at all — the
|
||||
* agent had memories it was never told about.
|
||||
*
|
||||
* The session directory is resolved to its project first. A session in a
|
||||
* worktree has the worktree's path, and loading by that path reads a store the
|
||||
* agent does not write to, which is the same mismatch in the other direction.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { subscribeOpenchamberEvents } from '@/lib/openchamberEvents';
|
||||
import { useAgentMemoryStore } from '@/stores/useAgentMemoryStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useProjectContextOwner } from '@/hooks/useProjectContextOwner';
|
||||
|
||||
/**
|
||||
* The directory is a parameter rather than read from `useEffectiveDirectory`,
|
||||
* because this runs above `SyncProvider` — that hook reads the sync context and
|
||||
* throws outside it, which took the whole app down with a blank window.
|
||||
*/
|
||||
export const useAgentMemorySync = (directory: string | null): void => {
|
||||
const enabled = useUIStore((state) => (
|
||||
state.agentMemoryFeatureAvailable && state.agentMemoryToolEnabled
|
||||
));
|
||||
const load = useAgentMemoryStore((state) => state.load);
|
||||
const owner = useProjectContextOwner(directory);
|
||||
const projectPath = owner?.path ?? null;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
void load(projectPath);
|
||||
}, [enabled, load, projectPath]);
|
||||
|
||||
// The agent writes memory mid-turn through its own tool, so the index for the
|
||||
// next message has to come from a fresh read rather than the snapshot taken
|
||||
// before the turn started.
|
||||
React.useEffect(() => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
return subscribeOpenchamberEvents((event) => {
|
||||
if (event.type === 'agent-memory-changed') {
|
||||
void load(projectPath);
|
||||
}
|
||||
});
|
||||
}, [enabled, load, projectPath]);
|
||||
};
|
||||
@@ -1,938 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import { MessageFreshnessDetector } from '@/lib/messageFreshness';
|
||||
import { createScrollSpy } from '@/components/chat/lib/scroll/scrollSpy';
|
||||
import { useViewportStore } from '@/sync/viewport-store';
|
||||
|
||||
type AutoFollowState = 'following' | 'released';
|
||||
|
||||
export type ContentChangeReason = 'text' | 'structural' | 'permission' | 'animation';
|
||||
|
||||
export interface AnimationHandlers {
|
||||
onChunk: () => void;
|
||||
onComplete: () => void;
|
||||
onStreamingCandidate?: () => void;
|
||||
onAnimationStart?: () => void;
|
||||
onReservationCancelled?: () => void;
|
||||
onReasoningBlock?: () => void;
|
||||
onAnimatedHeightChange?: (height: number) => void;
|
||||
}
|
||||
|
||||
interface UseChatAutoFollowOptions {
|
||||
currentSessionId: string | null;
|
||||
currentSessionKey: string | null;
|
||||
sessionMessageCount: number;
|
||||
sessionIsWorking: boolean;
|
||||
isMobile: boolean;
|
||||
onActiveTurnChange?: (turnId: string | null) => void;
|
||||
}
|
||||
|
||||
export interface UseChatAutoFollowResult {
|
||||
scrollRef: React.RefObject<HTMLDivElement | null>;
|
||||
state: AutoFollowState;
|
||||
isPinned: boolean;
|
||||
isOverflowing: boolean;
|
||||
isFollowingProgrammatically: boolean;
|
||||
showScrollButton: boolean;
|
||||
notifyContentChange: (reason?: ContentChangeReason) => void;
|
||||
getAnimationHandlers: (messageId: string) => AnimationHandlers;
|
||||
goToBottom: (mode?: 'instant' | 'smooth') => void;
|
||||
scrollToBottomOnSend: () => void;
|
||||
releaseAutoFollow: () => void;
|
||||
saveSnapshotNow: () => void;
|
||||
restoreSnapshot: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Chat auto-follow. The model is deliberately simple, which is what makes it
|
||||
// flicker-free:
|
||||
//
|
||||
// • Auto-follow is on unless the user scrolled up (`released`), AND passive
|
||||
// following only acts while the session is active (working, plus a short
|
||||
// settle window). When idle, content-size changes are layout churn
|
||||
// (virtualizer re-measurement, async tool/code rendering) rather than live
|
||||
// growth, so the hook leaves scroll alone — re-pinning then would fight the
|
||||
// virtualizer and twitch the viewport.
|
||||
// • Following the bottom is INSTANT — `scrollTop = scrollHeight` inside the
|
||||
// content ResizeObserver, which fires after layout and before paint. There
|
||||
// is NO easing loop and NO settle burst, so there are never two writers
|
||||
// racing for `scrollTop` (the root cause of the old jiggle/double-scroll).
|
||||
// • A short-lived "auto" marker (position + 1500ms) lets the scroll handler
|
||||
// distinguish our own programmatic writes from genuine user scrolling, so
|
||||
// a scroll event that lands at our just-written bottom never trips a false
|
||||
// release.
|
||||
//
|
||||
// The public interface below is unchanged from the old implementation so every
|
||||
// consumer (ChatContainer, message parts, the timeline controller) keeps
|
||||
// working without edits.
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const BOTTOM_SPACER_DESKTOP_VH = 0.10;
|
||||
const BOTTOM_SPACER_MOBILE_PX = 40;
|
||||
const SAVE_DEBOUNCE_MS = 150;
|
||||
const TOUCH_FINGER_DOWN_THRESHOLD = 2;
|
||||
// How long an "auto" (programmatic) scroll position stays trusted. Browsers can
|
||||
// dispatch the `scroll` event for our write asynchronously, after newer content
|
||||
// has already changed the geometry; the window keeps us from reading that lag as
|
||||
// a user scroll.
|
||||
const AUTO_MARK_TTL_MS = 1500;
|
||||
const AUTO_MATCH_TOLERANCE_PX = 2;
|
||||
// While a tracked height animation runs (e.g. a Thinking block auto-collapsing
|
||||
// mid-stream), the timeline shrinks/grows over a couple hundred ms and the
|
||||
// virtualizer re-measures, producing transient geometry. Browsers dispatch the
|
||||
// resulting `scroll` events asynchronously, so a stale event can land after we
|
||||
// have already re-pinned — its position matching neither the bottom zone nor the
|
||||
// freshly-moved auto marker — and be misread as a user scroll-away. During this
|
||||
// guard window we treat any `following`-state scroll event as our own and never
|
||||
// release via the heuristic. GENUINE user gestures still release instantly
|
||||
// through releaseFromUserIntent, so this is not glue. Sized to the reasoning
|
||||
// animation (200ms) plus headroom for trailing async scroll events.
|
||||
const ANIMATION_GUARD_MS = 350;
|
||||
// After streaming stops, keep following the bottom for a short window so the
|
||||
// final content can settle into place.
|
||||
const SETTLE_MS = 300;
|
||||
// Entry-stick window. On the FIRST open of a session, late async data (most
|
||||
// visibly a task/subagent tool whose nested rows are fetched from the child
|
||||
// session after entry — see useEnsureSessionMessages in ToolPart.tsx) grows the
|
||||
// timeline a beat or two AFTER we have already pinned to the bottom, leaving the
|
||||
// viewport stranded mid-history. The steady-state idle gate deliberately ignores
|
||||
// that growth (it can't tell entry from a user reading idle history). So instead
|
||||
// of weakening the gate, we open a short, gesture-cancellable window on entry
|
||||
// during which we FORCE the bottom on every growth. It ends QUIESCENCE_MS after
|
||||
// growth stops (capped by MAX_MS), or instantly on any real user scroll gesture.
|
||||
const ENTRY_STICK_QUIESCENCE_MS = 600;
|
||||
const ENTRY_STICK_MAX_MS = 8000;
|
||||
|
||||
const now = (): number => (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
||||
|
||||
// The bottom of the chat has an empty spacer (10vh on desktop, 40px on mobile)
|
||||
// — its height is exactly how far above scrollHeight the user can be while still
|
||||
// looking at "empty" space. We use that same value as the threshold for both
|
||||
// re-pinning auto-follow and showing the scroll-to-bottom button.
|
||||
const computeBottomZoneThreshold = (isMobile: boolean, container?: HTMLElement | null): number => {
|
||||
if (isMobile) return BOTTOM_SPACER_MOBILE_PX;
|
||||
const height = container?.clientHeight ?? 0;
|
||||
if (height <= 0) return 96;
|
||||
return Math.max(48, height * BOTTOM_SPACER_DESKTOP_VH);
|
||||
};
|
||||
|
||||
const distanceFromBottom = (el: HTMLElement): number => {
|
||||
return el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
};
|
||||
|
||||
const canScroll = (el: HTMLElement): boolean => {
|
||||
return el.scrollHeight - el.clientHeight > 1;
|
||||
};
|
||||
|
||||
const isNearBottom = (el: HTMLElement, isMobile: boolean): boolean => {
|
||||
return distanceFromBottom(el) <= computeBottomZoneThreshold(isMobile, el);
|
||||
};
|
||||
|
||||
const isReleaseKey = (event: KeyboardEvent): boolean => {
|
||||
if (event.altKey || event.ctrlKey || event.metaKey) {
|
||||
return false;
|
||||
}
|
||||
switch (event.key) {
|
||||
case 'ArrowUp':
|
||||
case 'PageUp':
|
||||
case 'Home':
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const nestedScrollableTarget = (root: HTMLElement, target: EventTarget | null): HTMLElement | null => {
|
||||
if (!(target instanceof Element)) return null;
|
||||
const nested = target.closest('[data-scrollable]');
|
||||
if (!nested || nested === root || !(nested instanceof HTMLElement)) return null;
|
||||
return nested;
|
||||
};
|
||||
|
||||
const nestedScrollableCanConsumeUp = (root: HTMLElement, target: EventTarget | null): boolean => {
|
||||
const nested = nestedScrollableTarget(root, target);
|
||||
if (!nested) return false;
|
||||
return nested.scrollTop > 0;
|
||||
};
|
||||
|
||||
export const useChatAutoFollow = ({
|
||||
currentSessionId,
|
||||
currentSessionKey,
|
||||
sessionMessageCount,
|
||||
sessionIsWorking,
|
||||
isMobile,
|
||||
onActiveTurnChange,
|
||||
}: UseChatAutoFollowOptions): UseChatAutoFollowResult => {
|
||||
const scrollRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const [containerEl, setContainerEl] = React.useState<HTMLDivElement | null>(null);
|
||||
const lastSeenContainerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const [state, setState] = React.useState<AutoFollowState>('following');
|
||||
const [isOverflowing, setIsOverflowing] = React.useState(false);
|
||||
const [showScrollButton, setShowScrollButton] = React.useState(false);
|
||||
const [isFollowingProgrammatically, setIsFollowingProgrammatically] = React.useState(false);
|
||||
|
||||
// `stateRef` is the single source of truth for follow vs released; the React
|
||||
// state above is a mirror for rendering. `released` means the user scrolled
|
||||
// up and away from the bottom.
|
||||
const stateRef = React.useRef<AutoFollowState>('following');
|
||||
const isMobileRef = React.useRef(isMobile);
|
||||
isMobileRef.current = isMobile;
|
||||
const sessionIsWorkingRef = React.useRef(sessionIsWorking);
|
||||
sessionIsWorkingRef.current = sessionIsWorking;
|
||||
// `settling` keeps passive follow alive for a short window after work stops
|
||||
// so the final content can land at the bottom.
|
||||
const settlingRef = React.useRef(false);
|
||||
const settleTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const sessionMessageCountRef = React.useRef(sessionMessageCount);
|
||||
sessionMessageCountRef.current = sessionMessageCount;
|
||||
const currentSessionIdRef = React.useRef(currentSessionId);
|
||||
currentSessionIdRef.current = currentSessionId;
|
||||
const currentSessionKeyRef = React.useRef(currentSessionKey);
|
||||
currentSessionKeyRef.current = currentSessionKey;
|
||||
|
||||
const lastSessionKeyRef = React.useRef<string | null>(null);
|
||||
|
||||
// Programmatic-scroll marker: the bottom position we last
|
||||
// wrote and when. A scroll event whose scrollTop matches `top` within a few
|
||||
// px while still inside the TTL is OUR write, not the user's.
|
||||
const autoRef = React.useRef<{ top: number; time: number } | null>(null);
|
||||
const autoTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Timestamp until which a tracked height animation is in flight (see
|
||||
// ANIMATION_GUARD_MS). 0 = no animation guard active.
|
||||
const animationGuardUntilRef = React.useRef(0);
|
||||
|
||||
// True while the native (Capacitor iOS) keyboard slide choreography is in
|
||||
// flight (between 'oc:keyboard-anim' and 'oc:keyboard-settled' from
|
||||
// useNativeMobileChrome). During that window the pinned content is moved by a
|
||||
// transform on the inner wrapper, so the ResizeObserver chase must stand down.
|
||||
const keyboardAnimRef = React.useRef(false);
|
||||
|
||||
// Last observed scrollTop, used to derive scroll DIRECTION in the scroll
|
||||
// handler so the bottom-zone re-engage only fires when arriving at the bottom
|
||||
// by scrolling down — never when a user scrolling UP merely lands in the zone.
|
||||
const lastScrollTopRef = React.useRef(0);
|
||||
|
||||
// Entry-stick window state (see ENTRY_STICK_* above).
|
||||
const entryStickRef = React.useRef(false);
|
||||
const entryStickQuietTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const entryStickCapTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const entryStickLastHeightRef = React.useRef(0);
|
||||
|
||||
const saveTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pendingSaveRef = React.useRef<{ sessionId: string; anchor: number } | null>(null);
|
||||
// When restoreSnapshot is invoked while ChatViewport is still hydrating
|
||||
// (skeleton rendered, no scroll container yet), we record the session here
|
||||
// so a follow-up effect can replay the restore once the container mounts.
|
||||
const pendingInitialRestoreRef = React.useRef<string | null>(null);
|
||||
|
||||
const updateViewportAnchor = useViewportStore((s) => s.updateViewportAnchor);
|
||||
|
||||
// Detect when the scroll container DOM element changes (mount, unmount, remount).
|
||||
// Without this, listener-attach effects would only ever bind to the element that
|
||||
// existed at the hook's first render, missing later mounts (e.g. after first send
|
||||
// promotes a draft session to a real chat with messages).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
React.useLayoutEffect(() => {
|
||||
if (scrollRef.current !== lastSeenContainerRef.current) {
|
||||
lastSeenContainerRef.current = scrollRef.current;
|
||||
setContainerEl(scrollRef.current);
|
||||
}
|
||||
});
|
||||
|
||||
// `active` is `working || settling`. Passive auto-follow
|
||||
// (the ResizeObserver re-pin and any non-forced scrollToBottom) only runs
|
||||
// while active. When the session is idle, content-size changes are layout
|
||||
// churn — virtualizer re-measurement, async tool/code rendering — NOT live
|
||||
// growth, so we must NOT yank the user to the bottom. Forcing this gate is
|
||||
// what stops the twitch when tall items (expanded tools) re-measure as the
|
||||
// user scrolls.
|
||||
const isActive = React.useCallback((): boolean => {
|
||||
return sessionIsWorkingRef.current || settlingRef.current;
|
||||
}, []);
|
||||
|
||||
const setStateValue = React.useCallback((next: AutoFollowState) => {
|
||||
if (stateRef.current === next) return;
|
||||
stateRef.current = next;
|
||||
setState(next);
|
||||
}, []);
|
||||
|
||||
// ── auto marker ────────────────────────────────────────────────────────
|
||||
const markAuto = React.useCallback((el: HTMLElement) => {
|
||||
autoRef.current = {
|
||||
top: Math.max(0, el.scrollHeight - el.clientHeight),
|
||||
time: now(),
|
||||
};
|
||||
if (autoTimerRef.current) clearTimeout(autoTimerRef.current);
|
||||
autoTimerRef.current = setTimeout(() => {
|
||||
autoRef.current = null;
|
||||
autoTimerRef.current = null;
|
||||
}, AUTO_MARK_TTL_MS);
|
||||
}, []);
|
||||
|
||||
const isAuto = React.useCallback((el: HTMLElement): boolean => {
|
||||
const a = autoRef.current;
|
||||
if (!a) return false;
|
||||
if (now() - a.time > AUTO_MARK_TTL_MS) {
|
||||
autoRef.current = null;
|
||||
return false;
|
||||
}
|
||||
return Math.abs(el.scrollTop - a.top) < AUTO_MATCH_TOLERANCE_PX;
|
||||
}, []);
|
||||
|
||||
const isAnimationGuardActive = React.useCallback((): boolean => {
|
||||
return now() < animationGuardUntilRef.current;
|
||||
}, []);
|
||||
|
||||
// ── entry-stick window ───────────────────────────────────────────────────
|
||||
const endEntryStick = React.useCallback(() => {
|
||||
entryStickRef.current = false;
|
||||
if (entryStickQuietTimerRef.current) {
|
||||
clearTimeout(entryStickQuietTimerRef.current);
|
||||
entryStickQuietTimerRef.current = null;
|
||||
}
|
||||
if (entryStickCapTimerRef.current) {
|
||||
clearTimeout(entryStickCapTimerRef.current);
|
||||
entryStickCapTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// (Re)arm the quiescence timer: the window closes this long after the last
|
||||
// growth. Called once on begin and again on every growth-driven re-pin.
|
||||
const armEntryStickQuiet = React.useCallback(() => {
|
||||
if (entryStickQuietTimerRef.current) {
|
||||
clearTimeout(entryStickQuietTimerRef.current);
|
||||
}
|
||||
entryStickQuietTimerRef.current = setTimeout(() => {
|
||||
entryStickQuietTimerRef.current = null;
|
||||
endEntryStick();
|
||||
}, ENTRY_STICK_QUIESCENCE_MS);
|
||||
}, [endEntryStick]);
|
||||
|
||||
const beginEntryStick = React.useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
entryStickRef.current = true;
|
||||
entryStickLastHeightRef.current = el.scrollHeight;
|
||||
armEntryStickQuiet();
|
||||
// Reset the absolute cap fresh on every entry (e.g. session switch) so a
|
||||
// stale cap from a previous open can't cut this window short.
|
||||
if (entryStickCapTimerRef.current) {
|
||||
clearTimeout(entryStickCapTimerRef.current);
|
||||
}
|
||||
entryStickCapTimerRef.current = setTimeout(() => {
|
||||
entryStickCapTimerRef.current = null;
|
||||
endEntryStick();
|
||||
}, ENTRY_STICK_MAX_MS);
|
||||
}, [armEntryStickQuiet, endEntryStick]);
|
||||
|
||||
// ── overflow / scroll-to-bottom button ──────────────────────────────────
|
||||
const updateOverflowAndButton = React.useCallback(() => {
|
||||
const container = scrollRef.current;
|
||||
if (!container) {
|
||||
setIsOverflowing(false);
|
||||
setShowScrollButton(false);
|
||||
return;
|
||||
}
|
||||
const overflowing = canScroll(container);
|
||||
setIsOverflowing(overflowing);
|
||||
if (!overflowing) {
|
||||
setShowScrollButton(false);
|
||||
return;
|
||||
}
|
||||
const showButton = stateRef.current === 'released' && !isNearBottom(container, isMobileRef.current);
|
||||
setShowScrollButton(showButton);
|
||||
}, []);
|
||||
|
||||
// ── core scroll primitives ───────────────────────────────────────────────
|
||||
const scrollToBottomNow = React.useCallback((behavior: ScrollBehavior) => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
markAuto(el);
|
||||
// `scrollHeight` is rounded to an integer while the real content height
|
||||
// is fractional (prose line-heights), so `scrollTop = scrollHeight`
|
||||
// leaves a 0–1px remainder that oscillates per streamed token and makes
|
||||
// bottom-anchored rows jitter vertically. An over-large target clamps to
|
||||
// the exact fractional maximum instead, pinning content to the bottom.
|
||||
const overshootTarget = el.scrollHeight + 4096;
|
||||
if (behavior === 'smooth') {
|
||||
el.scrollTo({ top: overshootTarget, behavior });
|
||||
return;
|
||||
}
|
||||
// Direct `scrollTop` assignment bypasses any CSS `scroll-behavior: smooth`
|
||||
// and lands in the same frame — no visible catch-up animation.
|
||||
el.scrollTop = overshootTarget;
|
||||
}, [markAuto]);
|
||||
|
||||
// `force` true = user-intent jump (clears released and always scrolls).
|
||||
// `force` false = passive follow (only while still following).
|
||||
const scrollToBottom = React.useCallback((force: boolean, behavior: ScrollBehavior = 'auto') => {
|
||||
const el = scrollRef.current;
|
||||
|
||||
// Passive follow only while active (working/settling). Forced jumps
|
||||
// (send, go-to-bottom, session restore) always proceed.
|
||||
if (!force && !isActive()) return;
|
||||
|
||||
if (force && stateRef.current !== 'following') {
|
||||
setStateValue('following');
|
||||
}
|
||||
if (!el) return;
|
||||
if (!force && stateRef.current !== 'following') return;
|
||||
|
||||
// Always re-pin, even when already within tolerance of the bottom.
|
||||
// Sub-tolerance growth (fractional line-height remainders) would
|
||||
// otherwise leave the bottom drifting by up to ±AUTO_MATCH_TOLERANCE_PX
|
||||
// between full re-pins, which reads as 1px vertical jitter on
|
||||
// bottom-anchored rows during streaming. The write happens pre-paint
|
||||
// (ResizeObserver) and is a no-op when the position is unchanged.
|
||||
scrollToBottomNow(force ? behavior : 'auto');
|
||||
}, [isActive, scrollToBottomNow, setStateValue]);
|
||||
|
||||
// User left the bottom — release auto-follow.
|
||||
const stop = React.useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
if (!canScroll(el)) {
|
||||
setStateValue('following');
|
||||
return;
|
||||
}
|
||||
if (stateRef.current === 'released') return;
|
||||
setStateValue('released');
|
||||
updateOverflowAndButton();
|
||||
}, [setStateValue, updateOverflowAndButton]);
|
||||
|
||||
// ── public scroll API (mapped onto the primitives) ───────────────────────
|
||||
const goToBottom = React.useCallback((mode: 'instant' | 'smooth' = 'instant') => {
|
||||
scrollToBottom(true, mode === 'smooth' ? 'smooth' : 'auto');
|
||||
}, [scrollToBottom]);
|
||||
|
||||
const scrollToBottomOnSend = React.useCallback(() => {
|
||||
// Single movement to the just-sent message. Force re-pins to the bottom
|
||||
// whether we were following or scrolled up; the content ResizeObserver
|
||||
// keeps us pinned as the optimistic message and its reply stream in.
|
||||
scrollToBottom(true);
|
||||
}, [scrollToBottom]);
|
||||
|
||||
const releaseAutoFollow = React.useCallback(() => {
|
||||
setStateValue('released');
|
||||
updateOverflowAndButton();
|
||||
}, [setStateValue, updateOverflowAndButton]);
|
||||
|
||||
const releaseFromUserIntent = React.useCallback(() => {
|
||||
// A genuine user gesture (wheel/touch/key/scrollbar) cancels the entry
|
||||
// window immediately so we never fight the user's read position.
|
||||
endEntryStick();
|
||||
stop();
|
||||
}, [endEntryStick, stop]);
|
||||
|
||||
// ── per-session snapshot persistence (kept; restore still goes to bottom) ─
|
||||
const flushSave = React.useCallback(() => {
|
||||
if (saveTimerRef.current !== null) {
|
||||
clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = null;
|
||||
}
|
||||
const pending = pendingSaveRef.current;
|
||||
if (!pending) return;
|
||||
const container = scrollRef.current;
|
||||
if (!container) {
|
||||
pendingSaveRef.current = null;
|
||||
return;
|
||||
}
|
||||
updateViewportAnchor(pending.sessionId, pending.anchor, {
|
||||
scrollTop: container.scrollTop,
|
||||
scrollHeight: container.scrollHeight,
|
||||
clientHeight: container.clientHeight,
|
||||
});
|
||||
pendingSaveRef.current = null;
|
||||
}, [updateViewportAnchor]);
|
||||
|
||||
const queueSave = React.useCallback(() => {
|
||||
const sessionId = currentSessionIdRef.current;
|
||||
if (!sessionId) return;
|
||||
const container = scrollRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = container;
|
||||
const anchorRatio = scrollHeight > 0
|
||||
? (scrollTop + clientHeight / 2) / scrollHeight
|
||||
: 0;
|
||||
const anchor = Math.floor(anchorRatio * sessionMessageCountRef.current);
|
||||
|
||||
pendingSaveRef.current = { sessionId, anchor };
|
||||
if (saveTimerRef.current !== null) return;
|
||||
saveTimerRef.current = setTimeout(() => {
|
||||
saveTimerRef.current = null;
|
||||
flushSave();
|
||||
}, SAVE_DEBOUNCE_MS);
|
||||
}, [flushSave]);
|
||||
|
||||
const saveSnapshotNow = React.useCallback(() => {
|
||||
flushSave();
|
||||
}, [flushSave]);
|
||||
|
||||
const restoreSnapshot = React.useCallback(async (): Promise<boolean> => {
|
||||
const sessionKey = currentSessionKeyRef.current;
|
||||
if (!sessionKey) return false;
|
||||
|
||||
const container = scrollRef.current;
|
||||
if (!container) {
|
||||
// ChatViewport not mounted yet (e.g., session still hydrating).
|
||||
// Record the request so the container-attach effect can replay it.
|
||||
pendingInitialRestoreRef.current = sessionKey;
|
||||
setStateValue('following');
|
||||
return false;
|
||||
}
|
||||
pendingInitialRestoreRef.current = null;
|
||||
|
||||
// Always return to the bottom on session switch. The content
|
||||
// ResizeObserver re-pins instantly as late
|
||||
// history measures in, so there is no smooth scroll-from-mid artifact.
|
||||
setStateValue('following');
|
||||
scrollToBottom(true);
|
||||
// Hold the bottom across late async growth (e.g. task/subagent child
|
||||
// session data landing a beat after entry) until content quiesces or the
|
||||
// user scrolls.
|
||||
beginEntryStick();
|
||||
updateOverflowAndButton();
|
||||
return false;
|
||||
}, [beginEntryStick, scrollToBottom, setStateValue, updateOverflowAndButton]);
|
||||
|
||||
// ── session change ───────────────────────────────────────────────────────
|
||||
React.useEffect(() => {
|
||||
if (!currentSessionId || !currentSessionKey || currentSessionKey === lastSessionKeyRef.current) {
|
||||
return;
|
||||
}
|
||||
lastSessionKeyRef.current = currentSessionKey;
|
||||
MessageFreshnessDetector.getInstance().recordSessionStart(currentSessionId);
|
||||
flushSave();
|
||||
autoRef.current = null;
|
||||
// Drop any pending restore request inherited from a different session.
|
||||
if (pendingInitialRestoreRef.current && pendingInitialRestoreRef.current !== currentSessionKey) {
|
||||
pendingInitialRestoreRef.current = null;
|
||||
}
|
||||
}, [currentSessionId, currentSessionKey, flushSave]);
|
||||
|
||||
// When work begins and we are still
|
||||
// following, pin to the bottom. When work stops, keep following alive for a
|
||||
// short settle window so the final content lands at the bottom, then go
|
||||
// idle (after which passive follow is disabled — see `isActive`).
|
||||
React.useEffect(() => {
|
||||
settlingRef.current = false;
|
||||
if (settleTimerRef.current) {
|
||||
clearTimeout(settleTimerRef.current);
|
||||
settleTimerRef.current = null;
|
||||
}
|
||||
|
||||
if (sessionIsWorking) {
|
||||
if (stateRef.current === 'following') {
|
||||
scrollToBottom(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
settlingRef.current = true;
|
||||
settleTimerRef.current = setTimeout(() => {
|
||||
settlingRef.current = false;
|
||||
settleTimerRef.current = null;
|
||||
}, SETTLE_MS);
|
||||
}, [sessionIsWorking, scrollToBottom]);
|
||||
|
||||
// Suppress the overlay scrollbar thumb only while we are actively following a
|
||||
// live stream (the thumb would otherwise jump on every instant re-pin). When
|
||||
// idle or released the scrollbar behaves normally. Stable: changes only when
|
||||
// follow-state or working-state flips, not on every frame.
|
||||
React.useEffect(() => {
|
||||
setIsFollowingProgrammatically(state === 'following' && sessionIsWorking);
|
||||
}, [state, sessionIsWorking]);
|
||||
|
||||
// Replay a deferred restoreSnapshot once ChatViewport mounts.
|
||||
// useLayoutEffect ensures scroll position is set before the browser paints,
|
||||
// preventing a visible flash of content at the wrong scroll position.
|
||||
React.useLayoutEffect(() => {
|
||||
if (!containerEl) return;
|
||||
if (pendingInitialRestoreRef.current && pendingInitialRestoreRef.current === currentSessionKey) {
|
||||
void restoreSnapshot();
|
||||
}
|
||||
}, [containerEl, currentSessionKey, restoreSnapshot]);
|
||||
|
||||
// ── scroll event handling ────────────────────────────────────────────────
|
||||
const handleScrollEvent = React.useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const previousTop = lastScrollTopRef.current;
|
||||
lastScrollTopRef.current = el.scrollTop;
|
||||
const scrollingDown = el.scrollTop > previousTop + 0.5;
|
||||
|
||||
updateOverflowAndButton();
|
||||
|
||||
if (!canScroll(el)) {
|
||||
setStateValue('following');
|
||||
return;
|
||||
}
|
||||
|
||||
// Within the bottom zone → (re-)pin to following. This is how scrolling
|
||||
// back DOWN to the bottom resumes auto-follow. Crucially, re-engage only
|
||||
// when the user arrives by scrolling down (or is already following, or is
|
||||
// essentially at the true bottom). A user scrolling UP that merely lands
|
||||
// in the bottom spacer zone must NOT be yanked back into follow — that is
|
||||
// the dead-zone fight that made small upward scrolls impossible while
|
||||
// content streams.
|
||||
if (isNearBottom(el, isMobileRef.current)) {
|
||||
const atTrueBottom = distanceFromBottom(el) <= AUTO_MATCH_TOLERANCE_PX;
|
||||
if (scrollingDown || stateRef.current === 'following' || atTrueBottom) {
|
||||
setStateValue('following');
|
||||
}
|
||||
queueSave();
|
||||
return;
|
||||
}
|
||||
|
||||
// Our own geometry change (a programmatic write that landed at the bottom
|
||||
// but where content grew between the write and this event, OR a tracked
|
||||
// height animation in flight) — keep following, don't release.
|
||||
if (stateRef.current === 'following' && (isAuto(el) || isAnimationGuardActive())) {
|
||||
scrollToBottom(false);
|
||||
queueSave();
|
||||
return;
|
||||
}
|
||||
|
||||
// Genuine user scroll away from the bottom.
|
||||
stop();
|
||||
queueSave();
|
||||
}, [isAnimationGuardActive, isAuto, queueSave, scrollToBottom, setStateValue, stop, updateOverflowAndButton]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const container = containerEl;
|
||||
if (!container) return;
|
||||
|
||||
lastScrollTopRef.current = container.scrollTop;
|
||||
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
if (event.deltaY >= 0) return;
|
||||
if (nestedScrollableCanConsumeUp(container, event.target)) return;
|
||||
releaseFromUserIntent();
|
||||
};
|
||||
|
||||
let touchLastY: number | null = null;
|
||||
const handleTouchStart = (event: TouchEvent) => {
|
||||
const touch = event.touches.item(0);
|
||||
touchLastY = touch ? touch.clientY : null;
|
||||
};
|
||||
const handleTouchMove = (event: TouchEvent) => {
|
||||
const touch = event.touches.item(0);
|
||||
if (!touch) {
|
||||
touchLastY = null;
|
||||
return;
|
||||
}
|
||||
const previousY = touchLastY;
|
||||
touchLastY = touch.clientY;
|
||||
if (previousY === null) return;
|
||||
const fingerDelta = touch.clientY - previousY;
|
||||
if (fingerDelta <= TOUCH_FINGER_DOWN_THRESHOLD) return;
|
||||
if (nestedScrollableCanConsumeUp(container, event.target)) return;
|
||||
releaseFromUserIntent();
|
||||
};
|
||||
const handleTouchEnd = () => {
|
||||
touchLastY = null;
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (!isReleaseKey(event)) return;
|
||||
releaseFromUserIntent();
|
||||
};
|
||||
|
||||
const handlePointerDownIntent = (event: PointerEvent) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) return;
|
||||
if (!target.closest('[data-overlay-scrollbar-thumb]')) return;
|
||||
releaseFromUserIntent();
|
||||
};
|
||||
|
||||
container.addEventListener('scroll', handleScrollEvent, { passive: true });
|
||||
container.addEventListener('wheel', handleWheel, { passive: true });
|
||||
container.addEventListener('touchstart', handleTouchStart, { passive: true });
|
||||
container.addEventListener('touchmove', handleTouchMove, { passive: true });
|
||||
container.addEventListener('touchend', handleTouchEnd, { passive: true });
|
||||
container.addEventListener('touchcancel', handleTouchEnd, { passive: true });
|
||||
container.addEventListener('keydown', handleKeyDown);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('pointerdown', handlePointerDownIntent, true);
|
||||
}
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('scroll', handleScrollEvent);
|
||||
container.removeEventListener('wheel', handleWheel);
|
||||
container.removeEventListener('touchstart', handleTouchStart);
|
||||
container.removeEventListener('touchmove', handleTouchMove);
|
||||
container.removeEventListener('touchend', handleTouchEnd);
|
||||
container.removeEventListener('touchcancel', handleTouchEnd);
|
||||
container.removeEventListener('keydown', handleKeyDown);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('pointerdown', handlePointerDownIntent, true);
|
||||
}
|
||||
};
|
||||
}, [containerEl, handleScrollEvent, releaseFromUserIntent]);
|
||||
|
||||
// The heart of the follow behaviour: the content ResizeObserver fires after
|
||||
// layout and before paint, so re-pinning to the bottom here is invisible —
|
||||
// there is no "jump up then catch up". Observe both the container (composer
|
||||
// growth shrinks the viewport) and the inner content (streaming growth).
|
||||
React.useEffect(() => {
|
||||
const container = containerEl;
|
||||
if (!container || typeof ResizeObserver === 'undefined') return;
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
// Keyboard slide in flight: the container/composer resizes it reports
|
||||
// are part of the transform choreography — the settle handler does the
|
||||
// single deterministic re-pin, so chasing here would just fight it.
|
||||
if (keyboardAnimRef.current) {
|
||||
updateOverflowAndButton();
|
||||
return;
|
||||
}
|
||||
const el = scrollRef.current;
|
||||
if (el && !canScroll(el)) {
|
||||
setStateValue('following');
|
||||
updateOverflowAndButton();
|
||||
return;
|
||||
}
|
||||
updateOverflowAndButton();
|
||||
// Entry-stick window: on first session open, FORCE the bottom on
|
||||
// every growth so late async data (task/subagent child rows, code
|
||||
// highlight, mermaid) can't strand the viewport mid-history. Force
|
||||
// overrides any false `released` from the growth itself; only a real
|
||||
// user gesture clears the window (releaseFromUserIntent).
|
||||
if (entryStickRef.current && el) {
|
||||
const grew = el.scrollHeight > entryStickLastHeightRef.current + 1;
|
||||
entryStickLastHeightRef.current = el.scrollHeight;
|
||||
scrollToBottom(true);
|
||||
if (grew) armEntryStickQuiet();
|
||||
return;
|
||||
}
|
||||
// Idle resize = layout churn (virtualizer re-measurement, async
|
||||
// tool/code rendering), NOT live growth. Never re-pin when idle, or
|
||||
// tall items re-measuring as the user scrolls cause an endless
|
||||
// scroll-to-bottom/re-measure twitch.
|
||||
if (!isActive()) return;
|
||||
if (stateRef.current !== 'following') return;
|
||||
scrollToBottom(false);
|
||||
});
|
||||
observer.observe(container);
|
||||
const inner = container.firstElementChild;
|
||||
if (inner instanceof Element) {
|
||||
observer.observe(inner);
|
||||
}
|
||||
return () => observer.disconnect();
|
||||
}, [armEntryStickQuiet, containerEl, isActive, scrollToBottom, setStateValue, updateOverflowAndButton]);
|
||||
|
||||
// ── native keyboard transitions (Capacitor choreography) ────────────────
|
||||
// The chat scroller gets NO transforms during the keyboard transition:
|
||||
// transforming the scroll container (or its content) forces WebKit to
|
||||
// rebuild the composited scrolling layers, which stalls for seconds on
|
||||
// long chats. Instead the chat repositions with instant snaps that hide
|
||||
// behind the keyboard itself:
|
||||
// show: content stays put while the keyboard/composer slide over it; the
|
||||
// settled event (shell layout snap) does ONE instant re-pin.
|
||||
// hide: the shell layout is restored up-front — the scrollTop clamp
|
||||
// happens while the keyboard still covers that region — and the
|
||||
// settled event re-pins once at the end.
|
||||
// During the window we only guard the scroll heuristics and the observer
|
||||
// chase. These events never fire outside the Capacitor app.
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const handleKeyboardAnim = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ phase: 'show' | 'hide'; slide: number; durationMs: number; easing: string }>).detail;
|
||||
if (!detail) return;
|
||||
keyboardAnimRef.current = true;
|
||||
// The clamp/resize during the choreography can dispatch scroll events
|
||||
// that land away from the auto marker — never read those as a user
|
||||
// scroll-away.
|
||||
animationGuardUntilRef.current = now() + detail.durationMs + ANIMATION_GUARD_MS;
|
||||
};
|
||||
|
||||
const handleKeyboardSettled = () => {
|
||||
keyboardAnimRef.current = false;
|
||||
const el = scrollRef.current;
|
||||
if (!el) {
|
||||
updateOverflowAndButton();
|
||||
return;
|
||||
}
|
||||
// Single deterministic re-pin, same task as the layout swap → lands
|
||||
// before paint. (scrollToBottomNow, not scrollToBottom: this must not
|
||||
// be gated on working/settling — the keyboard resize is a viewport
|
||||
// change, not content growth.)
|
||||
if (stateRef.current === 'following' && canScroll(el)) {
|
||||
scrollToBottomNow('auto');
|
||||
}
|
||||
updateOverflowAndButton();
|
||||
};
|
||||
|
||||
window.addEventListener('oc:keyboard-anim', handleKeyboardAnim);
|
||||
window.addEventListener('oc:keyboard-settled', handleKeyboardSettled);
|
||||
return () => {
|
||||
window.removeEventListener('oc:keyboard-anim', handleKeyboardAnim);
|
||||
window.removeEventListener('oc:keyboard-settled', handleKeyboardSettled);
|
||||
keyboardAnimRef.current = false;
|
||||
};
|
||||
}, [scrollToBottomNow, updateOverflowAndButton]);
|
||||
|
||||
React.useEffect(() => {
|
||||
updateOverflowAndButton();
|
||||
}, [sessionMessageCount, updateOverflowAndButton]);
|
||||
|
||||
const notifyContentChange = React.useCallback((reason?: ContentChangeReason) => {
|
||||
// A tracked height animation (e.g. Thinking auto-collapse) opens a guard
|
||||
// window so its transient geometry / async scroll events are not misread
|
||||
// as a user scroll-away. Real gestures still release through
|
||||
// releaseFromUserIntent, so the user can always scroll up freely.
|
||||
if (reason === 'animation') {
|
||||
animationGuardUntilRef.current = now() + ANIMATION_GUARD_MS;
|
||||
}
|
||||
updateOverflowAndButton();
|
||||
// Entry-stick window: late structural growth (notably the task/subagent
|
||||
// summary landing from the child session — ToolPart emits 'structural'
|
||||
// here) must keep us pinned and refresh the quiescence timer, even though
|
||||
// the session is idle.
|
||||
if (entryStickRef.current) {
|
||||
scrollToBottom(true);
|
||||
armEntryStickQuiet();
|
||||
return;
|
||||
}
|
||||
if (stateRef.current === 'following') {
|
||||
scrollToBottom(false);
|
||||
}
|
||||
}, [armEntryStickQuiet, scrollToBottom, updateOverflowAndButton]);
|
||||
|
||||
const animationHandlersRef = React.useRef<Map<string, AnimationHandlers>>(new Map());
|
||||
|
||||
const getAnimationHandlers = React.useCallback((messageId: string): AnimationHandlers => {
|
||||
const cached = animationHandlersRef.current.get(messageId);
|
||||
if (cached) return cached;
|
||||
|
||||
const kick = () => {
|
||||
if (stateRef.current === 'following') {
|
||||
scrollToBottom(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlers: AnimationHandlers = {
|
||||
onChunk: kick,
|
||||
onComplete: () => {
|
||||
updateOverflowAndButton();
|
||||
},
|
||||
onStreamingCandidate: () => {},
|
||||
onAnimationStart: () => {},
|
||||
onAnimatedHeightChange: kick,
|
||||
onReservationCancelled: () => {},
|
||||
onReasoningBlock: () => {},
|
||||
};
|
||||
animationHandlersRef.current.set(messageId, handlers);
|
||||
return handlers;
|
||||
}, [scrollToBottom, updateOverflowAndButton]);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (autoTimerRef.current) {
|
||||
clearTimeout(autoTimerRef.current);
|
||||
autoTimerRef.current = null;
|
||||
}
|
||||
if (settleTimerRef.current) {
|
||||
clearTimeout(settleTimerRef.current);
|
||||
settleTimerRef.current = null;
|
||||
}
|
||||
endEntryStick();
|
||||
flushSave();
|
||||
if (saveTimerRef.current !== null) {
|
||||
clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [endEntryStick, flushSave]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!onActiveTurnChange) return;
|
||||
const container = containerEl;
|
||||
if (!container) return;
|
||||
|
||||
let lastActiveTurnId: string | null = null;
|
||||
const spy = createScrollSpy({
|
||||
onActive: (turnId) => {
|
||||
if (turnId === lastActiveTurnId) return;
|
||||
lastActiveTurnId = turnId;
|
||||
onActiveTurnChange(turnId);
|
||||
},
|
||||
});
|
||||
spy.setContainer(container);
|
||||
|
||||
const elementByTurnId = new Map<string, HTMLElement>();
|
||||
const registerTurnNode = (node: HTMLElement) => {
|
||||
const turnId = node.dataset.turnId;
|
||||
if (!turnId) return false;
|
||||
elementByTurnId.set(turnId, node);
|
||||
spy.register(node, turnId);
|
||||
return true;
|
||||
};
|
||||
const unregisterTurnNode = (node: HTMLElement) => {
|
||||
const turnId = node.dataset.turnId;
|
||||
if (!turnId) return false;
|
||||
if (elementByTurnId.get(turnId) !== node) return false;
|
||||
elementByTurnId.delete(turnId);
|
||||
spy.unregister(turnId);
|
||||
return true;
|
||||
};
|
||||
const collectTurnNodes = (node: Node): HTMLElement[] => {
|
||||
if (!(node instanceof HTMLElement)) return [];
|
||||
const collected: HTMLElement[] = [];
|
||||
if (node.matches('[data-turn-id]')) collected.push(node);
|
||||
node.querySelectorAll<HTMLElement>('[data-turn-id]').forEach((el) => collected.push(el));
|
||||
return collected;
|
||||
};
|
||||
|
||||
container.querySelectorAll<HTMLElement>('[data-turn-id]').forEach(registerTurnNode);
|
||||
spy.markDirty();
|
||||
|
||||
const mutationObserver = new MutationObserver((records) => {
|
||||
let changed = false;
|
||||
records.forEach((record) => {
|
||||
record.removedNodes.forEach((node) => {
|
||||
collectTurnNodes(node).forEach((turnNode) => {
|
||||
if (unregisterTurnNode(turnNode)) changed = true;
|
||||
});
|
||||
});
|
||||
record.addedNodes.forEach((node) => {
|
||||
collectTurnNodes(node).forEach((turnNode) => {
|
||||
if (registerTurnNode(turnNode)) changed = true;
|
||||
});
|
||||
});
|
||||
});
|
||||
if (changed) spy.markDirty();
|
||||
});
|
||||
mutationObserver.observe(container, { subtree: true, childList: true });
|
||||
|
||||
const onScroll = () => spy.onScroll();
|
||||
container.addEventListener('scroll', onScroll, { passive: true });
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('scroll', onScroll);
|
||||
mutationObserver.disconnect();
|
||||
spy.destroy();
|
||||
};
|
||||
}, [containerEl, onActiveTurnChange]);
|
||||
|
||||
return {
|
||||
scrollRef,
|
||||
state,
|
||||
isPinned: state === 'following',
|
||||
isOverflowing,
|
||||
isFollowingProgrammatically,
|
||||
showScrollButton,
|
||||
notifyContentChange,
|
||||
getAnimationHandlers,
|
||||
goToBottom,
|
||||
scrollToBottomOnSend,
|
||||
releaseAutoFollow,
|
||||
saveSnapshotNow,
|
||||
restoreSnapshot,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,974 @@
|
||||
import React from 'react';
|
||||
|
||||
import { MessageFreshnessDetector } from '@/lib/messageFreshness';
|
||||
import { createScrollSpy } from '@/components/chat/lib/scroll/scrollSpy';
|
||||
import { useViewportStore } from '@/sync/viewport-store';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import {
|
||||
CHAT_LIST_ANCHOR_OFFSET,
|
||||
getAnchoredTurnMetrics,
|
||||
getRowBottom,
|
||||
resolveTimelineIsAtEnd,
|
||||
TIMELINE_FOLLOW_REARM_THRESHOLD_PX,
|
||||
type TimelineListMeasurementState,
|
||||
type TimelineScrollMode,
|
||||
} from '@/components/chat/lib/scroll/timelineScrollAnchoring';
|
||||
import {
|
||||
isFollowReleaseKey,
|
||||
isMiddleButtonPan,
|
||||
nestedScrollableConsumesWheelUp,
|
||||
} from '@/components/chat/lib/scroll/timelineScrollIntent';
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Chat timeline scroll ownership.
|
||||
//
|
||||
// The virtualized list owns the scroll position; this hook only decides which
|
||||
// of three mutually exclusive modes is active and, when a mode calls for it,
|
||||
// issues ONE deterministic scroll command:
|
||||
//
|
||||
// • `following-end` — pinned to the live edge. The list keeps us there
|
||||
// through `maintainScrollAtEnd`; we only re-assert after a data change.
|
||||
// • `anchoring-new-turn` — the just-sent user message is parked near the TOP
|
||||
// of the viewport and the reply streams into the reserved end space below
|
||||
// it. The viewport does NOT move while the turn still fits; once the turn
|
||||
// outgrows the usable viewport we scroll by the exact delta needed to keep
|
||||
// its end visible.
|
||||
// • `free-scrolling` — the user took over. Nothing moves until they opt
|
||||
// back in by returning to the end.
|
||||
//
|
||||
// Opting out of automatic movement is driven by REAL gestures (wheel /
|
||||
// touchmove / pointerdown), not by inferring intent from scroll positions. Each
|
||||
// gesture bumps a generation counter; any in-flight automatic movement compares
|
||||
// its captured generation against the current one and aborts if they differ.
|
||||
// That comparison replaces the timer windows the previous implementation needed
|
||||
// to tell its own writes apart from the user's, which is why there are no
|
||||
// guard/settle/entry-stick timers here.
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// The subset of the list ref this hook drives. Declared structurally so the
|
||||
// hook stays testable without a renderer and does not hard-depend on the list
|
||||
// implementation.
|
||||
export interface TimelineListHandle {
|
||||
getState: () => TimelineListMeasurementState & {
|
||||
readonly scroll: number;
|
||||
readonly listen?: (
|
||||
listenerType: 'totalSize',
|
||||
callback: (value: number) => void,
|
||||
) => () => void;
|
||||
};
|
||||
getScrollableNode: () => HTMLElement | null;
|
||||
scrollToEnd: (options?: { animated?: boolean }) => unknown;
|
||||
scrollToOffset: (params: { offset: number; animated?: boolean }) => unknown;
|
||||
scrollToIndex: (params: {
|
||||
index: number;
|
||||
animated?: boolean;
|
||||
viewPosition?: number;
|
||||
viewOffset?: number;
|
||||
}) => unknown;
|
||||
}
|
||||
|
||||
interface UseChatTimelineScrollOptions {
|
||||
currentSessionId: string | null;
|
||||
currentSessionKey: string | null;
|
||||
sessionMessageCount: number;
|
||||
composerOverlayHeight: number;
|
||||
// Id of the newest user message in the rendered timeline. When a send has
|
||||
// armed the anchor, the next new id here becomes the anchored row.
|
||||
lastUserMessageId: string | null;
|
||||
onActiveTurnChange?: (turnId: string | null) => void;
|
||||
}
|
||||
|
||||
export interface UseChatTimelineScrollResult {
|
||||
scrollRef: React.RefObject<HTMLDivElement | null>;
|
||||
// The live scroll element, as state, so effects that must re-bind when the
|
||||
// list remounts (session switch) can depend on it.
|
||||
scrollNode: HTMLDivElement | null;
|
||||
isPinned: boolean;
|
||||
registerList: (list: TimelineListHandle | null) => void;
|
||||
anchorMessageId: string | null;
|
||||
onAnchorReady: (messageId: string, anchorIndex: number) => void;
|
||||
onAnchorSizeChanged: (messageId: string) => void;
|
||||
onIsAtEndChange: (isAtEnd: boolean) => void;
|
||||
onManualNavigation: () => void;
|
||||
onTimelineDataChange: () => void;
|
||||
showScrollButton: boolean;
|
||||
/** A real gesture took the scroll; flips back on any explicit opt-in. */
|
||||
userOwnsScroll: boolean;
|
||||
isFollowingProgrammatically: boolean;
|
||||
goToBottom: (mode?: 'instant' | 'smooth') => void;
|
||||
scrollToBottomOnSend: () => void;
|
||||
saveSnapshotNow: () => void;
|
||||
restoreSnapshot: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
// Showing the pill is debounced so it does not flash while a thread switch
|
||||
// settles (the list reports isAtEnd=false until its initial end-scroll lands).
|
||||
// Hiding is always immediate.
|
||||
const SHOW_SCROLL_BUTTON_DELAY_MS = 150;
|
||||
const SAVE_DEBOUNCE_MS = 150;
|
||||
// The anchor scroll is animated; `scrollend` is the authoritative completion
|
||||
// signal, and this bounds the wait for browsers that drop it.
|
||||
const ANCHOR_SETTLE_FALLBACK_MS = 750;
|
||||
// Re-running the anchor positioning while the list is still mounting rows.
|
||||
const ANCHOR_POSITION_ATTEMPTS = 12;
|
||||
// Anchor restores only correct sub-pixel drift; anything larger is the user or
|
||||
// a genuine relayout and must not be undone.
|
||||
const ANCHOR_RESTORE_TOLERANCE_PX = 2;
|
||||
|
||||
export const useChatTimelineScroll = ({
|
||||
currentSessionId,
|
||||
currentSessionKey,
|
||||
sessionMessageCount,
|
||||
composerOverlayHeight,
|
||||
lastUserMessageId,
|
||||
onActiveTurnChange,
|
||||
}: UseChatTimelineScrollOptions): UseChatTimelineScrollResult => {
|
||||
const scrollRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const listRef = React.useRef<TimelineListHandle | null>(null);
|
||||
|
||||
const [scrollNode, setScrollNode] = React.useState<HTMLDivElement | null>(null);
|
||||
const [anchorMessageId, setAnchorMessageId] = React.useState<string | null>(null);
|
||||
const [showScrollButton, setShowScrollButton] = React.useState(false);
|
||||
// "Pinned" is the live edge, which history pagination uses to decide whether
|
||||
// it may load older pages without disturbing the read position.
|
||||
const [isPinned, setIsPinned] = React.useState(true);
|
||||
const [isFollowingProgrammatically, setIsFollowingProgrammatically] = React.useState(false);
|
||||
// True after a real gesture until an explicit opt back in; drives the
|
||||
// overlay scrollbar suppression instead of the anchor's mere existence.
|
||||
const [userOwnsScroll, setUserOwnsScroll] = React.useState(false);
|
||||
const userOwnsScrollRef = React.useRef(userOwnsScroll);
|
||||
userOwnsScrollRef.current = userOwnsScroll;
|
||||
|
||||
const modeRef = React.useRef<TimelineScrollMode>('following-end');
|
||||
const isAtEndRef = React.useRef(true);
|
||||
// Incremented by every real user gesture. Automatic movement is only valid
|
||||
// while `liveFollowGenerationRef` still equals it.
|
||||
const userGenerationRef = React.useRef(0);
|
||||
const liveFollowGenerationRef = React.useRef<number | null>(0);
|
||||
// Anchor lifecycle: armed on send → pending until the row exists → positioned
|
||||
// while the animated scroll runs → settled once it has come to rest.
|
||||
const armedForNextUserMessageRef = React.useRef(false);
|
||||
const pendingAnchorRef = React.useRef<string | null>(null);
|
||||
const positionedAnchorRef = React.useRef<string | null>(null);
|
||||
const settledAnchorRef = React.useRef<string | null>(null);
|
||||
const activeAnchorIndexRef = React.useRef<number | null>(null);
|
||||
const pendingAnchorRestoreRef = React.useRef<{
|
||||
readonly messageId: string;
|
||||
readonly offset: number;
|
||||
readonly userGeneration: number;
|
||||
} | null>(null);
|
||||
const anchorRestoreFrameRef = React.useRef<number | null>(null);
|
||||
const showButtonTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const composerOverlayHeightRef = React.useRef(composerOverlayHeight);
|
||||
composerOverlayHeightRef.current = composerOverlayHeight;
|
||||
const sessionMessageCountRef = React.useRef(sessionMessageCount);
|
||||
sessionMessageCountRef.current = sessionMessageCount;
|
||||
const currentSessionIdRef = React.useRef(currentSessionId);
|
||||
currentSessionIdRef.current = currentSessionId;
|
||||
const currentSessionKeyRef = React.useRef(currentSessionKey);
|
||||
currentSessionKeyRef.current = currentSessionKey;
|
||||
|
||||
const updateViewportAnchor = useViewportStore((state) => state.updateViewportAnchor);
|
||||
|
||||
const cancelShowButtonTimer = React.useCallback(() => {
|
||||
if (showButtonTimerRef.current !== null) {
|
||||
clearTimeout(showButtonTimerRef.current);
|
||||
showButtonTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const hideScrollButton = React.useCallback(() => {
|
||||
cancelShowButtonTimer();
|
||||
setShowScrollButton(false);
|
||||
}, [cancelShowButtonTimer]);
|
||||
|
||||
const scheduleShowScrollButton = React.useCallback(() => {
|
||||
if (showButtonTimerRef.current !== null) return;
|
||||
showButtonTimerRef.current = setTimeout(() => {
|
||||
showButtonTimerRef.current = null;
|
||||
setShowScrollButton(true);
|
||||
}, SHOW_SCROLL_BUTTON_DELAY_MS);
|
||||
}, []);
|
||||
|
||||
const clearAnchor = React.useCallback(() => {
|
||||
armedForNextUserMessageRef.current = false;
|
||||
pendingAnchorRef.current = null;
|
||||
positionedAnchorRef.current = null;
|
||||
settledAnchorRef.current = null;
|
||||
activeAnchorIndexRef.current = null;
|
||||
pendingAnchorRestoreRef.current = null;
|
||||
if (anchorRestoreFrameRef.current !== null) {
|
||||
cancelAnimationFrame(anchorRestoreFrameRef.current);
|
||||
anchorRestoreFrameRef.current = null;
|
||||
}
|
||||
setAnchorMessageId(null);
|
||||
}, []);
|
||||
|
||||
// A real gesture: stop every automatic movement until the user opts back
|
||||
// in. The anchored END SPACE stays — collapsing it mid-gesture clamps the
|
||||
// viewport back to the end — only the anchor machinery is disarmed.
|
||||
const onManualNavigation = React.useCallback(() => {
|
||||
userGenerationRef.current += 1;
|
||||
modeRef.current = 'free-scrolling';
|
||||
liveFollowGenerationRef.current = null;
|
||||
setUserOwnsScroll(true);
|
||||
// The end may already have been left by our own movement, in which
|
||||
// case no further at-end transition will fire — and while an animated
|
||||
// follow glide trails the live edge, isAtEndRef is deliberately not
|
||||
// updated, so measure the real distance instead of trusting it. This
|
||||
// is an explicit gesture — show the pill immediately, no debounce.
|
||||
const listState = listRef.current?.getState();
|
||||
const atEndNow = (listState ? resolveTimelineIsAtEnd(listState) : undefined) ?? isAtEndRef.current;
|
||||
isAtEndRef.current = atEndNow;
|
||||
if (!atEndNow) {
|
||||
cancelShowButtonTimer();
|
||||
setShowScrollButton(true);
|
||||
}
|
||||
armedForNextUserMessageRef.current = false;
|
||||
pendingAnchorRef.current = null;
|
||||
positionedAnchorRef.current = null;
|
||||
settledAnchorRef.current = null;
|
||||
activeAnchorIndexRef.current = null;
|
||||
pendingAnchorRestoreRef.current = null;
|
||||
if (anchorRestoreFrameRef.current !== null) {
|
||||
cancelAnimationFrame(anchorRestoreFrameRef.current);
|
||||
anchorRestoreFrameRef.current = null;
|
||||
}
|
||||
}, [cancelShowButtonTimer]);
|
||||
|
||||
const isLiveFollowActive = React.useCallback(() => (
|
||||
liveFollowGenerationRef.current === userGenerationRef.current
|
||||
), []);
|
||||
|
||||
// ── snapshot persistence ────────────────────────────────────────────────
|
||||
const pendingSaveRef = React.useRef<{ sessionId: string; anchor: number } | null>(null);
|
||||
const saveTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const flushSave = React.useCallback(() => {
|
||||
if (saveTimerRef.current !== null) {
|
||||
clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = null;
|
||||
}
|
||||
const pending = pendingSaveRef.current;
|
||||
if (!pending) return;
|
||||
const container = scrollRef.current;
|
||||
if (!container) {
|
||||
pendingSaveRef.current = null;
|
||||
return;
|
||||
}
|
||||
updateViewportAnchor(pending.sessionId, pending.anchor, {
|
||||
scrollTop: container.scrollTop,
|
||||
scrollHeight: container.scrollHeight,
|
||||
clientHeight: container.clientHeight,
|
||||
});
|
||||
pendingSaveRef.current = null;
|
||||
}, [updateViewportAnchor]);
|
||||
|
||||
const queueSave = React.useCallback(() => {
|
||||
const sessionId = currentSessionIdRef.current;
|
||||
if (!sessionId) return;
|
||||
const container = scrollRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = container;
|
||||
const anchorRatio = scrollHeight > 0
|
||||
? (scrollTop + clientHeight / 2) / scrollHeight
|
||||
: 0;
|
||||
const anchor = Math.floor(anchorRatio * sessionMessageCountRef.current);
|
||||
|
||||
pendingSaveRef.current = { sessionId, anchor };
|
||||
if (saveTimerRef.current !== null) return;
|
||||
saveTimerRef.current = setTimeout(() => {
|
||||
saveTimerRef.current = null;
|
||||
flushSave();
|
||||
}, SAVE_DEBOUNCE_MS);
|
||||
}, [flushSave]);
|
||||
|
||||
const saveSnapshotNow = React.useCallback(() => {
|
||||
flushSave();
|
||||
}, [flushSave]);
|
||||
|
||||
// ── scroll commands ─────────────────────────────────────────────────────
|
||||
const goToBottomReassertTimersRef = React.useRef<Array<ReturnType<typeof setTimeout>>>([]);
|
||||
const clearGoToBottomReasserts = React.useCallback(() => {
|
||||
for (const timer of goToBottomReassertTimersRef.current) clearTimeout(timer);
|
||||
goToBottomReassertTimersRef.current = [];
|
||||
}, []);
|
||||
|
||||
const goToBottom = React.useCallback((mode: 'instant' | 'smooth' = 'instant') => {
|
||||
isAtEndRef.current = true;
|
||||
setIsPinned(true);
|
||||
setUserOwnsScroll(false);
|
||||
modeRef.current = 'following-end';
|
||||
// Returning to the end is an explicit opt back IN to live follow.
|
||||
liveFollowGenerationRef.current = userGenerationRef.current;
|
||||
clearAnchor();
|
||||
hideScrollButton();
|
||||
void listRef.current?.scrollToEnd({ animated: mode === 'smooth' });
|
||||
// While a stream is growing the content, a single jump lands on the
|
||||
// end as of that moment and the list's own follow may not have
|
||||
// re-armed yet — re-assert a few times until the edge holds, then the
|
||||
// library follows onward. A new user gesture invalidates the window.
|
||||
clearGoToBottomReasserts();
|
||||
const generation = userGenerationRef.current;
|
||||
for (const delay of [150, 400, 800]) {
|
||||
goToBottomReassertTimersRef.current.push(setTimeout(() => {
|
||||
if (userGenerationRef.current !== generation) return;
|
||||
if (modeRef.current !== 'following-end') return;
|
||||
const state = listRef.current?.getState();
|
||||
if (state && resolveTimelineIsAtEnd(state) === true) return;
|
||||
void listRef.current?.scrollToEnd({ animated: false });
|
||||
}, delay));
|
||||
}
|
||||
}, [clearAnchor, clearGoToBottomReasserts, hideScrollButton]);
|
||||
|
||||
// User preference: with auto-follow off, streaming growth never moves the
|
||||
// viewport. Sending from the live edge still parks the new message at the
|
||||
// top, but no glide or end-follow correction runs afterwards; sending from
|
||||
// mid-history leaves the viewport untouched.
|
||||
const streamingAutoFollowEnabled = useUIStore((state) => state.streamingAutoFollowEnabled);
|
||||
const streamingAutoFollowEnabledRef = React.useRef(streamingAutoFollowEnabled);
|
||||
streamingAutoFollowEnabledRef.current = streamingAutoFollowEnabled;
|
||||
|
||||
// Sending arms the anchor. The message id is not known here (the optimistic
|
||||
// row is created by the store), so the next new user message id claims it.
|
||||
// Whether the send-time anchor positioning may animate. Sending from the
|
||||
// live edge parks the new message with a short smooth scroll; sending
|
||||
// from mid-history teleports — a long smooth scroll through the
|
||||
// virtualized timeline gets cancelled by rows mounting and measuring
|
||||
// along the way and dies partway there.
|
||||
const anchorPositionInstantRef = React.useRef(false);
|
||||
|
||||
const scrollToBottomOnSend = React.useCallback(() => {
|
||||
// With auto-follow off, a reader who scrolled away from the end stays
|
||||
// exactly where they are: the sent message is not anchored and the
|
||||
// scroll-to-bottom pill (already showing) leads to it. From the live
|
||||
// edge, sending anchors the new turn as usual.
|
||||
if (!streamingAutoFollowEnabledRef.current && !isAtEndRef.current) return;
|
||||
anchorPositionInstantRef.current = !isAtEndRef.current;
|
||||
isAtEndRef.current = true;
|
||||
setUserOwnsScroll(false);
|
||||
modeRef.current = 'anchoring-new-turn';
|
||||
liveFollowGenerationRef.current = userGenerationRef.current;
|
||||
armedForNextUserMessageRef.current = true;
|
||||
// The optimistic row is not committed yet; the next NEW user message id
|
||||
// relative to this baseline claims the anchor, independent of whether
|
||||
// the commit lands before or after this call.
|
||||
armBaselineUserMessageIdRef.current = lastArmedUserMessageIdRef.current;
|
||||
pendingAnchorRef.current = null;
|
||||
positionedAnchorRef.current = null;
|
||||
settledAnchorRef.current = null;
|
||||
activeAnchorIndexRef.current = null;
|
||||
hideScrollButton();
|
||||
}, [hideScrollButton]);
|
||||
|
||||
// Claim the anchor as soon as the sent row exists in the timeline. The
|
||||
// comparison is against the baseline captured when the send armed the
|
||||
// anchor, so the claim works whether the optimistic row committed before
|
||||
// or after the arming call.
|
||||
const lastArmedUserMessageIdRef = React.useRef<string | null>(lastUserMessageId);
|
||||
const armBaselineUserMessageIdRef = React.useRef<string | null>(lastUserMessageId);
|
||||
React.useEffect(() => {
|
||||
lastArmedUserMessageIdRef.current = lastUserMessageId;
|
||||
if (!armedForNextUserMessageRef.current) return;
|
||||
if (!lastUserMessageId || lastUserMessageId === armBaselineUserMessageIdRef.current) return;
|
||||
armedForNextUserMessageRef.current = false;
|
||||
pendingAnchorRef.current = lastUserMessageId;
|
||||
setAnchorMessageId(lastUserMessageId);
|
||||
}, [lastUserMessageId]);
|
||||
|
||||
const restoreSnapshot = React.useCallback(async (): Promise<boolean> => {
|
||||
const sessionKey = currentSessionKeyRef.current;
|
||||
if (!sessionKey) return false;
|
||||
|
||||
// Entering a session always returns to the live edge. Late async growth
|
||||
// is handled by the list staying at the end, not by a timed hold.
|
||||
isAtEndRef.current = true;
|
||||
setUserOwnsScroll(false);
|
||||
modeRef.current = 'following-end';
|
||||
liveFollowGenerationRef.current = userGenerationRef.current;
|
||||
clearAnchor();
|
||||
hideScrollButton();
|
||||
void listRef.current?.scrollToEnd({ animated: false });
|
||||
return false;
|
||||
}, [clearAnchor, hideScrollButton]);
|
||||
|
||||
// ── list callbacks ──────────────────────────────────────────────────────
|
||||
const registerList = React.useCallback((list: TimelineListHandle | null) => {
|
||||
listRef.current = list;
|
||||
const node = (list?.getScrollableNode() as HTMLDivElement | null) ?? null;
|
||||
scrollRef.current = node;
|
||||
setScrollNode(node);
|
||||
}, []);
|
||||
|
||||
const onIsAtEndChange = React.useCallback((isAtEnd: boolean) => {
|
||||
// While an automatic movement owns the viewport, leaving the end is our
|
||||
// own doing (the anchored turn parks mid-timeline, the glide trails its
|
||||
// target between corrections) — not a reason to offer the pill. Only a
|
||||
// real gesture (free-scrolling) shows it.
|
||||
if (!isAtEnd && isLiveFollowActive()) {
|
||||
hideScrollButton();
|
||||
return;
|
||||
}
|
||||
if (isAtEndRef.current === isAtEnd) return;
|
||||
isAtEndRef.current = isAtEnd;
|
||||
setIsPinned(isAtEnd);
|
||||
if (isAtEnd) {
|
||||
if (modeRef.current !== 'anchoring-new-turn') {
|
||||
modeRef.current = 'following-end';
|
||||
}
|
||||
liveFollowGenerationRef.current = userGenerationRef.current;
|
||||
setUserOwnsScroll(false);
|
||||
hideScrollButton();
|
||||
} else {
|
||||
modeRef.current = 'free-scrolling';
|
||||
liveFollowGenerationRef.current = null;
|
||||
scheduleShowScrollButton();
|
||||
}
|
||||
queueSave();
|
||||
}, [hideScrollButton, isLiveFollowActive, queueSave, scheduleShowScrollButton]);
|
||||
|
||||
// Park the anchored row near the top once the list has measured it.
|
||||
const onAnchorReady = React.useCallback((messageId: string, anchorIndex: number) => {
|
||||
// The anchored end space can be remeasured long after the send (turn
|
||||
// completion, images decoding). Only the send-time anchoring mode may
|
||||
// position the viewport.
|
||||
if (modeRef.current !== 'anchoring-new-turn') return;
|
||||
if (pendingAnchorRef.current === messageId) {
|
||||
pendingAnchorRef.current = null;
|
||||
}
|
||||
activeAnchorIndexRef.current = anchorIndex;
|
||||
if (positionedAnchorRef.current === messageId) return;
|
||||
positionedAnchorRef.current = messageId;
|
||||
settledAnchorRef.current = null;
|
||||
|
||||
const positionAnchor = (remainingAttempts: number) => {
|
||||
requestAnimationFrame(() => {
|
||||
if (positionedAnchorRef.current !== messageId) return;
|
||||
const list = listRef.current;
|
||||
if (!list) {
|
||||
if (remainingAttempts > 0) positionAnchor(remainingAttempts - 1);
|
||||
return;
|
||||
}
|
||||
const scrollNode = list.getScrollableNode();
|
||||
if (!scrollNode) {
|
||||
if (remainingAttempts > 0) positionAnchor(remainingAttempts - 1);
|
||||
return;
|
||||
}
|
||||
|
||||
let finished = false;
|
||||
const finishPositioning = () => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
clearTimeout(fallbackTimer);
|
||||
scrollNode.removeEventListener('scrollend', finishPositioning);
|
||||
if (positionedAnchorRef.current !== messageId) return;
|
||||
// Re-assert the resting offset without animation so the
|
||||
// smooth scroll's own momentum cannot drift past it.
|
||||
const scrollOffset = list.getState().scroll;
|
||||
void list.scrollToOffset({ offset: scrollOffset, animated: false });
|
||||
settledAnchorRef.current = messageId;
|
||||
};
|
||||
const fallbackTimer = setTimeout(finishPositioning, ANCHOR_SETTLE_FALLBACK_MS);
|
||||
scrollNode.addEventListener('scrollend', finishPositioning, { once: true });
|
||||
|
||||
void list.scrollToIndex({
|
||||
index: anchorIndex,
|
||||
animated: !anchorPositionInstantRef.current,
|
||||
viewPosition: 0,
|
||||
viewOffset: CHAT_LIST_ANCHOR_OFFSET,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
requestAnimationFrame(() => positionAnchor(ANCHOR_POSITION_ATTEMPTS));
|
||||
}, []);
|
||||
|
||||
// The anchored row can still change height after it settles (an image
|
||||
// decoding, a code block highlighting). Hold the resting offset, but only
|
||||
// against sub-pixel drift and only while the user has not taken over.
|
||||
const onAnchorSizeChanged = React.useCallback((messageId: string) => {
|
||||
if (settledAnchorRef.current !== messageId) return;
|
||||
if (!isLiveFollowActive()) return;
|
||||
const scrollOffset = listRef.current?.getState().scroll;
|
||||
if (scrollOffset === undefined) return;
|
||||
|
||||
if (pendingAnchorRestoreRef.current === null) {
|
||||
pendingAnchorRestoreRef.current = {
|
||||
messageId,
|
||||
offset: scrollOffset,
|
||||
userGeneration: userGenerationRef.current,
|
||||
};
|
||||
}
|
||||
if (anchorRestoreFrameRef.current !== null) return;
|
||||
|
||||
anchorRestoreFrameRef.current = requestAnimationFrame(() => {
|
||||
anchorRestoreFrameRef.current = null;
|
||||
const pending = pendingAnchorRestoreRef.current;
|
||||
pendingAnchorRestoreRef.current = null;
|
||||
if (
|
||||
!pending
|
||||
|| settledAnchorRef.current !== pending.messageId
|
||||
|| pending.userGeneration !== userGenerationRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const list = listRef.current;
|
||||
const currentOffset = list?.getState().scroll;
|
||||
if (
|
||||
typeof currentOffset === 'number'
|
||||
&& Math.abs(currentOffset - pending.offset) <= ANCHOR_RESTORE_TOLERANCE_PX
|
||||
) {
|
||||
void list?.scrollToOffset({ offset: pending.offset, animated: false });
|
||||
}
|
||||
});
|
||||
}, [isLiveFollowActive]);
|
||||
|
||||
// Whether the real rows (ignoring any reserved anchored end space) are tall
|
||||
// enough to scroll. Without this, entering a short session would scroll into
|
||||
// the reserved space and strand the content above the viewport.
|
||||
const realContentOverflowsViewport = React.useCallback((list: TimelineListHandle): boolean => {
|
||||
const state = list.getState();
|
||||
if (state.data.length === 0) return false;
|
||||
|
||||
const lastIndex = state.data.length - 1;
|
||||
const lastTop = state.positionAtIndex(lastIndex);
|
||||
const lastHeight = state.sizeAtIndex(lastIndex);
|
||||
if (
|
||||
typeof lastTop !== 'number'
|
||||
|| typeof lastHeight !== 'number'
|
||||
|| !Number.isFinite(lastTop)
|
||||
|| !Number.isFinite(lastHeight)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const realContentBottom = lastTop + Math.max(1, lastHeight);
|
||||
const visibleScrollLength = Math.max(
|
||||
0,
|
||||
state.scrollLength - composerOverlayHeightRef.current - CHAT_LIST_ANCHOR_OFFSET,
|
||||
);
|
||||
return realContentBottom > visibleScrollLength;
|
||||
}, []);
|
||||
|
||||
// One deterministic correction per data change, two frames out so the list
|
||||
// has measured the new rows. Nothing runs while the user owns the scroll.
|
||||
const dataChangeFramesRef = React.useRef<{ first: number | null; second: number | null }>({
|
||||
first: null,
|
||||
second: null,
|
||||
});
|
||||
// While the list width is resizing, every pinning write fights the
|
||||
// per-frame row re-measure and the pinned viewport shakes. Corrections
|
||||
// stand down for the whole resize and the visible content is held by the
|
||||
// list's size compensation instead. Deliberately NO snap back to the end
|
||||
// afterwards for a mid-conversation reader: a slow drag settles
|
||||
// repeatedly, and each snap reads as the very jump this suspension
|
||||
// removes. A reader who WAS at the end is the exception — after rows
|
||||
// re-wrap, stale cached sizes can leave a large phantom gap below the
|
||||
// last row, so re-asserting the end once on settle is what "staying
|
||||
// where the reader is" means for them.
|
||||
const widthResizingRef = React.useRef(false);
|
||||
React.useEffect(() => {
|
||||
if (!scrollNode || typeof ResizeObserver === 'undefined') return;
|
||||
let lastWidth: number | null = null;
|
||||
let quietTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const observer = new ResizeObserver((observerEntries) => {
|
||||
const width = observerEntries[observerEntries.length - 1]?.contentRect.width;
|
||||
if (typeof width !== 'number') return;
|
||||
if (lastWidth === null) {
|
||||
lastWidth = width;
|
||||
return;
|
||||
}
|
||||
if (Math.abs(width - lastWidth) < 1) return;
|
||||
lastWidth = width;
|
||||
widthResizingRef.current = true;
|
||||
if (quietTimer !== null) clearTimeout(quietTimer);
|
||||
quietTimer = setTimeout(() => {
|
||||
quietTimer = null;
|
||||
widthResizingRef.current = false;
|
||||
if (isAtEndRef.current && pendingAnchorRef.current === null) {
|
||||
void listRef.current?.scrollToEnd({ animated: false });
|
||||
}
|
||||
}, 350);
|
||||
});
|
||||
observer.observe(scrollNode);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
if (quietTimer !== null) clearTimeout(quietTimer);
|
||||
};
|
||||
}, [scrollNode]);
|
||||
|
||||
// Keep the live edge in view after content growth. Within a viewport of
|
||||
// the end the remaining distance is glided so a revealed block and the
|
||||
// scroll read as one motion; further behind, the viewport first jumps to
|
||||
// one screen above the end and glides only that last screen, so the
|
||||
// reader is never left staring at a gap several screens tall. Writes go
|
||||
// to the scroll node directly: routing each chunk through the list's
|
||||
// scrollToEnd bookkeeping roughly doubled frame production when measured.
|
||||
// A user gesture interrupts the native smooth scroll on its own, and the
|
||||
// gesture handler drops live follow so no later correction re-engages.
|
||||
const followEnd = React.useCallback(() => {
|
||||
const node = scrollRef.current;
|
||||
if (!node) return;
|
||||
const end = node.scrollHeight - node.clientHeight;
|
||||
const distance = end - node.scrollTop;
|
||||
if (distance <= 1) return;
|
||||
if (distance > node.clientHeight) {
|
||||
node.scrollTop = end - node.clientHeight;
|
||||
}
|
||||
node.scrollTo({ top: end, behavior: 'smooth' });
|
||||
}, []);
|
||||
|
||||
const onTimelineDataChange = React.useCallback(() => {
|
||||
if (widthResizingRef.current) return;
|
||||
|
||||
// Stranded-viewport rescue, independent of any follow mode or
|
||||
// preference: when off-screen size estimates settle smaller than
|
||||
// estimated, the measured content can end ABOVE the viewport while
|
||||
// the scroll offset stays at the stale end — the reader faces a blank
|
||||
// phantom tail with every row out of reach above. That state is never
|
||||
// intentional, so it is corrected even when auto-follow is off. Only
|
||||
// a fully blank viewport qualifies; partial visibility is left alone.
|
||||
if (!userOwnsScrollRef.current) {
|
||||
const list = listRef.current;
|
||||
if (list) {
|
||||
const state = list.getState();
|
||||
const lastIndex = state.data.length - 1;
|
||||
const lastBottom = lastIndex >= 0 ? getRowBottom(state, lastIndex) : null;
|
||||
if (lastBottom !== null && state.scroll > lastBottom) {
|
||||
const visibleLength = Math.max(
|
||||
0,
|
||||
state.scrollLength - composerOverlayHeightRef.current - CHAT_LIST_ANCHOR_OFFSET,
|
||||
);
|
||||
void list.scrollToOffset({
|
||||
offset: Math.max(0, lastBottom - visibleLength),
|
||||
animated: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!streamingAutoFollowEnabledRef.current) {
|
||||
// With auto-follow off nothing moves the viewport, so a growing
|
||||
// reply slides below the visible area without a single scroll
|
||||
// event — and the at-end transition that offers the pill never
|
||||
// fires. Content growth is the signal here: once the real last
|
||||
// row extends past what the composer leaves visible, the reader
|
||||
// is factually behind and the pill must say so.
|
||||
const list = listRef.current;
|
||||
if (list && isAtEndRef.current) {
|
||||
const state = list.getState();
|
||||
const lastIndex = state.data.length - 1;
|
||||
const lastBottom = lastIndex >= 0 ? getRowBottom(state, lastIndex) : null;
|
||||
if (lastBottom !== null) {
|
||||
const visibleBottom = state.scroll + state.scrollLength - composerOverlayHeightRef.current;
|
||||
if (lastBottom - visibleBottom > TIMELINE_FOLLOW_REARM_THRESHOLD_PX) {
|
||||
isAtEndRef.current = false;
|
||||
setIsPinned(false);
|
||||
scheduleShowScrollButton();
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!isLiveFollowActive()) return;
|
||||
|
||||
// Following the end is owned here, not left to the list's
|
||||
// maintainScrollAtEnd. The list's animated maintain is single-flight:
|
||||
// growth that lands while a glide is still in flight is dropped until
|
||||
// the next trigger, and its re-pin threshold is a tenth of the
|
||||
// viewport. In a narrow viewport (the VS Code sidebar) one revealed
|
||||
// block is several viewports tall, so every block left the reader a
|
||||
// second behind and multiple screens above the live edge — measured
|
||||
// at 45% of the stream time spent 500-1600px behind at 420x640.
|
||||
if (modeRef.current === 'following-end') {
|
||||
followEnd();
|
||||
return;
|
||||
}
|
||||
|
||||
const frames = dataChangeFramesRef.current;
|
||||
if (frames.first !== null) cancelAnimationFrame(frames.first);
|
||||
if (frames.second !== null) cancelAnimationFrame(frames.second);
|
||||
|
||||
frames.first = requestAnimationFrame(() => {
|
||||
frames.first = null;
|
||||
frames.second = requestAnimationFrame(() => {
|
||||
frames.second = null;
|
||||
if (!isLiveFollowActive()) return;
|
||||
// An anchor that exists but has not come to rest yet owns the
|
||||
// viewport; correcting now would fight its animation.
|
||||
if (pendingAnchorRef.current !== null) return;
|
||||
if (
|
||||
positionedAnchorRef.current !== null
|
||||
&& settledAnchorRef.current !== positionedAnchorRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const list = listRef.current;
|
||||
if (!list) return;
|
||||
|
||||
if (modeRef.current === 'anchoring-new-turn') {
|
||||
const anchorIndex = activeAnchorIndexRef.current;
|
||||
if (anchorIndex === null) return;
|
||||
const metrics = getAnchoredTurnMetrics({
|
||||
state: list.getState(),
|
||||
anchorIndex,
|
||||
composerOverlayHeight: composerOverlayHeightRef.current,
|
||||
anchorOffset: CHAT_LIST_ANCHOR_OFFSET,
|
||||
});
|
||||
// The turn still fits: leave the viewport exactly where the
|
||||
// user is reading.
|
||||
if (!metrics || metrics.scrollDeltaToRevealEnd <= 1) return;
|
||||
// Animated: successive corrections restart the smooth scroll
|
||||
// from the current position, so streaming reads as one
|
||||
// continuous glide instead of a per-line hop. A real user
|
||||
// gesture interrupts the native smooth scroll on its own.
|
||||
void list.scrollToOffset({
|
||||
offset: list.getState().scroll + metrics.scrollDeltaToRevealEnd,
|
||||
animated: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
});
|
||||
});
|
||||
}, [followEnd, isLiveFollowActive, scheduleShowScrollButton]);
|
||||
|
||||
// The streaming tail grows inside one row without changing the entries
|
||||
// array, so data-change callbacks are silent for the entire stream. The
|
||||
// list's total content size is the authoritative growth signal; every
|
||||
// change re-runs the same guarded correction.
|
||||
const onTimelineDataChangeRef = React.useRef(onTimelineDataChange);
|
||||
onTimelineDataChangeRef.current = onTimelineDataChange;
|
||||
React.useEffect(() => {
|
||||
if (!scrollNode) return;
|
||||
const listen = listRef.current?.getState().listen;
|
||||
if (!listen) return;
|
||||
const unsubscribe = listen('totalSize', () => {
|
||||
onTimelineDataChangeRef.current();
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [scrollNode]);
|
||||
|
||||
// ── gesture opt-out ─────────────────────────────────────────────────────
|
||||
const onManualNavigationRef = React.useRef(onManualNavigation);
|
||||
onManualNavigationRef.current = onManualNavigation;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!scrollNode) return;
|
||||
|
||||
// A gesture is meaningful when the viewport can move up AT ALL:
|
||||
// either the real rows overflow the viewport, or there is scrolled
|
||||
// history above (an anchored turn parks mid-conversation with
|
||||
// reserved space below — the real rows may not overflow yet, but
|
||||
// wheel-up is still a genuine opt-out; swallowing it left live-follow
|
||||
// armed, which suppressed the pill and kept corrections armed under a
|
||||
// viewport the user had taken).
|
||||
const canScrollUp = () => {
|
||||
const list = listRef.current;
|
||||
if (!list) return false;
|
||||
if (list.getState().scroll > 1) return true;
|
||||
return realContentOverflowsViewport(list);
|
||||
};
|
||||
const gesture = () => {
|
||||
onManualNavigationRef.current();
|
||||
};
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
// Scrolling toward the end is not opting out of follow, and an
|
||||
// upward wheel that a nested scroller still consumes never
|
||||
// reaches the timeline.
|
||||
if (event.deltaY < 0 && !nestedScrollableConsumesWheelUp(scrollNode, event.target) && canScrollUp()) {
|
||||
gesture();
|
||||
}
|
||||
};
|
||||
// Touch mirrors wheel by finger direction, not by having already left
|
||||
// the end: while a stream keeps re-pinning the viewport, waiting for
|
||||
// an at-end transition means the drag never registers — the user
|
||||
// cannot scroll, the pill never appears, and live-follow stays armed
|
||||
// under a viewport they are fighting for.
|
||||
let touchLastY: number | null = null;
|
||||
const handleTouchStart = (event: TouchEvent) => {
|
||||
touchLastY = event.touches[0]?.clientY ?? null;
|
||||
};
|
||||
const handleTouchMove = (event: TouchEvent) => {
|
||||
const y = event.touches[0]?.clientY ?? null;
|
||||
const lastY = touchLastY;
|
||||
touchLastY = y;
|
||||
if (y === null) return;
|
||||
// A downward finger drags the content up — the touch wheel-up.
|
||||
const draggedUp = lastY !== null && y > lastY;
|
||||
if ((draggedUp || !isAtEndRef.current) && canScrollUp()) gesture();
|
||||
};
|
||||
const handleTouchEnd = () => {
|
||||
touchLastY = null;
|
||||
};
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
// A middle-button pan scrolls without wheel events (and is the
|
||||
// only scroll gesture for wheel-less mice), so the press is the
|
||||
// opt-out. Otherwise the scrollbar track is the scroll node
|
||||
// itself; a tap on a row only breaks follow when the viewport
|
||||
// already left the end.
|
||||
if (isMiddleButtonPan(scrollNode, event)) {
|
||||
if (canScrollUp()) gesture();
|
||||
return;
|
||||
}
|
||||
if ((event.target === scrollNode || !isAtEndRef.current) && canScrollUp()) gesture();
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (isFollowReleaseKey(event) && canScrollUp()) gesture();
|
||||
};
|
||||
const handleScroll = () => {
|
||||
queueSave();
|
||||
};
|
||||
|
||||
scrollNode.addEventListener('wheel', handleWheel, { passive: true });
|
||||
scrollNode.addEventListener('touchstart', handleTouchStart, { passive: true });
|
||||
scrollNode.addEventListener('touchmove', handleTouchMove, { passive: true });
|
||||
scrollNode.addEventListener('touchend', handleTouchEnd, { passive: true });
|
||||
scrollNode.addEventListener('touchcancel', handleTouchEnd, { passive: true });
|
||||
scrollNode.addEventListener('pointerdown', handlePointerDown, { passive: true });
|
||||
scrollNode.addEventListener('keydown', handleKeyDown);
|
||||
scrollNode.addEventListener('scroll', handleScroll, { passive: true });
|
||||
|
||||
return () => {
|
||||
scrollNode.removeEventListener('wheel', handleWheel);
|
||||
scrollNode.removeEventListener('touchstart', handleTouchStart);
|
||||
scrollNode.removeEventListener('touchmove', handleTouchMove);
|
||||
scrollNode.removeEventListener('touchend', handleTouchEnd);
|
||||
scrollNode.removeEventListener('touchcancel', handleTouchEnd);
|
||||
scrollNode.removeEventListener('pointerdown', handlePointerDown);
|
||||
scrollNode.removeEventListener('keydown', handleKeyDown);
|
||||
scrollNode.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}, [queueSave, realContentOverflowsViewport, scrollNode]);
|
||||
|
||||
// ── session lifecycle ───────────────────────────────────────────────────
|
||||
const lastSessionKeyRef = React.useRef<string | null>(null);
|
||||
React.useEffect(() => {
|
||||
if (!currentSessionId || !currentSessionKey || currentSessionKey === lastSessionKeyRef.current) {
|
||||
return;
|
||||
}
|
||||
lastSessionKeyRef.current = currentSessionKey;
|
||||
MessageFreshnessDetector.getInstance().recordSessionStart(currentSessionId);
|
||||
// Persist the outgoing session's position before the new one takes over.
|
||||
flushSave();
|
||||
isAtEndRef.current = true;
|
||||
setUserOwnsScroll(false);
|
||||
modeRef.current = 'following-end';
|
||||
liveFollowGenerationRef.current = userGenerationRef.current;
|
||||
clearAnchor();
|
||||
hideScrollButton();
|
||||
}, [clearAnchor, currentSessionId, currentSessionKey, flushSave, hideScrollButton]);
|
||||
|
||||
// Suppress the overlay scrollbar thumb while automatic movement owns the
|
||||
// scroll position, so it does not jump on each correction.
|
||||
React.useEffect(() => {
|
||||
setIsFollowingProgrammatically(!showScrollButton && !userOwnsScroll);
|
||||
}, [showScrollButton, userOwnsScroll]);
|
||||
|
||||
React.useEffect(() => () => {
|
||||
cancelShowButtonTimer();
|
||||
if (saveTimerRef.current !== null) clearTimeout(saveTimerRef.current);
|
||||
if (anchorRestoreFrameRef.current !== null) cancelAnimationFrame(anchorRestoreFrameRef.current);
|
||||
const frames = dataChangeFramesRef.current;
|
||||
if (frames.first !== null) cancelAnimationFrame(frames.first);
|
||||
if (frames.second !== null) cancelAnimationFrame(frames.second);
|
||||
}, [cancelShowButtonTimer]);
|
||||
|
||||
// ── active-turn spy ─────────────────────────────────────────────────────
|
||||
// Reads turn positions straight from the DOM, so it is unaffected by which
|
||||
// list implementation owns the container. Rows mounting and unmounting
|
||||
// during virtualized scrolling are tracked through the mutation observer.
|
||||
React.useEffect(() => {
|
||||
if (!onActiveTurnChange) return;
|
||||
const container = scrollNode;
|
||||
if (!container) return;
|
||||
|
||||
let lastActiveTurnId: string | null = null;
|
||||
const spy = createScrollSpy({
|
||||
onActive: (turnId) => {
|
||||
if (turnId === lastActiveTurnId) return;
|
||||
lastActiveTurnId = turnId;
|
||||
onActiveTurnChange(turnId);
|
||||
},
|
||||
});
|
||||
spy.setContainer(container);
|
||||
|
||||
const elementByTurnId = new Map<string, HTMLElement>();
|
||||
const registerTurnNode = (node: HTMLElement) => {
|
||||
const turnId = node.dataset.turnId;
|
||||
if (!turnId) return false;
|
||||
elementByTurnId.set(turnId, node);
|
||||
spy.register(node, turnId);
|
||||
return true;
|
||||
};
|
||||
const unregisterTurnNode = (node: HTMLElement) => {
|
||||
const turnId = node.dataset.turnId;
|
||||
if (!turnId) return false;
|
||||
if (elementByTurnId.get(turnId) !== node) return false;
|
||||
elementByTurnId.delete(turnId);
|
||||
spy.unregister(turnId);
|
||||
return true;
|
||||
};
|
||||
const collectTurnNodes = (node: Node): HTMLElement[] => {
|
||||
if (!(node instanceof HTMLElement)) return [];
|
||||
const collected: HTMLElement[] = [];
|
||||
if (node.matches('[data-turn-id]')) collected.push(node);
|
||||
node.querySelectorAll<HTMLElement>('[data-turn-id]').forEach((el) => collected.push(el));
|
||||
return collected;
|
||||
};
|
||||
|
||||
container.querySelectorAll<HTMLElement>('[data-turn-id]').forEach(registerTurnNode);
|
||||
spy.markDirty();
|
||||
|
||||
const mutationObserver = new MutationObserver((records) => {
|
||||
let changed = false;
|
||||
records.forEach((record) => {
|
||||
record.removedNodes.forEach((node) => {
|
||||
collectTurnNodes(node).forEach((turnNode) => {
|
||||
if (unregisterTurnNode(turnNode)) changed = true;
|
||||
});
|
||||
});
|
||||
record.addedNodes.forEach((node) => {
|
||||
collectTurnNodes(node).forEach((turnNode) => {
|
||||
if (registerTurnNode(turnNode)) changed = true;
|
||||
});
|
||||
});
|
||||
});
|
||||
if (changed) spy.markDirty();
|
||||
});
|
||||
mutationObserver.observe(container, { subtree: true, childList: true });
|
||||
|
||||
const onScroll = () => spy.onScroll();
|
||||
container.addEventListener('scroll', onScroll, { passive: true });
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('scroll', onScroll);
|
||||
mutationObserver.disconnect();
|
||||
spy.destroy();
|
||||
};
|
||||
}, [onActiveTurnChange, scrollNode]);
|
||||
|
||||
return {
|
||||
scrollRef,
|
||||
scrollNode,
|
||||
isPinned,
|
||||
registerList,
|
||||
anchorMessageId,
|
||||
onAnchorReady,
|
||||
onAnchorSizeChanged,
|
||||
onIsAtEndChange,
|
||||
onManualNavigation,
|
||||
onTimelineDataChange,
|
||||
showScrollButton,
|
||||
userOwnsScroll,
|
||||
isFollowingProgrammatically,
|
||||
goToBottom,
|
||||
scrollToBottomOnSend,
|
||||
saveSnapshotNow,
|
||||
restoreSnapshot,
|
||||
};
|
||||
};
|
||||
@@ -2,16 +2,19 @@
|
||||
* Streaming dictation state machine.
|
||||
*
|
||||
* Status flow: idle -> recording -> uploading -> idle | failed.
|
||||
* While recording, mic PCM chunks stream to the server, which sends back live
|
||||
* partial transcripts. Confirm finalizes and resolves the full text; failed
|
||||
* dictations retain their audio segments so retry can replay them.
|
||||
* While recording, mic PCM chunks stream to the server, which transcribes them
|
||||
* segment by segment; confirm finalizes and resolves the full text. Nothing is
|
||||
* shown while recording — `partialTranscript` holds whatever the server has
|
||||
* transcribed so far and exists only so a failed dictation can be salvaged
|
||||
* instead of losing minutes of speech. Failed dictations also retain their
|
||||
* audio segments so retry can replay them.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { dictationClient, type DictationStartOptions } from '@/lib/dictation/dictation-client';
|
||||
import { DictationStreamSender } from '@/lib/dictation/dictation-stream-sender';
|
||||
import { useDictationAudioSource } from '@/lib/dictation/use-dictation-audio-source';
|
||||
import { useDictationAudioSource, type DictationLevelListener } from '@/lib/dictation/use-dictation-audio-source';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
|
||||
export type DictationStatus = 'idle' | 'recording' | 'uploading' | 'failed';
|
||||
@@ -26,8 +29,10 @@ export interface UseDictationResult {
|
||||
status: DictationStatus;
|
||||
isRecording: boolean;
|
||||
isProcessing: boolean;
|
||||
/** Server-side transcript so far; recovery only, never shown while recording. */
|
||||
partialTranscript: string;
|
||||
volume: number;
|
||||
/** Subscribe to the normalized (0..1) mic level for the waveform. */
|
||||
subscribeLevel: (listener: DictationLevelListener) => () => void;
|
||||
duration: number;
|
||||
error: string | null;
|
||||
errorReason: string | null;
|
||||
@@ -133,7 +138,8 @@ export function useDictation(options: UseDictationOptions = {}): UseDictationRes
|
||||
setPartialTranscript('');
|
||||
}, []);
|
||||
|
||||
// Live partial transcripts for the active dictation.
|
||||
// Transcripts of segments the server has already committed. Not rendered
|
||||
// while recording; kept so a failed dictation can be salvaged.
|
||||
useEffect(() => {
|
||||
return dictationClient.onPartial((dictationId, text) => {
|
||||
const activeDictationId = senderRef.current?.getDictationId();
|
||||
@@ -223,7 +229,8 @@ export function useDictation(options: UseDictationOptions = {}): UseDictationRes
|
||||
try {
|
||||
await audio.start();
|
||||
startDurationTracking();
|
||||
// Open the stream eagerly so partials start flowing immediately.
|
||||
// Open the stream eagerly so audio uploads while the user speaks
|
||||
// and only the tail is left to transcribe on stop.
|
||||
await senderRef.current?.restartStream().catch((err) => {
|
||||
// Non-fatal: segments buffer locally and finish() retries the
|
||||
// start, but surface the reason (e.g. model downloading) so
|
||||
@@ -394,7 +401,7 @@ export function useDictation(options: UseDictationOptions = {}): UseDictationRes
|
||||
isRecording: status === 'recording',
|
||||
isProcessing: status === 'uploading',
|
||||
partialTranscript,
|
||||
volume: audio.volume,
|
||||
subscribeLevel: audio.subscribeLevel,
|
||||
duration,
|
||||
error,
|
||||
errorReason,
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { isDesktopShell, requestDirectoryAccess, startAccessingDirectory, stopAccessingDirectory } from '@/lib/desktop';
|
||||
import {
|
||||
canRequestNativeDirectoryAccess,
|
||||
isDesktopShell,
|
||||
requestDirectoryAccess,
|
||||
startAccessingDirectory,
|
||||
stopAccessingDirectory,
|
||||
} from '@/lib/desktop';
|
||||
|
||||
export const useFileSystemAccess = () => {
|
||||
const [isDesktop, setIsDesktop] = useState(false);
|
||||
const [canRequestAccess, setCanRequestAccess] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsDesktop(isDesktopShell());
|
||||
setCanRequestAccess(canRequestNativeDirectoryAccess());
|
||||
}, []);
|
||||
|
||||
const requestAccess = useCallback(async (directoryPath: string): Promise<{ success: boolean; path?: string; projectId?: string; error?: string }> => {
|
||||
@@ -34,6 +42,7 @@ export const useFileSystemAccess = () => {
|
||||
|
||||
return {
|
||||
isDesktop,
|
||||
canRequestAccess,
|
||||
requestAccess,
|
||||
startAccessing,
|
||||
stopAccessing
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import {
|
||||
GLOBAL_SESSIONS_REFRESH_INTERVAL_MS,
|
||||
startGlobalSessionsPolling,
|
||||
} from './useGlobalSessionsPolling';
|
||||
|
||||
describe('global sessions polling lifecycle', () => {
|
||||
test('loads immediately, owns one interval, and clears it on disposal', () => {
|
||||
let initialLoads = 0;
|
||||
let refreshes = 0;
|
||||
let scheduledCallback = () => {};
|
||||
let scheduledIntervals = 0;
|
||||
let scheduledDelay = 0;
|
||||
let clearedIntervalId: number | null = null;
|
||||
|
||||
const dispose = startGlobalSessionsPolling(
|
||||
() => { initialLoads += 1; },
|
||||
() => { refreshes += 1; },
|
||||
(callback, delay) => {
|
||||
scheduledIntervals += 1;
|
||||
scheduledCallback = callback;
|
||||
scheduledDelay = delay;
|
||||
return 42;
|
||||
},
|
||||
(intervalId) => { clearedIntervalId = intervalId; },
|
||||
);
|
||||
|
||||
expect(initialLoads).toBe(1);
|
||||
expect(refreshes).toBe(0);
|
||||
expect(scheduledIntervals).toBe(1);
|
||||
expect(scheduledDelay).toBe(GLOBAL_SESSIONS_REFRESH_INTERVAL_MS);
|
||||
|
||||
scheduledCallback();
|
||||
expect(refreshes).toBe(1);
|
||||
|
||||
dispose();
|
||||
expect(clearedIntervalId).toBe(42);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import { getAllSyncSessions } from '@/sync/sync-refs';
|
||||
import {
|
||||
ensureGlobalSessionsLoaded,
|
||||
refreshGlobalSessions,
|
||||
} from '@/stores/useGlobalSessionsStore';
|
||||
|
||||
export const GLOBAL_SESSIONS_REFRESH_INTERVAL_MS = 45_000;
|
||||
|
||||
type ScheduleInterval = (callback: () => void, delay: number) => number;
|
||||
type ClearInterval = (intervalId: number) => void;
|
||||
|
||||
export const startGlobalSessionsPolling = (
|
||||
initialLoad: () => void,
|
||||
refresh: () => void,
|
||||
scheduleInterval: ScheduleInterval = window.setInterval.bind(window),
|
||||
clearScheduledInterval: ClearInterval = window.clearInterval.bind(window),
|
||||
): (() => void) => {
|
||||
initialLoad();
|
||||
const intervalId = scheduleInterval(refresh, GLOBAL_SESSIONS_REFRESH_INTERVAL_MS);
|
||||
return () => clearScheduledInterval(intervalId);
|
||||
};
|
||||
|
||||
/** Owns the one global-session polling lifecycle for the main app runtime. */
|
||||
export const useGlobalSessionsPolling = (enabled: boolean): void => {
|
||||
React.useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
return startGlobalSessionsPolling(
|
||||
() => { void ensureGlobalSessionsLoaded(getAllSyncSessions()); },
|
||||
() => { void refreshGlobalSessions(); },
|
||||
);
|
||||
}, [enabled]);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import type { ShortcutHandler } from '@/lib/shortcuts';
|
||||
import type { ShortcutBindings } from './useKeybind';
|
||||
|
||||
const handler: ShortcutHandler = () => {};
|
||||
const validBindings = {
|
||||
open_session_list: handler,
|
||||
};
|
||||
const mixedBindingsWithTypo = {
|
||||
open_session_list: handler,
|
||||
open_session_lsit: handler,
|
||||
};
|
||||
|
||||
const acceptedBindings: ShortcutBindings<typeof validBindings> = validBindings;
|
||||
// @ts-expect-error A misspelled key must fail even when the object also contains a valid ID.
|
||||
const rejectedBindings: ShortcutBindings<typeof mixedBindingsWithTypo> = mixedBindingsWithTypo;
|
||||
void rejectedBindings;
|
||||
|
||||
test('accepts bindings whose IDs are declared in the shortcut schema', () => {
|
||||
expect(Object.keys(acceptedBindings)).toEqual(['open_session_list']);
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
import { shortcutRegistry, type ShortcutActionId, type ShortcutHandler } from '@/lib/shortcuts';
|
||||
|
||||
export function useKeybind(actionId: ShortcutActionId, handler: ShortcutHandler): void {
|
||||
const handlerRef = React.useRef(handler);
|
||||
handlerRef.current = handler;
|
||||
|
||||
React.useEffect(() => shortcutRegistry.register(actionId, (event) => handlerRef.current(event)), [actionId]);
|
||||
}
|
||||
|
||||
export type ShortcutBindings<
|
||||
Bindings extends Partial<Record<ShortcutActionId, ShortcutHandler>>,
|
||||
> = Bindings & Record<Exclude<keyof Bindings, ShortcutActionId>, never>;
|
||||
|
||||
export function useKeybinds<
|
||||
const Bindings extends Partial<Record<ShortcutActionId, ShortcutHandler>>,
|
||||
>(bindings: ShortcutBindings<Bindings>): void {
|
||||
const handlersRef = React.useRef(bindings);
|
||||
handlersRef.current = bindings;
|
||||
const actionIdsKey = Object.keys(bindings).sort().join('\0');
|
||||
|
||||
React.useEffect(() => {
|
||||
const actionIds = (actionIdsKey ? actionIdsKey.split('\0') : []) as ShortcutActionId[];
|
||||
const unregister = actionIds.map((actionId) => shortcutRegistry.register(actionId, (event) => {
|
||||
const handler = handlersRef.current[actionId];
|
||||
return handler ? handler(event) : false;
|
||||
}));
|
||||
return () => unregister.forEach((remove) => remove());
|
||||
}, [actionIdsKey]);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -41,7 +41,7 @@ const MAX_CHUNK_CHARS = 400;
|
||||
* Sentences are merged until MIN_CHUNK_CHARS and hard-split at
|
||||
* MAX_CHUNK_CHARS so a single run-on sentence cannot stall the pipeline.
|
||||
*/
|
||||
export function splitTextForSynthesis(text: string): string[] {
|
||||
function splitTextForSynthesis(text: string): string[] {
|
||||
const normalized = text.replace(/\s+/g, ' ').trim();
|
||||
if (!normalized) {
|
||||
return [];
|
||||
|
||||
@@ -102,7 +102,6 @@ export const useMenuActions = (
|
||||
const toggleHelpDialog = useUIStore((s) => s.toggleHelpDialog);
|
||||
const toggleSidebar = useUIStore((s) => s.toggleSidebar);
|
||||
const setSessionSwitcherOpen = useUIStore((s) => s.setSessionSwitcherOpen);
|
||||
const setActiveMainTab = useUIStore((s) => s.setActiveMainTab);
|
||||
const setSettingsDialogOpen = useUIStore((s) => s.setSettingsDialogOpen);
|
||||
const setAboutDialogOpen = useUIStore((s) => s.setAboutDialogOpen);
|
||||
const checkForUpdates = useUpdateStore((state) => state.checkForUpdates);
|
||||
@@ -151,10 +150,9 @@ export const useMenuActions = (
|
||||
const nextSession = sessions[nextIndex];
|
||||
if (!nextSession) return;
|
||||
|
||||
setActiveMainTab('chat');
|
||||
setSessionSwitcherOpen(false);
|
||||
useSessionUIStore.getState().setCurrentSession(nextSession.id);
|
||||
}, [setActiveMainTab, setSessionSwitcherOpen]);
|
||||
}, [setSessionSwitcherOpen]);
|
||||
|
||||
const navigateProject = React.useCallback((direction: -1 | 1) => {
|
||||
const { activeProjectId, projects, setActiveProject } = useProjectsStore.getState();
|
||||
@@ -191,14 +189,18 @@ export const useMenuActions = (
|
||||
break;
|
||||
|
||||
case 'new-session':
|
||||
setActiveMainTab('chat');
|
||||
setSessionSwitcherOpen(false);
|
||||
openNewSessionDraft();
|
||||
setSessionSwitcherOpen(false);
|
||||
{
|
||||
const sessionState = useSessionUIStore.getState();
|
||||
const directory = useDirectoryStore.getState().currentDirectory;
|
||||
openNewSessionDraft(sessionState.currentSessionId && directory
|
||||
? { directoryOverride: directory }
|
||||
: undefined);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'new-worktree-session':
|
||||
setActiveMainTab('chat');
|
||||
setSessionSwitcherOpen(false);
|
||||
setSessionSwitcherOpen(false);
|
||||
createWorktreeSession();
|
||||
break;
|
||||
|
||||
@@ -335,7 +337,6 @@ export const useMenuActions = (
|
||||
onToggleMemoryDebug,
|
||||
openNewSessionDraft,
|
||||
setAboutDialogOpen,
|
||||
setActiveMainTab,
|
||||
setSessionSwitcherOpen,
|
||||
setCommandPaletteOpen,
|
||||
setSettingsDialogOpen,
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useSayTTS } from './useSayTTS';
|
||||
import { useLocalTTS } from './useLocalTTS';
|
||||
import { browserVoiceService } from '@/lib/voice/browserVoiceService';
|
||||
import { sanitizeForTTS } from '@/lib/voice/summarize';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { requestSmallModel } from '@/lib/smallModelRequest';
|
||||
|
||||
// Below this length the reply is comfortable to listen to as-is; summarizing
|
||||
// would only add latency.
|
||||
@@ -25,7 +25,7 @@ async function summarizeForSpeech(
|
||||
preferred: { providerID?: string; modelID?: string },
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const response = await runtimeFetch('/api/small-model/generate', {
|
||||
const response = await requestSmallModel({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
|
||||
@@ -1,102 +1,129 @@
|
||||
import React from 'react';
|
||||
import { focusChatInput } from '@/components/chat/composer/editor/dom';
|
||||
import { canUseElectronDesktopIPC, invokeDesktop } from '@/lib/desktop';
|
||||
import { eventMatchesShortcut, getEffectiveShortcutCombo } from '@/lib/shortcuts';
|
||||
import { ShortcutDispatcher, getEffectiveShortcutCombo, shortcutRegistry } from '@/lib/shortcuts';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useSelectionStore } from '@/sync/selection-store';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useKeybinds } from './useKeybind';
|
||||
import { isEditableEventTarget } from './keyboard-shortcut-dom';
|
||||
|
||||
export const useMiniChatKeyboardShortcuts = () => {
|
||||
const shortcutOverrides = useUIStore((state) => state.shortcutOverrides);
|
||||
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
||||
const activeProject = useProjectsStore((state) => state.getActiveProject());
|
||||
const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft);
|
||||
const dispatcherRef = React.useRef<ShortcutDispatcher | null>(null);
|
||||
|
||||
if (!dispatcherRef.current) {
|
||||
dispatcherRef.current = new ShortcutDispatcher({
|
||||
registry: shortcutRegistry,
|
||||
getBinding: (actionId) => getEffectiveShortcutCombo(
|
||||
actionId,
|
||||
useUIStore.getState().shortcutOverrides,
|
||||
),
|
||||
});
|
||||
}
|
||||
const dispatcher = dispatcherRef.current;
|
||||
|
||||
const cycleFavoriteModel = (delta: number): boolean | void => {
|
||||
const { favoriteModels, addRecentModel } = useUIStore.getState();
|
||||
if (favoriteModels.length === 0) return false;
|
||||
|
||||
const {
|
||||
currentProviderId,
|
||||
currentModelId,
|
||||
setProvider,
|
||||
setModel,
|
||||
} = useConfigStore.getState();
|
||||
const currentIndex = favoriteModels.findIndex(
|
||||
(favorite) => favorite.providerID === currentProviderId && favorite.modelID === currentModelId,
|
||||
);
|
||||
const next = favoriteModels[(currentIndex + delta + favoriteModels.length) % favoriteModels.length];
|
||||
setProvider(next.providerID);
|
||||
setModel(next.modelID);
|
||||
addRecentModel(next.providerID, next.modelID);
|
||||
};
|
||||
|
||||
useKeybinds({
|
||||
focus_input: () => {
|
||||
focusChatInput();
|
||||
},
|
||||
new_mini_chat: () => {
|
||||
if (!canUseElectronDesktopIPC()) return false;
|
||||
void invokeDesktop('desktop_open_draft_mini_chat_window', {
|
||||
directory: '',
|
||||
projectId: null,
|
||||
})?.catch((error) => {
|
||||
console.warn('[mini-chat-shortcuts] failed to open draft mini chat window', error);
|
||||
});
|
||||
},
|
||||
new_chat: () => {
|
||||
const sessionState = useSessionUIStore.getState();
|
||||
openNewSessionDraft(sessionState.currentSessionId && sessionState.currentSessionDirectory
|
||||
? { directoryOverride: sessionState.currentSessionDirectory }
|
||||
: undefined);
|
||||
focusChatInput();
|
||||
},
|
||||
open_model_selector: () => {
|
||||
const { isModelSelectorOpen, setModelSelectorOpen } = useUIStore.getState();
|
||||
setModelSelectorOpen(!isModelSelectorOpen);
|
||||
},
|
||||
cycle_thinking_variant: () => {
|
||||
const configState = useConfigStore.getState();
|
||||
if (configState.getCurrentModelVariants().length === 0) return false;
|
||||
|
||||
const nextVariantOverride = configState.cycleCurrentVariant();
|
||||
const sessionId = useSessionUIStore.getState().currentSessionId;
|
||||
const {
|
||||
currentAgentName,
|
||||
currentProviderId,
|
||||
currentModelId,
|
||||
} = useConfigStore.getState();
|
||||
if (sessionId && currentAgentName && currentProviderId && currentModelId) {
|
||||
useSelectionStore.getState().saveAgentModelVariantForSession(
|
||||
sessionId,
|
||||
currentAgentName,
|
||||
currentProviderId,
|
||||
currentModelId,
|
||||
nextVariantOverride,
|
||||
);
|
||||
}
|
||||
},
|
||||
cycle_favorite_model_forward: () => cycleFavoriteModel(1),
|
||||
cycle_favorite_model_backward: () => cycleFavoriteModel(-1),
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
const combo = (actionId: string) => getEffectiveShortcutCombo(actionId, shortcutOverrides);
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (eventMatchesShortcut(event, combo('focus_input'))) {
|
||||
event.preventDefault();
|
||||
focusChatInput();
|
||||
const handleActivePrefixKeyDownCapture = (event: KeyboardEvent) => {
|
||||
if (!dispatcher.hasActivePrefix()) return;
|
||||
// An unmodified completion key typed into an editable target is only a
|
||||
// deliberate sequence when the prefix was armed from that same target;
|
||||
// otherwise it is regular typing and must not be swallowed.
|
||||
if (
|
||||
!event.ctrlKey && !event.metaKey && !event.altKey
|
||||
&& isEditableEventTarget(event.target)
|
||||
&& dispatcher.getActivePrefixTarget() !== event.target
|
||||
) {
|
||||
dispatcher.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
if (canUseElectronDesktopIPC() && eventMatchesShortcut(event, combo('new_mini_chat'))) {
|
||||
if (dispatcher.dispatchActivePrefix(event)) {
|
||||
event.preventDefault();
|
||||
void invokeDesktop('desktop_open_draft_mini_chat_window', {
|
||||
directory: currentDirectory || activeProject?.path || '',
|
||||
projectId: activeProject?.id ?? null,
|
||||
})?.catch((error) => {
|
||||
console.warn('[mini-chat-shortcuts] failed to open draft mini chat window', error);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventMatchesShortcut(event, combo('new_chat'))) {
|
||||
event.preventDefault();
|
||||
openNewSessionDraft({
|
||||
selectedProjectId: activeProject?.id ?? null,
|
||||
directoryOverride: currentDirectory || activeProject?.path || null,
|
||||
preserveDirectoryOverride: Boolean(currentDirectory || activeProject?.path),
|
||||
});
|
||||
focusChatInput();
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventMatchesShortcut(event, combo('open_model_selector'))) {
|
||||
event.preventDefault();
|
||||
const { isModelSelectorOpen, setModelSelectorOpen } = useUIStore.getState();
|
||||
setModelSelectorOpen(!isModelSelectorOpen);
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventMatchesShortcut(event, combo('cycle_thinking_variant'))) {
|
||||
const configState = useConfigStore.getState();
|
||||
const variants = configState.getCurrentModelVariants();
|
||||
if (variants.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
configState.cycleCurrentVariant();
|
||||
|
||||
const nextVariant = useConfigStore.getState().currentVariant;
|
||||
const sessionId = useSessionUIStore.getState().currentSessionId;
|
||||
const agentName = useConfigStore.getState().currentAgentName;
|
||||
const providerId = useConfigStore.getState().currentProviderId;
|
||||
const modelId = useConfigStore.getState().currentModelId;
|
||||
|
||||
if (sessionId && agentName && providerId && modelId) {
|
||||
useSelectionStore.getState().saveAgentModelVariantForSession(sessionId, agentName, providerId, modelId, nextVariant);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const cyclesForward = eventMatchesShortcut(event, combo('cycle_favorite_model_forward'));
|
||||
const cyclesBackward = eventMatchesShortcut(event, combo('cycle_favorite_model_backward'));
|
||||
if (cyclesForward || cyclesBackward) {
|
||||
const { favoriteModels, addRecentModel } = useUIStore.getState();
|
||||
if (favoriteModels.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
const { currentProviderId, currentModelId, setProvider, setModel } = useConfigStore.getState();
|
||||
const currentIndex = favoriteModels.findIndex((favorite) => favorite.providerID === currentProviderId && favorite.modelID === currentModelId);
|
||||
const delta = cyclesForward ? 1 : -1;
|
||||
const next = favoriteModels[(currentIndex + delta + favoriteModels.length) % favoriteModels.length];
|
||||
|
||||
setProvider(next.providerID);
|
||||
setModel(next.modelID);
|
||||
addRecentModel(next.providerID, next.modelID);
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (dispatcher.consumeCapturedPrefixEvent(event)) return;
|
||||
if (dispatcher.dispatch(event)) event.preventDefault();
|
||||
};
|
||||
const handleBlur = () => dispatcher.handleBlur();
|
||||
|
||||
window.addEventListener('keydown', handleActivePrefixKeyDownCapture, true);
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [activeProject?.id, activeProject?.path, currentDirectory, openNewSessionDraft, shortcutOverrides]);
|
||||
window.addEventListener('blur', handleBlur);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleActivePrefixKeyDownCapture, true);
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
window.removeEventListener('blur', handleBlur);
|
||||
};
|
||||
}, [dispatcher]);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { CHAT_DRAFT_PROJECT_ID } from '@/lib/chatDirectories';
|
||||
import { resolveProjectContextOwner } from './useProjectContextOwner';
|
||||
|
||||
const projects = [
|
||||
{ id: 'openchamber', path: '/workspace/openchamber', label: 'OpenChamber' },
|
||||
];
|
||||
|
||||
describe('resolveProjectContextOwner', () => {
|
||||
test('resolves a managed chat directory to the Chats root instead of the active project', () => {
|
||||
const owner = resolveProjectContextOwner({
|
||||
projects,
|
||||
worktreesByProject: new Map(),
|
||||
directory: '/Users/test/.config/openchamber/chats/2026-08-27/session-a',
|
||||
activeProjectId: 'openchamber',
|
||||
chatDraftOpen: false,
|
||||
chatDraftTarget: 'project',
|
||||
homeDirectory: '/Users/test',
|
||||
});
|
||||
|
||||
expect(owner).toEqual({
|
||||
id: CHAT_DRAFT_PROJECT_ID,
|
||||
path: '/Users/test/.config/openchamber/chats',
|
||||
});
|
||||
});
|
||||
|
||||
test('resolves a worktree session to its owning project', () => {
|
||||
const owner = resolveProjectContextOwner({
|
||||
projects,
|
||||
worktreesByProject: new Map([
|
||||
['/workspace/openchamber', [{
|
||||
path: '/workspace/openchamber-feature',
|
||||
projectDirectory: '/workspace/openchamber',
|
||||
branch: 'feature',
|
||||
label: 'feature',
|
||||
}]],
|
||||
]),
|
||||
directory: '/workspace/openchamber-feature',
|
||||
activeProjectId: null,
|
||||
chatDraftOpen: false,
|
||||
chatDraftTarget: 'project',
|
||||
homeDirectory: '/Users/test',
|
||||
});
|
||||
|
||||
expect(owner).toEqual({ id: 'openchamber', path: '/workspace/openchamber' });
|
||||
});
|
||||
|
||||
test('returns null for a recognized directory that owns nothing, instead of borrowing the active project', () => {
|
||||
const owner = resolveProjectContextOwner({
|
||||
projects,
|
||||
worktreesByProject: new Map(),
|
||||
directory: '/some/other/project',
|
||||
activeProjectId: 'openchamber',
|
||||
chatDraftOpen: false,
|
||||
chatDraftTarget: 'project',
|
||||
homeDirectory: '/Users/test',
|
||||
});
|
||||
|
||||
expect(owner).toBeNull();
|
||||
});
|
||||
|
||||
test('falls back to the active project only when there is no directory at all', () => {
|
||||
const owner = resolveProjectContextOwner({
|
||||
projects,
|
||||
worktreesByProject: new Map(),
|
||||
directory: null,
|
||||
activeProjectId: 'openchamber',
|
||||
chatDraftOpen: false,
|
||||
chatDraftTarget: 'project',
|
||||
homeDirectory: '/Users/test',
|
||||
});
|
||||
|
||||
expect(owner).toEqual({ id: 'openchamber', path: '/workspace/openchamber' });
|
||||
});
|
||||
|
||||
test('never falls back to the first project when the active project is unknown', () => {
|
||||
const owner = resolveProjectContextOwner({
|
||||
projects,
|
||||
worktreesByProject: new Map(),
|
||||
directory: null,
|
||||
activeProjectId: 'missing-project',
|
||||
chatDraftOpen: false,
|
||||
chatDraftTarget: 'project',
|
||||
homeDirectory: '/Users/test',
|
||||
});
|
||||
|
||||
expect(owner).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import React from 'react';
|
||||
|
||||
import { CHAT_DRAFT_PROJECT_ID, getChatsRootForHome, getChatsRootFromDirectory } from '@/lib/chatDirectories';
|
||||
import { normalizePath } from '@/lib/pathNormalization';
|
||||
import { resolveProjectForSessionDirectory } from '@/lib/projectResolution';
|
||||
import type { ProjectRef } from '@/lib/projectContextApi';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
import type { ProjectEntry } from '@/lib/api/types';
|
||||
|
||||
interface ProjectContextOwnerInput {
|
||||
projects: ProjectEntry[];
|
||||
worktreesByProject: Map<string, WorktreeMetadata[]>;
|
||||
directory: string | null;
|
||||
activeProjectId: string | null;
|
||||
chatDraftOpen: boolean;
|
||||
chatDraftTarget: 'chat' | 'project';
|
||||
homeDirectory: string | null;
|
||||
}
|
||||
|
||||
export const resolveProjectContextOwner = ({
|
||||
projects,
|
||||
worktreesByProject,
|
||||
directory,
|
||||
activeProjectId,
|
||||
chatDraftOpen,
|
||||
chatDraftTarget,
|
||||
homeDirectory,
|
||||
}: ProjectContextOwnerInput): ProjectRef | null => {
|
||||
const chatsRoot = getChatsRootFromDirectory(directory) ?? getChatsRootForHome(homeDirectory);
|
||||
const normalizedDirectory = normalizePath(directory);
|
||||
const normalizedChatsRoot = normalizePath(chatsRoot);
|
||||
const ownsChats = chatDraftOpen
|
||||
? chatDraftTarget === 'chat'
|
||||
: Boolean(normalizedDirectory && normalizedChatsRoot && (
|
||||
normalizedDirectory === normalizedChatsRoot || normalizedDirectory.startsWith(`${normalizedChatsRoot}/`)
|
||||
));
|
||||
|
||||
if (ownsChats && chatsRoot) {
|
||||
return { id: CHAT_DRAFT_PROJECT_ID, path: chatsRoot };
|
||||
}
|
||||
|
||||
const sessionProject = resolveProjectForSessionDirectory(projects, worktreesByProject, directory);
|
||||
if (sessionProject) {
|
||||
return { id: sessionProject.id, path: sessionProject.path };
|
||||
}
|
||||
|
||||
// A concrete directory that resolves to nothing owns nothing. Falling back
|
||||
// to the active project here showed one project's knowledge under another
|
||||
// project's name (the "plans open empty" bug), so the panel stays empty
|
||||
// instead of lying. The active-project fallback is only for states with no
|
||||
// directory at all, such as a new-session draft that has not landed yet.
|
||||
if (normalizedDirectory) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeProject = projects.find((project) => project.id === activeProjectId) ?? null;
|
||||
return activeProject ? { id: activeProject.id, path: activeProject.path } : null;
|
||||
};
|
||||
|
||||
/** The single owner used by Project knowledge and agent-memory synchronization. */
|
||||
export const useProjectContextOwner = (directory: string | null): ProjectRef | null => {
|
||||
const projects = useProjectsStore((state) => state.projects);
|
||||
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
|
||||
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
|
||||
const worktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
|
||||
const chatDraftOpen = useSessionUIStore((state) => state.newSessionDraft.open);
|
||||
const chatDraftTarget = useSessionUIStore((state) => state.newSessionDraft.target);
|
||||
|
||||
return React.useMemo(() => resolveProjectContextOwner({
|
||||
projects,
|
||||
worktreesByProject,
|
||||
directory,
|
||||
activeProjectId,
|
||||
chatDraftOpen,
|
||||
chatDraftTarget,
|
||||
homeDirectory,
|
||||
}), [
|
||||
activeProjectId,
|
||||
chatDraftOpen,
|
||||
chatDraftTarget,
|
||||
directory,
|
||||
homeDirectory,
|
||||
projects,
|
||||
worktreesByProject,
|
||||
]);
|
||||
};
|
||||
@@ -1,6 +1,8 @@
|
||||
import { beforeEach, describe, expect, mock, test } from 'bun:test';
|
||||
import type { Agent } from '@opencode-ai/sdk/v2';
|
||||
import type { Agent, Message } from '@opencode-ai/sdk/v2';
|
||||
import type { QueuedMessage } from '../stores/messageQueueStore';
|
||||
import { ChildStoreManager } from '@/sync/child-store';
|
||||
import { setSyncRefs } from '@/sync/sync-refs';
|
||||
|
||||
let visibleAgents: Agent[] = [];
|
||||
const sendMessageCalls: unknown[][] = [];
|
||||
@@ -32,6 +34,7 @@ import {
|
||||
createQueuedAutoSendRetryScheduler,
|
||||
getQueuedAutoSendRetryDelayMs,
|
||||
isQueuedAutoSendBackedOff,
|
||||
resolveQueuedSessionStatusType,
|
||||
sendQueuedAutoSendPayload,
|
||||
shouldDispatchQueuedAutoSend,
|
||||
} from './useQueuedMessageAutoSend';
|
||||
@@ -119,6 +122,59 @@ describe('queued auto-send retry backoff', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveQueuedSessionStatusType', () => {
|
||||
const DIRECTORY = '/repo';
|
||||
|
||||
const assistantMessage = (id: string, completed?: number): Message => ({
|
||||
id,
|
||||
role: 'assistant',
|
||||
sessionID: 'ses_1',
|
||||
time: { created: 1, ...(completed !== undefined ? { completed } : {}) },
|
||||
} as Message);
|
||||
|
||||
let childStores: ChildStoreManager;
|
||||
|
||||
beforeEach(() => {
|
||||
childStores = new ChildStoreManager();
|
||||
const store = childStores.ensureChild(DIRECTORY, { bootstrap: false });
|
||||
store.setState({ status: 'complete', session_status: {}, message: {} });
|
||||
setSyncRefs({} as never, childStores, DIRECTORY);
|
||||
});
|
||||
|
||||
test('treats a session with an in-flight assistant turn as busy even when the status entry is missing', () => {
|
||||
// The server status map only lists busy/retry sessions, so a missed busy
|
||||
// event leaves NO status entry while the turn is still streaming. The
|
||||
// queue gate must not read that absence as idle: queued prompts would be
|
||||
// dispatched into the running turn and merged into one model response.
|
||||
childStores.ensureChild(DIRECTORY, { bootstrap: false }).setState({
|
||||
message: { ses_1: [assistantMessage('msg_streaming')] },
|
||||
});
|
||||
|
||||
expect(resolveQueuedSessionStatusType('ses_1', DIRECTORY)).toBe('busy');
|
||||
});
|
||||
|
||||
test('resolves an explicit busy or retry status entry', () => {
|
||||
const store = childStores.ensureChild(DIRECTORY, { bootstrap: false });
|
||||
store.setState({ session_status: { ses_1: { type: 'busy' } } });
|
||||
expect(resolveQueuedSessionStatusType('ses_1', DIRECTORY)).toBe('busy');
|
||||
store.setState({ session_status: { ses_1: { type: 'retry', attempt: 2, message: 'boom', next: 30 } } });
|
||||
expect(resolveQueuedSessionStatusType('ses_1', DIRECTORY)).toBe('retry');
|
||||
});
|
||||
|
||||
test('resolves idle when the trailing assistant message has completed', () => {
|
||||
const store = childStores.ensureChild(DIRECTORY, { bootstrap: false });
|
||||
store.setState({ message: { ses_1: [assistantMessage('msg_done', 5)] } });
|
||||
expect(resolveQueuedSessionStatusType('ses_1', DIRECTORY)).toBe('idle');
|
||||
});
|
||||
|
||||
test('resolves an explicit idle entry and unknown sessions as idle', () => {
|
||||
const store = childStores.ensureChild(DIRECTORY, { bootstrap: false });
|
||||
store.setState({ session_status: { ses_1: { type: 'idle' } } });
|
||||
expect(resolveQueuedSessionStatusType('ses_1', DIRECTORY)).toBe('idle');
|
||||
expect(resolveQueuedSessionStatusType('ses_unknown', DIRECTORY)).toBe('idle');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildQueuedAutoSendPayload', () => {
|
||||
beforeEach(() => {
|
||||
visibleAgents = [];
|
||||
@@ -216,7 +272,11 @@ describe('buildQueuedAutoSendPayload', () => {
|
||||
]);
|
||||
|
||||
expect(payload).not.toBeNull();
|
||||
await sendQueuedAutoSendPayload('session-original', '/repo', payload!, {
|
||||
await sendQueuedAutoSendPayload({
|
||||
runtimeKey: 'runtime-original',
|
||||
sessionId: 'session-original',
|
||||
directory: '/repo',
|
||||
}, payload!, {
|
||||
providerID: 'provider-1',
|
||||
modelID: 'model-1',
|
||||
agent: 'agent-1',
|
||||
@@ -234,7 +294,13 @@ describe('buildQueuedAutoSendPayload', () => {
|
||||
undefined,
|
||||
'variant-1',
|
||||
'normal',
|
||||
{ sessionId: 'session-original', directory: '/repo' },
|
||||
{
|
||||
target: {
|
||||
runtimeKey: 'runtime-original',
|
||||
sessionId: 'session-original',
|
||||
directory: '/repo',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -103,8 +103,7 @@ type ResolvedQueuedSendConfig = {
|
||||
};
|
||||
|
||||
export const sendQueuedAutoSendPayload = (
|
||||
sessionId: string,
|
||||
directory: string,
|
||||
target: MessageQueueTarget,
|
||||
payload: QueuedAutoSendPayload,
|
||||
resolved: ResolvedQueuedSendConfig,
|
||||
) => {
|
||||
@@ -118,7 +117,7 @@ export const sendQueuedAutoSendPayload = (
|
||||
undefined,
|
||||
resolved.variant,
|
||||
'normal',
|
||||
{ sessionId, directory },
|
||||
{ target },
|
||||
);
|
||||
};
|
||||
|
||||
@@ -173,11 +172,50 @@ export const shouldDispatchQueuedAutoSend = (
|
||||
&& currentStatusType === 'idle';
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the live status the queue gate should honor for a session.
|
||||
*
|
||||
* The server's `/session/status` map only lists busy/retry sessions — idle
|
||||
* sessions are absent — so a missing entry means "idle per the snapshot", not
|
||||
* "no information". A missed busy event therefore leaves no entry while a turn
|
||||
* is still streaming. The trailing in-flight assistant message is the live
|
||||
* evidence of that running turn: treat it as busy so the queue never dispatches
|
||||
* into it (mirrors `useSessionActivity`'s fallback). The entry becomes idle the
|
||||
* moment the message completes or an idle status event lands. This reads the
|
||||
* directory child store directly so both the effect-loop gate and the
|
||||
* dispatch-time re-check agree.
|
||||
*/
|
||||
export const resolveQueuedSessionStatusType = (
|
||||
sessionId: string,
|
||||
directory: string,
|
||||
): SessionStatusType => {
|
||||
const state = getDirectoryState(directory);
|
||||
const statusType = state?.session_status?.[sessionId]?.type;
|
||||
if (statusType === 'busy' || statusType === 'retry') {
|
||||
return statusType;
|
||||
}
|
||||
const sessionMessages = state?.message?.[sessionId];
|
||||
const lastMessage = sessionMessages && sessionMessages.length > 0
|
||||
? sessionMessages[sessionMessages.length - 1]
|
||||
: undefined;
|
||||
if (
|
||||
lastMessage?.role === 'assistant'
|
||||
&& typeof (lastMessage as { time?: { completed?: number } }).time?.completed !== 'number'
|
||||
) {
|
||||
return 'busy';
|
||||
}
|
||||
return 'idle';
|
||||
};
|
||||
|
||||
export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?: boolean }) {
|
||||
const enabled = typeof enabledOrOptions === 'boolean' ? enabledOrOptions : (enabledOrOptions?.enabled ?? true);
|
||||
const queuedMessages = useMessageQueueStore((state) => state.queuedMessages);
|
||||
const autoReviewRuns = useAutoReviewStore((state) => state.runsByOriginalSessionID);
|
||||
const sessionStatusRecord = useDirectorySync((state) => state.session_status);
|
||||
// Message completion clears the in-flight fallback in
|
||||
// resolveQueuedSessionStatusType; subscribe so the queue drains the moment
|
||||
// the trailing assistant message completes even if status events were missed.
|
||||
const sessionMessages = useDirectorySync((state) => state.message);
|
||||
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
||||
|
||||
const inFlightSessionsRef = React.useRef<Set<string>>(new Set());
|
||||
@@ -216,7 +254,7 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?
|
||||
return;
|
||||
}
|
||||
|
||||
const currentStatus = getDirectoryState(target.directory)?.session_status?.[sessionId]?.type ?? 'idle';
|
||||
const currentStatus = resolveQueuedSessionStatusType(sessionId, target.directory);
|
||||
if (currentStatus !== 'idle') {
|
||||
return;
|
||||
}
|
||||
@@ -256,7 +294,7 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?
|
||||
useMessageQueueStore.getState().markSending(target, payload.queuedMessageId);
|
||||
|
||||
try {
|
||||
await sendQueuedAutoSendPayload(sessionId, target.directory, payload, {
|
||||
await sendQueuedAutoSendPayload(target, payload, {
|
||||
providerID: resolved.providerID,
|
||||
modelID: resolved.modelID,
|
||||
agent: resolved.agent,
|
||||
@@ -294,7 +332,7 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?
|
||||
const target = parseMessageQueueKey(key);
|
||||
if (!target || target.runtimeKey !== getRuntimeKey() || target.directory !== currentDirectory) return;
|
||||
const { sessionId } = target;
|
||||
const currentStatusType = (statusRecord[sessionId]?.type ?? 'idle') as SessionStatusType;
|
||||
const currentStatusType = resolveQueuedSessionStatusType(sessionId, target.directory);
|
||||
const previousStatusType = previousStatusRef.current.get(sessionId);
|
||||
const wasAutoReviewBlocked = autoReviewBlockedSessionsRef.current.has(sessionId);
|
||||
const isAutoReviewRunning = useAutoReviewStore.getState().isRunningForSession(sessionId);
|
||||
@@ -315,5 +353,5 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?
|
||||
});
|
||||
|
||||
previousStatusRef.current = nextStatusMap;
|
||||
}, [enabled, queuedMessages, sessionStatusRecord, autoReviewRuns, currentDirectory, retryTick, retryScheduler]);
|
||||
}, [enabled, queuedMessages, sessionStatusRecord, sessionMessages, autoReviewRuns, currentDirectory, retryTick, retryScheduler]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { isRootScrollTarget, resetRootScroll } from './useRootScrollLock';
|
||||
|
||||
type FakeElement = EventTarget & { id: string; scrollTop: number; scrollLeft: number };
|
||||
|
||||
const element = (id: string): FakeElement => Object.assign(new EventTarget(), { id, scrollTop: 0, scrollLeft: 0 });
|
||||
|
||||
/** Installs a minimal stand-in for `document` for the duration of `run`. */
|
||||
const withDocument = (setup: { root?: FakeElement }, run: () => void) => {
|
||||
const fakeDocument = {
|
||||
documentElement: element('html'),
|
||||
body: element('body'),
|
||||
getElementById: (id: string) => (setup.root && setup.root.id === id ? setup.root : null),
|
||||
};
|
||||
// The hook only reads documentElement/body/getElementById from `document`;
|
||||
// this stand-in provides exactly those members for a DOM-less test process.
|
||||
const hadDocument = 'document' in globalThis;
|
||||
const previous = hadDocument ? globalThis.document : undefined;
|
||||
Reflect.set(globalThis, 'document', fakeDocument);
|
||||
try {
|
||||
run();
|
||||
} finally {
|
||||
if (hadDocument) Reflect.set(globalThis, 'document', previous);
|
||||
else Reflect.deleteProperty(globalThis, 'document');
|
||||
}
|
||||
};
|
||||
|
||||
describe('resetRootScroll', () => {
|
||||
test('snaps every root scroll offset back to zero and reports the reset', () => {
|
||||
const root = element('root');
|
||||
withDocument({ root }, () => {
|
||||
document.documentElement.scrollTop = 48;
|
||||
document.body.scrollLeft = 12;
|
||||
root.scrollTop = 200;
|
||||
expect(resetRootScroll()).toBe(true);
|
||||
expect(document.documentElement.scrollTop).toBe(0);
|
||||
expect(document.body.scrollLeft).toBe(0);
|
||||
expect(root.scrollTop).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
test('reports nothing to do when the root is already at zero', () => {
|
||||
withDocument({}, () => {
|
||||
expect(resetRootScroll()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isRootScrollTarget', () => {
|
||||
test('recognises the document, html and body as root scroll sources', () => {
|
||||
withDocument({}, () => {
|
||||
expect(isRootScrollTarget(document)).toBe(true);
|
||||
expect(isRootScrollTarget(document.documentElement)).toBe(true);
|
||||
expect(isRootScrollTarget(document.body)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test('ignores scroll events from inner containers', () => {
|
||||
withDocument({}, () => {
|
||||
expect(isRootScrollTarget(element('chat-timeline'))).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* The document root (`html`, `body`, `#root`) is `overflow: hidden` and must
|
||||
* never scroll — every scrollable area lives in a dedicated container. Chromium
|
||||
* still scrolls hidden-overflow ancestors programmatically, most visibly when
|
||||
* a textarea caret moves out of view (PageUp/PageDown in the prompt box, or a
|
||||
* long prompt being typed) and the browser scrolls it into view. Once that
|
||||
* happens the whole app shifts up, hides the title bar, and nothing the user
|
||||
* does with the wheel or keyboard can scroll it back.
|
||||
*
|
||||
* Snap every root scroll straight back to zero.
|
||||
*/
|
||||
|
||||
const rootScrollTargets = (): HTMLElement[] => {
|
||||
const targets = [document.documentElement, document.body];
|
||||
const appRoot = document.getElementById('root');
|
||||
if (appRoot) targets.push(appRoot);
|
||||
return targets;
|
||||
};
|
||||
|
||||
export const resetRootScroll = (): boolean => {
|
||||
let reset = false;
|
||||
for (const target of rootScrollTargets()) {
|
||||
if (target.scrollTop !== 0) {
|
||||
target.scrollTop = 0;
|
||||
reset = true;
|
||||
}
|
||||
if (target.scrollLeft !== 0) {
|
||||
target.scrollLeft = 0;
|
||||
reset = true;
|
||||
}
|
||||
}
|
||||
return reset;
|
||||
};
|
||||
|
||||
export const isRootScrollTarget = (target: EventTarget | null): boolean =>
|
||||
target === document || rootScrollTargets().some((element) => element === target);
|
||||
|
||||
export const useRootScrollLock = (): void => {
|
||||
React.useEffect(() => {
|
||||
const handleScroll = (event: Event) => {
|
||||
if (isRootScrollTarget(event.target)) resetRootScroll();
|
||||
};
|
||||
// Capture: the root's own scroll events don't bubble to inner listeners,
|
||||
// and scroll events from inner containers are filtered out above.
|
||||
document.addEventListener('scroll', handleScroll, { capture: true, passive: true });
|
||||
resetRootScroll();
|
||||
return () => document.removeEventListener('scroll', handleScroll, { capture: true });
|
||||
}, []);
|
||||
};
|
||||
@@ -1,11 +1,11 @@
|
||||
import React from 'react';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useUIStore, type ContextPanelMode } from '@/stores/useUIStore';
|
||||
import { parseRoute, updateBrowserURL, hasRouteParams } from '@/lib/router';
|
||||
import type { RouteState, AppRouteState } from '@/lib/router';
|
||||
import type { MainTab } from '@/stores/useUIStore';
|
||||
import { resolveSettingsSlug } from '@/lib/settings/metadata';
|
||||
import { isEmbeddedSessionChat } from '@/components/layout/contextPanelEmbeddedChat';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
|
||||
/**
|
||||
* Check if running in VS Code webview context.
|
||||
@@ -49,7 +49,6 @@ export function useRouter(): void {
|
||||
|
||||
// Get store actions (stable references)
|
||||
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
|
||||
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
|
||||
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
|
||||
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
|
||||
const navigateToDiff = useUIStore((state) => state.navigateToDiff);
|
||||
@@ -75,11 +74,11 @@ export function useRouter(): void {
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Handle settings (takes precedence over tabs - it's a full-screen overlay)
|
||||
// 2. Handle settings first because it is a full-screen overlay.
|
||||
if (route.settingsPath) {
|
||||
setSettingsPage(resolveSettingsSlug(route.settingsPath));
|
||||
setSettingsDialogOpen(true);
|
||||
// Don't process tab when settings is open
|
||||
// Do not process a route view while settings is open.
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -88,9 +87,16 @@ export function useRouter(): void {
|
||||
setSettingsDialogOpen(false);
|
||||
}
|
||||
|
||||
// 3. Apply tab
|
||||
if (route.tab) {
|
||||
setActiveMainTab(route.tab);
|
||||
// 3. Apply the view selected by the legacy URL parameter. Desktop
|
||||
// surfaces live in the context panel, so a non-chat tab deep link
|
||||
// opens the matching panel surface; activeSurface itself stays 'chat'
|
||||
// (nothing renders non-chat surfaces in the main area).
|
||||
if (route.tab && route.tab !== 'chat') {
|
||||
const directory = useDirectoryStore.getState().currentDirectory;
|
||||
if (directory) {
|
||||
const mode: ContextPanelMode = route.tab === 'files' ? 'file' : route.tab;
|
||||
useUIStore.getState().openContextSurface(directory, mode);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Apply diff file (only if going to diff tab)
|
||||
@@ -101,7 +107,7 @@ export function useRouter(): void {
|
||||
isApplyingRouteRef.current = false;
|
||||
}
|
||||
},
|
||||
[setCurrentSession, setActiveMainTab, setSettingsDialogOpen, setSettingsPage, navigateToDiff]
|
||||
[setCurrentSession, setSettingsDialogOpen, setSettingsPage, navigateToDiff]
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -113,10 +119,8 @@ export function useRouter(): void {
|
||||
|
||||
return {
|
||||
sessionId: sessionState.currentSessionId,
|
||||
tab: uiState.activeMainTab,
|
||||
isSettingsOpen: uiState.isSettingsDialogOpen,
|
||||
settingsPath: uiState.settingsPage,
|
||||
diffFile: uiState.pendingDiffFile,
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -162,9 +166,7 @@ export function useRouter(): void {
|
||||
updateBrowserURL({
|
||||
...getCurrentAppState(),
|
||||
sessionId: route.sessionId ?? useSessionUIStore.getState().currentSessionId,
|
||||
tab: route.tab ?? useUIStore.getState().activeMainTab,
|
||||
settingsPath: route.settingsPath ?? useUIStore.getState().settingsPage,
|
||||
diffFile: route.diffFile ?? useUIStore.getState().pendingDiffFile,
|
||||
}, { replace: true, force: true });
|
||||
}
|
||||
};
|
||||
@@ -195,16 +197,14 @@ export function useRouter(): void {
|
||||
return unsubscribe;
|
||||
}, [isVSCode, isEmbeddedChat, syncURLFromState]);
|
||||
|
||||
// Subscribe to UI store changes (tab, settings)
|
||||
// Subscribe to UI store changes (view, settings)
|
||||
React.useEffect(() => {
|
||||
if (isVSCode || isEmbeddedChat) {
|
||||
return;
|
||||
}
|
||||
|
||||
let prevTab: MainTab = useUIStore.getState().activeMainTab;
|
||||
let prevSettingsOpen: boolean = useUIStore.getState().isSettingsDialogOpen;
|
||||
let prevSettingsPath: string = useUIStore.getState().settingsPage;
|
||||
let prevDiffFile: string | null = useUIStore.getState().pendingDiffFile;
|
||||
|
||||
const unsubscribe = useUIStore.subscribe((state) => {
|
||||
// Skip if we're currently applying a route
|
||||
@@ -212,19 +212,13 @@ export function useRouter(): void {
|
||||
return;
|
||||
}
|
||||
|
||||
const tabChanged = state.activeMainTab !== prevTab;
|
||||
const settingsOpenChanged = state.isSettingsDialogOpen !== prevSettingsOpen;
|
||||
const settingsPathChanged = state.settingsPage !== prevSettingsPath;
|
||||
const diffFileChanged = state.pendingDiffFile !== prevDiffFile && state.activeMainTab === 'diff';
|
||||
|
||||
// Update tracking vars
|
||||
prevTab = state.activeMainTab;
|
||||
prevSettingsOpen = state.isSettingsDialogOpen;
|
||||
prevSettingsPath = state.settingsPage;
|
||||
prevDiffFile = state.pendingDiffFile;
|
||||
|
||||
// Only sync if something relevant changed
|
||||
if (tabChanged || settingsOpenChanged || settingsPathChanged || diffFileChanged) {
|
||||
if (settingsOpenChanged || settingsPathChanged) {
|
||||
syncURLFromState();
|
||||
}
|
||||
});
|
||||
@@ -252,10 +246,6 @@ export function useRouter(): void {
|
||||
if (uiState.isSettingsDialogOpen) {
|
||||
setSettingsDialogOpen(false);
|
||||
}
|
||||
// Reset to chat tab if not already there
|
||||
if (uiState.activeMainTab !== 'chat') {
|
||||
setActiveMainTab('chat');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -264,5 +254,5 @@ export function useRouter(): void {
|
||||
return () => {
|
||||
window.removeEventListener('popstate', handlePopState);
|
||||
};
|
||||
}, [applyRoute, isVSCode, isEmbeddedChat, setActiveMainTab, setSettingsDialogOpen]);
|
||||
}, [applyRoute, isVSCode, isEmbeddedChat, setSettingsDialogOpen]);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ const IDLE_RESULT: SessionActivityResult = {
|
||||
* question indicator takes priority, and the send button must stay available so
|
||||
* the user can supersede the prompt with a new message).
|
||||
*/
|
||||
function useSessionActivity(sessionId: string | null | undefined, directory?: string): SessionActivityResult {
|
||||
export function useSessionActivity(sessionId: string | null | undefined, directory?: string): SessionActivityResult {
|
||||
const status = useSessionStatus(sessionId ?? '', directory);
|
||||
const messages = useSessionMessages(sessionId ?? '', directory);
|
||||
const permissions = useSessionPermissions(sessionId ?? '', directory);
|
||||
|
||||
@@ -5,7 +5,7 @@ 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.
|
||||
export const RECAP_VISIBILITY_DELAY_MS = 60 * 1000;
|
||||
const RECAP_VISIBILITY_DELAY_MS = 60 * 1000;
|
||||
|
||||
interface LastMessageSnapshot {
|
||||
id: string;
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { ProjectEntry } from '@/lib/api/types';
|
||||
import { resolveSettingsDirectory } from './useSettingsDirectory';
|
||||
|
||||
const project = (id: string, path: string): ProjectEntry => ({ id, path } as ProjectEntry);
|
||||
|
||||
const projects = [
|
||||
project('a', '/workspace/alpha'),
|
||||
project('b', '/workspace/beta'),
|
||||
];
|
||||
|
||||
describe('resolveSettingsDirectory', () => {
|
||||
test('follows the active project until Settings picks one', () => {
|
||||
expect(resolveSettingsDirectory(null, projects, 'b')).toBe('/workspace/beta');
|
||||
});
|
||||
|
||||
test('keeps the Settings pick even when the app is on another project', () => {
|
||||
// The whole point: browsing another project's configuration must not depend
|
||||
// on moving the app to it.
|
||||
expect(resolveSettingsDirectory('/workspace/alpha', projects, 'b')).toBe('/workspace/alpha');
|
||||
});
|
||||
|
||||
test('falls back to the active project when the picked one is gone', () => {
|
||||
expect(resolveSettingsDirectory('/workspace/removed', projects, 'b')).toBe('/workspace/beta');
|
||||
});
|
||||
|
||||
test('resolves to nothing when there are no projects', () => {
|
||||
expect(resolveSettingsDirectory('/workspace/alpha', [], null)).toBe(null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { ProjectEntry } from '@/lib/api/types';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
|
||||
/**
|
||||
* Resolves which project the Settings pages describe.
|
||||
*
|
||||
* `settingsProjectPath` is the user's pick in the Settings project selector.
|
||||
* Until they make one — or when the project it names is gone — Settings follows
|
||||
* the app's active project, so nothing looks different before it is used.
|
||||
*/
|
||||
export const resolveSettingsDirectory = (
|
||||
settingsProjectPath: string | null,
|
||||
projects: ProjectEntry[],
|
||||
activeProjectId: string | null,
|
||||
): string | null => {
|
||||
if (settingsProjectPath && projects.some((project) => project.path === settingsProjectPath)) {
|
||||
return settingsProjectPath;
|
||||
}
|
||||
|
||||
const activeProject = projects.find((project) => project.id === activeProjectId) ?? projects[0];
|
||||
return activeProject?.path ?? null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Directory the Settings pages read and write configuration for.
|
||||
*
|
||||
* Settings has its own project selector. Picking a project there used to call
|
||||
* `setActiveProject`, which moves the whole app — chat, sessions, files, git —
|
||||
* so reading another project's MCP servers silently relocated the user.
|
||||
*/
|
||||
export const useSettingsDirectory = (): string | null => {
|
||||
const settingsProjectPath = useUIStore((state) => state.settingsProjectPath);
|
||||
const projects = useProjectsStore((state) => state.projects);
|
||||
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
|
||||
|
||||
return resolveSettingsDirectory(settingsProjectPath, projects, activeProjectId);
|
||||
};
|
||||
@@ -3,7 +3,7 @@ import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { canUseElectronDesktopIPC, invokeDesktop, isDesktopLocalOriginActive } from '@/lib/desktop';
|
||||
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
|
||||
import { desktopHostsGet, getDesktopHostApiUrl, locationMatchesHost, redactSensitiveUrl } from '@/lib/desktopHosts';
|
||||
import { getSyncChildStores, getAllSyncSessions } from '@/sync/sync-refs';
|
||||
import { getSyncChildStores } from '@/sync/sync-refs';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { useGlobalSessionStatusStore, applyGlobalSessionStatusSnapshot } from '@/sync/global-session-status';
|
||||
import { compareSessionsByLifecycleOrder, useSessionOrderingStore } from '@/sync/session-ordering';
|
||||
@@ -12,8 +12,6 @@ import { useSessionPinnedStore } from '@/stores/useSessionPinnedStore';
|
||||
import { respondToPermission } from '@/sync/session-actions';
|
||||
import {
|
||||
useGlobalSessionsStore,
|
||||
ensureGlobalSessionsLoaded,
|
||||
refreshGlobalSessions,
|
||||
resolveGlobalSessionDirectory,
|
||||
} from '@/stores/useGlobalSessionsStore';
|
||||
import { useQuotaStore } from '@/stores/useQuotaStore';
|
||||
@@ -40,10 +38,6 @@ const TRAY_ACTION_EVENT = 'openchamber:tray-action';
|
||||
// Event-driven updates do the real work; this is just a slow safety net.
|
||||
const POLL_INTERVAL_MS = 5000;
|
||||
const FLUSH_DEBOUNCE_MS = 500;
|
||||
// Pull the full cross-project session list periodically. SSE keeps the active
|
||||
// directory instant; this catches sessions created in directories this client
|
||||
// never opened (other worktrees, other projects, the TUI, …).
|
||||
const GLOBAL_REFRESH_MS = 45000;
|
||||
const MAX_SESSIONS = 20;
|
||||
|
||||
type TraySessionStatus = 'idle' | 'busy' | 'retry';
|
||||
@@ -535,12 +529,6 @@ export const useTraySync = (): void => {
|
||||
const unsubscribeSessionOrder = useSessionOrderingStore.subscribe(() => scheduleFlush());
|
||||
const unsubscribePinnedSessions = useSessionPinnedStore.subscribe(() => scheduleFlush());
|
||||
|
||||
// Make the tray self-sufficient: load the full cross-project list now
|
||||
// (independent of the sidebar) and refresh it periodically so sessions from
|
||||
// directories this client never opened still show up and stay current.
|
||||
void ensureGlobalSessionsLoaded(getAllSyncSessions());
|
||||
const refreshInterval = window.setInterval(() => { void refreshGlobalSessions(); }, GLOBAL_REFRESH_MS);
|
||||
|
||||
// Global busy/retry status: fetch now and poll, so unsynced sessions don't
|
||||
// sit looking idle. Synced directories stay instant via their SSE stores.
|
||||
void refreshGlobalStatus();
|
||||
@@ -554,14 +542,8 @@ export const useTraySync = (): void => {
|
||||
const { dropdownProviderIds, results } = useQuotaStore.getState();
|
||||
const needsFetch = dropdownProviderIds.length > 0
|
||||
&& dropdownProviderIds.some((id) => !results.some((r) => r.providerId === id));
|
||||
if (needsFetch) void useQuotaStore.getState().fetchAllQuotas();
|
||||
if (needsFetch) void useQuotaStore.getState().fetchQuotas(dropdownProviderIds);
|
||||
});
|
||||
// Keep the Usage submenu current per the user's auto-refresh setting
|
||||
// (desktop-only; checked each tick so toggling it mid-session applies).
|
||||
const usageRefreshTick = window.setInterval(() => {
|
||||
const quota = useQuotaStore.getState();
|
||||
if (quota.autoRefresh && quota.dropdownProviderIds.length > 0) void quota.fetchAllQuotas();
|
||||
}, Math.max(30000, useQuotaStore.getState().refreshIntervalMs || 60000));
|
||||
|
||||
// Safety net: catches anything the event subscriptions miss (e.g. a store
|
||||
// that existed before the registry subscription was attached).
|
||||
@@ -573,9 +555,7 @@ export const useTraySync = (): void => {
|
||||
disposed = true;
|
||||
if (flushTimer !== null) window.clearTimeout(flushTimer);
|
||||
window.clearInterval(interval);
|
||||
window.clearInterval(refreshInterval);
|
||||
window.clearInterval(globalStatusInterval);
|
||||
window.clearInterval(usageRefreshTick);
|
||||
unsubscribeNotif();
|
||||
unsubscribeGlobal();
|
||||
unsubscribeProjects();
|
||||
|
||||
@@ -7,9 +7,7 @@ import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
|
||||
|
||||
const APP_TITLE = 'OpenChamber';
|
||||
|
||||
const formatProjectLabel = (label: string): string => {
|
||||
return label.replace(/[-_]/g, ' ').replace(/\b\w/g, (char) => char.toUpperCase());
|
||||
};
|
||||
const formatProjectLabel = (label: string): string => label.trim();
|
||||
|
||||
const getProjectNameFromPath = (path: string): string => {
|
||||
const normalized = path.replace(/\\/g, '/').replace(/\/+$/, '');
|
||||
|
||||
Reference in New Issue
Block a user