feat: add transcript auto-scroll toggle (#2039)

This commit is contained in:
Clément Fiere
2026-07-29 19:51:38 +02:00
committed by GitHub
parent 3c5578285e
commit f0b816a89f
19 changed files with 1969 additions and 51 deletions
@@ -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<string, boolean> = {};
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(
<TooltipProvider>
<AutoScrollToggleButton sessionId={sessionId} />
</TooltipProvider>,
);
}
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");
});
});
@@ -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 (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setEnabled(sessionId, !enabled)}
className={TOP_BAR_BUTTON_CLASS}
aria-label={label}
aria-pressed={enabled}
data-testid="auto-scroll-toggle-button"
>
{enabled ? (
<IconArrowBarToDown className="h-3.5 w-3.5" />
) : (
<IconArrowBarToDownDashed className="h-3.5 w-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
);
}
@@ -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<typeof useToast>["toast"]) {
console.error("Failed to send message:", error);
if (isMessageSendError(error)) {
@@ -135,6 +145,7 @@ function showMessageSendToast(error: unknown, toast: ReturnType<typeof useToast>
});
}
/** Adapts {@link useChatPanelState}'s fields into {@link useMessageHandler}'s params. */
function usePanelMessageHandler(panelState: ReturnType<typeof useChatPanelState>) {
const {
resolvedSessionId,
@@ -160,6 +171,8 @@ function usePanelMessageHandler(panelState: ReturnType<typeof useChatPanelState>
});
}
/** Builds the composer's submit handler, tracking in-flight sends and
* routing errors to a toast. */
export function useSubmitHandler(
panelState: ReturnType<typeof useChatPanelState>,
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<ChatInputContainerHandle | null>,
@@ -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 (
<div
@@ -337,9 +359,12 @@ function ChatStatusBar({
different avoids a duplicate-sibling-key collision. */}
{taskId && <PRMergedBanner key={`${taskId}-merged`} taskId={taskId} />}
{taskId && <PRClosedBanner key={`${taskId}-closed`} taskId={taskId} />}
{canShare && taskId && sessionId && (
<div className="ml-auto shrink-0">
<ShareButton taskId={taskId} sessionId={sessionId} iconOnly />
{showRightControls && (
<div className="ml-auto flex shrink-0 items-center gap-1">
{sessionId && <AutoScrollToggleButton sessionId={sessionId} />}
{canShare && taskId && sessionId && (
<ShareButton taskId={taskId} sessionId={sessionId} iconOnly />
)}
</div>
)}
{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<typeof useChatPanelState>,
chatInputRef: React.RefObject<ChatInputContainerHandle | null>,
@@ -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,
@@ -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<HTMLDivElement | null>,
itemCount: number,
items: RenderItem[],
) {
const scrollState = useRef({ scrollHeight: 0, scrollTop: 0 });
const prevItemCount = useRef(itemCount);
const prevItemCountRef = useRef(items.length);
const prevFirstKeyRef = useRef<string | null>(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<HTMLDivElement | null>,
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<HTMLDivElement | null>,
messages: Message[],
enabled: boolean,
isNearBottomRef: React.RefObject<boolean>,
) {
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<HTMLDivElement | null>,
itemCount: number,
sessionId: string | null,
enabled: boolean,
isNearBottomRef: React.RefObject<boolean>,
) {
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-<id>` 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 (
<SessionPanelContent ref={scrollRef} className="relative p-4 chat-message-list">
@@ -265,8 +449,12 @@ export const NativeMessageList = memo(function NativeMessageList({
footerActionMessages={footerActionMessages}
/>
{/* Bottom anchor — browser keeps scroll pinned here when new content appends */}
<div style={{ overflowAnchor: "auto", height: 1 }} />
{/* 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. */}
<div style={{ overflowAnchor: autoScrollEnabled ? "auto" : "none", height: 1 }} />
</SessionPanelContent>
);
});
@@ -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<number>;
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 `<Virtuoso>` 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<VirtuosoHandle | null>;
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<StateSnapshot | undefined>(() => {
if (enabled || !sessionId) return undefined;
return storeApi.getState().transcriptAutoScroll.virtuosoStateBySessionId[sessionId];
});
return { handleAtBottomStateChange, resolvedFollowOutput, restoreStateFrom };
}
/**
* Renders the `<Virtuoso>` 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 (
<Virtuoso
ref={virtuosoRef}
@@ -224,9 +374,11 @@ function VirtuosoBody(props: VirtuosoBodyProps) {
totalCount={itemCount}
firstItemIndex={firstItemIndex}
initialTopMostItemIndex={itemCount - 1}
restoreStateFrom={restoreStateFrom}
computeItemKey={computeItemKey}
itemContent={renderItem}
followOutput={followOutput}
followOutput={resolvedFollowOutput}
atBottomStateChange={handleAtBottomStateChange}
startReached={handleStartReached}
increaseViewportBy={200}
atBottomThreshold={100}
@@ -242,6 +394,8 @@ type VirtuosoSnapshot = {
scrollParentReady: boolean;
};
/** Whether any field the Virtuoso debug snapshot tracks has changed since
* the last render, used to gate debug-only logging. */
function virtuosoSnapshotChanged(prev: VirtuosoSnapshot | null, next: VirtuosoSnapshot): boolean {
if (!prev) return true;
return (
@@ -261,6 +415,8 @@ type VirtuosoDebugExtras = {
lastItemKey: string;
};
/** Logs a Virtuoso render-branch snapshot change/init for `chat:virtuoso`
* debug tracing — a no-op unless debug logging is enabled. */
function logVirtuosoSnapshotChange(
prev: VirtuosoSnapshot | null,
next: VirtuosoSnapshot,
@@ -446,6 +602,11 @@ function useVirtuosoHeaderFooter(args: HeaderFooterArgs) {
return { Header, Footer, footerActions };
}
/**
* Windowed transcript renderer for very long conversations (1000+ messages),
* backed by react-virtuoso. Shows the loading/empty state until the scroll
* parent and initial page are ready, then delegates to {@link VirtuosoBody}.
*/
export const VirtuosoMessageList = memo(function VirtuosoMessageList(props: MessageListProps) {
const {
items,
@@ -467,6 +628,7 @@ export const VirtuosoMessageList = memo(function VirtuosoMessageList(props: Mess
const { activeTurnId } = useSessionTurn(sessionId);
const effectiveActiveTurnId = getEffectiveActiveTurnId(activeTurnId, isWorking);
const lastTurnGroupId = useMemo(() => 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}
/>
)}
</SessionPanelContent>
@@ -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<number, () => 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);
});
});
@@ -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;
}
@@ -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);
});
}
@@ -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<SessionPage> {
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);
});
});
@@ -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<SessionPage> {
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);
});
});
+42
View File
@@ -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);
});
});
+83
View File
@@ -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<T extends JsonValue>(key: string, fallback: T): T {
if (typeof window === "undefined") return fallback;
try {
@@ -14,6 +16,8 @@ export function getSessionStorage<T extends JsonValue>(key: string, fallback: T)
}
}
/** JSON-serialize and write a value to `sessionStorage`, silently ignoring
* write failures (storage full, blocked, SSR). */
export function setSessionStorage<T extends JsonValue>(key: string, value: T): void {
if (typeof window === "undefined") return;
try {
@@ -24,6 +28,8 @@ export function setSessionStorage<T extends JsonValue>(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<T extends JsonValue>(key: string, fallback: T): T {
if (typeof window === "undefined") return fallback;
try {
@@ -35,6 +41,8 @@ export function getLocalStorage<T extends JsonValue>(key: string, fallback: T):
}
}
/** JSON-serialize and write a value to `localStorage`, silently ignoring
* write failures (storage full, blocked, SSR). */
export function setLocalStorage<T extends JsonValue>(key: string, value: T): void {
if (typeof window === "undefined") return;
try {
@@ -44,6 +52,7 @@ export function setLocalStorage<T extends JsonValue>(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<string, string | null>;
/** 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<number | null>(
@@ -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<number | null>(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<string, unknown>;
@@ -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<JsonValue | null>(`${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<StoredFileAttachment[]>(
`${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<number | null>(`${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<boolean | null>(`${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<number | null>(`${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<string, string> {
const raw = getLocalStorage<Record<string, string>>(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();
+1
View File
@@ -178,6 +178,7 @@ export type {
ActiveDocument,
DocumentPanelState,
SystemHealthState,
TranscriptAutoScrollState,
} from "./ui/types";
export type {
GitHubStatusState,
@@ -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<UISlice>()(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
immer((...a) => ({ ...(createUISlice as any)(...a) })),
);
}
type UIStore = UseBoundStore<StoreApi<UISlice>>;
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,
);
});
});
@@ -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);
});
});
@@ -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<string, boolean>,
sessionId: string | null,
): boolean {
if (!sessionId) return true;
return enabledBySessionId[sessionId] ?? true;
}
+16
View File
@@ -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<string, boolean>;
};
export type TranscriptAutoScrollState = {
/** Per-session auto-scroll preference. Absent = enabled (the default). */
enabledBySessionId: Record<string, boolean>;
/** Last known scrollTop for the native renderer, captured continuously so
* a disabled session's position survives a dockview panel remount. */
scrollTopBySessionId: Record<string, number>;
/** Last captured Virtuoso state snapshot (scroll offset + measured item
* sizes) for the virtuoso renderer, captured on disable/unmount. */
virtuosoStateBySessionId: Record<string, StateSnapshot>;
};
export type ReviewPRSelectionState = {
selectedKeyByTaskId: Record<string, string>;
};
@@ -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;
+45 -2
View File
@@ -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<string>([
"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<typeof createUISlice>[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<UISlice, [["zustand/immer", never]], []
set((draft) => {
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;
+6
View File
@@ -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<Partial<AppState>, "system"> & {
system?: Partial<AppState["system"]>;
};
/** 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);