diff --git a/apps/web/components/task/chat/auto-scroll-toggle-button.test.tsx b/apps/web/components/task/chat/auto-scroll-toggle-button.test.tsx new file mode 100644 index 0000000000..f846e112e9 --- /dev/null +++ b/apps/web/components/task/chat/auto-scroll-toggle-button.test.tsx @@ -0,0 +1,83 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { TooltipProvider } from "@kandev/ui/tooltip"; + +const setTranscriptAutoScrollEnabledMock = vi.fn(); +let enabledBySessionId: Record = {}; + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (state: unknown) => unknown) => + selector({ + transcriptAutoScroll: { enabledBySessionId }, + setTranscriptAutoScrollEnabled: setTranscriptAutoScrollEnabledMock, + }), +})); + +import { AutoScrollToggleButton } from "./auto-scroll-toggle-button"; + +const TOGGLE_TESTID = "auto-scroll-toggle-button"; + +function renderButton(sessionId: string) { + return render( + + + , + ); +} + +function getButton() { + return screen.getByTestId(TOGGLE_TESTID); +} + +describe("AutoScrollToggleButton", () => { + beforeEach(() => { + enabledBySessionId = {}; + setTranscriptAutoScrollEnabledMock.mockReset(); + window.sessionStorage.clear(); + }); + + afterEach(() => { + cleanup(); + }); + it("renders enabled by default and offers to turn auto-scroll off", () => { + renderButton("session-a"); + const button = getButton(); + expect(button.getAttribute("aria-pressed")).toBe("true"); + expect(button.getAttribute("aria-label")).toMatch(/turn off/i); + }); + + it("clicking while enabled disables auto-scroll for the session", () => { + renderButton("session-a"); + fireEvent.click(getButton()); + expect(setTranscriptAutoScrollEnabledMock).toHaveBeenCalledWith("session-a", false); + }); + + it("renders disabled state and offers to turn auto-scroll on", () => { + enabledBySessionId = { "session-a": false }; + renderButton("session-a"); + const button = getButton(); + expect(button.getAttribute("aria-pressed")).toBe("false"); + expect(button.getAttribute("aria-label")).toMatch(/turn on/i); + }); + + it("clicking while disabled re-enables auto-scroll for the session", () => { + enabledBySessionId = { "session-a": false }; + renderButton("session-a"); + fireEvent.click(getButton()); + expect(setTranscriptAutoScrollEnabledMock).toHaveBeenCalledWith("session-a", true); + }); + + it("only toggles the session it was rendered for", () => { + enabledBySessionId = { "session-a": false, "session-b": true }; + renderButton("session-b"); + fireEvent.click(getButton()); + expect(setTranscriptAutoScrollEnabledMock).toHaveBeenCalledWith("session-b", false); + }); + + it("falls back to the sessionStorage-persisted preference when the store hasn't hydrated this session yet", () => { + window.sessionStorage.setItem("kandev.transcript-auto-scroll-enabled.session-a", "false"); + renderButton("session-a"); + const button = getButton(); + expect(button.getAttribute("aria-pressed")).toBe("false"); + }); +}); diff --git a/apps/web/components/task/chat/auto-scroll-toggle-button.tsx b/apps/web/components/task/chat/auto-scroll-toggle-button.tsx new file mode 100644 index 0000000000..8185520873 --- /dev/null +++ b/apps/web/components/task/chat/auto-scroll-toggle-button.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { IconArrowBarToDown, IconArrowBarToDownDashed } from "@tabler/icons-react"; + +import { Button } from "@kandev/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { useAppStore } from "@/components/state-provider"; +import { useTranscriptAutoScrollEnabled } from "./use-transcript-auto-scroll-enabled"; + +// Matches the icon-only top-bar buttons (e.g. Share) so this control sits +// flush with them. +const TOP_BAR_BUTTON_CLASS = + "h-6 w-6 p-0 cursor-pointer text-muted-foreground hover:bg-muted/70 hover:text-foreground focus-visible:ring-1 focus-visible:ring-ring"; + +type Props = { + sessionId: string; +}; + +/** + * Toggles whether the transcript auto-scrolls to the bottom as new messages + * arrive. Enabled by default. Disabling freezes the current scroll position + * (preserved across navigating away and back); re-enabling resumes + * auto-scroll and catches the view up if the transcript progressed while + * disabled. See message-list-native.tsx / message-list-virtuoso.tsx for the + * renderer-side behavior driven by this preference. + */ +export function AutoScrollToggleButton({ sessionId }: Props) { + const enabled = useTranscriptAutoScrollEnabled(sessionId); + const setEnabled = useAppStore((state) => state.setTranscriptAutoScrollEnabled); + + const label = enabled ? "Turn off transcript auto-scroll" : "Turn on transcript auto-scroll"; + + return ( + + + + + {label} + + ); +} diff --git a/apps/web/components/task/chat/chat-input-area.tsx b/apps/web/components/task/chat/chat-input-area.tsx index fcf254ede0..909d3597c0 100644 --- a/apps/web/components/task/chat/chat-input-area.tsx +++ b/apps/web/components/task/chat/chat-input-area.tsx @@ -5,6 +5,7 @@ import { IconArrowRight } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { TodoIndicator } from "./todo-indicator"; +import { AutoScrollToggleButton } from "./auto-scroll-toggle-button"; import { PRMergedBanner, PRClosedBanner } from "./pr-archive-banners"; import { PRStatusChip } from "@/components/github/pr-status-chip"; import { AzureDevOpsTaskPullRequestChip } from "@/components/azure-devops/azure-devops-task-pull-request-chip"; @@ -40,6 +41,10 @@ import { resolveComposerWorkspaceId } from "./composer-workspace"; const PLAN_CONTEXT_PATH = "plan:context"; +/** + * Prepends any pending review/walkthrough/PR-feedback/plan/message comments + * to the composer text as Markdown, in a fixed stacking order, before send. + */ export function buildSubmitMessage({ message, reviewComments, @@ -78,6 +83,7 @@ export function buildSubmitMessage({ return finalMessage; } +/** Resolves the composer placeholder text for the current agent/document/plan state. */ export function resolveInputPlaceholder( isAgentBusy: boolean, activeDocumentType: string | undefined, @@ -103,6 +109,8 @@ type PlaceholderArgs = { needsRecovery: boolean; }; +/** Picks the composer placeholder: an explicit override wins, then the + * "switching agent" state, then {@link resolveInputPlaceholder}. */ function pickInputPlaceholder(a: PlaceholderArgs): string { if (a.isMoving) return "Switching agent..."; // Preserve the prior `??` semantics: an explicit "" override (caller wants @@ -117,6 +125,8 @@ function pickInputPlaceholder(a: PlaceholderArgs): string { ); } +/** Shows an error toast for a failed message send, distinguishing a known + * send error from an ambiguous connection drop/timeout. */ function showMessageSendToast(error: unknown, toast: ReturnType["toast"]) { console.error("Failed to send message:", error); if (isMessageSendError(error)) { @@ -135,6 +145,7 @@ function showMessageSendToast(error: unknown, toast: ReturnType }); } +/** Adapts {@link useChatPanelState}'s fields into {@link useMessageHandler}'s params. */ function usePanelMessageHandler(panelState: ReturnType) { const { resolvedSessionId, @@ -160,6 +171,8 @@ function usePanelMessageHandler(panelState: ReturnType }); } +/** Builds the composer's submit handler, tracking in-flight sends and + * routing errors to a toast. */ export function useSubmitHandler( panelState: ReturnType, onSend?: (payload: ChatSubmitPayload) => ChatSubmitResult, @@ -252,6 +265,7 @@ export function useSubmitHandler( return { isSending, handleSubmit }; } +/** Builds the cancel-turn handler and registers the global focus-input keyboard shortcut. */ export function useChatPanelHandlers( resolvedSessionId: string | null, chatInputRef: React.RefObject, @@ -298,6 +312,11 @@ type TodoDisplayItem = { status?: "pending" | "in_progress" | "completed" | "failed"; }; +/** + * Row above the composer showing todo progress, PR/CI status chips, + * merged/closed PR banners, the auto-scroll toggle + Share (right-aligned), + * and a "move to next step" action when the workflow allows it. + */ function ChatStatusBar({ todoItems, taskId, @@ -322,6 +341,9 @@ function ChatStatusBar({ const showTodos = todoItems.length > 0; const showProceed = !!nextStepName && !isAgentBusy; const canShare = !!taskId && !!sessionId && shareableSessionStateClient(sessionState); + // Auto-scroll toggle only needs a session (quick-chat/ephemeral sessions + // have a transcript with no taskId); Share additionally needs a taskId. + const showRightControls = !!sessionId; // PRMergedBanner returns null internally when not applicable return (
} {taskId && } - {canShare && taskId && sessionId && ( -
- + {showRightControls && ( +
+ {sessionId && } + {canShare && taskId && sessionId && ( + + )}
)} {showProceed && ( @@ -349,7 +374,7 @@ function ChatStatusBar({ type="button" variant="outline" size="sm" - className={`${canShare ? "" : "ml-auto "}h-6 gap-1 px-2.5 text-xs cursor-pointer text-primary`} + className={`${showRightControls ? "" : "ml-auto "}h-6 gap-1 px-2.5 text-xs cursor-pointer text-primary`} onClick={onProceed} disabled={isMoving} data-testid="proceed-next-step" @@ -383,6 +408,7 @@ type ChatInputAreaProps = { surfaceClassName?: string; }; +/** Resolves whether this session's executor environment is unavailable, and why. */ function useExecutorUnavailable(taskId: string | null, sessionId: string | null) { const availability = useExecutorEnvironmentAvailability(taskId, Boolean(sessionId && taskId)); return { @@ -391,6 +417,7 @@ function useExecutorUnavailable(taskId: string | null, sessionId: string | null) }; } +/** Resolves the workspace ID the composer should attribute a new message to. */ function useComposerWorkspaceId(sessionId: string | null, taskId: string | null) { return useAppStore((state) => resolveComposerWorkspaceId({ @@ -405,6 +432,7 @@ function useComposerWorkspaceId(sessionId: string | null, taskId: string | null) ); } +/** Derives the composer's plan actions, executor-availability state, and placeholder text. */ function useChatInputDerived( panelState: ReturnType, chatInputRef: React.RefObject, @@ -433,10 +461,16 @@ function useChatInputDerived( return { planActions, executor, placeholder }; } +/** Whether the user can manually drain the queued-message backlog right now + * (no pending clarification, and the session is idle/waiting for input). */ function canManuallyDrainQueue(pendingClarification: unknown, sessionState: string | null) { return !pendingClarification && (sessionState === "WAITING_FOR_INPUT" || sessionState === "IDLE"); } +/** + * The chat composer: input box, submit/cancel handling, plan-mode toggle, + * clarification banner, and the {@link ChatStatusBar} above it. + */ export function ChatInputArea({ chatInputRef, clarificationKey, diff --git a/apps/web/components/task/chat/message-list-native.tsx b/apps/web/components/task/chat/message-list-native.tsx index 0a10de6bbb..ebba72e575 100644 --- a/apps/web/components/task/chat/message-list-native.tsx +++ b/apps/web/components/task/chat/message-list-native.tsx @@ -3,10 +3,24 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, memo } from "react"; import { SessionPanelContent } from "@kandev/ui/pannel-session"; import { useDockviewStore } from "@/lib/state/dockview-store"; +import { useAppStoreApi } from "@/components/state-provider"; +import { getStoredAutoScrollTop } from "@/lib/local-storage"; import type { Message } from "@/lib/types/http"; +import type { RenderItem } from "@/hooks/use-processed-messages"; import { useLazyLoadMessages } from "@/hooks/use-lazy-load-messages"; import { useSessionTurn } from "@/hooks/domains/session/use-session-turn"; import { MessageListFooter } from "./message-list-footer"; +import { useTranscriptAutoScrollEnabled } from "./use-transcript-auto-scroll-enabled"; +import { + shouldAutoScrollOnMessagesChange, + shouldAutoScrollOnWorkingStart, + shouldCatchUpOnAutoScrollEnable, + hasTranscriptProgressedPastView, + hasTranscriptAppendedSinceBaseline, + resolveNativeInitialScrollTop, + isPrependUpdate, + createFrameCoalescer, +} from "./transcript-auto-scroll"; import { type MessageListProps, MessageListStatus, @@ -20,15 +34,19 @@ import { /** * Continuously captures scroll state via scroll listener. - * On prepend (itemCount increases), restores scroll position so the user - * stays at the same visual spot. + * On a genuine prepend (older messages loaded above the current view, so the + * first rendered item's identity changes), restores scroll position so the + * user stays at the same visual spot. A plain append (new item count grows + * but the first item is unchanged) is left alone — that's the auto-scroll + * hook's concern, not this one's. */ function useScrollPositionOnPrepend( scrollRef: React.RefObject, - itemCount: number, + items: RenderItem[], ) { const scrollState = useRef({ scrollHeight: 0, scrollTop: 0 }); - const prevItemCount = useRef(itemCount); + const prevItemCountRef = useRef(items.length); + const prevFirstKeyRef = useRef(items.length > 0 ? getItemKey(items[0]) : null); useEffect(() => { const el = scrollRef.current; @@ -44,17 +62,24 @@ function useScrollPositionOnPrepend( useLayoutEffect(() => { const el = scrollRef.current; - if (!el || itemCount <= prevItemCount.current) { - prevItemCount.current = itemCount; - return; - } + const nextFirstKey = items.length > 0 ? getItemKey(items[0]) : null; + const prepend = + !!el && + isPrependUpdate({ + prevItemCount: prevItemCountRef.current, + nextItemCount: items.length, + prevFirstKey: prevFirstKeyRef.current, + nextFirstKey, + }); + prevItemCountRef.current = items.length; + prevFirstKeyRef.current = nextFirstKey; + if (!el || !prepend) return; const prev = scrollState.current; const delta = el.scrollHeight - prev.scrollHeight; if (delta > 0) { el.scrollTop = prev.scrollTop + delta; } - prevItemCount.current = itemCount; - }, [itemCount, scrollRef]); + }, [items, scrollRef]); } /** @@ -121,50 +146,211 @@ function useLazyLoadSentinel( /** * Auto-scrolls to bottom when new messages arrive (if user is near bottom) - * or when the agent starts working (isWorking transitions to true). + * or when the agent starts working (isWorking transitions to true) — unless + * auto-scroll is disabled for this session, in which case both are + * suppressed and the current position is continuously persisted so it + * survives a dockview panel remount. Re-enabling catches the view up to the + * bottom if the transcript progressed past it while disabled. + * + * Returns `isNearBottomRef` so the caller's initial-scroll effect can keep it + * in sync after applying the resolved initial scrollTop. */ function useAutoScroll( scrollRef: React.RefObject, messages: Message[], isWorking: boolean, + sessionId: string | null, + enabled: boolean, ) { + const storeApi = useAppStoreApi(); const isNearBottomRef = useRef(true); const prevIsWorkingRef = useRef(isWorking); useEffect(() => { const el = scrollRef.current; if (!el) return; + const captureScrollTop = () => { + if (sessionId) storeApi.getState().setTranscriptScrollTop(sessionId, el.scrollTop); + }; + // Coalesce persisted writes to at most one per animation frame — native + // scroll events can fire far more often than that, and each write is a + // synchronous sessionStorage.setItem plus a store update. + const coalescer = createFrameCoalescer(captureScrollTop); const onScroll = () => { isNearBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 100; + coalescer.schedule(); }; el.addEventListener("scroll", onScroll, { passive: true }); - return () => el.removeEventListener("scroll", onScroll); - }, [scrollRef]); + return () => { + el.removeEventListener("scroll", onScroll); + // Final capture on unmount so a disabled session's exact position + // 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(); + }; + }, [scrollRef, sessionId]); - // When isWorking transitions to true, force scroll to bottom + // When isWorking transitions to true, force scroll to bottom (unless + // disabled, or a layout rebuild scroll restore is pending — same guard + // as the messages-change effect below). useEffect(() => { - if (isWorking && !prevIsWorkingRef.current) { + if (isWorking && !prevIsWorkingRef.current && shouldAutoScrollOnWorkingStart(enabled)) { const el = scrollRef.current; - if (el) { + if (el && useDockviewStore.getState().pendingChatScrollTop === null) { el.scrollTop = el.scrollHeight; isNearBottomRef.current = true; } } prevIsWorkingRef.current = isWorking; - }, [isWorking, scrollRef]); + }, [isWorking, scrollRef, enabled]); - // Auto-scroll on new messages if near bottom + // Auto-scroll on new messages if near bottom (unless disabled) useLayoutEffect(() => { const el = scrollRef.current; if (!el) return; // Skip auto-scroll when a layout rebuild scroll restore is pending if (useDockviewStore.getState().pendingChatScrollTop !== null) return; - if (isNearBottomRef.current) { + if (shouldAutoScrollOnMessagesChange(enabled, isNearBottomRef.current)) { el.scrollTop = el.scrollHeight; } - }, [messages, scrollRef]); + }, [messages, scrollRef, enabled]); + + useCatchUpOnReEnable(scrollRef, messages, enabled, isNearBottomRef); + + return isNearBottomRef; } +/** + * Catches the view up to the bottom when the user re-enables auto-scroll, + * but only if the transcript actually appended content while disabled — + * never on a manual scroll or a prepend, which don't change baselineRef's + * identity. A one-time mount-time init covers panels that remount already + * disabled (no live transition to capture a baseline from). + */ +function useCatchUpOnReEnable( + scrollRef: React.RefObject, + messages: Message[], + enabled: boolean, + isNearBottomRef: React.RefObject, +) { + const prevEnabledRef = useRef(enabled); + const baselineRef = useRef<{ + count: number; + lastId: string | null; + lastUpdatedAt: string | undefined; + } | null>(null); + const hasInitializedBaselineRef = useRef(false); + useEffect(() => { + const wasEnabled = prevEnabledRef.current; + prevEnabledRef.current = enabled; + const captureBaseline = () => { + const last = messages[messages.length - 1]; + baselineRef.current = { + count: messages.length, + lastId: last?.id ?? null, + lastUpdatedAt: last?.updated_at, + }; + }; + + // First run: if the panel mounted already disabled (e.g. a session + // opened, or remounted via a dockview rebuild, with a persisted + // disabled preference), there was no in-process disable transition to + // capture a baseline from — establish one now from whatever the + // transcript looks like at mount, so a later re-enable can still detect + // genuine progression instead of never catching up. + if (!hasInitializedBaselineRef.current) { + hasInitializedBaselineRef.current = true; + if (!enabled) captureBaseline(); + return; + } + + if (wasEnabled === enabled) return; + if (!enabled) { + // Transitioning to disabled: capture the baseline used to detect real + // progression (a new row, or the trailing row streaming more content) + // once re-enabled. + captureBaseline(); + return; + } + // Transitioning to enabled. + const el = scrollRef.current; + const baseline = baselineRef.current; + baselineRef.current = null; + if (!el || !baseline) return; + const lastNow = messages[messages.length - 1]; + const appendedSinceDisable = hasTranscriptAppendedSinceBaseline({ + baselineCount: baseline.count, + currentCount: messages.length, + baselineLastId: baseline.lastId, + currentLastId: lastNow?.id ?? null, + baselineLastUpdatedAt: baseline.lastUpdatedAt, + currentLastUpdatedAt: lastNow?.updated_at, + }); + const isAtBottom = !hasTranscriptProgressedPastView({ + scrollTop: el.scrollTop, + scrollHeight: el.scrollHeight, + clientHeight: el.clientHeight, + }); + if ( + shouldCatchUpOnAutoScrollEnable({ + wasEnabled, + nowEnabled: enabled, + appendedSinceDisable, + isAtBottom, + }) + ) { + el.scrollTop = el.scrollHeight; + isNearBottomRef.current = true; + } + }, [enabled, scrollRef, messages]); +} + +/** + * 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. + */ +function useInitialScrollPosition( + scrollRef: React.RefObject, + itemCount: number, + sessionId: string | null, + enabled: boolean, + isNearBottomRef: React.RefObject, +) { + const storeApi = useAppStoreApi(); + const didInitialScroll = useRef(false); + useEffect(() => { + if (didInitialScroll.current || itemCount === 0) return; + const el = scrollRef.current; + if (!el) return; + const hasPendingLayoutRestore = useDockviewStore.getState().pendingChatScrollTop !== null; + const savedScrollTop = sessionId + ? (storeApi.getState().transcriptAutoScroll.scrollTopBySessionId[sessionId] ?? + getStoredAutoScrollTop(sessionId) ?? + undefined) + : undefined; + const scrollTop = resolveNativeInitialScrollTop({ + enabled, + hasPendingLayoutRestore, + savedScrollTop, + scrollHeight: el.scrollHeight, + }); + if (scrollTop !== null) { + el.scrollTop = scrollTop; + isNearBottomRef.current = !hasTranscriptProgressedPastView({ + scrollTop, + scrollHeight: el.scrollHeight, + clientHeight: el.clientHeight, + }); + } + didInitialScroll.current = true; + }, [itemCount, sessionId, enabled, isNearBottomRef, storeApi]); +} + +/** Returns a stable callback that smooth-scrolls a given message into the + * center of the viewport, given its rendered `msg-` element. */ function useScrollToMessage() { return useCallback((messageId: string) => { const el = document.getElementById(`msg-${messageId}`); @@ -172,6 +358,12 @@ function useScrollToMessage() { }, []); } +/** + * Renders the transcript as plain DOM nodes with `overflow-anchor` for + * scroll pinning. Wires together lazy-loading of older messages, the + * scroll-position-on-prepend fix-up, and the session's auto-scroll toggle + * (freeze/resume/catch-up) via the hooks defined above. + */ export const NativeMessageList = memo(function NativeMessageList({ items, messages, @@ -200,26 +392,18 @@ export const NativeMessageList = memo(function NativeMessageList({ const streamingMessageId = getStreamingAgentMessageId(messages); const lastTurnGroupId = useMemo(() => getLastTurnGroupId(items), [items]); const handleScrollToMessage = useScrollToMessage(); + const autoScrollEnabled = useTranscriptAutoScrollEnabled(sessionId); - useScrollPositionOnPrepend(scrollRef, items.length); + useScrollPositionOnPrepend(scrollRef, items); const sentinelRef = useLazyLoadSentinel(scrollRef, hasMore, isLoadingMore, loadMore); - useAutoScroll(scrollRef, messages, isWorking); - - // Scroll to bottom on initial load - const didInitialScroll = useRef(false); - useEffect(() => { - if (didInitialScroll.current || items.length === 0) return; - const el = scrollRef.current; - if (!el) return; - // If a layout rebuild scroll restore is pending, skip initial scroll - // (the restore handler will set the correct position) - if (useDockviewStore.getState().pendingChatScrollTop !== null) { - didInitialScroll.current = true; - return; - } - el.scrollTop = el.scrollHeight; - didInitialScroll.current = true; - }, [items.length]); + const isNearBottomRef = useAutoScroll( + scrollRef, + messages, + isWorking, + sessionId, + autoScrollEnabled, + ); + useInitialScrollPosition(scrollRef, items.length, sessionId, autoScrollEnabled, isNearBottomRef); return ( @@ -265,8 +449,12 @@ export const NativeMessageList = memo(function NativeMessageList({ footerActionMessages={footerActionMessages} /> - {/* Bottom anchor — browser keeps scroll pinned here when new content appends */} -
+ {/* Bottom anchor — browser keeps scroll pinned here when new content + appends, but only while auto-scroll is enabled: without disabling + the anchor too, the browser's native scroll-anchoring keeps + tracking the bottom on its own and defeats the toggle for a user + who disables from the current bottom. */} +
); }); diff --git a/apps/web/components/task/chat/message-list-virtuoso.tsx b/apps/web/components/task/chat/message-list-virtuoso.tsx index 20734ea1cb..7d33d0accd 100644 --- a/apps/web/components/task/chat/message-list-virtuoso.tsx +++ b/apps/web/components/task/chat/message-list-virtuoso.tsx @@ -2,13 +2,20 @@ import type React from "react"; import { useCallback, useEffect, useMemo, useRef, useState, memo } from "react"; -import { Virtuoso, type VirtuosoHandle } from "react-virtuoso"; +import { Virtuoso, type VirtuosoHandle, type StateSnapshot } from "react-virtuoso"; import { SessionPanelContent } from "@kandev/ui/pannel-session"; +import { useAppStoreApi } from "@/components/state-provider"; import type { RenderItem } from "@/hooks/use-processed-messages"; import type { Message, TaskSessionState } from "@/lib/types/http"; import { useLazyLoadMessages } from "@/hooks/use-lazy-load-messages"; import { useSessionTurn } from "@/hooks/domains/session/use-session-turn"; import { MessageListFooter } from "./message-list-footer"; +import { useTranscriptAutoScrollEnabled } from "./use-transcript-auto-scroll-enabled"; +import { + resolveFollowOutput, + shouldCatchUpOnAutoScrollEnable, + hasTranscriptAppendedSinceBaseline, +} from "./transcript-auto-scroll"; import { type MessageListProps, MessageListStatus, @@ -36,8 +43,16 @@ type VirtuosoBodyProps = MessageListProps & { loadMore: () => Promise; Header: () => React.ReactNode; Footer: () => React.ReactNode; + /** Whether the transcript auto-scroll toggle is enabled for this session. */ + enabled: boolean; }; +/** + * Computes the next Virtuoso `firstItemIndex` when the underlying item keys + * change, keeping the previously-first item's absolute index stable as + * older messages are prepended (or the list is empty and just populated for + * the first time). + */ function computeFirstItemIndex(prevKeys: string[], prevIndex: number, keys: string[]): number { if (prevKeys.length > 0 && keys.length > prevKeys.length) { const oldFirstKey = prevKeys[0]; @@ -59,6 +74,11 @@ function computeFirstItemIndex(prevKeys: string[], prevIndex: number, keys: stri type IndexState = { keys: string[]; firstItemIndex: number }; +/** + * Tracks a Virtuoso `firstItemIndex` that stays stable across renders as + * items are prepended, recomputing it via {@link computeFirstItemIndex} + * whenever the item keys change. + */ function useStableFirstItemIndex(items: RenderItem[]) { const keys = useMemo(() => items.map(getItemKey), [items]); @@ -97,6 +117,9 @@ function useStableFirstItemIndex(items: RenderItem[]) { return state.firstItemIndex; } +/** Builds the imperative Virtuoso ref, item count/index bookkeeping, and + * the `computeItemKey`/`itemContent`/scroll-to-message callbacks passed to + * the underlying `` element. */ function useVirtuosoCallbacks(props: VirtuosoBodyProps) { const { items, @@ -189,11 +212,128 @@ function useVirtuosoCallbacks(props: VirtuosoBodyProps) { return { virtuosoRef, itemCount, firstItemIndex, handleStartReached, computeItemKey, renderItem }; } -const FOLLOW_SMOOTH = "smooth" as const; -const followOutput = (isAtBottom: boolean) => (isAtBottom ? FOLLOW_SMOOTH : false); +/** + * Owns the atBottom tracking, followOutput resolution, snapshot capture, and + * append-identity baseline used to decide whether re-enabling auto-scroll + * should catch the view up to the bottom — see message-list-native.tsx's + * `useAutoScroll` for the equivalent native-renderer logic. + */ +function useVirtuosoAutoScrollLifecycle(params: { + sessionId: string | null; + enabled: boolean; + messages: Message[]; + virtuosoRef: React.RefObject; + firstItemIndex: number; + itemCount: number; +}) { + const { sessionId, enabled, messages, virtuosoRef, firstItemIndex, itemCount } = params; + const storeApi = useAppStoreApi(); + // Virtuoso's own atBottom tracking, reused both to gate `followOutput` and + // to decide whether re-enabling should catch the view up to the bottom. + const isAtBottomRef = useRef(true); + const handleAtBottomStateChange = useCallback((isAtBottom: boolean) => { + isAtBottomRef.current = isAtBottom; + }, []); + const resolvedFollowOutput = useCallback( + (isAtBottom: boolean) => resolveFollowOutput(enabled, isAtBottom), + [enabled], + ); + + const captureSnapshot = useCallback(() => { + if (!sessionId) return; + virtuosoRef.current?.getState((state) => { + storeApi.getState().setTranscriptVirtuosoState(sessionId, state); + }); + }, [sessionId, storeApi, virtuosoRef]); + + // Capture a restorable snapshot on unmount, so a remounted Virtuoso + // instance (e.g. navigating away and back while disabled) can pick up + // exactly where the user left off. + useEffect(() => captureSnapshot, [captureSnapshot]); + + const baselineRef = useRef<{ + count: number; + lastId: string | null; + lastUpdatedAt: string | undefined; + } | null>(null); + const hasInitializedBaselineRef = useRef(false); + const prevEnabledRef = useRef(enabled); + useEffect(() => { + const wasEnabled = prevEnabledRef.current; + prevEnabledRef.current = enabled; + const captureBaseline = () => { + const last = messages[messages.length - 1]; + baselineRef.current = { + count: messages.length, + lastId: last?.id ?? null, + lastUpdatedAt: last?.updated_at, + }; + }; + + // First run: if the panel mounted already disabled, there was no + // in-process disable transition to capture a baseline from — + // establish one now from the transcript at mount time. + if (!hasInitializedBaselineRef.current) { + hasInitializedBaselineRef.current = true; + if (!enabled) captureBaseline(); + return; + } + + if (wasEnabled === enabled) return; + if (!enabled) { + // Disabling: snapshot immediately, in addition to the unmount capture + // above, so a remount that happens without an intervening re-render + // still restores this exact position. Also capture the append-identity + // baseline used to detect real progression once re-enabled. + captureSnapshot(); + captureBaseline(); + return; + } + // Re-enabling: catch up to the bottom only if the transcript genuinely + // appended content while disabled and it isn't already in view. + const baseline = baselineRef.current; + baselineRef.current = null; + if (!baseline) return; + const lastNow = messages[messages.length - 1]; + const appendedSinceDisable = hasTranscriptAppendedSinceBaseline({ + baselineCount: baseline.count, + currentCount: messages.length, + baselineLastId: baseline.lastId, + currentLastId: lastNow?.id ?? null, + baselineLastUpdatedAt: baseline.lastUpdatedAt, + currentLastUpdatedAt: lastNow?.updated_at, + }); + if ( + shouldCatchUpOnAutoScrollEnable({ + wasEnabled, + nowEnabled: enabled, + appendedSinceDisable, + isAtBottom: isAtBottomRef.current, + }) + ) { + virtuosoRef.current?.scrollToIndex({ index: firstItemIndex + itemCount - 1, align: "end" }); + } + }, [enabled, captureSnapshot, firstItemIndex, itemCount, virtuosoRef, messages]); + + // Restore the saved position on first mount when disabled. Lazy-initialized + // so it's read once at mount time, not on every render. + const [restoreStateFrom] = useState(() => { + if (enabled || !sessionId) return undefined; + return storeApi.getState().transcriptAutoScroll.virtuosoStateBySessionId[sessionId]; + }); + + return { handleAtBottomStateChange, resolvedFollowOutput, restoreStateFrom }; +} + +/** + * Renders the `` element once a scroll parent is available, + * wiring together the stable item index, render callbacks, and the + * auto-scroll lifecycle (follow/catch-up/restore) from + * {@link useVirtuosoAutoScrollLifecycle}. + */ function VirtuosoBody(props: VirtuosoBodyProps) { - const { scrollParent, Header, Footer } = props; + const { scrollParent, Header, Footer, sessionId, enabled, messages } = props; const { virtuosoRef, itemCount, firstItemIndex, handleStartReached, computeItemKey, renderItem } = useVirtuosoCallbacks(props); @@ -215,6 +355,16 @@ function VirtuosoBody(props: VirtuosoBodyProps) { }); }, [itemCount, firstItemIndex, props.hasMore, props.activeTurnId, props.lastTurnGroupId]); + const { handleAtBottomStateChange, resolvedFollowOutput, restoreStateFrom } = + useVirtuosoAutoScrollLifecycle({ + sessionId, + enabled, + messages, + virtuosoRef, + firstItemIndex, + itemCount, + }); + return ( getLastTurnGroupId(items), [items]); + const autoScrollEnabled = useTranscriptAutoScrollEnabled(sessionId); // Track which render branch fires and how itemCount/messageCount transition. // See useVirtuosoDebugSnapshot for details on the remote-executor scroll bug. @@ -531,6 +693,7 @@ export const VirtuosoMessageList = memo(function VirtuosoMessageList(props: Mess loadMore={loadMore} Header={Header} Footer={Footer} + enabled={autoScrollEnabled} /> )} diff --git a/apps/web/components/task/chat/transcript-auto-scroll.test.ts b/apps/web/components/task/chat/transcript-auto-scroll.test.ts new file mode 100644 index 0000000000..6b4b588cf3 --- /dev/null +++ b/apps/web/components/task/chat/transcript-auto-scroll.test.ts @@ -0,0 +1,412 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + shouldAutoScrollOnMessagesChange, + shouldAutoScrollOnWorkingStart, + hasTranscriptProgressedPastView, + hasTranscriptAppendedSinceBaseline, + shouldCatchUpOnAutoScrollEnable, + resolveNativeInitialScrollTop, + resolveFollowOutput, + isPrependUpdate, + createFrameCoalescer, +} from "./transcript-auto-scroll"; + +describe("isPrependUpdate", () => { + it("is a prepend when item count grows and the first item's key changed", () => { + expect( + isPrependUpdate({ + prevItemCount: 20, + nextItemCount: 40, + prevFirstKey: "msg-20", + nextFirstKey: "msg-0", + }), + ).toBe(true); + }); + + it("is NOT a prepend when item count grows but the first item's key is unchanged (append)", () => { + expect( + isPrependUpdate({ + prevItemCount: 20, + nextItemCount: 21, + prevFirstKey: "msg-0", + nextFirstKey: "msg-0", + }), + ).toBe(false); + }); + + it("is not a prepend when item count did not grow", () => { + expect( + isPrependUpdate({ + prevItemCount: 20, + nextItemCount: 20, + prevFirstKey: "msg-0", + nextFirstKey: "msg-5", + }), + ).toBe(false); + }); + + it("is not a prepend when there is no first item yet", () => { + expect( + isPrependUpdate({ + prevItemCount: 0, + nextItemCount: 1, + prevFirstKey: null, + nextFirstKey: null, + }), + ).toBe(false); + }); + + it("is NOT a prepend for the empty-to-first-message transition (initial population, not a prepend)", () => { + expect( + isPrependUpdate({ + prevItemCount: 0, + nextItemCount: 1, + prevFirstKey: null, + nextFirstKey: "msg-0", + }), + ).toBe(false); + }); +}); + +describe("shouldAutoScrollOnMessagesChange", () => { + it("scrolls when enabled and near the bottom", () => { + expect(shouldAutoScrollOnMessagesChange(true, true)).toBe(true); + }); + + it("does not scroll when enabled but scrolled away from the bottom", () => { + expect(shouldAutoScrollOnMessagesChange(true, false)).toBe(false); + }); + + it("never scrolls while auto-scroll is disabled, even near the bottom", () => { + expect(shouldAutoScrollOnMessagesChange(false, true)).toBe(false); + }); +}); + +describe("shouldAutoScrollOnWorkingStart", () => { + it("forces scroll on turn start when enabled", () => { + expect(shouldAutoScrollOnWorkingStart(true)).toBe(true); + }); + + it("suppresses the forced scroll when disabled", () => { + expect(shouldAutoScrollOnWorkingStart(false)).toBe(false); + }); +}); + +describe("hasTranscriptProgressedPastView", () => { + it("is false when the viewport bottom already matches the content bottom", () => { + expect( + hasTranscriptProgressedPastView({ scrollTop: 800, scrollHeight: 900, clientHeight: 100 }), + ).toBe(false); + }); + + it("is false within the sub-pixel settle tolerance", () => { + expect( + hasTranscriptProgressedPastView({ scrollTop: 799, scrollHeight: 900, clientHeight: 100 }), + ).toBe(false); + }); + + it("is true once content extends meaningfully past the current viewport", () => { + expect( + hasTranscriptProgressedPastView({ scrollTop: 500, scrollHeight: 900, clientHeight: 100 }), + ).toBe(true); + }); +}); + +const BASELINE_UPDATED_AT = "2026-01-01T00:00:00Z"; + +const baseline = { + baselineCount: 20, + baselineLastId: "msg-19", + baselineLastUpdatedAt: BASELINE_UPDATED_AT, +}; + +describe("hasTranscriptAppendedSinceBaseline", () => { + it("is true when a new row was appended (count grew and the last id changed)", () => { + expect( + hasTranscriptAppendedSinceBaseline({ + ...baseline, + currentCount: 21, + currentLastId: "msg-20", + currentLastUpdatedAt: "2026-01-01T00:00:05Z", + }), + ).toBe(true); + }); + + it("is true when the same last row streamed new content (id unchanged, updated_at advanced)", () => { + expect( + hasTranscriptAppendedSinceBaseline({ + ...baseline, + currentCount: 20, + currentLastId: "msg-19", + currentLastUpdatedAt: "2026-01-01T00:00:05Z", + }), + ).toBe(true); + }); + + it("is false for a prepend-only load (count grew but the last id and its updated_at are unchanged)", () => { + expect( + hasTranscriptAppendedSinceBaseline({ + ...baseline, + currentCount: 40, + currentLastId: "msg-19", + currentLastUpdatedAt: BASELINE_UPDATED_AT, + }), + ).toBe(false); + }); + + it("is false when nothing changed", () => { + expect( + hasTranscriptAppendedSinceBaseline({ + ...baseline, + currentCount: 20, + currentLastId: "msg-19", + currentLastUpdatedAt: BASELINE_UPDATED_AT, + }), + ).toBe(false); + }); + + it("does not treat a missing current updated_at as a change from a defined baseline", () => { + expect( + hasTranscriptAppendedSinceBaseline({ + ...baseline, + currentCount: 20, + currentLastId: "msg-19", + currentLastUpdatedAt: undefined, + }), + ).toBe(false); + }); + + it("is false when there is no current last id yet", () => { + expect( + hasTranscriptAppendedSinceBaseline({ + baselineCount: 0, + baselineLastId: null, + baselineLastUpdatedAt: undefined, + currentCount: 0, + currentLastId: null, + currentLastUpdatedAt: undefined, + }), + ).toBe(false); + }); + + it("is true for the very first message appearing", () => { + expect( + hasTranscriptAppendedSinceBaseline({ + baselineCount: 0, + baselineLastId: null, + baselineLastUpdatedAt: undefined, + currentCount: 1, + currentLastId: "msg-0", + currentLastUpdatedAt: BASELINE_UPDATED_AT, + }), + ).toBe(true); + }); +}); + +describe("shouldCatchUpOnAutoScrollEnable", () => { + it("catches up when content was appended while disabled and it is not yet in view", () => { + expect( + shouldCatchUpOnAutoScrollEnable({ + wasEnabled: false, + nowEnabled: true, + appendedSinceDisable: true, + isAtBottom: false, + }), + ).toBe(true); + }); + + it("does NOT catch up when nothing genuinely progressed while disabled — neither pre-existing unseen history nor the user scrolling further away themselves counts (appendedSinceDisable is derived purely from message identity/updated_at, so neither can flip it)", () => { + expect( + shouldCatchUpOnAutoScrollEnable({ + wasEnabled: false, + nowEnabled: true, + appendedSinceDisable: false, + isAtBottom: false, + }), + ).toBe(false); + }); + + it("does not catch up when content appended but the user already scrolled down to see it", () => { + expect( + shouldCatchUpOnAutoScrollEnable({ + wasEnabled: false, + nowEnabled: true, + appendedSinceDisable: true, + isAtBottom: true, + }), + ).toBe(false); + }); + + it("does nothing when flipping from enabled to disabled", () => { + expect( + shouldCatchUpOnAutoScrollEnable({ + wasEnabled: true, + nowEnabled: false, + appendedSinceDisable: true, + isAtBottom: false, + }), + ).toBe(false); + }); + + it("does nothing when the enabled state is unchanged", () => { + expect( + shouldCatchUpOnAutoScrollEnable({ + wasEnabled: true, + nowEnabled: true, + appendedSinceDisable: true, + isAtBottom: false, + }), + ).toBe(false); + expect( + shouldCatchUpOnAutoScrollEnable({ + wasEnabled: false, + nowEnabled: false, + appendedSinceDisable: true, + isAtBottom: false, + }), + ).toBe(false); + }); +}); + +describe("resolveNativeInitialScrollTop", () => { + it("defers to a pending dockview layout restore regardless of the toggle", () => { + expect( + resolveNativeInitialScrollTop({ + enabled: false, + hasPendingLayoutRestore: true, + savedScrollTop: 42, + scrollHeight: 900, + }), + ).toBeNull(); + }); + + it("restores the saved offset when disabled", () => { + expect( + resolveNativeInitialScrollTop({ + enabled: false, + hasPendingLayoutRestore: false, + savedScrollTop: 250, + scrollHeight: 900, + }), + ).toBe(250); + }); + + it("falls back to the bottom when disabled but nothing was ever captured", () => { + expect( + resolveNativeInitialScrollTop({ + enabled: false, + hasPendingLayoutRestore: false, + savedScrollTop: undefined, + scrollHeight: 900, + }), + ).toBe(900); + }); + + it("scrolls to the bottom when enabled, ignoring any saved offset", () => { + expect( + resolveNativeInitialScrollTop({ + enabled: true, + hasPendingLayoutRestore: false, + savedScrollTop: 250, + scrollHeight: 900, + }), + ).toBe(900); + }); +}); + +describe("resolveFollowOutput", () => { + it("never follows while auto-scroll is disabled", () => { + expect(resolveFollowOutput(false, true)).toBe(false); + expect(resolveFollowOutput(false, false)).toBe(false); + }); + + it("follows smoothly when enabled and at the bottom", () => { + expect(resolveFollowOutput(true, true)).toBe("smooth"); + }); + + it("does not follow when enabled but scrolled away from the bottom", () => { + expect(resolveFollowOutput(true, false)).toBe(false); + }); +}); + +describe("createFrameCoalescer", () => { + let frameCallbacks: Map void>; + let nextFrameId: number; + let pendingOrder: number[]; + + beforeEach(() => { + frameCallbacks = new Map(); + pendingOrder = []; + nextFrameId = 1; + vi.stubGlobal("requestAnimationFrame", (cb: () => void) => { + const id = nextFrameId++; + frameCallbacks.set(id, cb); + pendingOrder.push(id); + return id; + }); + vi.stubGlobal("cancelAnimationFrame", (id: number) => { + frameCallbacks.delete(id); + pendingOrder = pendingOrder.filter((pendingId) => pendingId !== id); + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + function runPendingFrame() { + const id = pendingOrder.shift(); + if (id === undefined) return; + const cb = frameCallbacks.get(id); + frameCallbacks.delete(id); + cb?.(); + } + + it("coalesces multiple schedule() calls within a frame into a single run() call", () => { + const run = vi.fn(); + const coalescer = createFrameCoalescer(run); + coalescer.schedule(); + coalescer.schedule(); + coalescer.schedule(); + expect(run).not.toHaveBeenCalled(); + runPendingFrame(); + expect(run).toHaveBeenCalledTimes(1); + }); + + it("schedules a new frame after the previous one has fired", () => { + const run = vi.fn(); + const coalescer = createFrameCoalescer(run); + coalescer.schedule(); + runPendingFrame(); + coalescer.schedule(); + runPendingFrame(); + expect(run).toHaveBeenCalledTimes(2); + }); + + it("flush() runs immediately and cancels a pending frame so it does not also fire", () => { + const run = vi.fn(); + const coalescer = createFrameCoalescer(run); + coalescer.schedule(); + coalescer.flush(); + expect(run).toHaveBeenCalledTimes(1); + runPendingFrame(); + expect(run).toHaveBeenCalledTimes(1); + }); + + it("flush() with no pending frame still runs once", () => { + const run = vi.fn(); + const coalescer = createFrameCoalescer(run); + coalescer.flush(); + expect(run).toHaveBeenCalledTimes(1); + }); + + it("schedule() after a flush() can schedule again", () => { + const run = vi.fn(); + const coalescer = createFrameCoalescer(run); + coalescer.schedule(); + coalescer.flush(); + coalescer.schedule(); + runPendingFrame(); + expect(run).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/web/components/task/chat/transcript-auto-scroll.ts b/apps/web/components/task/chat/transcript-auto-scroll.ts new file mode 100644 index 0000000000..953f13195c --- /dev/null +++ b/apps/web/components/task/chat/transcript-auto-scroll.ts @@ -0,0 +1,185 @@ +/** + * Pure decision logic for the transcript auto-scroll toggle, shared by the + * native and Virtuoso message list renderers so the two stay behaviorally + * consistent. Kept side-effect free for direct unit testing — the renderers + * own all DOM / Virtuoso imperative calls and just consult these functions. + */ + +/** + * Distinguishes a genuine prepend (older messages loaded above the current + * view, e.g. via lazy-loading) from a plain append (a new message arriving + * at the end) when the rendered item count grows. `useScrollPositionOnPrepend` + * only needs to compensate scrollTop for the former — compensating an append + * too would drag a scrolled-away user's view down by the height of every new + * message, fighting the auto-scroll toggle's "stay put while disabled" + * contract (and, independently of that toggle, jittering anyone who has + * scrolled up to read older messages while new ones arrive). + */ +export function isPrependUpdate(params: { + prevItemCount: number; + nextItemCount: number; + prevFirstKey: string | null; + nextFirstKey: string | null; +}): boolean { + if (params.nextItemCount <= params.prevItemCount) return false; + if (params.prevFirstKey === null) return false; + if (params.nextFirstKey === null) return false; + return params.nextFirstKey !== params.prevFirstKey; +} + +/** + * Whether new messages arriving should force-scroll the transcript to the + * bottom. The native renderer already gates this on `isNearBottom`; auto-scroll + * being disabled overrides that and suppresses the jump entirely. + */ +export function shouldAutoScrollOnMessagesChange(enabled: boolean, isNearBottom: boolean): boolean { + return enabled && isNearBottom; +} + +/** + * Whether the agent starting a turn (isWorking transitioning to true) should + * force-scroll to the bottom. Disabled auto-scroll suppresses this too — while + * off, nothing should move the view. + */ +export function shouldAutoScrollOnWorkingStart(enabled: boolean): boolean { + return enabled; +} + +// Matches the settle tolerance used elsewhere for "has this scrolled past +// view" checks (see the last-prompt scroll-nav feature) — sub-pixel jitter +// from layout rounding shouldn't count as "progressed". +const SETTLE_TOLERANCE_PX = 2; + +/** + * Whether the transcript's content bottom currently sits meaningfully below + * the visible viewport bottom, i.e. there is unseen content past the current + * scroll position. + */ +export function hasTranscriptProgressedPastView(params: { + scrollTop: number; + scrollHeight: number; + clientHeight: number; +}): boolean { + return params.scrollHeight - params.scrollTop - params.clientHeight > SETTLE_TOLERANCE_PX; +} + +/** + * Whether the transcript actually appended new content since a baseline + * captured at disable time — i.e. a genuinely new message row, or the same + * trailing message streaming in more content. Derived purely from message + * identity and `updated_at` (never scrollTop/scrollHeight), so it can't be + * fooled by: + * - the user scrolling further away themselves while disabled (no message + * data changed, so this stays false), or + * - a prepend (older messages loaded above): count grows but the LAST + * message's id and `updated_at` are untouched, so this stays false. + * + * `updated_at` is the backend's documented "authoritative per-message change + * signal; advances on every content/metadata update" (see Message in + * lib/types/http.ts) — it catches in-place streaming growth of the last row + * even when no new row was appended. + */ +export function hasTranscriptAppendedSinceBaseline(params: { + baselineCount: number; + currentCount: number; + baselineLastId: string | null; + currentLastId: string | null; + baselineLastUpdatedAt: string | undefined; + currentLastUpdatedAt: string | undefined; +}): boolean { + if (params.currentLastId === null) return false; + const newRowAppended = + params.currentCount > params.baselineCount && params.currentLastId !== params.baselineLastId; + const sameRowChanged = + params.currentLastId === params.baselineLastId && + params.currentLastUpdatedAt !== undefined && + params.currentLastUpdatedAt !== params.baselineLastUpdatedAt; + return newRowAppended || sameRowChanged; +} + +/** + * Whether re-enabling auto-scroll (the user flips the toggle back on) should + * immediately catch the view up to the bottom. Only fires on the disabled -> + * enabled transition, and only when the transcript genuinely appended new + * content while disabled (see `hasTranscriptAppendedSinceBaseline`) AND that + * content isn't already in view. Resuming auto-follow with no new content — + * whether the user simply never scrolled back down, or scrolled further away + * themselves while disabled — never forces a scroll here. + */ +export function shouldCatchUpOnAutoScrollEnable(params: { + wasEnabled: boolean; + nowEnabled: boolean; + appendedSinceDisable: boolean; + isAtBottom: boolean; +}): boolean { + if (params.wasEnabled || !params.nowEnabled) return false; + return params.appendedSinceDisable && !params.isAtBottom; +} + +/** + * Resolves the scrollTop the native list should apply right after mount + * (session opened, or the panel remounted via a dockview layout rebuild). + * + * - A pending dockview layout-rebuild restore always wins; that separate + * mechanism owns the position for maximize/un-maximize and runs + * independently of this feature. + * - When auto-scroll is disabled, restore whatever offset was captured the + * last time this session's transcript was visible — falling back to the + * bottom only if nothing was ever captured (first-time disable, or a + * session that has never been scrolled). + * - When enabled, the pre-existing behavior (scroll to bottom) applies. + * + * Returns `null` when the caller should skip applying any scrollTop. + */ +export function resolveNativeInitialScrollTop(params: { + enabled: boolean; + hasPendingLayoutRestore: boolean; + savedScrollTop: number | undefined; + scrollHeight: number; +}): number | null { + if (params.hasPendingLayoutRestore) return null; + if (!params.enabled) return params.savedScrollTop ?? params.scrollHeight; + return params.scrollHeight; +} + +/** + * Coalesces frequent `schedule()` calls into at most one `run` execution per + * animation frame — e.g. persisting scroll position on every native `scroll` + * event is far more expensive (sync storage write + store update) than the + * events actually need. `flush` cancels any pending frame and runs `run` + * once immediately, guaranteeing a final call on cleanup even mid-frame. + */ +export function createFrameCoalescer(run: () => void): { + schedule: () => void; + flush: () => void; +} { + let frameId: number | null = null; + return { + schedule: () => { + if (frameId !== null) return; + frameId = requestAnimationFrame(() => { + frameId = null; + run(); + }); + }, + flush: () => { + if (frameId !== null) { + cancelAnimationFrame(frameId); + frameId = null; + } + run(); + }, + }; +} + +const FOLLOW_SMOOTH = "smooth" as const; + +/** + * Virtuoso `followOutput` resolver: identical to the renderer's original + * always-on behavior, except disabled auto-scroll unconditionally suppresses + * following regardless of Virtuoso's own atBottom tracking. + */ +export function resolveFollowOutput(enabled: boolean, isAtBottom: boolean): "smooth" | false { + if (!enabled) return false; + return isAtBottom ? FOLLOW_SMOOTH : false; +} diff --git a/apps/web/components/task/chat/use-transcript-auto-scroll-enabled.ts b/apps/web/components/task/chat/use-transcript-auto-scroll-enabled.ts new file mode 100644 index 0000000000..7c9dcaeec7 --- /dev/null +++ b/apps/web/components/task/chat/use-transcript-auto-scroll-enabled.ts @@ -0,0 +1,29 @@ +"use client"; + +import { useAppStore } from "@/components/state-provider"; +import { getStoredAutoScrollEnabled } from "@/lib/local-storage"; +import { isTranscriptAutoScrollEnabled } from "@/lib/state/slices/ui/transcript-auto-scroll-selectors"; + +/** + * Reactive read of the per-session auto-scroll preference. Shared by the + * toggle button and both message list renderers so all three consistently + * default new sessions to enabled. Non-reactive reads/writes (from scroll + * listeners, imperative Virtuoso callbacks) go straight through + * `useAppStore.getState()` instead — see message-list-native.tsx / + * message-list-virtuoso.tsx. + * + * Falls back to the sessionStorage-persisted preference (see + * setTranscriptAutoScrollEnabled) when the in-memory store hasn't seen this + * session yet — e.g. right after a hard reload or reopening a task in a new + * tab within the same browser session. + */ +export function useTranscriptAutoScrollEnabled(sessionId: string | null): boolean { + return useAppStore((state) => { + const { enabledBySessionId } = state.transcriptAutoScroll; + if (sessionId && !(sessionId in enabledBySessionId)) { + const stored = getStoredAutoScrollEnabled(sessionId); + if (stored !== null) return stored; + } + return isTranscriptAutoScrollEnabled(enabledBySessionId, sessionId); + }); +} diff --git a/apps/web/e2e/tests/chat/auto-scroll-toggle.spec.ts b/apps/web/e2e/tests/chat/auto-scroll-toggle.spec.ts new file mode 100644 index 0000000000..b1bc82984a --- /dev/null +++ b/apps/web/e2e/tests/chat/auto-scroll-toggle.spec.ts @@ -0,0 +1,370 @@ +import { type Page } from "@playwright/test"; +import { test, expect } from "../../fixtures/test-base"; +import type { SeedData } from "../../fixtures/test-base"; +import type { ApiClient } from "../../helpers/api-client"; +import { KanbanPage } from "../../pages/kanban-page"; +import { SessionPage } from "../../pages/session-page"; + +/** + * Seed a task whose mock-agent script emits enough distinct messages to + * overflow the chat list, then open it and wait for the turn to finish. + */ +async function seedOverflowingTask( + testPage: Page, + apiClient: ApiClient, + seedData: SeedData, + title: string, + messageCount = 30, +): Promise { + const script = Array.from( + { length: messageCount }, + (_, i) => + `e2e:message("Filler message ${i + 1} - lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua")`, + ).join("\n"); + + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + title, + seedData.agentProfileId, + { + description: script, + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id"); + + await testPage.goto(`/t/${task.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await session.waitForChatIdle({ timeout: 30_000 }); + return session; +} + +function chatList(testPage: Page) { + return testPage.locator(".chat-message-list:visible").first(); +} + +async function waitForOverflow(testPage: Page) { + await expect + .poll(async () => chatList(testPage).evaluate((el) => el.scrollHeight - el.clientHeight), { + timeout: 15_000, + message: "Waiting for chat to overflow", + }) + .toBeGreaterThan(200); +} + +test.describe("Transcript auto-scroll toggle", () => { + test("is visible next to Share, enabled by default, and toggles on click", async ({ + testPage, + apiClient, + seedData, + }) => { + const session = await seedOverflowingTask( + testPage, + apiClient, + seedData, + "Auto-scroll Toggle Basic", + 2, + ); + + const statusBar = session.chatStatusBar(); + const toggle = statusBar.getByTestId("auto-scroll-toggle-button"); + await expect(toggle).toBeVisible({ timeout: 10_000 }); + await expect(toggle).toHaveAttribute("aria-pressed", "true"); + + // Sits immediately to the left of Share within the same right-aligned cluster. + const shareButton = statusBar.getByTestId("share-task-button"); + if (await shareButton.isVisible()) { + const toggleBox = await toggle.boundingBox(); + const shareBox = await shareButton.boundingBox(); + expect(toggleBox).not.toBeNull(); + expect(shareBox).not.toBeNull(); + if (toggleBox && shareBox) { + expect(toggleBox.x).toBeLessThan(shareBox.x); + } + } + + await toggle.click(); + await expect(toggle).toHaveAttribute("aria-pressed", "false"); + + await toggle.click(); + await expect(toggle).toHaveAttribute("aria-pressed", "true"); + }); + + test("disabling freezes the position and suppresses auto-scroll for new messages", async ({ + testPage, + apiClient, + seedData, + }) => { + const session = await seedOverflowingTask( + testPage, + apiClient, + seedData, + "Auto-scroll Toggle Freeze", + ); + await waitForOverflow(testPage); + + const list = chatList(testPage); + const targetScrollTop = await list.evaluate((el) => { + el.scrollTop = Math.floor((el.scrollHeight - el.clientHeight) / 2); + 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"); + + // A brand-new message arrives (a real follow-up turn over the live WS + // pipeline) while scrolled away from the bottom and disabled. + await session.sendMessage('e2e:message("New content while disabled")'); + await expect(session.chat.getByText("New content while disabled").last()).toBeVisible({ + timeout: 15_000, + }); + + // The view must not have jumped — scrollTop stays put even though the + // transcript grew taller. + await expect + .poll(async () => list.evaluate((el) => el.scrollTop), { timeout: 2_000 }) + .toBeLessThan(targetScrollTop + 10); + expect(await list.evaluate((el) => el.scrollTop)).toBeGreaterThan(targetScrollTop - 10); + }); + + test("disabling while genuinely at the bottom still freezes the view when new content arrives", async ({ + testPage, + apiClient, + seedData, + }) => { + const session = await seedOverflowingTask( + testPage, + apiClient, + seedData, + "Auto-scroll Toggle Bottom Anchor", + ); + await waitForOverflow(testPage); + + const list = chatList(testPage); + // The renderer auto-scrolls to the bottom by default, so this should + // already hold — assert it explicitly so the test fails loudly if that + // assumption ever breaks instead of silently passing for the wrong + // reason. + await expect + .poll(async () => list.evaluate((el) => el.scrollHeight - el.scrollTop - el.clientHeight), { + timeout: 5_000, + message: "expected to be at the bottom before disabling", + }) + .toBeLessThan(5); + const bottomScrollTop = await list.evaluate((el) => el.scrollTop); + + const toggle = session.chatStatusBar().getByTestId("auto-scroll-toggle-button"); + await toggle.click(); + await expect(toggle).toHaveAttribute("aria-pressed", "false"); + + // A new message arrives while disabled from the true bottom. The + // browser's native bottom overflow-anchor must not override the + // toggle: without disabling it too, the anchor keeps itself pinned to + // the viewport, dragging scrollTop down to chase the new content. + await session.sendMessage('e2e:message("New content while disabled at bottom")'); + await expect(session.chat.getByText("New content while disabled at bottom").last()).toBeVisible( + { timeout: 15_000 }, + ); + + await expect + .poll(async () => list.evaluate((el) => el.scrollTop), { timeout: 2_000 }) + .toBeLessThanOrEqual(bottomScrollTop + 2); + }); + + test("preserves the frozen scroll position across navigating away and back", async ({ + testPage, + apiClient, + seedData, + }) => { + const session = await seedOverflowingTask( + testPage, + apiClient, + seedData, + "Auto-scroll Toggle Nav", + ); + await waitForOverflow(testPage); + + const list = chatList(testPage); + const targetScrollTop = await list.evaluate((el) => { + el.scrollTop = Math.floor((el.scrollHeight - el.clientHeight) / 2); + 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"); + + // Navigate away to the kanban board, then back into the same task — + // this remounts the chat panel via dockview's layout rebuild. + const kanban = new KanbanPage(testPage); + await kanban.goto(); + const card = kanban.taskCardByTitle("Auto-scroll Toggle Nav"); + await expect(card).toBeVisible({ timeout: 15_000 }); + await card.click(); + await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 }); + + const sessionAfter = new SessionPage(testPage); + await sessionAfter.waitForLoad(); + + const listAfter = chatList(testPage); + await listAfter.waitFor({ state: "visible", timeout: 10_000 }); + + // Position is restored, not reset to the bottom — and the toggle itself + // still reflects the disabled preference. + await expect + .poll(async () => listAfter.evaluate((el) => el.scrollTop), { + timeout: 5_000, + message: "scroll position should be restored after navigating back", + }) + .toBeGreaterThan(targetScrollTop - 20); + const toggleAfter = sessionAfter.chatStatusBar().getByTestId("auto-scroll-toggle-button"); + await expect(toggleAfter).toHaveAttribute("aria-pressed", "false"); + + // Re-enabling must NOT jump to the bottom: nothing new arrived while + // disabled — the unseen content below is pre-existing history the user + // was already scrolled away from before disabling, not progression. + await toggleAfter.click(); + await expect(toggleAfter).toHaveAttribute("aria-pressed", "true"); + await testPage.waitForTimeout(300); + expect(await listAfter.evaluate((el) => el.scrollTop)).toBeGreaterThan(targetScrollTop - 20); + }); + + test("re-enabling does not jump to the bottom when nothing progressed while disabled", async ({ + testPage, + apiClient, + seedData, + }) => { + const session = await seedOverflowingTask( + testPage, + apiClient, + seedData, + "Auto-scroll Toggle No Progress", + ); + await waitForOverflow(testPage); + + const list = chatList(testPage); + const targetScrollTop = await list.evaluate((el) => { + el.scrollTop = Math.floor((el.scrollHeight - el.clientHeight) / 2); + 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"); + + // No new message arrives — the user is just reading older history that + // already existed below their view before they disabled. + await toggle.click(); + await expect(toggle).toHaveAttribute("aria-pressed", "true"); + await testPage.waitForTimeout(300); + expect(await list.evaluate((el) => el.scrollTop)).toBeGreaterThan(targetScrollTop - 20); + expect(await list.evaluate((el) => el.scrollTop)).toBeLessThan(targetScrollTop + 20); + }); + + test("re-enabling does not jump when the user scrolls further away themselves while disabled", async ({ + testPage, + apiClient, + seedData, + }) => { + const session = await seedOverflowingTask( + testPage, + apiClient, + seedData, + "Auto-scroll Toggle Manual Scroll", + ); + await waitForOverflow(testPage); + + const list = chatList(testPage); + await list.evaluate((el) => { + el.scrollTop = Math.floor((el.scrollHeight - el.clientHeight) / 2); + }); + + const toggle = session.chatStatusBar().getByTestId("auto-scroll-toggle-button"); + await toggle.click(); + await expect(toggle).toHaveAttribute("aria-pressed", "false"); + + // The user scrolls further up on their own — no new content arrives. + const scrolledUpTarget = await list.evaluate((el) => { + el.scrollTop = 0; + return el.scrollTop; + }); + expect(scrolledUpTarget).toBe(0); + + await toggle.click(); + await expect(toggle).toHaveAttribute("aria-pressed", "true"); + await testPage.waitForTimeout(300); + expect(await list.evaluate((el) => el.scrollTop)).toBe(0); + }); + + test("catches up on re-enable when content arrives after remounting while already disabled", async ({ + testPage, + apiClient, + seedData, + }) => { + const session = await seedOverflowingTask( + testPage, + apiClient, + seedData, + "Auto-scroll Toggle Remount Progress", + ); + await waitForOverflow(testPage); + + const list = chatList(testPage); + const targetScrollTop = await list.evaluate((el) => { + el.scrollTop = Math.floor((el.scrollHeight - el.clientHeight) / 2); + 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"); + + // Navigate away and back — the panel remounts while the persisted + // preference is still disabled, exercising the mount-time baseline + // initialization (not a live enabled->disabled transition). + const kanban = new KanbanPage(testPage); + await kanban.goto(); + const card = kanban.taskCardByTitle("Auto-scroll Toggle Remount Progress"); + await expect(card).toBeVisible({ timeout: 15_000 }); + await card.click(); + await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 }); + + const sessionAfter = new SessionPage(testPage); + await sessionAfter.waitForLoad(); + const toggleAfter = sessionAfter.chatStatusBar().getByTestId("auto-scroll-toggle-button"); + await expect(toggleAfter).toHaveAttribute("aria-pressed", "false"); + + const listAfterRemount = chatList(testPage); + await listAfterRemount.waitFor({ state: "visible", timeout: 10_000 }); + await expect + .poll(async () => listAfterRemount.evaluate((el) => el.scrollTop), { + timeout: 5_000, + message: "scroll position should be restored after remounting while disabled", + }) + .toBeGreaterThan(targetScrollTop - 20); + + // Genuinely new content arrives now, after the remount, while still disabled. + await sessionAfter.sendMessage('e2e:message("New content after remount while disabled")'); + await expect( + sessionAfter.chat.getByText("New content after remount while disabled").last(), + ).toBeVisible({ timeout: 15_000 }); + + const listAfter = chatList(testPage); + await toggleAfter.click(); + await expect(toggleAfter).toHaveAttribute("aria-pressed", "true"); + await expect + .poll( + async () => listAfter.evaluate((el) => el.scrollHeight - el.scrollTop - el.clientHeight), + { timeout: 5_000, message: "re-enabling should catch up to the genuinely new content" }, + ) + .toBeLessThan(10); + }); +}); diff --git a/apps/web/e2e/tests/chat/mobile-auto-scroll-toggle.spec.ts b/apps/web/e2e/tests/chat/mobile-auto-scroll-toggle.spec.ts new file mode 100644 index 0000000000..91f348195e --- /dev/null +++ b/apps/web/e2e/tests/chat/mobile-auto-scroll-toggle.spec.ts @@ -0,0 +1,114 @@ +import { type Page } from "@playwright/test"; +import { test, expect } from "../../fixtures/test-base"; +import type { SeedData } from "../../fixtures/test-base"; +import type { ApiClient } from "../../helpers/api-client"; +import { SessionPage } from "../../pages/session-page"; + +/** + * Seed a task whose mock-agent script emits enough distinct messages to + * overflow the chat list, then open it and wait for the turn to finish. + * Mirrors `auto-scroll-toggle.spec.ts`'s desktop fixture — kept as a + * separate local copy rather than a shared import so this mobile-only file + * stays self-contained (mobile specs live in their own Playwright project). + */ +async function seedOverflowingTask( + testPage: Page, + apiClient: ApiClient, + seedData: SeedData, + title: string, + messageCount = 30, +): Promise { + const script = Array.from( + { length: messageCount }, + (_, i) => + `e2e:message("Filler message ${i + 1} - lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua")`, + ).join("\n"); + + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + title, + seedData.agentProfileId, + { + description: script, + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id"); + + await testPage.goto(`/t/${task.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await session.waitForChatIdle({ timeout: 30_000 }); + return session; +} + +test.describe("Mobile transcript auto-scroll toggle", () => { + test("is reachable and toggles by touch", async ({ testPage, apiClient, seedData }) => { + const session = await seedOverflowingTask( + testPage, + apiClient, + seedData, + "Mobile Auto-scroll Toggle Reachable", + 2, + ); + + const toggle = session.chatStatusBar().getByTestId("auto-scroll-toggle-button"); + await expect(toggle).toBeVisible({ timeout: 10_000 }); + await expect(toggle).toBeInViewport(); + await expect(toggle).toBeEnabled(); + await expect(toggle).toHaveAttribute("aria-pressed", "true"); + + await toggle.tap(); + await expect(toggle).toHaveAttribute("aria-pressed", "false"); + + await toggle.tap(); + await expect(toggle).toHaveAttribute("aria-pressed", "true"); + }); + + test("disabling freezes the position and suppresses auto-scroll for new messages in the mobile chat layout", async ({ + testPage, + apiClient, + seedData, + }) => { + const session = await seedOverflowingTask( + testPage, + apiClient, + seedData, + "Mobile Auto-scroll Toggle Freeze", + ); + + const activeChat = session.activeChat(); + const list = activeChat.locator(".chat-message-list"); + await expect(list).toHaveCount(1); + await expect + .poll(async () => list.evaluate((el) => el.scrollHeight - el.clientHeight), { + timeout: 15_000, + message: "Waiting for chat to overflow", + }) + .toBeGreaterThan(200); + + const targetScrollTop = await list.evaluate((el) => { + el.scrollTop = Math.floor((el.scrollHeight - el.clientHeight) / 2); + return el.scrollTop; + }); + expect(targetScrollTop).toBeGreaterThan(100); + + const toggle = session.chatStatusBar().getByTestId("auto-scroll-toggle-button"); + await toggle.tap(); + await expect(toggle).toHaveAttribute("aria-pressed", "false"); + + // A brand-new message arrives while scrolled away from the bottom and + // disabled — the view must not jump. + const marker = "New content while disabled on mobile"; + await session.sendMessageViaButton(`e2e:message("${marker}")`); + await expect(activeChat.getByText(marker, { exact: false }).last()).toBeVisible({ + timeout: 15_000, + }); + await expect + .poll(async () => list.evaluate((el) => el.scrollTop), { timeout: 2_000 }) + .toBeLessThan(targetScrollTop + 10); + expect(await list.evaluate((el) => el.scrollTop)).toBeGreaterThan(targetScrollTop - 10); + }); +}); diff --git a/apps/web/lib/local-storage.test.ts b/apps/web/lib/local-storage.test.ts index 5988497b4c..1744258f0f 100644 --- a/apps/web/lib/local-storage.test.ts +++ b/apps/web/lib/local-storage.test.ts @@ -5,6 +5,8 @@ import { getGlobalSidebarWidth, getManualRightWidth, getOpenFileTabs, + getStoredAutoScrollEnabled, + getStoredAutoScrollTop, markPRClosedBannerDismissed, markPRMergedBannerDismissed, restoreAttachmentPreview, @@ -12,6 +14,8 @@ import { setManualRightWidth, clearManualRightWidth, setOpenFileTabs, + setStoredAutoScrollEnabled, + setStoredAutoScrollTop, wasPRClosedBannerDismissed, wasPRMergedBannerDismissed, } from "./local-storage"; @@ -247,3 +251,41 @@ describe("chat draft attachment storage", () => { expect(restored.deliveryMode).toBe("path"); }); }); + +describe("transcript auto-scroll storage", () => { + beforeEach(() => { + window.sessionStorage.clear(); + }); + + it("returns null for a session with no recorded preference", () => { + expect(getStoredAutoScrollEnabled("session-a")).toBeNull(); + }); + + it("persists the enabled preference per session and reads it back", () => { + setStoredAutoScrollEnabled("session-a", false); + + expect(getStoredAutoScrollEnabled("session-a")).toBe(false); + expect(getStoredAutoScrollEnabled("session-b")).toBeNull(); + }); + + it("persists the last scrollTop per session and reads it back", () => { + setStoredAutoScrollTop("session-a", 480); + + expect(getStoredAutoScrollTop("session-a")).toBe(480); + expect(getStoredAutoScrollTop("session-b")).toBeNull(); + }); + + it("clears both the enabled preference and scrollTop via cleanupTaskStorage", () => { + setStoredAutoScrollEnabled("session-a", false); + setStoredAutoScrollTop("session-a", 480); + setStoredAutoScrollEnabled("session-b", false); + setStoredAutoScrollTop("session-b", 200); + + cleanupTaskStorage("task-a", ["session-a"]); + + expect(getStoredAutoScrollEnabled("session-a")).toBeNull(); + expect(getStoredAutoScrollTop("session-a")).toBeNull(); + expect(getStoredAutoScrollEnabled("session-b")).toBe(false); + expect(getStoredAutoScrollTop("session-b")).toBe(200); + }); +}); diff --git a/apps/web/lib/local-storage.ts b/apps/web/lib/local-storage.ts index e5f19ef876..f006c4f603 100644 --- a/apps/web/lib/local-storage.ts +++ b/apps/web/lib/local-storage.ts @@ -3,6 +3,8 @@ import { setWalkthroughLastSeen } from "@/lib/walkthrough-notification-storage"; type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; // Session Storage helpers (cleared when browser tab closes) +/** Read and JSON-parse a `sessionStorage` value, returning `fallback` when + * absent, unparseable, or storage is unavailable (SSR, blocked). */ export function getSessionStorage(key: string, fallback: T): T { if (typeof window === "undefined") return fallback; try { @@ -14,6 +16,8 @@ export function getSessionStorage(key: string, fallback: T) } } +/** JSON-serialize and write a value to `sessionStorage`, silently ignoring + * write failures (storage full, blocked, SSR). */ export function setSessionStorage(key: string, value: T): void { if (typeof window === "undefined") return; try { @@ -24,6 +28,8 @@ export function setSessionStorage(key: string, value: T): v } // Local Storage helpers (persists across browser sessions) +/** Read and JSON-parse a `localStorage` value, returning `fallback` when + * absent, unparseable, or storage is unavailable (SSR, blocked). */ export function getLocalStorage(key: string, fallback: T): T { if (typeof window === "undefined") return fallback; try { @@ -35,6 +41,8 @@ export function getLocalStorage(key: string, fallback: T): } } +/** JSON-serialize and write a value to `localStorage`, silently ignoring + * write failures (storage full, blocked, SSR). */ export function setLocalStorage(key: string, value: T): void { if (typeof window === "undefined") return; try { @@ -44,6 +52,7 @@ export function setLocalStorage(key: string, value: T): voi } } +/** Remove a `sessionStorage` key, silently ignoring removal failures. */ export function removeSessionStorage(key: string): void { if (typeof window === "undefined") return; try { @@ -53,6 +62,7 @@ export function removeSessionStorage(key: string): void { } } +/** Remove a `localStorage` key, silently ignoring removal failures. */ export function removeLocalStorage(key: string): void { if (typeof window === "undefined") return; try { @@ -113,10 +123,13 @@ const PLAN_NOTIFICATION_KEY = "kandev.plan.lastSeenByTask"; export type PlanNotificationState = Record; +/** Read the map of task ID to last-seen plan-update timestamp (localStorage). */ export function getPlanNotificationState(): PlanNotificationState { return getLocalStorage(PLAN_NOTIFICATION_KEY, {} as PlanNotificationState); } +/** Record (or clear, when `timestamp` is `null`) the last-seen plan-update + * timestamp for a task. */ export function setPlanLastSeen(taskId: string, timestamp: string | null): void { const state = getPlanNotificationState(); if (timestamp === null) { @@ -127,6 +140,7 @@ export function setPlanLastSeen(taskId: string, timestamp: string | null): void setLocalStorage(PLAN_NOTIFICATION_KEY, state); } +/** Read the last-seen plan-update timestamp for a task, or `null` if never recorded. */ export function getPlanLastSeen(taskId: string): string | null { const state = getPlanNotificationState(); return state[taskId] ?? null; @@ -277,6 +291,8 @@ export function setEnvLayout(envId: string, layout: object): void { } } +/** Read the manually-dragged right-panel width for a task env, or `null` if + * never set or invalid (sessionStorage). */ export function getManualRightWidth(envId: string | null): number | null { if (!envId) return null; const value = getSessionStorage( @@ -288,11 +304,14 @@ export function getManualRightWidth(envId: string | null): number | null { : null; } +/** Record a genuine sash-drag width for the right panel of a task env. */ export function setManualRightWidth(envId: string | null, width: number): void { if (!envId || !Number.isFinite(width) || width <= 0) return; setSessionStorage(`${DOCKVIEW_MANUAL_RIGHT_WIDTH_PREFIX}${envId}`, Math.round(width)); } +/** Clear the manually-dragged right-panel width for a task env, reverting + * to the responsive default. */ export function clearManualRightWidth(envId: string | null): void { if (!envId) return; removeSessionStorage(`${DOCKVIEW_MANUAL_RIGHT_WIDTH_PREFIX}${envId}`); @@ -306,16 +325,20 @@ export function clearManualRightWidth(envId: string | null): void { // layout build/restore/switch via getPinnedWidth. const DOCKVIEW_GLOBAL_SIDEBAR_WIDTH_KEY = "kandev.dockview.sidebar-width"; +/** Read the global left-sidebar width, or `null` if never set or invalid + * (localStorage, shared across every task env). */ export function getGlobalSidebarWidth(): number | null { const v = getLocalStorage(DOCKVIEW_GLOBAL_SIDEBAR_WIDTH_KEY, null); return typeof v === "number" && Number.isFinite(v) && v > 0 ? v : null; } +/** Record a genuine sash-drag width for the global left sidebar. */ export function setGlobalSidebarWidth(width: number): void { if (!Number.isFinite(width) || width <= 0) return; setLocalStorage(DOCKVIEW_GLOBAL_SIDEBAR_WIDTH_KEY, Math.round(width)); } +/** Clear the global left-sidebar width, reverting to the responsive default. */ export function clearGlobalSidebarWidth(): void { removeLocalStorage(DOCKVIEW_GLOBAL_SIDEBAR_WIDTH_KEY); } @@ -333,6 +356,7 @@ export type EnvMaximizeState = { maximizedDockviewJson: object; }; +/** Type guard validating a parsed value has the shape of {@link EnvMaximizeState}. */ function isEnvMaximizeState(value: unknown): value is EnvMaximizeState { if (!value || typeof value !== "object") return false; const v = value as Record; @@ -344,6 +368,8 @@ function isEnvMaximizeState(value: unknown): value is EnvMaximizeState { ); } +/** Read the saved pre-maximize layout + maximized dockview JSON for a task + * env, or `null` if absent or malformed. */ export function getEnvMaximizeState(envId: string): EnvMaximizeState | null { if (typeof window === "undefined") return null; try { @@ -356,6 +382,8 @@ export function getEnvMaximizeState(envId: string): EnvMaximizeState | null { } } +/** Save the pre-maximize layout + maximized dockview JSON for a task env, + * so exit-maximize can restore it. */ export function setEnvMaximizeState(envId: string, state: EnvMaximizeState): void { if (typeof window === "undefined") return; try { @@ -365,6 +393,7 @@ export function setEnvMaximizeState(envId: string, state: EnvMaximizeState): voi } } +/** Clear the saved maximize state for a task env (e.g. on layout reset). */ export function removeEnvMaximizeState(envId: string): void { if (typeof window === "undefined") return; try { @@ -378,6 +407,7 @@ export function removeEnvMaximizeState(envId: string): void { // for a session. If offered and then closed by the user, we respect the dismissal. const PR_PANEL_OFFERED_PREFIX = "kandev.pr-panel-offered."; +/** Whether the auto-show PR panel was already offered for this session. */ export function wasPRPanelOffered(sessionId: string): boolean { if (typeof window === "undefined") return false; try { @@ -387,6 +417,7 @@ export function wasPRPanelOffered(sessionId: string): boolean { } } +/** Record that the auto-show PR panel was offered for this session. */ export function markPRPanelOffered(sessionId: string): void { if (typeof window === "undefined") return; try { @@ -400,6 +431,7 @@ export function markPRPanelOffered(sessionId: string): void { // the tab session, resets on tab close. const PR_MERGED_BANNER_DISMISSED_PREFIX = "kandev.pr-merged-banner-dismissed."; +/** Whether the user dismissed the PR-merged banner for this task. */ export function wasPRMergedBannerDismissed(taskId: string): boolean { if (typeof window === "undefined") return false; try { @@ -409,6 +441,7 @@ export function wasPRMergedBannerDismissed(taskId: string): boolean { } } +/** Record that the user dismissed the PR-merged banner for this task. */ export function markPRMergedBannerDismissed(taskId: string): void { if (typeof window === "undefined") return; try { @@ -422,6 +455,7 @@ export function markPRMergedBannerDismissed(taskId: string): void { // survives reload + task switch within the tab session, resets on tab close. const PR_CLOSED_BANNER_DISMISSED_PREFIX = "kandev.pr-closed-banner-dismissed."; +/** Whether the user dismissed the PR-closed banner for this task. */ export function wasPRClosedBannerDismissed(taskId: string): boolean { if (typeof window === "undefined") return false; try { @@ -431,6 +465,7 @@ export function wasPRClosedBannerDismissed(taskId: string): boolean { } } +/** Record that the user dismissed the PR-closed banner for this task. */ export function markPRClosedBannerDismissed(taskId: string): void { if (typeof window === "undefined") return; try { @@ -496,6 +531,7 @@ export function getOpenFileTabs(sessionId: string): StoredFileTab[] { } } +/** Save the open file tabs for a session. */ export function setOpenFileTabs(sessionId: string, tabs: StoredFileTab[]): void { if (typeof window === "undefined") return; try { @@ -557,10 +593,12 @@ type StoredFileAttachment = { deliveryMode?: "prompt" | "path"; }; +/** Read the draft chat message text for a session, or `""` if none saved. */ export function getChatDraftText(sessionId: string): string { return getSessionStorage(`${CHAT_DRAFT_TEXT_KEY}.${sessionId}`, ""); } +/** Save (or clear, when empty) the draft chat message text for a session. */ export function setChatDraftText(sessionId: string, text: string): void { if (text === "") { removeSessionStorage(`${CHAT_DRAFT_TEXT_KEY}.${sessionId}`); @@ -574,6 +612,7 @@ export function getChatDraftContent(sessionId: string): unknown { return getSessionStorage(`${CHAT_DRAFT_CONTENT_KEY}.${sessionId}`, null); } +/** Save (or clear, when falsy) the draft TipTap editor JSON for a session. */ export function setChatDraftContent(sessionId: string, content: unknown): void { if (!content) { removeSessionStorage(`${CHAT_DRAFT_CONTENT_KEY}.${sessionId}`); @@ -582,6 +621,8 @@ export function setChatDraftContent(sessionId: string, content: unknown): void { } } +/** Read the draft chat attachments for a session (previews reconstructed by + * the caller, not persisted). */ export function getChatDraftAttachments(sessionId: string): StoredFileAttachment[] { return getSessionStorage( `${CHAT_DRAFT_ATTACHMENTS_KEY}.${sessionId}`, @@ -589,6 +630,8 @@ export function getChatDraftAttachments(sessionId: string): StoredFileAttachment ); } +/** Coerce a stored attachment delivery-mode value, falling back for anything + * other than `"prompt"` or `"path"`. */ function normalizeAttachmentDeliveryMode( value: unknown, fallback: "prompt" | "path", @@ -596,6 +639,8 @@ function normalizeAttachmentDeliveryMode( return value === "prompt" || value === "path" ? value : fallback; } +/** Save (or clear, when empty) the draft chat attachments for a session, + * stripping `preview` to halve storage cost. */ export function setChatDraftAttachments( sessionId: string, attachments: Array<{ @@ -644,14 +689,47 @@ export function restoreAttachmentPreview( return { ...att, deliveryMode: normalizeAttachmentDeliveryMode(att.deliveryMode, "path") }; } +/** Read the saved chat composer height for a session, or `null` if never set. */ export function getChatInputHeight(sessionId: string): number | null { return getSessionStorage(`${CHAT_INPUT_HEIGHT_KEY}.${sessionId}`, null); } +/** Save the chat composer height for a session. */ export function setChatInputHeight(sessionId: string, height: number): void { setSessionStorage(`${CHAT_INPUT_HEIGHT_KEY}.${sessionId}`, height); } +// --- Transcript auto-scroll toggle (sessionStorage, per session) --- +// Survives reload + task switch within the tab session, resets on tab close. +const AUTO_SCROLL_ENABLED_PREFIX = "kandev.transcript-auto-scroll-enabled."; + +/** Read the per-session auto-scroll preference persisted by + * {@link setStoredAutoScrollEnabled}, or `null` if never recorded. */ +export function getStoredAutoScrollEnabled(sessionId: string): boolean | null { + return getSessionStorage(`${AUTO_SCROLL_ENABLED_PREFIX}${sessionId}`, null); +} + +/** Persist the per-session auto-scroll toggle preference so it survives a + * reload or task switch within the tab session (sessionStorage). */ +export function setStoredAutoScrollEnabled(sessionId: string, enabled: boolean): void { + setSessionStorage(`${AUTO_SCROLL_ENABLED_PREFIX}${sessionId}`, enabled); +} + +const AUTO_SCROLL_TOP_PREFIX = "kandev.transcript-auto-scroll-top."; + +/** Read the last scrollTop persisted by {@link setStoredAutoScrollTop} for + * this session, or `null` if never recorded. */ +export function getStoredAutoScrollTop(sessionId: string): number | null { + return getSessionStorage(`${AUTO_SCROLL_TOP_PREFIX}${sessionId}`, null); +} + +/** Persist the transcript's last scrollTop for this session so a disabled + * auto-scroll preference restores the same position after a reload or task + * switch within the tab session (sessionStorage). */ +export function setStoredAutoScrollTop(sessionId: string, scrollTop: number): void { + setSessionStorage(`${AUTO_SCROLL_TOP_PREFIX}${sessionId}`, scrollTop); +} + // --- Task storage cleanup --- /** @@ -699,6 +777,8 @@ export function cleanupTaskStorage( removeSessionStorage(`kandev.contextFiles.${sessionId}`); removeSessionStorage(`kandev.comments.${sessionId}`); removeSessionStorage(`kandev.messageFavorites.${sessionId}`); + removeSessionStorage(`${AUTO_SCROLL_ENABLED_PREFIX}${sessionId}`); + removeSessionStorage(`${AUTO_SCROLL_TOP_PREFIX}${sessionId}`); } } @@ -710,12 +790,14 @@ export function cleanupTaskStorage( const QUICK_CHAT_NAMES_KEY = "kandev.quickChat.names"; +/** Read the map of session ID to user-renamed quick-chat name (localStorage). */ export function getStoredQuickChatNames(): Record { const raw = getLocalStorage>(QUICK_CHAT_NAMES_KEY, {}); if (!raw || typeof raw !== "object") return {}; return raw; } +/** Set (or clear, when `name` is empty) the user-renamed quick-chat name for a session. */ export function setStoredQuickChatName(sessionId: string, name: string): void { if (!sessionId) return; const all = getStoredQuickChatNames(); @@ -724,6 +806,7 @@ export function setStoredQuickChatName(sessionId: string, name: string): void { setLocalStorage(QUICK_CHAT_NAMES_KEY, all); } +/** Remove the user-renamed quick-chat name for a session. */ export function removeStoredQuickChatName(sessionId: string): void { if (!sessionId) return; const all = getStoredQuickChatNames(); diff --git a/apps/web/lib/state/slices/index.ts b/apps/web/lib/state/slices/index.ts index 59d92c93f8..2ac87b91d9 100644 --- a/apps/web/lib/state/slices/index.ts +++ b/apps/web/lib/state/slices/index.ts @@ -178,6 +178,7 @@ export type { ActiveDocument, DocumentPanelState, SystemHealthState, + TranscriptAutoScrollState, } from "./ui/types"; export type { GitHubStatusState, diff --git a/apps/web/lib/state/slices/ui/transcript-auto-scroll-actions.test.ts b/apps/web/lib/state/slices/ui/transcript-auto-scroll-actions.test.ts new file mode 100644 index 0000000000..3379b15920 --- /dev/null +++ b/apps/web/lib/state/slices/ui/transcript-auto-scroll-actions.test.ts @@ -0,0 +1,55 @@ +import { beforeEach, describe, it, expect } from "vitest"; +import { create, type StoreApi, type UseBoundStore } from "zustand"; +import { immer } from "zustand/middleware/immer"; +import { createUISlice } from "./ui-slice"; +import type { UISlice } from "./types"; + +function makeStore() { + return create()( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + immer((...a) => ({ ...(createUISlice as any)(...a) })), + ); +} + +type UIStore = UseBoundStore>; + +describe("transcript auto-scroll actions", () => { + let store: UIStore; + + beforeEach(() => { + store = makeStore(); + }); + + it("defaults every session to enabled", () => { + expect(store.getState().transcriptAutoScroll.enabledBySessionId["session-a"]).toBeUndefined(); + }); + + it("setTranscriptAutoScrollEnabled records a per-session preference", () => { + store.getState().setTranscriptAutoScrollEnabled("session-a", false); + expect(store.getState().transcriptAutoScroll.enabledBySessionId["session-a"]).toBe(false); + + store.getState().setTranscriptAutoScrollEnabled("session-a", true); + expect(store.getState().transcriptAutoScroll.enabledBySessionId["session-a"]).toBe(true); + }); + + it("setTranscriptAutoScrollEnabled does not affect other sessions", () => { + store.getState().setTranscriptAutoScrollEnabled("session-a", false); + expect(store.getState().transcriptAutoScroll.enabledBySessionId["session-b"]).toBeUndefined(); + }); + + it("setTranscriptScrollTop records the last known scrollTop per session", () => { + store.getState().setTranscriptScrollTop("session-a", 250); + expect(store.getState().transcriptAutoScroll.scrollTopBySessionId["session-a"]).toBe(250); + + store.getState().setTranscriptScrollTop("session-a", 400); + expect(store.getState().transcriptAutoScroll.scrollTopBySessionId["session-a"]).toBe(400); + }); + + it("setTranscriptVirtuosoState records an opaque per-session snapshot", () => { + const snapshot = { ranges: [{ startIndex: 0, endIndex: 10, size: 40 }], scrollTop: 300 }; + store.getState().setTranscriptVirtuosoState("session-a", snapshot); + expect(store.getState().transcriptAutoScroll.virtuosoStateBySessionId["session-a"]).toEqual( + snapshot, + ); + }); +}); diff --git a/apps/web/lib/state/slices/ui/transcript-auto-scroll-selectors.test.ts b/apps/web/lib/state/slices/ui/transcript-auto-scroll-selectors.test.ts new file mode 100644 index 0000000000..eaea51eaef --- /dev/null +++ b/apps/web/lib/state/slices/ui/transcript-auto-scroll-selectors.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from "vitest"; +import { isTranscriptAutoScrollEnabled } from "./transcript-auto-scroll-selectors"; + +describe("isTranscriptAutoScrollEnabled", () => { + it("defaults to enabled for a session with no recorded preference", () => { + expect(isTranscriptAutoScrollEnabled({}, "session-a")).toBe(true); + }); + + it("defaults to enabled when sessionId is null", () => { + expect(isTranscriptAutoScrollEnabled({ "session-a": false }, null)).toBe(true); + }); + + it("returns the recorded false preference for the session", () => { + expect(isTranscriptAutoScrollEnabled({ "session-a": false }, "session-a")).toBe(false); + }); + + it("returns the recorded true preference for the session", () => { + expect( + isTranscriptAutoScrollEnabled({ "session-a": false, "session-b": true }, "session-b"), + ).toBe(true); + }); + + it("does not leak one session's preference into another", () => { + expect(isTranscriptAutoScrollEnabled({ "session-a": false }, "session-b")).toBe(true); + }); +}); diff --git a/apps/web/lib/state/slices/ui/transcript-auto-scroll-selectors.ts b/apps/web/lib/state/slices/ui/transcript-auto-scroll-selectors.ts new file mode 100644 index 0000000000..f9b890a59a --- /dev/null +++ b/apps/web/lib/state/slices/ui/transcript-auto-scroll-selectors.ts @@ -0,0 +1,12 @@ +/** + * Auto-scroll is enabled by default for every session; a session only + * shows up in the map once the user has explicitly toggled it off (or back + * on) via the transcript's auto-scroll toggle button. + */ +export function isTranscriptAutoScrollEnabled( + enabledBySessionId: Record, + sessionId: string | null, +): boolean { + if (!sessionId) return true; + return enabledBySessionId[sessionId] ?? true; +} diff --git a/apps/web/lib/state/slices/ui/types.ts b/apps/web/lib/state/slices/ui/types.ts index a1cf9bc0cf..6fe05d531a 100644 --- a/apps/web/lib/state/slices/ui/types.ts +++ b/apps/web/lib/state/slices/ui/types.ts @@ -1,5 +1,6 @@ import type { ConnectionStatus } from "@/lib/types/connection"; import type { HealthCheckSummary, HealthIssue, SystemHealthResponse } from "@/lib/types/health"; +import type { StateSnapshot } from "react-virtuoso"; import type { FilterClause, GroupKey, @@ -53,6 +54,17 @@ export type ChatInputState = { planModeBySessionId: Record; }; +export type TranscriptAutoScrollState = { + /** Per-session auto-scroll preference. Absent = enabled (the default). */ + enabledBySessionId: Record; + /** Last known scrollTop for the native renderer, captured continuously so + * a disabled session's position survives a dockview panel remount. */ + scrollTopBySessionId: Record; + /** Last captured Virtuoso state snapshot (scroll offset + measured item + * sizes) for the virtuoso renderer, captured on disable/unmount. */ + virtuosoStateBySessionId: Record; +}; + export type ReviewPRSelectionState = { selectedKeyByTaskId: Record; }; @@ -155,6 +167,7 @@ export type UISliceState = { mobileKanban: MobileKanbanState; mobileSession: MobileSessionState; chatInput: ChatInputState; + transcriptAutoScroll: TranscriptAutoScrollState; reviewPRSelection: ReviewPRSelectionState; documentPanel: DocumentPanelState; systemHealth: SystemHealthState; @@ -205,6 +218,9 @@ export type UISliceActions = { setMobileSessionReview: (sessionId: string, mrKey: string | null) => void; setMobileSessionTaskSwitcherOpen: (open: boolean) => void; setPlanMode: (sessionId: string, enabled: boolean) => void; + setTranscriptAutoScrollEnabled: (sessionId: string, enabled: boolean) => void; + setTranscriptScrollTop: (sessionId: string, scrollTop: number) => void; + setTranscriptVirtuosoState: (sessionId: string, state: StateSnapshot) => void; setReviewPRSelection: (taskId: string, selectedKey: string) => void; setActiveDocument: (sessionId: string, doc: ActiveDocument | null) => void; setSystemHealth: (response: SystemHealthResponse) => void; diff --git a/apps/web/lib/state/slices/ui/ui-slice.ts b/apps/web/lib/state/slices/ui/ui-slice.ts index 8d16c0efab..0b001c6709 100644 --- a/apps/web/lib/state/slices/ui/ui-slice.ts +++ b/apps/web/lib/state/slices/ui/ui-slice.ts @@ -4,6 +4,8 @@ import { setLocalStorage, setStoredCollapsedSubtaskParents, setStoredQuickChatName, + setStoredAutoScrollEnabled, + setStoredAutoScrollTop, } from "@/lib/local-storage"; import { buildDismissedAgentErrors } from "./dismissed-agent-errors-actions"; import { @@ -20,6 +22,7 @@ import type { SystemHealthResponse } from "@/lib/types/health"; import type { ActiveDocument, UISlice, UISliceState } from "./types"; import { getQuickChatSetupSessionId } from "./quick-chat-session"; +/** Default sidebar view state: the single built-in "All tasks" view, active, no draft. */ function createDefaultSidebarState(): UISliceState["sidebarViews"] { return { views: [DEFAULT_VIEW], activeViewId: DEFAULT_VIEW.id, draft: null, syncError: null }; } @@ -46,8 +49,11 @@ export const KNOWN_SORT_KEYS = new Set([ "custom", ]); -// Drops clauses whose dimension is no longer known (e.g. renamed or removed in an upgrade), -// and resets stale sort keys, so the popover does not crash when rendering stored views. +/** + * Drops clauses whose dimension is no longer known (e.g. renamed or removed + * in an upgrade), and resets stale sort keys, so the popover does not crash + * when rendering stored views. + */ export function migrateView(view: SidebarView): SidebarView { const sort: SortSpec = KNOWN_SORT_KEYS.has(view.sort.key) ? view.sort @@ -78,6 +84,11 @@ export const defaultUIState: UISliceState = { isTaskSwitcherOpen: false, }, chatInput: { planModeBySessionId: {} }, + transcriptAutoScroll: { + enabledBySessionId: {}, + scrollTopBySessionId: {}, + virtuosoStateBySessionId: {}, + }, reviewPRSelection: { selectedKeyByTaskId: {} }, documentPanel: { activeDocumentBySessionId: {} }, systemHealth: { issues: [], checks: [], healthy: true, loaded: false, loading: false }, @@ -102,6 +113,7 @@ export const defaultUIState: UISliceState = { type ImmerSet = Parameters[0]; +/** Builds the preview-panel actions (open/toggle/view/device/stage/url), each persisting to localStorage where noted. */ function buildPreviewActions(set: ImmerSet) { return { setPreviewOpen: (sessionId: string, open: boolean) => @@ -149,6 +161,7 @@ function buildPreviewActions(set: ImmerSet) { }; } +/** Builds the mobile Kanban view's column-index, menu, and search-open actions. */ function buildMobileActions(set: ImmerSet) { return { setMobileKanbanColumnIndex: (index: number) => @@ -189,6 +202,7 @@ function buildMobileActions(set: ImmerSet) { }; } +/** Builds the bottom terminal's open/toggle and pending-command actions, persisting open state to localStorage. */ function buildBottomTerminalActions(set: ImmerSet) { return { toggleBottomTerminal: () => @@ -210,6 +224,7 @@ function buildBottomTerminalActions(set: ImmerSet) { }; } +/** Builds the system-health cache's set/loading/invalidate actions. */ function buildSystemHealthActions(set: ImmerSet) { return { setSystemHealth: (response: SystemHealthResponse) => @@ -230,6 +245,12 @@ function buildSystemHealthActions(set: ImmerSet) { }; } +/** + * Builds the collapsed-subtask-parents toggle action. Tab-scoped collapse of + * a parent task's subtasks, persisted via sessionStorage (survives reload / + * task switch within the tab, resets on tab close). Not per-view and not + * synced to the backend — purely visual. + */ function buildCollapsedSubtaskActions(set: ImmerSet, get: () => UISlice) { return { // Tab-scoped collapse of a parent task's subtasks. Persisted via @@ -247,6 +268,7 @@ function buildCollapsedSubtaskActions(set: ImmerSet, get: () => UISlice) { }; } +/** Builds the setters for session-failure, task-deleted, and update-available notifications. */ function buildNotificationActions(set: ImmerSet) { return { setSessionFailureNotification: (n: UISlice["sessionFailureNotification"]) => @@ -264,6 +286,7 @@ function buildNotificationActions(set: ImmerSet) { }; } +/** Finds this workspace's quick-chat "config" session, if one is already open. */ function findWorkspaceConfigSession( sessions: UISliceState["quickChat"]["sessions"], workspaceId: string, @@ -273,6 +296,11 @@ function findWorkspaceConfigSession( ); } +/** + * Builds the `openQuickChat` action: opens (creating if needed) a quick-chat + * session, reusing an existing workspace config session for `kind: "config"` + * rather than creating a duplicate. + */ function buildOpenQuickChatAction(set: ImmerSet) { return ( sessionId: string, @@ -311,6 +339,7 @@ function buildOpenQuickChatAction(set: ImmerSet) { }); } +/** Builds the remaining quick-chat session CRUD actions (add/remove/rename/close). */ function buildQuickChatActions(set: ImmerSet) { return { addQuickChatSession: ( @@ -415,6 +444,20 @@ export const createUISlice: StateCreator { draft.chatInput.planModeBySessionId[sessionId] = enabled; }), + setTranscriptAutoScrollEnabled: (sessionId, enabled) => + set((draft) => { + draft.transcriptAutoScroll.enabledBySessionId[sessionId] = enabled; + setStoredAutoScrollEnabled(sessionId, enabled); + }), + setTranscriptScrollTop: (sessionId, scrollTop) => + set((draft) => { + draft.transcriptAutoScroll.scrollTopBySessionId[sessionId] = scrollTop; + setStoredAutoScrollTop(sessionId, scrollTop); + }), + setTranscriptVirtuosoState: (sessionId, state) => + set((draft) => { + draft.transcriptAutoScroll.virtuosoStateBySessionId[sessionId] = state; + }), setReviewPRSelection: (taskId, selectedKey) => set((draft) => { draft.reviewPRSelection.selectedKeyByTaskId[taskId] = selectedKey; diff --git a/apps/web/lib/state/store.ts b/apps/web/lib/state/store.ts index 2c79b61bca..dfe0c3643b 100644 --- a/apps/web/lib/state/store.ts +++ b/apps/web/lib/state/store.ts @@ -221,6 +221,7 @@ export type AppState = KanbanSlice & { mobileKanban: (typeof defaultUIState)["mobileKanban"]; mobileSession: (typeof defaultUIState)["mobileSession"]; chatInput: (typeof defaultUIState)["chatInput"]; + transcriptAutoScroll: (typeof defaultUIState)["transcriptAutoScroll"]; reviewPRSelection: (typeof defaultUIState)["reviewPRSelection"]; documentPanel: (typeof defaultUIState)["documentPanel"]; systemHealth: (typeof defaultUIState)["systemHealth"]; @@ -317,6 +318,9 @@ export type AppState = KanbanSlice & { setMobileSessionReview: (sessionId: string, mrKey: string | null) => void; setMobileSessionTaskSwitcherOpen: (open: boolean) => void; setPlanMode: (sessionId: string, enabled: boolean) => void; + setTranscriptAutoScrollEnabled: UIA["setTranscriptAutoScrollEnabled"]; + setTranscriptScrollTop: UIA["setTranscriptScrollTop"]; + setTranscriptVirtuosoState: UIA["setTranscriptVirtuosoState"]; setReviewPRSelection: UIA["setReviewPRSelection"]; setActiveDocument: (sessionId: string, doc: UISliceTypes.ActiveDocument | null) => void; setSystemHealth: (response: SystemHealthResponse) => void; @@ -518,6 +522,8 @@ export type HydrationState = Omit, "system"> & { system?: Partial; }; +/** Creates the Zustand app store, hydrating from `initialState` and + * composing every domain slice (kanban, ui, workspace, settings, ...). */ export function createAppStore(initialState?: HydrationState) { const merged = mergeInitialState(initialState);