From 37054eeb8f31aeabda3fa6b933b57d6d5993ef34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Thu, 3 Sep 2026 19:54:16 +0000 Subject: [PATCH] fix(web): restore chat scroll on env-changing task switch (#3342) --- .../task/chat/clamped-scroll-restore.test.ts | 9 + .../task/chat/clamped-scroll-restore.ts | 15 +- .../task/chat/message-list-native-scroll.ts | 275 ++++++++++++------ .../task/chat/message-list-native.test.tsx | 123 ++++++++ .../task/chat/message-list-native.tsx | 11 +- .../task/chat/message-list-shared.tsx | 2 + .../task/chat/transcript-auto-scroll.test.ts | 20 ++ .../task/chat/transcript-auto-scroll.ts | 14 +- .../task/chat/use-chat-panel-state.ts | 2 + apps/web/components/task/task-chat-panel.tsx | 2 + .../e2e/tests/chat/auto-scroll-toggle.spec.ts | 94 ++++++ .../session/use-message-fetch-state.ts | 32 ++ .../session/use-session-message-fetch.test.ts | 16 + .../session/use-session-message-fetch.ts | 4 +- .../session/use-session-messages.test.ts | 32 ++ .../domains/session/use-session-messages.ts | 177 ++++++----- .../state/dockview-env-switch-action.test.ts | 38 +++ .../state/dockview-scroll-preserve.test.ts | 2 + apps/web/lib/state/dockview-store.ts | 27 +- .../transcript-env-switch-scroll/plan.md | 177 +++++++++++ ...concile-env-switch-transcript-placement.md | 156 ++++++++++ .../ui/requirements/transcript-auto-scroll.md | 40 ++- .../system-design/transcript-auto-scroll.md | 55 +++- 23 files changed, 1132 insertions(+), 191 deletions(-) create mode 100644 apps/web/hooks/domains/session/use-message-fetch-state.ts create mode 100644 docs/plans/transcript-env-switch-scroll/plan.md create mode 100644 docs/plans/transcript-env-switch-scroll/task-01-reconcile-env-switch-transcript-placement.md diff --git a/apps/web/components/task/chat/clamped-scroll-restore.test.ts b/apps/web/components/task/chat/clamped-scroll-restore.test.ts index 29021dae0b..400235ed2b 100644 --- a/apps/web/components/task/chat/clamped-scroll-restore.test.ts +++ b/apps/web/components/task/chat/clamped-scroll-restore.test.ts @@ -32,11 +32,13 @@ describe("scheduleClampedScrollRestore", () => { let maxScrollTop = 100; const element = clampedElement(() => maxScrollTop); const onApply = vi.fn(); + const onComplete = vi.fn(); scheduleClampedScrollRestore({ element, targetScrollTop: 500, onApply, + onComplete, requestFrame: frames.requestFrame, }); frames.runNext(); @@ -47,6 +49,7 @@ describe("scheduleClampedScrollRestore", () => { expect(element.scrollTop).toBe(500); expect(onApply).toHaveBeenCalledTimes(2); + expect(onComplete).toHaveBeenCalledTimes(1); expect(frames.pending()).toBe(0); }); @@ -55,16 +58,19 @@ describe("scheduleClampedScrollRestore", () => { const element = clampedElement(() => 0); element.isConnected = false; const onApply = vi.fn(); + const onComplete = vi.fn(); scheduleClampedScrollRestore({ element, targetScrollTop: 500, onApply, + onComplete, requestFrame: frames.requestFrame, }); frames.runNext(); expect(onApply).not.toHaveBeenCalled(); + expect(onComplete).toHaveBeenCalledTimes(1); expect(frames.pending()).toBe(0); }); @@ -72,17 +78,20 @@ describe("scheduleClampedScrollRestore", () => { const frames = frameQueue(); const element = clampedElement(() => 100); const onApply = vi.fn(); + const onComplete = vi.fn(); scheduleClampedScrollRestore({ element, targetScrollTop: 500, onApply, + onComplete, maxFrames: 3, requestFrame: frames.requestFrame, }); while (frames.pending()) frames.runNext(); expect(onApply).toHaveBeenCalledTimes(3); + expect(onComplete).toHaveBeenCalledTimes(1); expect(element.scrollTop).toBe(100); }); }); diff --git a/apps/web/components/task/chat/clamped-scroll-restore.ts b/apps/web/components/task/chat/clamped-scroll-restore.ts index 39a0e77280..482d8c18ff 100644 --- a/apps/web/components/task/chat/clamped-scroll-restore.ts +++ b/apps/web/components/task/chat/clamped-scroll-restore.ts @@ -7,6 +7,7 @@ type ScheduleClampedScrollRestoreOptions = { element: ScrollTarget; targetScrollTop: number; onApply: () => void; + onComplete?: () => void; maxFrames?: number; requestFrame?: (callback: FrameRequestCallback) => number; }; @@ -21,17 +22,29 @@ export function scheduleClampedScrollRestore({ element, targetScrollTop, onApply, + onComplete, maxFrames = 20, requestFrame = requestAnimationFrame, }: ScheduleClampedScrollRestoreOptions): void { let framesRemaining = maxFrames; + let completed = false; + const complete = () => { + if (completed) return; + completed = true; + onComplete?.(); + }; const reapply = () => { - if (!element.isConnected || framesRemaining <= 0) return; + if (!element.isConnected || framesRemaining <= 0) { + complete(); + return; + } framesRemaining -= 1; element.scrollTop = targetScrollTop; onApply(); if (element.scrollTop < targetScrollTop - 1) { requestFrame(reapply); + } else { + complete(); } }; requestFrame(reapply); diff --git a/apps/web/components/task/chat/message-list-native-scroll.ts b/apps/web/components/task/chat/message-list-native-scroll.ts index 16ae367695..db30adbb9f 100644 --- a/apps/web/components/task/chat/message-list-native-scroll.ts +++ b/apps/web/components/task/chat/message-list-native-scroll.ts @@ -577,6 +577,7 @@ export function useAutoScroll(params: { hasUnreadDivider: boolean; isProgrammaticScrollLocked: () => boolean; isVisible?: boolean; + initialPlacementPending?: boolean; }) { const { scrollRef, @@ -587,6 +588,7 @@ export function useAutoScroll(params: { hasUnreadDivider, isProgrammaticScrollLocked, isVisible = true, + initialPlacementPending = false, } = params; const storeApi = useAppStoreApi(); const isNearBottomRef = useRef(true); @@ -621,6 +623,7 @@ export function useAutoScroll(params: { isVisible, isVisibleRef, messages, + initialPlacementPending, }); useAutoScrollOnContent({ @@ -633,6 +636,7 @@ export function useAutoScroll(params: { isVisibleRef, isProgrammaticScrollLocked, prevIsWorkingRef, + initialPlacementPending, }); useCatchUpOnReEnable(scrollRef, messages, enabled, isNearBottomRef); @@ -659,6 +663,7 @@ function usePersistedTranscriptScroll({ isVisible, isVisibleRef, messages, + initialPlacementPending, }: { scrollRef: React.RefObject; sessionId: string | null; @@ -669,8 +674,14 @@ function usePersistedTranscriptScroll({ isVisible: boolean; isVisibleRef: React.RefObject; messages: Message[]; + initialPlacementPending: boolean; }) { const frozenScrollTopRef = useRef(null); + const latestSessionIdRef = useRef(sessionId); + if (latestSessionIdRef.current !== sessionId) { + latestSessionIdRef.current = sessionId; + frozenScrollTopRef.current = null; + } useEffect(() => { const el = scrollRef.current; if (!el) return; @@ -702,9 +713,9 @@ function usePersistedTranscriptScroll({ // survives a dockview panel teardown/remount (e.g. navigating away // and back), even if no scroll event fired right before it, and even // if a coalesced write above was still pending. - coalescer.flush(); + if (!initialPlacementPending || latestSessionIdRef.current !== sessionId) coalescer.flush(); }; - }, [scrollRef, sessionId, storeApi, resyncIsNearBottom, enabled]); + }, [scrollRef, sessionId, storeApi, resyncIsNearBottom, enabled, initialPlacementPending]); // Own the disabled offset across every transcript layout update. Sending a // prompt can briefly shrink the scroll range before the new message row is @@ -713,6 +724,7 @@ function usePersistedTranscriptScroll({ // each message/working-state render so that transient clamp cannot move // the reader. Real user scroll events update the owned offset above. useLayoutEffect(() => { + if (initialPlacementPending) return; const el = scrollRef.current; if (!el || !isVisibleRef.current) return; const wasEnabled = frozenScrollTopRef.current === null || enabled; @@ -725,7 +737,7 @@ function usePersistedTranscriptScroll({ return; } el.scrollTop = frozenScrollTopRef.current; - }, [enabled, isWorking, isVisible, messages, scrollRef]); + }, [enabled, isWorking, isVisible, messages, scrollRef, initialPlacementPending]); } function useAutoScrollOnContent({ @@ -738,6 +750,7 @@ function useAutoScrollOnContent({ isVisibleRef, isProgrammaticScrollLocked, prevIsWorkingRef, + initialPlacementPending, }: { scrollRef: React.RefObject; messages: Message[]; @@ -748,6 +761,7 @@ function useAutoScrollOnContent({ isVisibleRef: React.RefObject; isProgrammaticScrollLocked: () => boolean; prevIsWorkingRef: React.MutableRefObject; + initialPlacementPending: boolean; }) { // When isWorking transitions to true, force scroll to bottom (unless // disabled, locked, or a layout rebuild scroll restore is pending). @@ -756,6 +770,7 @@ function useAutoScrollOnContent({ isWorking && !prevIsWorkingRef.current && isVisibleRef.current && + !initialPlacementPending && shouldAutoScrollToBottom({ isNearBottom: !hasUnreadDivider, isProgrammaticScrollLocked: isProgrammaticScrollLocked(), @@ -770,13 +785,20 @@ function useAutoScrollOnContent({ } } prevIsWorkingRef.current = isWorking; - }, [hasUnreadDivider, isWorking, scrollRef, enabled, isProgrammaticScrollLocked]); + }, [ + hasUnreadDivider, + isWorking, + scrollRef, + enabled, + isProgrammaticScrollLocked, + initialPlacementPending, + ]); // Auto-scroll on new messages if near bottom (unless disabled, locked, or a // layout rebuild scroll restore is pending). useLayoutEffect(() => { const el = scrollRef.current; - if (!el || !isVisibleRef.current) return; + if (!el || !isVisibleRef.current || initialPlacementPending) return; if ( shouldAutoScrollToBottom({ isNearBottom: isNearBottomRef.current, @@ -787,7 +809,7 @@ function useAutoScrollOnContent({ ) { scrollNativeToBottom(el); } - }, [messages, scrollRef, enabled, isProgrammaticScrollLocked]); + }, [messages, scrollRef, enabled, isProgrammaticScrollLocked, initialPlacementPending]); } function useCatchUpOnVisible({ @@ -1058,26 +1080,94 @@ export function useScrollToMessage( ); } -/** - * Applies the initial scrollTop once items are available: bottom when - * enabled, or the last captured offset for this session when disabled (see - * `resolveNativeInitialScrollTop`). Skips while a dockview layout-rebuild - * restore is pending — that separate mechanism owns the position instead. - * - * When restoring a concrete saved offset (auto-scroll disabled), the first - * write can be clamped short: on a dockview remount the container's content is - * still laying out, so `scrollHeight - clientHeight` momentarily sits below - * the target and the browser caps the write. The content grows to full height - * a frame or two later, so the offset is re-applied across a bounded run of - * animation frames until it lands (or the budget runs out). That re-apply loop - * is deliberately NOT torn down on unmount: dockview detaches (rather than - * destroys) the panel's DOM as it finalizes the layout, so the component that - * seeded the restore unmounts while its scroll element stays on screen — the - * loop keeps a direct reference to that element and self-terminates once the - * offset lands, reaches its frame budget, or the element leaves the document. - * The enabled "scroll to bottom" path needs no retry and settles in a single - * write. - */ +type InitialScrollApplyParams = { + element: HTMLDivElement; + sessionId: string | null; + enabled: boolean; + storeApi: ReturnType; + didInitialScroll: React.RefObject; + activationPendingRef: React.RefObject; + isVisibleRef: React.RefObject; + isNearBottomRef: React.RefObject; + envSwitchPlacementToken: number | null; + isProgrammaticScrollLocked: () => boolean; +}; + +function markInitialScrollConsumed( + didInitialScroll: React.RefObject, + activationPendingRef: React.RefObject, +): void { + didInitialScroll.current = true; + activationPendingRef.current = false; +} + +function completeEnvSwitchPlacement(envSwitchPlacementToken: number | null): void { + if (envSwitchPlacementToken !== null) { + useDockviewStore.getState().completePendingChatInitialPlacement(envSwitchPlacementToken); + } +} + +function applyInitialScrollPosition(params: InitialScrollApplyParams): void { + const { + element, + sessionId, + enabled, + storeApi, + didInitialScroll, + activationPendingRef, + isVisibleRef, + isNearBottomRef, + envSwitchPlacementToken, + isProgrammaticScrollLocked, + } = params; + if (!isVisibleRef.current) return; + const dockviewState = useDockviewStore.getState(); + const hasPendingLayoutRestore = dockviewState.pendingChatScrollTop !== null; + const hasExplicitScrollTarget = + sessionId !== null && dockviewState.scrollTarget?.sessionId === sessionId; + if (hasPendingLayoutRestore || hasExplicitScrollTarget || isProgrammaticScrollLocked()) { + markInitialScrollConsumed(didInitialScroll, activationPendingRef); + completeEnvSwitchPlacement(envSwitchPlacementToken); + return; + } + const savedScrollTop = sessionId + ? (storeApi.getState().transcriptAutoScroll.scrollTopBySessionId[sessionId] ?? + getStoredAutoScrollTop(sessionId) ?? + undefined) + : undefined; + const scrollTop = resolveNativeInitialScrollTop({ + enabled, + hasPendingLayoutRestore, + savedScrollTop, + scrollHeight: element.scrollHeight, + }); + markInitialScrollConsumed(didInitialScroll, activationPendingRef); + if (scrollTop === null) { + completeEnvSwitchPlacement(envSwitchPlacementToken); + return; + } + + const syncNearBottom = () => { + isNearBottomRef.current = !hasTranscriptProgressedPastView({ + scrollTop, + scrollHeight: element.scrollHeight, + clientHeight: element.clientHeight, + }); + }; + element.scrollTop = scrollTop; + syncNearBottom(); + if (enabled || scrollTop <= 0 || element.scrollTop >= scrollTop - 1) { + completeEnvSwitchPlacement(envSwitchPlacementToken); + return; + } + scheduleClampedScrollRestore({ + element, + targetScrollTop: scrollTop, + onApply: syncNearBottom, + onComplete: () => completeEnvSwitchPlacement(envSwitchPlacementToken), + }); +} + function useInitialScrollPosition({ scrollRef, itemCount, @@ -1085,6 +1175,9 @@ function useInitialScrollPosition({ enabled, isNearBottomRef, isVisible, + historyRefreshPending, + envSwitchPlacementToken, + isRestoringLayout, isProgrammaticScrollLocked, }: { scrollRef: React.RefObject; @@ -1093,69 +1186,52 @@ function useInitialScrollPosition({ enabled: boolean; isNearBottomRef: React.RefObject; isVisible: boolean; + historyRefreshPending: boolean; + envSwitchPlacementToken: number | null; + isRestoringLayout: boolean; isProgrammaticScrollLocked: () => boolean; }) { const storeApi = useAppStoreApi(); const didInitialScroll = useRef(false); - const { isVisibleRef, activationPendingRef } = useActivationPending(isVisible); + const lastEnvSwitchPlacementTokenRef = useRef(null); + if ( + envSwitchPlacementToken !== null && + envSwitchPlacementToken !== lastEnvSwitchPlacementTokenRef.current + ) { + lastEnvSwitchPlacementTokenRef.current = envSwitchPlacementToken; + didInitialScroll.current = false; + } + const { isVisibleRef, activationPendingRef } = useActivationPending( + isVisible, + envSwitchPlacementToken, + ); useEffect(() => { if (!isVisible) { return; } + if (envSwitchPlacementToken !== null && (isRestoringLayout || historyRefreshPending)) return; + if (envSwitchPlacementToken !== null && itemCount === 0) { + markInitialScrollConsumed(didInitialScroll, activationPendingRef); + completeEnvSwitchPlacement(envSwitchPlacementToken); + return; + } if (didInitialScroll.current || itemCount === 0) return; const el = scrollRef.current; if (!el) return; - const applyInitialScroll = () => { - if (!isVisibleRef.current) return; - const dockviewState = useDockviewStore.getState(); - const hasPendingLayoutRestore = dockviewState.pendingChatScrollTop !== null; - const hasExplicitScrollTarget = - sessionId !== null && dockviewState.scrollTarget?.sessionId === sessionId; - if (hasPendingLayoutRestore || hasExplicitScrollTarget || isProgrammaticScrollLocked()) { - didInitialScroll.current = true; - activationPendingRef.current = false; - return; - } - const savedScrollTop = sessionId - ? (storeApi.getState().transcriptAutoScroll.scrollTopBySessionId[sessionId] ?? - getStoredAutoScrollTop(sessionId) ?? - undefined) - : undefined; - const scrollTop = resolveNativeInitialScrollTop({ - enabled, - hasPendingLayoutRestore, - savedScrollTop, - scrollHeight: el.scrollHeight, - }); - didInitialScroll.current = true; - activationPendingRef.current = false; - if (scrollTop === null) return; - - /** Derives and stores the near-bottom flag from the applied scrollTop - * against the container's current dimensions. */ - const syncNearBottom = () => { - isNearBottomRef.current = !hasTranscriptProgressedPastView({ - scrollTop, - scrollHeight: el.scrollHeight, - clientHeight: el.clientHeight, - }); - }; - - el.scrollTop = scrollTop; - syncNearBottom(); - - // Only a concrete disabled-restore offset can be clamped by a not-yet- - // settled layout; the enabled bottom target lands in one write, and a - // write that already reached its target needs no follow-up. - if (enabled || scrollTop <= 0 || el.scrollTop >= scrollTop - 1) return; - - scheduleClampedScrollRestore({ + const applyInitialScroll = () => + applyInitialScrollPosition({ element: el, - targetScrollTop: scrollTop, - onApply: syncNearBottom, + sessionId, + enabled, + storeApi, + didInitialScroll, + activationPendingRef, + isVisibleRef, + isNearBottomRef, + envSwitchPlacementToken, + isProgrammaticScrollLocked, }); - }; if (activationPendingRef.current) { return scheduleAfterPanelRestore(applyInitialScroll); @@ -1168,11 +1244,31 @@ function useInitialScrollPosition({ isNearBottomRef, storeApi, isVisible, + historyRefreshPending, + envSwitchPlacementToken, + isRestoringLayout, isProgrammaticScrollLocked, scrollRef, ]); } +type NativeScrollManagementParams = { + scrollRef: React.RefObject; + items: RenderItem[]; + messages: Message[]; + isWorking: boolean; + sessionId: string | null; + enabled: boolean; + hasUnreadDivider: boolean; + /** Initial/refetch loading: the sentinel's hard block (never fires, never joins). */ + messagesLoading: boolean; + historyRefreshPending?: boolean; + hasMore: boolean; + isLoadingMore: boolean; + loadMore: () => Promise; + isVisible: boolean; +}; + /** * Composes every native-renderer scroll behavior — auto-follow-bottom (honoring * the session's auto-scroll toggle, with position persistence and re-enable @@ -1182,21 +1278,7 @@ function useInitialScrollPosition({ * position — behind one call so `NativeMessageList` only wires a scroll * container, item list, and the auto-scroll preference through it. */ -export function useNativeScrollManagement(params: { - scrollRef: React.RefObject; - items: RenderItem[]; - messages: Message[]; - isWorking: boolean; - sessionId: string | null; - enabled: boolean; - hasUnreadDivider: boolean; - /** Initial/refetch loading: the sentinel's hard block (never fires, never joins). */ - messagesLoading: boolean; - hasMore: boolean; - isLoadingMore: boolean; - loadMore: () => Promise; - isVisible: boolean; -}) { +export function useNativeScrollManagement(params: NativeScrollManagementParams) { const { scrollRef, items, @@ -1206,11 +1288,18 @@ export function useNativeScrollManagement(params: { enabled, hasUnreadDivider, messagesLoading, + historyRefreshPending = false, hasMore, isLoadingMore, loadMore, isVisible, } = params; + const pendingChatInitialPlacement = useDockviewStore( + (state) => state.pendingChatInitialPlacement, + ); + const isRestoringLayout = useDockviewStore((state) => state.isRestoringLayout); + const envSwitchPlacementToken = + pendingChatInitialPlacement?.sessionId === sessionId ? pendingChatInitialPlacement.token : null; const programmaticScrollLockRef = useRef(false); const isProgrammaticScrollLocked = useCallback(() => programmaticScrollLockRef.current, []); const { isNearBottomRef, resyncIsNearBottom, markNotNearBottom } = useAutoScroll({ @@ -1222,6 +1311,7 @@ export function useNativeScrollManagement(params: { hasUnreadDivider, isProgrammaticScrollLocked, isVisible, + initialPlacementPending: envSwitchPlacementToken !== null, }); const runGuardedScroll = useProgrammaticScrollGuard( scrollRef, @@ -1250,7 +1340,7 @@ export function useNativeScrollManagement(params: { items, sessionId, hasMore, - blocked: messagesLoading, + blocked: messagesLoading || envSwitchPlacementToken !== null, isLoadingMore, loadMore: loadMoreWithPrependBaseline, }); @@ -1263,6 +1353,9 @@ export function useNativeScrollManagement(params: { enabled, isNearBottomRef, isVisible, + historyRefreshPending, + envSwitchPlacementToken, + isRestoringLayout, isProgrammaticScrollLocked, }); diff --git a/apps/web/components/task/chat/message-list-native.test.tsx b/apps/web/components/task/chat/message-list-native.test.tsx index 5087b89b43..a16f74956f 100644 --- a/apps/web/components/task/chat/message-list-native.test.tsx +++ b/apps/web/components/task/chat/message-list-native.test.tsx @@ -19,10 +19,20 @@ const mockDockviewState = vi.hoisted( () => ({ pendingChatScrollTop: null, + pendingChatInitialPlacement: null, + isRestoringLayout: false, scrollTarget: null, + completePendingChatInitialPlacement: vi.fn((token: number) => { + if (mockDockviewState.pendingChatInitialPlacement?.token === token) { + mockDockviewState.pendingChatInitialPlacement = null; + } + }), }) as { pendingChatScrollTop: number | null; + pendingChatInitialPlacement: { sessionId: string; token: number } | null; + isRestoringLayout: boolean; scrollTarget: { sessionId: string } | null; + completePendingChatInitialPlacement: ReturnType; }, ); @@ -210,7 +220,10 @@ function setScrollMetrics(element: HTMLElement) { afterEach(() => { cleanup(); mockDockviewState.pendingChatScrollTop = null; + mockDockviewState.pendingChatInitialPlacement = null; + mockDockviewState.isRestoringLayout = false; mockDockviewState.scrollTarget = null; + mockDockviewState.completePendingChatInitialPlacement.mockClear(); sharedSentinelCalls.length = 0; sharedSentinelUserGesture.mockReset(); sharedSentinelRetry.mockReset(); @@ -264,6 +277,7 @@ function NativeScrollManagementHarness({ recoveryRef, isVisible = true, enabled = false, + historyRefreshPending = false, }: { items: RenderItem[]; metrics?: NativeScrollMetrics; @@ -273,6 +287,7 @@ function NativeScrollManagementHarness({ recoveryRef?: { current: boolean }; isVisible?: boolean; enabled?: boolean; + historyRefreshPending?: boolean; }) { const scrollRef = useRef(null); useNativeScrollMetrics(scrollRef, metrics); @@ -289,6 +304,7 @@ function NativeScrollManagementHarness({ isLoadingMore, loadMore, isVisible, + historyRefreshPending, }); if (recoveryRef) recoveryRef.current = showRecovery; return ( @@ -337,6 +353,113 @@ describe("isElementInPreloadRegion", () => { // eslint-disable-next-line max-lines-per-function -- pagination invariants share one fixture and lifecycle. describe("useNativeScrollManagement transcript pagination", () => { + // @covers AC-UI-TRANSCRIPT-AUTO-SCROLL-001.11 + it("places an env-switched enabled transcript after layout and history settle", () => { + const frames: Array = []; + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + frames.push(callback); + return frames.length; + }); + const metrics = { scrollHeight: 600, scrollTop: 275, clientHeight: 400 }; + mockDockviewState.pendingChatInitialPlacement = { sessionId: "session-b", token: 7 }; + mockDockviewState.isRestoringLayout = true; + try { + const { rerender } = render( + , + ); + const scrollContainer = screen.getByTestId(NATIVE_SCROLL_MANAGEMENT_TEST_ID); + + expect(scrollContainer.scrollTop).toBe(275); + expect(sharedSentinelCalls.at(-1)?.[2]).toBe(true); + + mockDockviewState.isRestoringLayout = false; + metrics.scrollHeight = 1400; + rerender( + , + ); + act(() => { + for (let frame = frames.shift(); frame; frame = frames.shift()) frame(0); + }); + + expect(scrollContainer.scrollTop).toBe(1400); + expect(mockDockviewState.pendingChatInitialPlacement).toBeNull(); + } finally { + vi.unstubAllGlobals(); + } + }); + + // @covers AC-UI-TRANSCRIPT-AUTO-SCROLL-001.12 + it("restores only the incoming session offset after an env switch", () => { + const frames: Array = []; + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + frames.push(callback); + return frames.length; + }); + transcriptScrollTopBySessionId.set("session-a", 810); + transcriptScrollTopBySessionId.set("session-b", 320); + const metrics = { scrollHeight: 1400, scrollTop: 810, clientHeight: 400 }; + mockDockviewState.pendingChatInitialPlacement = { sessionId: "session-b", token: 8 }; + mockDockviewState.isRestoringLayout = true; + try { + const { rerender } = render( + , + ); + const scrollContainer = screen.getByTestId(NATIVE_SCROLL_MANAGEMENT_TEST_ID); + expect(scrollContainer.scrollTop).toBe(810); + + mockDockviewState.isRestoringLayout = false; + rerender( + , + ); + act(() => { + for (let frame = frames.shift(); frame; frame = frames.shift()) frame(0); + }); + + expect(scrollContainer.scrollTop).toBe(320); + expect(scrollContainer.scrollTop).not.toBe(810); + } finally { + vi.unstubAllGlobals(); + } + }); + + // @covers AC-UI-TRANSCRIPT-AUTO-SCROLL-001.13 + it("does not defer a same-env session placement without an env-switch token", () => { + const metrics = { scrollHeight: 900, scrollTop: 125, clientHeight: 400 }; + + render( + , + ); + + expect(screen.getByTestId(NATIVE_SCROLL_MANAGEMENT_TEST_ID).scrollTop).toBe(900); + expect(sharedSentinelCalls.at(-1)?.[2]).toBe(false); + }); + it("defers the native initial placement until a hidden transcript is active", () => { const frames: Array = []; vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { diff --git a/apps/web/components/task/chat/message-list-native.tsx b/apps/web/components/task/chat/message-list-native.tsx index 09f87376f2..f23432404b 100644 --- a/apps/web/components/task/chat/message-list-native.tsx +++ b/apps/web/components/task/chat/message-list-native.tsx @@ -87,6 +87,7 @@ type NativeMessageListScrollParams = { anchoredBarHeight?: number; /** Initial/refetch loading: the sentinel's hard block. */ messagesLoading: boolean; + historyRefreshPending: boolean; hasMore: boolean; isLoadingMore: boolean; loadMore: () => Promise; @@ -106,6 +107,7 @@ type ScrollToDividerOptions = { sessionId?: string | null; isProgrammaticScrollLocked?: () => boolean; isVisible?: boolean; + historyRefreshPending?: boolean; }; function useNativeMessageListScroll(params: NativeMessageListScrollParams) { @@ -120,6 +122,7 @@ function useNativeMessageListScroll(params: NativeMessageListScrollParams) { dividerBeforeItemKey, anchoredBarHeight, messagesLoading, + historyRefreshPending, hasMore, isLoadingMore, loadMore, @@ -146,6 +149,7 @@ function useNativeMessageListScroll(params: NativeMessageListScrollParams) { enabled, hasUnreadDivider: Boolean(dividerBeforeItemKey), messagesLoading, + historyRefreshPending, hasMore, isLoadingMore, loadMore, @@ -162,6 +166,7 @@ function useNativeMessageListScroll(params: NativeMessageListScrollParams) { sessionId, isProgrammaticScrollLocked, isVisible, + historyRefreshPending, }); useImperativeHandle(ref, () => ({ scrollToMessage: handleScrollToMessage }), [ handleScrollToMessage, @@ -318,6 +323,7 @@ export function useScrollToDividerOrBottom( sessionId = null, isProgrammaticScrollLocked = () => false, isVisible = true, + historyRefreshPending = false, } = options; const { isVisibleRef, activationPendingRef } = useActivationPending(isVisible); const isUserScrollingRef = useDividerUserScrolling(scrollRef); @@ -347,7 +353,7 @@ export function useScrollToDividerOrBottom( settlingDeadlineRef.current = Date.now() + DIVIDER_SETTLING_WINDOW_MS; } const el = scrollRef.current; - if (!el || itemCount === 0) return; + if (!el || itemCount === 0 || historyRefreshPending) return; const placeInitialPosition = () => { if (!isVisibleRef.current) return; @@ -415,6 +421,7 @@ export function useScrollToDividerOrBottom( sessionId, isProgrammaticScrollLocked, isVisible, + historyRefreshPending, scrollRef, ]); } @@ -538,6 +545,7 @@ export const NativeMessageList = memo( taskId, sessionId, messagesLoading, + historyRefreshPending = false, isWorking, sessionState, worktreePath, @@ -581,6 +589,7 @@ export const NativeMessageList = memo( dividerBeforeItemKey, anchoredBarHeight, messagesLoading, + historyRefreshPending, hasMore, isLoadingMore, loadMore, diff --git a/apps/web/components/task/chat/message-list-shared.tsx b/apps/web/components/task/chat/message-list-shared.tsx index f2aeb833d1..05a1c06a8c 100644 --- a/apps/web/components/task/chat/message-list-shared.tsx +++ b/apps/web/components/task/chat/message-list-shared.tsx @@ -30,6 +30,8 @@ export type MessageListProps = { taskId?: string; sessionId: string | null; messagesLoading: boolean; + /** Latest-session history is still reconciling while cached rows remain visible. */ + historyRefreshPending?: boolean; isWorking: boolean; sessionState?: TaskSessionState; worktreePath?: string; diff --git a/apps/web/components/task/chat/transcript-auto-scroll.test.ts b/apps/web/components/task/chat/transcript-auto-scroll.test.ts index 9498b947c8..ce48d77ff6 100644 --- a/apps/web/components/task/chat/transcript-auto-scroll.test.ts +++ b/apps/web/components/task/chat/transcript-auto-scroll.test.ts @@ -461,4 +461,24 @@ describe("useActivationPending", () => { rerender({ isVisible: true }); expect(result.current.activationPendingRef.current).toBe(true); }); + + it("treats a new external token as activation while the panel stays visible", () => { + const { result, rerender } = renderHook( + ({ token }: { token: number | null }) => useActivationPending(true, token), + { initialProps: { token: null as number | null } }, + ); + + expect(result.current.activationPendingRef.current).toBe(false); + + rerender({ token: 1 }); + + expect(result.current.activationPendingRef.current).toBe(true); + + result.current.activationPendingRef.current = false; + rerender({ token: 1 }); + expect(result.current.activationPendingRef.current).toBe(false); + + rerender({ token: 2 }); + expect(result.current.activationPendingRef.current).toBe(true); + }); }); diff --git a/apps/web/components/task/chat/transcript-auto-scroll.ts b/apps/web/components/task/chat/transcript-auto-scroll.ts index 1c1db53f53..e4f207658e 100644 --- a/apps/web/components/task/chat/transcript-auto-scroll.ts +++ b/apps/web/components/task/chat/transcript-auto-scroll.ts @@ -163,19 +163,27 @@ export function createFrameCoalescer(run: () => void): { * completes its activation work. This keeps every activation owner aligned on * the same visibility and pending-transition contract. */ -export function useActivationPending(isVisible: boolean) { +export function useActivationPending( + isVisible: boolean, + externalActivationToken: number | null = null, +) { const isVisibleRef = useRef(isVisible); const previousVisibleRef = useRef(isVisible); + const previousExternalTokenRef = useRef(null); const activationPendingRef = useRef(false); isVisibleRef.current = isVisible; useEffect(() => { const becameVisible = !previousVisibleRef.current && isVisible; + const externallyActivated = + externalActivationToken !== null && + externalActivationToken !== previousExternalTokenRef.current; previousVisibleRef.current = isVisible; + previousExternalTokenRef.current = externalActivationToken; activationPendingRef.current = isVisible - ? becameVisible || activationPendingRef.current + ? becameVisible || externallyActivated || activationPendingRef.current : false; - }, [isVisible]); + }, [externalActivationToken, isVisible]); return { isVisibleRef, activationPendingRef }; } diff --git a/apps/web/components/task/chat/use-chat-panel-state.ts b/apps/web/components/task/chat/use-chat-panel-state.ts index 2e42092e10..a572769e07 100644 --- a/apps/web/components/task/chat/use-chat-panel-state.ts +++ b/apps/web/components/task/chat/use-chat-panel-state.ts @@ -443,6 +443,7 @@ function useSessionData( messages, isLoading: messagesLoading, isInitialMessagesLoading, + historyRefreshPending, historyInitialized, hasMore: hasOlderMessages, } = useSessionMessages(resolvedSessionId); @@ -479,6 +480,7 @@ function useSessionData( messages, messagesLoading, isInitialMessagesLoading, + historyRefreshPending, ...processed, sessionModel, activeModel, diff --git a/apps/web/components/task/task-chat-panel.tsx b/apps/web/components/task/task-chat-panel.tsx index 339a967c96..67c78ee9b6 100644 --- a/apps/web/components/task/task-chat-panel.tsx +++ b/apps/web/components/task/task-chat-panel.tsx @@ -497,6 +497,7 @@ export const TaskChatPanel = memo(function TaskChatPanel({ taskId, isWorking, messagesLoading, + historyRefreshPending, isInitialMessagesLoading, groupedItems, allMessages, @@ -655,6 +656,7 @@ export const TaskChatPanel = memo(function TaskChatPanel({ taskId={taskId ?? undefined} sessionId={resolvedSessionId} messagesLoading={messagesLoading} + historyRefreshPending={historyRefreshPending} isWorking={isWorking} sessionState={session?.state} worktreePath={getSessionWorkspacePath(session)} diff --git a/apps/web/e2e/tests/chat/auto-scroll-toggle.spec.ts b/apps/web/e2e/tests/chat/auto-scroll-toggle.spec.ts index 1ce61114e9..91e8607c67 100644 --- a/apps/web/e2e/tests/chat/auto-scroll-toggle.spec.ts +++ b/apps/web/e2e/tests/chat/auto-scroll-toggle.spec.ts @@ -146,6 +146,100 @@ async function waitForOverflow(testPage: Page) { } test.describe("Transcript auto-scroll toggle", () => { + test("environment-changing task switch restores the incoming transcript", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(240_000); + + const taskA = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Env switch transcript A", + seedData.agentProfileId, + { + description: overflowScript("Environment A history"), + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + const taskB = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Env switch transcript B", + seedData.agentProfileId, + { + description: overflowScript("Environment B history"), + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + if (!taskA.session_id || !taskB.session_id) { + throw new Error("createTaskWithAgent did not return both session ids"); + } + await waitForSessionDone(apiClient, taskA.id, taskA.session_id, "task A should finish"); + await waitForSessionDone(apiClient, taskB.id, taskB.session_id, "task B should finish"); + const [sessionsA, sessionsB] = await Promise.all([ + apiClient.listTaskSessions(taskA.id), + apiClient.listTaskSessions(taskB.id), + ]); + const envA = sessionsA.sessions.find( + (session) => session.id === taskA.session_id, + )?.task_environment_id; + const envB = sessionsB.sessions.find( + (session) => session.id === taskB.session_id, + )?.task_environment_id; + expect(envA).toBeTruthy(); + expect(envB).toBeTruthy(); + expect(envA).not.toBe(envB); + + await testPage.goto(`/t/${taskA.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await waitForStableActiveSession(testPage, taskA.session_id); + await waitForOverflow(testPage); + + await session.sidebarTaskItem("Env switch transcript B").click(); + await waitForStableActiveSession(testPage, taskB.session_id); + await session.showSessionContext(); + await waitForOverflow(testPage); + await session.sidebarTaskItem("Env switch transcript A").click(); + await waitForStableActiveSession(testPage, taskA.session_id); + await session.showSessionContext(); + const list = chatList(testPage); + await waitForOverflow(testPage); + await expect + .poll(async () => list.evaluate((el) => el.scrollHeight - el.scrollTop - el.clientHeight), { + timeout: 15_000, + message: "enabled transcript should settle at the bottom after an environment switch", + }) + .toBeLessThan(10); + + const targetScrollTop = await list.evaluate((el) => { + el.scrollTop = Math.floor((el.scrollHeight - el.clientHeight) / 2); + el.dispatchEvent(new Event("scroll")); + return el.scrollTop; + }); + expect(targetScrollTop).toBeGreaterThan(100); + const toggle = session.chatStatusBar().getByTestId("auto-scroll-toggle-button"); + await toggle.click(); + await expect(toggle).toHaveAttribute("aria-pressed", "false"); + + await session.sidebarTaskItem("Env switch transcript B").click(); + await waitForStableActiveSession(testPage, taskB.session_id); + await session.sidebarTaskItem("Env switch transcript A").click(); + await waitForStableActiveSession(testPage, taskA.session_id); + await session.showSessionContext(); + await expect + .poll(async () => Math.abs((await list.evaluate((el) => el.scrollTop)) - targetScrollTop), { + timeout: 15_000, + message: "disabled transcript should restore its own offset after an environment switch", + }) + .toBeLessThanOrEqual(20); + await expect(toggle).toHaveAttribute("aria-pressed", "false"); + }); + test("inactive session transcript reopens at the bottom after refresh and hidden updates", async ({ testPage, apiClient, diff --git a/apps/web/hooks/domains/session/use-message-fetch-state.ts b/apps/web/hooks/domains/session/use-message-fetch-state.ts new file mode 100644 index 0000000000..1a56785ddb --- /dev/null +++ b/apps/web/hooks/domains/session/use-message-fetch-state.ts @@ -0,0 +1,32 @@ +import { useMemo, useRef, useState } from "react"; +import type { useAppStoreApi } from "@/components/state-provider"; + +export function useMessageFetchState(store: ReturnType) { + const [isLoading, setIsLoading] = useState(false); + const [isWaitingForInitialMessages, setIsWaitingForInitialMessages] = useState(false); + const [isCachedHistoryRefreshPending, setIsCachedHistoryRefreshPending] = useState(false); + const cachedRefreshGenerationRef = useRef(0); + const initialFetchStartRef = useRef(null); + const lastFetchedSessionIdRef = useRef(null); + const refs = useMemo( + () => ({ + store, + setIsLoading, + setIsWaitingForInitialMessages, + initialFetchStartRef, + lastFetchedSessionIdRef, + }), + [store], + ); + return { + isLoading, + isWaitingForInitialMessages, + isCachedHistoryRefreshPending, + setIsCachedHistoryRefreshPending, + setIsWaitingForInitialMessages, + initialFetchStartRef, + lastFetchedSessionIdRef, + cachedRefreshGenerationRef, + refs, + }; +} diff --git a/apps/web/hooks/domains/session/use-session-message-fetch.test.ts b/apps/web/hooks/domains/session/use-session-message-fetch.test.ts index 5c5be23dcf..d983d38585 100644 --- a/apps/web/hooks/domains/session/use-session-message-fetch.test.ts +++ b/apps/web/hooks/domains/session/use-session-message-fetch.test.ts @@ -79,4 +79,20 @@ describe("doFetchMessages", () => { await secondFetch; expect(setMessagesLoading).toHaveBeenLastCalledWith(SESSION_ID, false); }); + + it("clears hook gates when an active fetch becomes stale before settling", async () => { + const result = deferred(); + const setMessagesLoading = vi.fn(); + const params = makeParams(vi.fn().mockReturnValue(result.promise), setMessagesLoading); + const isActive = { value: true }; + const fetch = doFetchMessages({ ...params, isActive: () => isActive.value } as never); + + isActive.value = false; + result.resolve([]); + await fetch; + + expect(params.setIsLoading).toHaveBeenLastCalledWith(false); + expect(params.setIsWaitingForInitialMessages).toHaveBeenLastCalledWith(false); + expect(setMessagesLoading).toHaveBeenLastCalledWith(SESSION_ID, false); + }); }); diff --git a/apps/web/hooks/domains/session/use-session-message-fetch.ts b/apps/web/hooks/domains/session/use-session-message-fetch.ts index 174b936c4a..668c47c146 100644 --- a/apps/web/hooks/domains/session/use-session-message-fetch.ts +++ b/apps/web/hooks/domains/session/use-session-message-fetch.ts @@ -78,8 +78,8 @@ export async function doFetchMessages({ } finally { if (endSessionFetch(taskSessionId)) { store.getState().setMessagesLoading(taskSessionId, false); + setIsLoading(false); } - if (isInactive(isActive)) return; - setIsLoading(false); + setIsWaitingForInitialMessages(false); } } diff --git a/apps/web/hooks/domains/session/use-session-messages.test.ts b/apps/web/hooks/domains/session/use-session-messages.test.ts index 2b17d20899..08f4a1937b 100644 --- a/apps/web/hooks/domains/session/use-session-messages.test.ts +++ b/apps/web/hooks/domains/session/use-session-messages.test.ts @@ -295,6 +295,38 @@ function deferred() { return { promise, resolve }; } +describe("cached session entry history readiness", () => { + // @covers AC-UI-TRANSCRIPT-AUTO-SCROLL-001.11 + it("keeps cached-entry history placement pending until its background refresh settles", async () => { + const readiness = deferred(); + const response = deferred<{ messages: Message[]; has_more: boolean }>(); + mockWebSocketClient.getSessionSubscriptionReadiness.mockReturnValue(readiness.promise); + mockWebSocketClient.subscribeSessionWithReady.mockReturnValue({ + ready: readiness.promise, + unsubscribe: vi.fn(), + }); + mockWebSocketClient.request.mockReturnValue(response.promise); + mockState.messages.bySession["sess-1"] = [makeMessage({ id: "cached" })]; + + const { result, unmount } = renderHook(() => useSessionMessages("sess-1")); + + expect(result.current.historyRefreshPending).toBe(true); + + await act(async () => { + readiness.resolve(); + await readiness.promise; + }); + expect(result.current.historyRefreshPending).toBe(true); + + await act(async () => { + response.resolve({ messages: [], has_more: false }); + await response.promise; + }); + expect(result.current.historyRefreshPending).toBe(false); + unmount(); + }); +}); + describe("session subscription hydration ordering", () => { // @covers AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.10 it("replaces a disjoint stale cache while preserving rows received during the fetch", async () => { diff --git a/apps/web/hooks/domains/session/use-session-messages.ts b/apps/web/hooks/domains/session/use-session-messages.ts index 590b29b7cf..f58bfdbf52 100644 --- a/apps/web/hooks/domains/session/use-session-messages.ts +++ b/apps/web/hooks/domains/session/use-session-messages.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState, type MutableRefObject } from "react"; +import { useEffect, useRef, type MutableRefObject } from "react"; import { getWebSocketClient } from "@/lib/ws/connection"; import { useForegroundRefresh } from "@/hooks/use-foreground-refresh"; import { useAppStore, useAppStoreApi } from "@/components/state-provider"; @@ -10,6 +10,7 @@ import { useUnknownSessionSubscriptionRetryEffect, } from "./use-session-subscription-retry"; import { doFetchMessages } from "./use-session-message-fetch"; +import { useMessageFetchState } from "./use-message-fetch-state"; import { reconcileLatestMessageWindow } from "./message-window-reconciliation"; import { t } from "@/lib/i18n"; @@ -130,6 +131,7 @@ function summarizeMessages(messages: Message[]): { interface UseSessionMessagesReturn { isLoading: boolean; isInitialMessagesLoading: boolean; + historyRefreshPending: boolean; messages: Message[]; historyInitialized: boolean; hasMore: boolean; @@ -252,7 +254,6 @@ async function fetchAndStoreMessages( cachedAtResponse: store.getState().messages.bySession[sessionId] ?? [], fetched, }); - // Stale-fetch guard: if a newer fetch for this session already merged while // this one was in flight, skip the merge so the older snapshot can't drop // newer messages. The newer state is already in the store. @@ -260,7 +261,6 @@ async function fetchAndStoreMessages( debug("message.list stale fetch skipped", { sessionId, seq }); return store.getState().messages.bySession[sessionId] ?? merged; } - // Idempotent merge: the store reconciles this snapshot against current state, // preserving object/array identity for unchanged messages so the periodic // refetch doesn't re-render the whole chat (see reconcileMessages). @@ -273,7 +273,6 @@ async function fetchAndStoreMessages( // and message content from the return, so `merged` is equivalent. return merged; } - /** * When the initial fetch window contains no user/agent message rows (common * when the latest turn produced hundreds of tool calls), the chat would render @@ -297,18 +296,14 @@ function useTerminalStateFetch( ) { const lastFetchStateKeyRef = useRef(null); const connectionStatus = useAppStore((state) => state.connection.status); - useEffect(() => { if (!taskSessionId || connectionStatus !== "connected") return; if (!taskSessionState || hasAgentMessage) return; - const terminalStates = new Set(["WAITING_FOR_INPUT", "COMPLETED", "FAILED"]); if (!terminalStates.has(taskSessionState)) return; - const key = `${taskSessionId}:${taskSessionState}`; if (lastFetchStateKeyRef.current === key) return; lastFetchStateKeyRef.current = key; - void doFetchMessages({ taskSessionId, ...refs, @@ -472,31 +467,6 @@ function useRunningMessageBackfill( }, [taskSessionId, shouldBackfill, store]); } -function useMessageFetchState(store: ReturnType) { - const [isLoading, setIsLoading] = useState(false); - const [isWaitingForInitialMessages, setIsWaitingForInitialMessages] = useState(false); - const initialFetchStartRef = useRef(null); - const lastFetchedSessionIdRef = useRef(null); - const refs = useMemo( - () => ({ - store, - setIsLoading, - setIsWaitingForInitialMessages, - initialFetchStartRef, - lastFetchedSessionIdRef, - }), - [store], - ); - return { - isLoading, - isWaitingForInitialMessages, - setIsWaitingForInitialMessages, - initialFetchStartRef, - lastFetchedSessionIdRef, - refs, - }; -} - function useSessionMessageInputs(taskSessionId: string | null) { const messages = useAppStore((state) => taskSessionId ? (state.messages.bySession[taskSessionId] ?? EMPTY_MESSAGES) : EMPTY_MESSAGES, @@ -513,7 +483,6 @@ function useSessionMessageInputs(taskSessionId: string | null) { const connectionStatus = useAppStore((state) => state.connection.status); return { messages, messagesMeta, taskSessionState, activeTurnId, connectionStatus }; } - function useSessionLifecycleSubscriptions(params: { taskSessionId: string | null; taskSessionState: TaskSessionState | null; @@ -534,7 +503,6 @@ function useSessionLifecycleSubscriptions(params: { taskSessionState, connectionStatus, }); - useSessionSubscription(taskSessionId, connectionStatus, isSessionStartingOrUnknown, store); useUnknownSessionSubscriptionRetryEffect({ taskSessionId, @@ -553,39 +521,31 @@ function useSessionLifecycleSubscriptions(params: { store, ); } - -export function useSessionMessages(taskSessionId: string | null): UseSessionMessagesReturn { - const store = useAppStoreApi(); - const { messages, messagesMeta, taskSessionState, activeTurnId, connectionStatus } = - useSessionMessageInputs(taskSessionId); - const prevSessionIdRef = useRef(null); - const hasAgentMessage = messages.some((message: Message) => message.author_type === "agent"); +type SessionEntryFetchParams = { + taskSessionId: string | null; + connectionStatus: string; + messagesLength: number; + store: ReturnType; + prevSessionIdRef: MutableRefObject; + fetchState: ReturnType; +}; +function useInitialMessagesWait(params: SessionEntryFetchParams): void { + const { taskSessionId, messagesLength, fetchState } = params; const { - isLoading, - isWaitingForInitialMessages, - setIsWaitingForInitialMessages, initialFetchStartRef, lastFetchedSessionIdRef, - refs: fetchRefs, - } = useMessageFetchState(store); - - useSessionLifecycleSubscriptions({ - taskSessionId, - taskSessionState, - connectionStatus, - activeTurnId, - messages, - store, - }); - + setIsWaitingForInitialMessages, + setIsCachedHistoryRefreshPending, + } = fetchState; useEffect(() => { if (!taskSessionId) { initialFetchStartRef.current = null; lastFetchedSessionIdRef.current = null; setIsWaitingForInitialMessages(false); + setIsCachedHistoryRefreshPending(false); return; } - if (messages.length > 0) { + if (messagesLength > 0) { setIsWaitingForInitialMessages(false); return; } @@ -593,45 +553,60 @@ export function useSessionMessages(taskSessionId: string | null): UseSessionMess initialFetchStartRef.current = Date.now(); setIsWaitingForInitialMessages(true); } - }, [taskSessionId, messages.length, initialFetchStartRef, setIsWaitingForInitialMessages]); - + }, [ + taskSessionId, + messagesLength, + initialFetchStartRef, + lastFetchedSessionIdRef, + setIsWaitingForInitialMessages, + setIsCachedHistoryRefreshPending, + ]); +} +function useSessionEntryMessageFetch(params: SessionEntryFetchParams): void { + const { taskSessionId, connectionStatus, messagesLength, store, prevSessionIdRef, fetchState } = + params; + const { + lastFetchedSessionIdRef, + setIsWaitingForInitialMessages, + setIsCachedHistoryRefreshPending, + cachedRefreshGenerationRef, + refs: fetchRefs, + } = fetchState; useEffect(() => { let active = true; const deactivate = () => { active = false; }; - if (!taskSessionId || connectionStatus !== "connected") { - return deactivate; - } - + if (!taskSessionId || connectionStatus !== "connected") return deactivate; const isFreshMount = prevSessionIdRef.current === null; const sessionChanged = prevSessionIdRef.current !== null && prevSessionIdRef.current !== taskSessionId; prevSessionIdRef.current = taskSessionId; - if (sessionChanged) { lastFetchedSessionIdRef.current = null; + cachedRefreshGenerationRef.current += 1; + setIsCachedHistoryRefreshPending(false); } - - // Normal re-render with cached messages — skip fetch - if (messages.length > 0 && !sessionChanged && !isFreshMount) { + if (messagesLength > 0 && !sessionChanged && !isFreshMount) { lastFetchedSessionIdRef.current = taskSessionId; setIsWaitingForInitialMessages(false); return deactivate; } - - // Fresh mount with cached messages — show cached instantly, fetch in background - if (isFreshMount && messages.length > 0) { + if (isFreshMount && messagesLength > 0) { lastFetchedSessionIdRef.current = taskSessionId; setIsWaitingForInitialMessages(false); - fetchAndStoreMessages(taskSessionId, store, () => active).catch(() => {}); + setIsCachedHistoryRefreshPending(true); + const refreshGeneration = ++cachedRefreshGenerationRef.current; + void fetchAndStoreMessages(taskSessionId, store, () => active) + .catch(() => {}) + .finally(() => { + if (cachedRefreshGenerationRef.current === refreshGeneration) { + setIsCachedHistoryRefreshPending(false); + } + }); return deactivate; } - - if (lastFetchedSessionIdRef.current === taskSessionId) { - return deactivate; - } - + if (lastFetchedSessionIdRef.current === taskSessionId) return deactivate; void doFetchMessages({ taskSessionId, ...fetchRefs, @@ -642,20 +617,62 @@ export function useSessionMessages(taskSessionId: string | null): UseSessionMess }, [ taskSessionId, connectionStatus, - messages.length, + messagesLength, store, + prevSessionIdRef, lastFetchedSessionIdRef, setIsWaitingForInitialMessages, + setIsCachedHistoryRefreshPending, fetchRefs, ]); - +} +export function useSessionMessages(taskSessionId: string | null): UseSessionMessagesReturn { + const store = useAppStoreApi(); + const { messages, messagesMeta, taskSessionState, activeTurnId, connectionStatus } = + useSessionMessageInputs(taskSessionId); + const prevSessionIdRef = useRef(null); + const isSessionEntryRefreshPending = + taskSessionId !== null && + connectionStatus === "connected" && + prevSessionIdRef.current !== taskSessionId; + const hasAgentMessage = messages.some((message: Message) => message.author_type === "agent"); + const fetchState = useMessageFetchState(store); + const { + isLoading, + isWaitingForInitialMessages, + isCachedHistoryRefreshPending, + refs: fetchRefs, + } = fetchState; + useSessionLifecycleSubscriptions({ + taskSessionId, + taskSessionState, + connectionStatus, + activeTurnId, + messages, + store, + }); + const entryFetchParams = { + taskSessionId, + connectionStatus, + messagesLength: messages.length, + store, + prevSessionIdRef, + fetchState, + }; + useInitialMessagesWait(entryFetchParams); + useSessionEntryMessageFetch(entryFetchParams); useVisibilityBackfill(taskSessionId, store); - useTerminalStateFetch(taskSessionId, taskSessionState, hasAgentMessage, fetchRefs); return { isLoading: isLoading || isWaitingForInitialMessages || messagesMeta.isLoading, isInitialMessagesLoading: isWaitingForInitialMessages, + historyRefreshPending: + isLoading || + isWaitingForInitialMessages || + messagesMeta.isLoading || + isCachedHistoryRefreshPending || + isSessionEntryRefreshPending, messages, historyInitialized: messagesMeta.historyInitialized, hasMore: messagesMeta.hasMore, diff --git a/apps/web/lib/state/dockview-env-switch-action.test.ts b/apps/web/lib/state/dockview-env-switch-action.test.ts index adbf9245fc..01311ca71c 100644 --- a/apps/web/lib/state/dockview-env-switch-action.test.ts +++ b/apps/web/lib/state/dockview-env-switch-action.test.ts @@ -141,6 +141,7 @@ describe("switchEnvLayout — root fix for terminal/layout swapping", () => { preMaximizeLayout: null, maximizedGroupId: null, isRestoringLayout: false, + pendingChatInitialPlacement: null, }); }); @@ -155,6 +156,7 @@ describe("switchEnvLayout — root fix for terminal/layout swapping", () => { expect(api.fromJSON).not.toHaveBeenCalled(); expect(panelPortalManager.releaseByEnv).not.toHaveBeenCalled(); expect(setEnvLayout).not.toHaveBeenCalled(); + expect(useDockviewStore.getState().pendingChatInitialPlacement).toBeNull(); }); it("saves outgoing env + releases its portals when switching to a new env", () => { @@ -169,6 +171,42 @@ describe("switchEnvLayout — root fix for terminal/layout swapping", () => { expect(useDockviewStore.getState().currentLayoutEnvId).toBe("env-new"); }); + it("arms fresh transcript placement for the incoming session without capturing scrollTop", () => { + const api = makeMockApi(); + useDockviewStore.setState({ + api, + currentLayoutEnvId: "env-old", + pendingChatScrollTop: null, + }); + + useDockviewStore.getState().switchEnvLayout("env-old", "env-new", "session-X"); + + const state = useDockviewStore.getState(); + expect(state.pendingChatInitialPlacement).toEqual({ + sessionId: "session-X", + token: expect.any(Number), + }); + expect(state.pendingChatScrollTop).toBeNull(); + }); + + it("ignores completion from a superseded env-switch placement", () => { + const api = makeMockApi(); + useDockviewStore.setState({ api, currentLayoutEnvId: "env-a" }); + + useDockviewStore.getState().switchEnvLayout("env-a", "env-b", "session-B"); + const firstRequest = useDockviewStore.getState().pendingChatInitialPlacement; + useDockviewStore.getState().switchEnvLayout("env-b", "env-c", "session-C"); + const secondRequest = useDockviewStore.getState().pendingChatInitialPlacement; + + expect(firstRequest).not.toBeNull(); + expect(secondRequest).not.toBeNull(); + useDockviewStore.getState().completePendingChatInitialPlacement(firstRequest?.token ?? -1); + expect(useDockviewStore.getState().pendingChatInitialPlacement).toEqual(secondRequest); + + useDockviewStore.getState().completePendingChatInitialPlacement(secondRequest?.token ?? -1); + expect(useDockviewStore.getState().pendingChatInitialPlacement).toBeNull(); + }); + it("restores the saved env layout profile when switching to a new env", () => { const api = makeMockApi(); vi.mocked(getEnvLayoutProfile).mockReturnValue({ kind: "built-in", id: "vscode" }); diff --git a/apps/web/lib/state/dockview-scroll-preserve.test.ts b/apps/web/lib/state/dockview-scroll-preserve.test.ts index 5a793ea7e5..b35e47c9f2 100644 --- a/apps/web/lib/state/dockview-scroll-preserve.test.ts +++ b/apps/web/lib/state/dockview-scroll-preserve.test.ts @@ -6,6 +6,7 @@ type Listener = (state: { isRestoringLayout: boolean }) => void; const fakeStore = { isRestoringLayout: false, pendingChatScrollTop: null as number | null, + pendingChatInitialPlacement: null as { sessionId: string; token: number } | null, setPendingChatScrollTop: vi.fn((v: number | null) => { fakeStore.pendingChatScrollTop = v; }), @@ -51,6 +52,7 @@ describe("preserveChatScrollDuringLayout", () => { document.body.innerHTML = ""; fakeStore.isRestoringLayout = false; fakeStore.pendingChatScrollTop = null; + fakeStore.pendingChatInitialPlacement = null; fakeStore.listeners.clear(); fakeStore.setPendingChatScrollTop.mockClear(); }); diff --git a/apps/web/lib/state/dockview-store.ts b/apps/web/lib/state/dockview-store.ts index a862672104..413eba14f3 100644 --- a/apps/web/lib/state/dockview-store.ts +++ b/apps/web/lib/state/dockview-store.ts @@ -312,6 +312,8 @@ type DockviewStore = { activePanelComponent: string | null; pendingChatScrollTop: number | null; setPendingChatScrollTop: (value: number | null) => void; + pendingChatInitialPlacement: { sessionId: string; token: number } | null; + completePendingChatInitialPlacement: (token: number) => void; /** Saved layout from before a manual maximize. Null when not maximized. */ preMaximizeLayout: LayoutState | null; /** The group ID that was maximized (used for session restore). */ @@ -325,6 +327,17 @@ type StoreSet = ( partial: Partial | ((s: DockviewStore) => Partial), ) => void; +let chatInitialPlacementToken = 0; + +function nextChatInitialPlacementToken(): number { + chatInitialPlacementToken += 1; + return chatInitialPlacementToken; +} + +function createChatInitialPlacement(sessionId: string | null) { + return sessionId ? { sessionId, token: nextChatInitialPlacementToken() } : null; +} + /** * Apply queued deferred panel actions to the dockview API after a layout * build/restore. "tab" placements land in the reference panel's group; other @@ -1037,6 +1050,7 @@ function buildEnvSwitchAction(set: StoreSet, get: StoreGet) { debugSwitch("envSwitch: skip (same env)", { newEnvId }); return; } + set({ pendingChatInitialPlacement: createChatInitialPlacement(activeSessionId) }); // First adoption (oldEnvId and currentLayoutEnvId both null) falls through // to the general path below. We deliberately do NOT "just adopt" whatever // onReady rendered: this branch only fires when onReady ran with a null @@ -1292,7 +1306,10 @@ function performBuildDefault( */ function resetToEffectiveDefault(set: StoreSet, get: StoreGet): void { const { api, currentLayoutEnvId, preMaximizeLayout } = get(); - if (!api) return; + if (!api) { + useDockviewStore.setState({ pendingChatInitialPlacement: null }); + return; + } if (preMaximizeLayout) { set({ preMaximizeLayout: null, maximizedGroupId: null }); if (currentLayoutEnvId) removeEnvMaximizeState(currentLayoutEnvId); @@ -1460,6 +1477,13 @@ export const useDockviewStore = create((set, get) => ({ resetLayout: () => resetToEffectiveDefault(set, get), pendingChatScrollTop: null, setPendingChatScrollTop: (value) => set({ pendingChatScrollTop: value }), + pendingChatInitialPlacement: null, + completePendingChatInitialPlacement: (token) => + set((state) => + state.pendingChatInitialPlacement?.token === token + ? { pendingChatInitialPlacement: null } + : {}, + ), preMaximizeLayout: null, maximizedGroupId: null, ...buildMaximizeActions(set, get), @@ -1514,6 +1538,7 @@ export function releaseLayoutToDefault(oldEnvId: string | null): void { maximizedGroupId: null, currentLayoutEnvId: null, isRestoringLayout: true, + pendingChatInitialPlacement: null, }); try { buildDefaultLayout(api); diff --git a/docs/plans/transcript-env-switch-scroll/plan.md b/docs/plans/transcript-env-switch-scroll/plan.md new file mode 100644 index 0000000000..02f4e83e18 --- /dev/null +++ b/docs/plans/transcript-env-switch-scroll/plan.md @@ -0,0 +1,177 @@ +--- +created: 2026-09-03 +status: complete +requirements: + - REQ-UI-TRANSCRIPT-AUTO-SCROLL-001 +system_design: + - ../../specs/ui/system-design/transcript-auto-scroll.md +legacy_specs: [] +--- + +# Implementation Plan: Restore Transcript Scroll Across Environment Switches + +## Overview + +Make an environment-changing task switch request fresh placement for the +incoming session after its Dockview layout and latest message window settle. +Keep the request session-scoped so enabled transcripts land at the newest +message, disabled transcripts use their own saved position, and the older- +history sentinel cannot paginate from transient top geometry. + +## Scope + +### In scope + +- Arm a tokened incoming-session placement request only for cross-environment + Dockview switches. +- Keep the absolute `pendingChatScrollTop` restore reserved for same-transcript + layout rebuilds. +- Expose cached session-entry history refresh readiness to the transcript + coordinator without changing its loading presentation. +- Re-arm initial placement for a matching request and resolve it after layout + and history settle. +- Block automatic older-history intersection loading until placement completes. +- Add focused store, hook, and browser regressions for enabled, disabled, + wrong-session-offset, pagination, and same-environment behavior. +- Run the existing mobile auto-scroll suite because mobile shares the native + transcript coordinator. + +### Out of scope + +- Changing the auto-scroll control, persisted preference, saved-offset format, + or message-pagination contract. +- Replaying the outgoing task's absolute transcript offset. +- Changing Dockview layout persistence, portal ownership, or fast/slow switch + selection. +- Changing normal live tailing, unread-divider placement, explicit transcript + navigation, maximize, un-maximize, preset, or custom-layout restoration. +- Adding a mobile Dockview environment-switch lifecycle. + +## Confirmed root cause + +`buildSwitchToSession` routes a task whose session has a different environment +through `performLayoutSwitch` and `buildEnvSwitchAction`. That action toggles +`isRestoringLayout` around `performEnvSwitch`, but intentionally does not call +`preserveChatScrollDuringLayout`, because its absolute `scrollTop` belongs to +the outgoing session. + +The incoming panel remains logically visible, so `useActivationPending` does +not observe a hidden-to-visible transition. `useInitialScrollPosition` can then +consume its one-shot latch against cached or partial rows before the incoming +`message.list` refresh completes. Later rows change the content height without +another placement. The transient near-top geometry exposes the lazy-load +sentinel and can start repeated `top-intersection` pagination. + +The cached-history entry path in `useSessionMessages` starts a background +`message.list` refresh without including that operation in the loading state +currently available to the transcript. Layout state alone therefore cannot +prove that the incoming history has settled. + +## Technical approach + +### Session-scoped environment-switch request + +- Extend `DockviewStore` with a transient tokened placement request containing + the incoming `sessionId`, plus a compare-and-clear completion action. +- Arm it inside `buildEnvSwitchAction` after the same-environment early return + and before the layout mutation. Do not write `pendingChatScrollTop`. +- Preserve the request through both `performEnvSwitch` paths and default-layout + fallback. A later switch replaces the token. + +### History and placement readiness + +- Track the cached-history background refresh in `useSessionMessages` and + expose a dedicated readiness value through `useChatPanelState` to + `NativeMessageList`. Do not use it to add a new loading indicator. +- Extend the activation-pending primitive with an optional external token so a + matching environment-switch request re-arms placement while `isVisible` + remains true. +- Keep the initial-placement latch pending while the layout is restoring or + incoming history is refreshing. Once both are ready, run placement through + `scheduleAfterPanelRestore` and re-check all scroll owners before writing. +- Resolve the position with the existing `resolveNativeInitialScrollTop` logic, + using only the incoming `sessionId` for the saved offset. Clear the request + only when the token still matches. + +### Pagination isolation + +- Treat a matching, unresolved environment-switch placement as a hard block for + `useLazyLoadSentinel`, alongside initial/refetch loading. +- Release the block only after placement has completed or a higher-priority + explicit scroll owner has consumed the request. + +## Tests + +| Acceptance criterion | Test evidence | +| --- | --- | +| `AC-UI-TRANSCRIPT-AUTO-SCROLL-001.11` | `dockview-env-switch-action.test.ts` proves only a cross-environment switch arms the incoming session request. `message-list-native.test.tsx` holds layout/history pending, settles with a taller incoming transcript, and requires enabled placement at the current bottom. The desktop Playwright flow switches between two tasks with distinct environments and requires the returned enabled transcript to settle at the bottom. | +| `AC-UI-TRANSCRIPT-AUTO-SCROLL-001.12` | Native-scroll regressions save distinct offsets for outgoing and incoming sessions and require the disabled incoming session's value. The desktop flow disables the incoming transcript, records its position, visits the other task, and requires the incoming position after return. | +| `AC-UI-TRANSCRIPT-AUTO-SCROLL-001.13` | The native-scroll harness requires the lazy-load sentinel to stay blocked throughout unresolved environment placement and verifies no load starts from transient top geometry. | +| Existing criteria 8-10 | Same-environment store and hook cases remain unchanged. The existing inactive-session desktop scenario and mobile suite continue to pass. | + +`dockview-scroll-preserve.test.ts` also proves the absolute layout-preservation +helper does not arm, replace, or clear the environment-switch request. + +## E2E tests + +- Desktop: extend `apps/web/e2e/tests/chat/auto-scroll-toggle.spec.ts` with a + two-task fixture whose sessions have different task environments and + overflowing persisted histories. Cover enabled return-to-bottom and disabled + incoming-position restoration through sidebar task selection. +- Mobile: the defect path is desktop Dockview-only. Run the existing + `mobile-auto-scroll-toggle.spec.ts` suite to prove shared initial placement, + bottom following, disabled freezing, and touch controls remain intact. + +## Mobile design contract + +- **Desktop outcome:** returning to a task in another environment shows its own + transcript at the current bottom or its own disabled saved position. +- **Mobile entry point:** the existing selected Chat panel under + `MobileSessionsPicker`. +- **Nearest shipped exemplar:** `SessionMobileLayout` renders one selected + `TaskChatPanel`; the existing mobile auto-scroll suite covers the shared + coordinator. +- **Hierarchy and presentation:** no controls, composition, or scroll ownership + change. The transcript remains the single vertical scroll owner. +- **Shared state and logic:** desktop and mobile share session auto-scroll state + and native placement. The transient Dockview request is desktop-only. +- **Mobile coverage decision:** no new mobile scenario can reproduce a Dockview + environment rebuild. Existing mobile Playwright coverage is the parity proof + for the shared code path. + +## Work orders + +- [x] [Task 01: Reconcile environment-switch transcript placement](task-01-reconcile-env-switch-transcript-placement.md) + +## Verification results + +- Cross-environment Dockview switches now arm a tokened request for the incoming + session. The native transcript holds placement and pagination until layout + restoration and the session-entry history refresh settle. +- Enabled transcripts resolve at the latest message. Disabled transcripts use + only their own saved offset. Compare-and-clear tokens protect later switches, + and the same-environment path remains a no-op. +- Focused Vitest passed in test and production environment modes (128 tests). +- The complete frontend Vitest run passed (1,756 files, 15,076 tests, 4 + skipped). Typecheck, lint, Prettier, spec lint, and diff checks passed. +- The desktop differing-environment Playwright regression passed (1 test). The + existing mobile auto-scroll suite passed (5 tests). + +## Risks + +- React effect order can let initial placement run before a session-entry fetch + marks itself pending. Arming the store request before the layout mutation and + reading a dedicated refresh signal prevents that first-render race. +- A stale animation frame can target a session after another task switch. The + token and live session/owner checks must reject it. +- Releasing the pagination block before the bottom or saved-offset write can + recreate the `top-intersection` cascade. +- Broadening the existing loading presentation for cached history would be an + unrelated UI change; readiness stays internal to scroll coordination. + +## Decisions + +- No ADR is required. The repair preserves the current Dockview, transcript + scroll-owner, per-session storage, and pagination boundaries. +- No public documentation change is required because the repair restores + existing behavior and adds no user-facing control or workflow. diff --git a/docs/plans/transcript-env-switch-scroll/task-01-reconcile-env-switch-transcript-placement.md b/docs/plans/transcript-env-switch-scroll/task-01-reconcile-env-switch-transcript-placement.md new file mode 100644 index 0000000000..405beb498a --- /dev/null +++ b/docs/plans/transcript-env-switch-scroll/task-01-reconcile-env-switch-transcript-placement.md @@ -0,0 +1,156 @@ +--- +id: "01-reconcile-env-switch-transcript-placement" +title: "Reconcile environment-switch transcript placement" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-UI-TRANSCRIPT-AUTO-SCROLL-001 +acceptance_criteria: + - AC-UI-TRANSCRIPT-AUTO-SCROLL-001.11 + - AC-UI-TRANSCRIPT-AUTO-SCROLL-001.12 + - AC-UI-TRANSCRIPT-AUTO-SCROLL-001.13 +system_design: + - ../../specs/ui/system-design/transcript-auto-scroll.md +--- + +# Task 01: Reconcile Environment-switch Transcript Placement + +## Summary + +Arm a session-scoped placement request for environment-changing Dockview +switches, defer the incoming transcript until its layout and latest history are +ready, and block automatic older-history pagination during that window. Prove +that enabled and disabled sessions resolve from their own state while same- +environment and ordinary layout behavior remain unchanged. + +## Failing regressions first + +1. Add a store action test that performs an `env-a` to `env-b` switch and + requires a tokened request for the incoming session while + `pendingChatScrollTop` remains null. Require the same-environment early + return not to arm a request. +2. Add a native-scroll hook case with outgoing and incoming sessions, distinct + saved offsets, a matching environment-switch token, and a pending history + refresh. Require no initial write or pagination eligibility until the + incoming rows settle. +3. Complete the refresh with a larger scroll height and require an enabled + session to land at the current bottom. +4. Repeat with auto-scroll disabled and require the incoming session's saved + offset, never the outgoing session's offset. +5. Add a cached-entry session-message test that holds the background + `message.list` promise and requires the placement-readiness signal to remain + pending until that exact refresh settles. +6. Add a desktop Playwright case that switches between two overflowing tasks + in different environments and fails on the current top reset. + +## In scope + +- Add and consume the tokened Dockview transcript-placement request. +- Extend activation pending to support a visible external reactivation token. +- Expose cached history-refresh readiness without changing loading copy or + presentation. +- Block the older-history sentinel while environment placement is unresolved. +- Cover the env-switch store action, absolute scroll preservation isolation, + initial placement, session-specific saved offset, pagination block, and + same-environment behavior. +- Add the desktop differing-environment browser regression and run the existing + mobile auto-scroll suite. +- Update plan and work-order results after implementation. + +## Out of scope + +- Reusing `preserveChatScrollDuringLayout` for the environment switch. +- Changing saved-offset persistence, auto-scroll controls, pagination page + sizes, or message fetching contracts. +- Changing maximize, un-maximize, preset/custom layout, explicit navigation, + unread divider, or live-tailing behavior. +- New copy, layout, responsive composition, or mobile navigation. + +## Acceptance + +- A cross-environment switch places an enabled incoming transcript at the + bottom after its latest history settles, without applying the outgoing + session's offset. +- A disabled incoming transcript restores only its own saved offset. +- Automatic top-intersection pagination remains blocked until placement is + complete, and same-environment switching retains the current path. + +## Verification + +```bash +cd apps && pnpm --filter @kandev/web test -- lib/state/dockview-env-switch-action.test.ts lib/state/dockview-scroll-preserve.test.ts hooks/domains/session/use-session-messages.test.ts components/task/chat/transcript-auto-scroll.test.ts components/task/chat/message-list-native.test.tsx +cd apps && NODE_ENV=production pnpm --filter @kandev/web test -- lib/state/dockview-env-switch-action.test.ts lib/state/dockview-scroll-preserve.test.ts hooks/domains/session/use-session-messages.test.ts components/task/chat/transcript-auto-scroll.test.ts components/task/chat/message-list-native.test.tsx +cd apps/web && pnpm run typecheck +cd apps && pnpm --filter @kandev/web lint +cd apps/web && pnpm e2e:run --host --project chromium tests/chat/auto-scroll-toggle.spec.ts -- --grep "environment-changing task switch" --retries=0 +cd apps/web && pnpm e2e:run --host --no-build --project mobile-chrome tests/chat/mobile-auto-scroll-toggle.spec.ts -- --retries=0 +python3 scripts/lint-spec-files.test.py +python3 scripts/lint-spec-files.py --all +git diff --check -- docs/specs/ui docs/plans/transcript-env-switch-scroll apps/web/components/task/chat apps/web/components/task/task-chat-panel.tsx apps/web/hooks/domains/session apps/web/lib/state apps/web/e2e/tests/chat +``` + +Run `pnpm install --frozen-lockfile` from `apps/` before the first package +command in a fresh worktree. + +## Files likely touched + +- `apps/web/lib/state/dockview-store.ts` +- `apps/web/lib/state/dockview-env-switch-action.test.ts` +- `apps/web/lib/state/dockview-scroll-preserve.test.ts` +- `apps/web/hooks/domains/session/use-session-messages.ts` +- `apps/web/hooks/domains/session/use-session-messages.test.ts` +- `apps/web/components/task/chat/use-chat-panel-state.ts` +- `apps/web/components/task/task-chat-panel.tsx` +- `apps/web/components/task/chat/message-list-shared.tsx` +- `apps/web/components/task/chat/message-list-native.tsx` +- `apps/web/components/task/chat/message-list-native-scroll.ts` +- `apps/web/components/task/chat/message-list-native.test.tsx` +- `apps/web/components/task/chat/transcript-auto-scroll.ts` +- `apps/web/components/task/chat/transcript-auto-scroll.test.ts` +- `apps/web/e2e/tests/chat/auto-scroll-toggle.spec.ts` + +## Dependencies + +None. + +## Risks + +- A request that is not session- and token-checked can clear or reposition a + later task switch. +- Placement must wait for both layout and cached-history refresh readiness, not + only one signal. +- The sentinel block must be released after the final placement write, not when + the request is first observed. + +## Parallelism + +`sequential` + +## Inputs + +- `REQ-UI-TRANSCRIPT-AUTO-SCROLL-001`, criteria 11 through 13. +- `docs/specs/ui/system-design/transcript-auto-scroll.md`, especially + Environment-change rebuild lifecycle. +- Existing env-switch store-action and native-scroll hook harnesses. +- Existing desktop and mobile transcript auto-scroll Playwright suites. + +## Results + +- Added a session-scoped, tokened Dockview placement request that is armed only + by environment-changing switches and never captures the outgoing transcript's + absolute offset. +- Added cached-entry history-refresh readiness and delayed native placement + until both Dockview and message history settle. Auto-scroll writes and the + older-history sentinel stay inactive while the request owns placement. +- Added unit regressions for enabled bottom placement, disabled incoming offset, + stale-token completion, same-environment behavior, pagination blocking, and + isolation from absolute layout restore. +- Added a desktop Playwright regression using two confirmed distinct task + environments. It covers enabled and disabled return trips through the task + sidebar. +- Verification passed: focused Vitest in test and production modes (128 tests), + full Vitest (1,756 files and 15,076 tests, with 4 skipped), typecheck, lint, + Prettier, spec lint, desktop Playwright (1 test), and mobile Playwright (5 + tests). diff --git a/docs/specs/ui/requirements/transcript-auto-scroll.md b/docs/specs/ui/requirements/transcript-auto-scroll.md index 9be5706fb4..3660e6ea56 100644 --- a/docs/specs/ui/requirements/transcript-auto-scroll.md +++ b/docs/specs/ui/requirements/transcript-auto-scroll.md @@ -2,7 +2,7 @@ status: active system: ui created: 2026-07-30 -updated: 2026-09-02 +updated: 2026-09-03 owners: - cfl --- @@ -13,11 +13,13 @@ owners: Users expect an enabled transcript to follow the newest message, including when they activate a long-running session tab that was mounted outside the visible -Dockview layout. Users who turn off transcript auto-scroll expect the visible -conversation to stay fixed while new content arrives. Chrome can still adjust -the view through its native overflow anchoring when the toggle is disabled from -the bottom. Separately, the clarification-recovery regression test must reliably -observe the asynchronous hand-off it is designed to protect. +Dockview layout or return to a task whose environment rebuilds that layout. +Users who turn off transcript auto-scroll expect the visible conversation to +stay fixed while new content arrives and to reopen at that session's own saved +position. Chrome can still adjust the view through its native overflow +anchoring when the toggle is disabled from the bottom. Separately, the +clarification-recovery regression test must reliably observe the asynchronous +hand-off it is designed to protect. ## Requirements @@ -50,6 +52,18 @@ observe the asynchronous hand-off it is designed to protect. message, **WHEN** its desktop session tab becomes inactive and visible again, **THEN** the transcript preserves the reader-owned position instead of forcing the newest message into view. +- **AC-UI-TRANSCRIPT-AUTO-SCROLL-001.11:** **GIVEN** an enabled transcript for + a task in a different environment, **WHEN** the user switches to that task + and its message history finishes loading, **THEN** the transcript settles at + its newest message. +- **AC-UI-TRANSCRIPT-AUTO-SCROLL-001.12:** **GIVEN** a disabled transcript for + a task in a different environment with a saved reader position, **WHEN** the + user switches to that task, **THEN** the transcript restores that session's + saved position and does not apply the outgoing session's position. +- **AC-UI-TRANSCRIPT-AUTO-SCROLL-001.13:** **GIVEN** a task switch that rebuilds + the Dockview layout, **WHEN** the incoming transcript has not completed its + initial placement, **THEN** automatic older-history pagination does not start + from the transient pre-placement geometry. ## Migrated source detail @@ -71,6 +85,11 @@ the asynchronous hand-off it is designed to protect. or hidden message delivery places a bottom-following reader at the newest message after the panel becomes measurable. - Activating a reader-owned transcript position preserves that position. +- Returning to a task in another environment repeats initial placement after + that session's current message history is ready without replaying the + outgoing transcript's absolute offset. +- Automatic older-history pagination waits until environment-switch placement + has completed. - The clarification-recovery concurrency regression waits for its asynchronous completion signal within a bounded interval instead of treating scheduler timing as a product failure. @@ -95,6 +114,15 @@ the asynchronous hand-off it is designed to protect. - **GIVEN** the reader disabled auto-scroll or scrolled away from the newest message before switching desktop session tabs, **WHEN** the reader returns, **THEN** the prior reading position remains visible. +- **GIVEN** two tasks in different environments with overflowing transcripts, + **WHEN** the user switches between them and returns, **THEN** the incoming + enabled transcript opens at the newest message after its history refresh. +- **GIVEN** the incoming transcript has auto-scroll disabled, **WHEN** that + environment-switch placement runs, **THEN** it restores the incoming + session's saved position rather than the outgoing transcript's position. +- **GIVEN** incoming history and layout are still settling, **WHEN** the + older-history sentinel is temporarily at the viewport top, **THEN** the + sentinel does not start an automatic pagination cascade. ## Out of scope diff --git a/docs/specs/ui/system-design/transcript-auto-scroll.md b/docs/specs/ui/system-design/transcript-auto-scroll.md index b83f90e3ed..60985d7b3d 100644 --- a/docs/specs/ui/system-design/transcript-auto-scroll.md +++ b/docs/specs/ui/system-design/transcript-auto-scroll.md @@ -4,7 +4,7 @@ system: ui requirements: - REQ-UI-TRANSCRIPT-AUTO-SCROLL-001 created: 2026-08-27 -updated: 2026-09-02 +updated: 2026-09-03 owners: - kandev --- @@ -15,15 +15,16 @@ owners: This design keeps an enabled transcript pinned during streamed updates and restores that bottom-follow intent when a persistent desktop session panel -becomes visible. It does not force browser layout during each React commit. It -preserves disabled-state freezing, reader-owned positions, explicit navigation, -prepend restoration, and catch-up after the user enables auto-scroll again. +becomes visible or a task switch rebuilds Dockview for another environment. It +does not force browser layout during each React commit. It preserves +disabled-state freezing, reader-owned positions, explicit navigation, prepend +restoration, and catch-up after the user enables auto-scroll again. ## Requirement mapping | Requirement | Design section | | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | -| `REQ-UI-TRANSCRIPT-AUTO-SCROLL-001` | [Bottom placement](#bottom-placement), [Persistent-panel visibility lifecycle](#persistent-panel-visibility-lifecycle), [Interaction boundaries](#interaction-boundaries), [Responsive behavior](#responsive-behavior) | +| `REQ-UI-TRANSCRIPT-AUTO-SCROLL-001` | [Bottom placement](#bottom-placement), [Persistent-panel visibility lifecycle](#persistent-panel-visibility-lifecycle), [Environment-change rebuild lifecycle](#environment-change-rebuild-lifecycle), [Interaction boundaries](#interaction-boundaries), [Responsive behavior](#responsive-behavior) | ## Bottom placement @@ -91,6 +92,41 @@ place. The visibility lifecycle uses component refs only. It does not add persisted scroll state or change the existing per-session disabled-position storage. +## Environment-change rebuild lifecycle + +`buildEnvSwitchAction` distinguishes an environment-changing task switch from +same-environment session activation before it mutates Dockview. A cross- +environment switch arms a tokened initial-placement request for the incoming +session. The request is independent of `pendingChatScrollTop`: the latter keeps +owning same-transcript layout rebuilds, while the environment-switch request +must never capture or replay the outgoing session's absolute offset. + +The incoming native transcript treats a matching request as a reactivation even +when its panel remains logically visible through the rebuild. It does not +consume the one-shot initial-placement latch while Dockview is restoring or the +incoming session's entry history refresh is pending. A cached-history refresh +therefore exposes a placement-readiness signal even when the cached rows remain +rendered and the normal loading presentation stays unchanged. + +After layout restoration and history refresh complete, the transcript uses +`scheduleAfterPanelRestore` and re-resolves placement from the incoming session: + +1. A pending explicit navigation target or same-transcript layout restore keeps + priority. +2. Disabled auto-scroll restores + `transcriptAutoScroll.scrollTopBySessionId[sessionId]`, with + `getStoredAutoScrollTop(sessionId)` as its storage fallback. +3. Enabled auto-scroll places the transcript at its current native maximum. + +Completion conditionally clears the same token so a superseded switch cannot +consume a newer request. While a matching request remains pending, the +older-history intersection sentinel is blocked. It can observe geometry again +only after the incoming transcript has been placed. + +Same-environment session switches return before arming this lifecycle. +Maximize, un-maximize, preset, and custom-layout rebuilds retain the existing +`pendingChatScrollTop` path and do not request session initialization. + ## Interaction boundaries The optimization does not replace geometry reads that answer a user-action @@ -108,6 +144,7 @@ placement: - The reader is near the bottom. - No user programmatic scroll lock is active. - No layout restoration is pending. +- No environment-switch initial placement is pending for the session. Disabled auto-scroll continues to restore the captured `scrollTop`. New content cannot move that frozen position through application code or browser @@ -123,7 +160,8 @@ The inactive persistent-portal state exists only in the desktop Dockview workbench. Mobile renders one selected `TaskChatPanel` and replaces its session input instead of keeping inactive session panels mounted. Mobile therefore keeps `isVisible` true and retains its current initial, enabled, and disabled -scroll behavior through the shared coordinator. +scroll behavior through the shared coordinator. It does not consume Dockview's +environment-switch placement requests. The nearest mobile exemplar is the current task Chat tab in `apps/web/components/task/task-layout.tsx`. Existing desktop and mobile @@ -144,6 +182,11 @@ change. transcript to the top. - If a layout restore or explicit navigation owns the scroll, the existing guards suppress bottom placement until that owner releases control. +- If a newer environment switch supersedes a pending placement, the older + token cannot clear or position the newer session. +- If the incoming history refresh fails, the request resolves against the + session history that remains available; it does not fall back to the outgoing + session's offset. ## Related decisions