feat: add transcript auto-scroll toggle (#2039)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user