Smooth WebUI streaming with state-driven viewport motion (#4696)

This commit is contained in:
chengyongru
2026-07-26 00:18:24 +08:00
committed by GitHub
parent 9a7debcb48
commit b0ef759e2c
28 changed files with 3202 additions and 670 deletions
+4 -1
View File
@@ -13,6 +13,7 @@ interface MarkdownTextProps {
children: string;
className?: string;
streaming?: boolean;
preserveStreamingLayout?: boolean;
onOpenFilePreview?: (path: string) => void;
}
@@ -74,11 +75,13 @@ export function MarkdownText({
children,
className,
streaming = false,
preserveStreamingLayout = false,
onOpenFilePreview,
}: MarkdownTextProps) {
const renderedSource = children;
const renderPhase = streaming ? "streaming" : "complete";
const highlightCode = !streaming;
const renderWithStreamingLayout = streaming || preserveStreamingLayout;
useEffect(() => {
if (streaming) void preloadMarkdownText();
@@ -103,7 +106,7 @@ export function MarkdownText({
source={renderedSource}
className={className}
highlightCode={highlightCode}
streaming={streaming}
streaming={renderWithStreamingLayout}
onOpenFilePreview={onOpenFilePreview}
/>
</Suspense>
+2 -10
View File
@@ -248,13 +248,6 @@ const rehypePlugins: NonNullable<StreamdownProps["rehypePlugins"]> = [rehypeKate
const DIRECT_LINKS = { enabled: false } as const;
const SAFE_MARKDOWN_PROTOCOL = /^(https?|ircs?|mailto|xmpp)$/i;
const STREAMING_ANIMATION = {
animation: "fadeIn",
duration: 180,
easing: "cubic-bezier(0.16, 1, 0.3, 1)",
sep: "word",
stagger: 18,
} as const;
/** Preserve react-markdown's URL policy when rendering through Streamdown. */
const safeMarkdownUrl: NonNullable<StreamdownProps["urlTransform"]> = (url) => {
@@ -727,9 +720,8 @@ export default function MarkdownTextRenderer({
<Streamdown
mode={streaming ? "streaming" : "static"}
parseIncompleteMarkdown
isAnimating={streaming}
animated={streaming ? STREAMING_ANIMATION : false}
caret={streaming ? "block" : undefined}
isAnimating={false}
animated={false}
linkSafety={DIRECT_LINKS}
urlTransform={safeMarkdownUrl}
remarkPlugins={remarkPlugins}
+63 -42
View File
@@ -31,7 +31,7 @@ import {
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { copyTextToClipboard } from "@/lib/clipboard";
import { formatTurnLatency } from "@/lib/format";
import { fmtDateTime, formatMessageEndTime } from "@/lib/format";
import { toMediaAttachment } from "@/lib/media";
import { matchingSlashCommand } from "@/lib/slash-command";
import { parseQuotedUserMessage } from "@/lib/user-message-quote";
@@ -239,13 +239,19 @@ export function MessageBubble({
const showCopyButton = showCopyAction && showAssistantActions;
const showForkButton = showAssistantActions && !!onForkFromHere;
const forkLabel = t("message.forkFromHere");
const latencyMs = message.latencyMs;
const showLatencyFooter =
const completedAt = message.completedAt;
const completedAtLabel =
message.role === "assistant" && !message.isStreaming
? formatMessageEndTime(completedAt)
: "";
const showCompletedAt =
completedAtLabel.length > 0
&& (!empty || hasReasoning || media.length > 0);
const completedAtTitle = showCompletedAt ? fmtDateTime(completedAt) : "";
const showAssistantFooterRow = showCopyButton || showForkButton || showCompletedAt;
const showAssistantFooterSlot =
message.role === "assistant"
&& latencyMs != null
&& !message.isStreaming
&& (!empty || hasReasoning || media.length > 0);
const showAssistantFooterRow = showCopyButton || showForkButton || showLatencyFooter;
return (
<div className={cn("w-full text-[15px]", baseAnim)} style={{ lineHeight: "var(--cjk-line-height)" }}>
{hasReasoning ? (
@@ -266,52 +272,67 @@ export function MessageBubble({
/>
) : null}
<div data-assistant-selectable={message.isStreaming ? undefined : "true"}>
{/* A mode switch rebuilds Streamdown's subtree and moves the scroll anchor. */}
<MarkdownText
streaming={!!message.isStreaming}
preserveStreamingLayout
onOpenFilePreview={onOpenFilePreview}
>
{message.content}
</MarkdownText>
</div>
{media.length > 0 ? <MessageMedia media={media} align="left" /> : null}
{showAssistantFooterRow ? (
<TooltipProvider delayDuration={220} skipDelayDuration={80}>
<div className="mt-2 flex min-h-8 flex-wrap items-center gap-x-2 gap-y-1 text-muted-foreground">
{showCopyButton ? (
<MessageCopyButton content={message.content} />
) : null}
{showForkButton ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onForkFromHere}
aria-label={forkLabel}
className={cn(
"touch-target inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
"transition-colors hover:bg-muted/55 hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
>
<ForkArrowIcon className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="top" align="center">{forkLabel}</TooltipContent>
</Tooltip>
) : null}
{showLatencyFooter ? (
<span
className="text-[11px] leading-none text-muted-foreground/70 tabular-nums"
title={t("message.turnLatencyTitle")}
>
{formatTurnLatency(latencyMs)}
</span>
) : null}
</div>
</TooltipProvider>
) : null}
</>
)}
{showAssistantFooterSlot ? (
<TooltipProvider delayDuration={220} skipDelayDuration={80}>
<div
data-assistant-footer
data-state={showAssistantFooterRow ? "visible" : "reserved"}
aria-hidden={showAssistantFooterRow ? undefined : true}
className={cn(
"mt-2 flex min-h-8 flex-wrap items-center gap-x-2 gap-y-1 text-muted-foreground",
"transition-opacity duration-300 ease-out motion-reduce:transition-none",
showAssistantFooterRow
? "opacity-100"
: "pointer-events-none opacity-0",
)}
>
{showCopyButton ? (
<MessageCopyButton content={message.content} />
) : null}
{showForkButton ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onForkFromHere}
aria-label={forkLabel}
className={cn(
"touch-target inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
"transition-colors hover:bg-muted/55 hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
>
<ForkArrowIcon className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="top" align="center">{forkLabel}</TooltipContent>
</Tooltip>
) : null}
{showCompletedAt ? (
<time
data-assistant-completed-at
dateTime={new Date(completedAt!).toISOString()}
className="text-[11px] leading-none text-muted-foreground/70 tabular-nums"
title={completedAtTitle}
>
{completedAtLabel}
</time>
) : null}
</div>
</TooltipProvider>
) : null}
</div>
);
}
+3 -2
View File
@@ -4,7 +4,6 @@ import { cn } from "@/lib/utils";
import type { UIMessage } from "@/lib/types";
import {
findPromptElement,
jumpToPrompt,
type PromptAnchor,
promptTop,
userPromptAnchors,
@@ -13,6 +12,7 @@ import {
interface PromptRailProps {
bottomOffset: number;
messages: UIMessage[];
onJumpToPrompt: (promptId: string) => void;
scrollRef: RefObject<HTMLDivElement>;
}
@@ -46,6 +46,7 @@ const HOVER_MARKER_WIDTHS_PX = [28, 22, 16, 11];
export function PromptRail({
bottomOffset,
messages,
onJumpToPrompt,
scrollRef,
}: PromptRailProps) {
const railRef = useRef<HTMLDivElement>(null);
@@ -159,7 +160,7 @@ export function PromptRail({
key={marker.ids.join("|")}
type="button"
aria-label={`Jump to prompt: ${marker.label}`}
onClick={() => jumpToPrompt(scrollRef.current, marker.ids[marker.ids.length - 1])}
onClick={() => onJumpToPrompt(marker.ids[marker.ids.length - 1])}
onBlur={() => setFocusedMarkerIndex(null)}
onFocus={() => setFocusedMarkerIndex(index)}
onPointerEnter={() => setFocusedMarkerIndex(index)}
+51 -61
View File
@@ -584,8 +584,6 @@ function RunElapsedStrip({
const stripLabel = goalStateStripPreview(goalState, t);
const showGoal = !!stripLabel?.trim();
const active = showTimer || showGoal;
const [renderStrip, setRenderStrip] = useState(active);
const [leaving, setLeaving] = useState(false);
const [, setTick] = useState(0);
const stripWrapperRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
@@ -602,20 +600,8 @@ function RunElapsedStrip({
}
useEffect(() => {
if (active) {
setRenderStrip(true);
setLeaving(false);
return;
}
setGoalPanelOpen(false);
if (!renderStrip) return;
setLeaving(true);
const id = window.setTimeout(() => {
setRenderStrip(false);
setLeaving(false);
}, 180);
return () => window.clearTimeout(id);
}, [active, renderStrip]);
if (!active) setGoalPanelOpen(false);
}, [active]);
useEffect(() => {
if (startedAt == null || !pageVisible) return;
@@ -699,8 +685,6 @@ function RunElapsedStrip({
};
}, [goalPanelOpen]);
if (!renderStrip || !display) return null;
const elapsed =
displayStartedAt != null ? Math.max(0, Math.floor(Date.now() / 1000 - displayStartedAt)) : 0;
const m = Math.floor(elapsed / 60);
@@ -716,8 +700,10 @@ function RunElapsedStrip({
return (
<div
ref={stripWrapperRef}
className="composer-status-strip relative z-30"
data-state={leaving ? "exit" : "enter"}
className="composer-status-drawer relative z-30"
data-composer-status-drawer=""
data-state={active ? "open" : "closed"}
aria-hidden={active ? undefined : true}
>
{goalPanelOpen && canExpandGoal && markdownBody ? (
<div
@@ -764,50 +750,54 @@ function RunElapsedStrip({
</div>
</div>
) : null}
<div
className="flex min-h-[36px] items-center gap-2 border-b border-black/[0.04] px-3 py-2 dark:border-white/[0.06]"
role="status"
aria-label={ariaLabel}
>
{displayShowTimer ? (
<RunPulseIcon />
) : (
<Target className="h-4 w-4 shrink-0 text-primary/75" aria-hidden />
)}
<span className="flex min-w-0 flex-1 items-center gap-1.5 text-[12px] font-medium text-foreground/75">
{timerTitle ? <span className="shrink-0">{timerTitle}</span> : null}
{timerTitle && displayShowGoal ? (
<span className="shrink-0 text-muted-foreground/45" aria-hidden>
·
</span>
) : null}
{displayShowGoal ? (
<span className="truncate">
{t("thread.composer.goalStateStrip", { label: displayStripLabel })}
</span>
) : null}
</span>
{canExpandGoal ? (
<button
ref={expandToggleRef}
type="button"
className={cn(
"inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
"text-muted-foreground transition-colors hover:bg-muted/55 hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
aria-expanded={goalPanelOpen}
aria-controls={goalPanelOpen ? "nanobot-goal-panel-root" : undefined}
aria-label={t("thread.composer.goalStateExpandAria")}
title={t("thread.composer.goalStateExpandAria")}
onClick={() => setGoalPanelOpen((o) => !o)}
<div className="composer-status-drawer-clip">
{display ? (
<div
className="composer-status-drawer-content flex min-h-[36px] items-center gap-2 px-3 py-2"
role="status"
aria-label={ariaLabel}
>
{goalPanelOpen ? (
<ChevronDown className="h-4 w-4" aria-hidden />
{displayShowTimer ? (
<RunPulseIcon />
) : (
<ChevronUp className="h-4 w-4" aria-hidden />
<Target className="h-4 w-4 shrink-0 text-primary/75" aria-hidden />
)}
</button>
<span className="flex min-w-0 flex-1 items-center gap-1.5 text-[12px] font-medium text-foreground/75">
{timerTitle ? <span className="shrink-0">{timerTitle}</span> : null}
{timerTitle && displayShowGoal ? (
<span className="shrink-0 text-muted-foreground/45" aria-hidden>
·
</span>
) : null}
{displayShowGoal ? (
<span className="truncate">
{t("thread.composer.goalStateStrip", { label: displayStripLabel })}
</span>
) : null}
</span>
{canExpandGoal ? (
<button
ref={expandToggleRef}
type="button"
className={cn(
"inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
"text-muted-foreground transition-colors hover:bg-muted/55 hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
aria-expanded={goalPanelOpen}
aria-controls={goalPanelOpen ? "nanobot-goal-panel-root" : undefined}
aria-label={t("thread.composer.goalStateExpandAria")}
title={t("thread.composer.goalStateExpandAria")}
onClick={() => setGoalPanelOpen((o) => !o)}
>
{goalPanelOpen ? (
<ChevronDown className="h-4 w-4" aria-hidden />
) : (
<ChevronUp className="h-4 w-4" aria-hidden />
)}
</button>
) : null}
</div>
) : null}
</div>
</div>
+19 -6
View File
@@ -92,7 +92,13 @@ export function ThreadMessages({
unit.type === "activity"
&& next?.type === "message"
&& next.message.role === "assistant";
const deferOffscreenRender =
index < units.length - 1
&& (
unit.type === "activity"
? !liveActivityClusterIndices.has(index)
: unit.message.role === "assistant" && !unit.message.isStreaming
);
const userPromptId =
unit.type === "message" && unit.message.role === "user"
? unit.message.id
@@ -110,6 +116,7 @@ export function ThreadMessages({
marginTop={marginTop}
userPromptId={userPromptId}
hasBodyBelow={hasBodyBelow}
deferOffscreenRender={deferOffscreenRender}
isTurnStreaming={liveActivityClusterIndices.has(index)}
forkIndex={forkIndex}
showForkBoundary={index === forkBoundaryAfterUnitIndex}
@@ -131,6 +138,7 @@ interface ThreadDisplayUnitProps {
marginTop: string;
userPromptId?: string;
hasBodyBelow: boolean;
deferOffscreenRender: boolean;
isTurnStreaming: boolean;
forkIndex?: number;
showForkBoundary: boolean;
@@ -147,6 +155,7 @@ const ThreadDisplayUnit = memo(function ThreadDisplayUnit({
marginTop,
userPromptId,
hasBodyBelow,
deferOffscreenRender,
isTurnStreaming,
forkIndex,
showForkBoundary,
@@ -157,17 +166,20 @@ const ThreadDisplayUnit = memo(function ThreadDisplayUnit({
onOpenFilePreview,
onForkFromMessage,
}: ThreadDisplayUnitProps) {
// Introducing content-visibility after a unit has painted can move the
// browser's scroll anchor. Only units deferred on their first render may
// remain deferred.
const hasRenderedEagerlyRef = useRef(!deferOffscreenRender);
if (!deferOffscreenRender) hasRenderedEagerlyRef.current = true;
const stableDeferOffscreenRender =
deferOffscreenRender && !hasRenderedEagerlyRef.current;
const onForkFromHere = useCallback(() => {
if (forkIndex !== undefined) onForkFromMessage?.(forkIndex);
}, [forkIndex, onForkFromMessage]);
const deferOffscreenRender = unit.type === "activity"
? !isTurnStreaming
: unit.message.role === "assistant" && !unit.message.isStreaming;
return (
<>
<div
className={`${marginTop}${deferOffscreenRender ? " thread-render-unit" : ""}`}
className={`${marginTop}${stableDeferOffscreenRender ? " thread-render-unit" : ""}`}
data-user-prompt-id={userPromptId}
>
{unit.type === "activity" ? (
@@ -206,6 +218,7 @@ function threadDisplayUnitPropsEqual(
&& previous.marginTop === next.marginTop
&& previous.userPromptId === next.userPromptId
&& previous.hasBodyBelow === next.hasBodyBelow
&& previous.deferOffscreenRender === next.deferOffscreenRender
&& previous.isTurnStreaming === next.isTurnStreaming
&& previous.forkIndex === next.forkIndex
&& previous.showForkBoundary === next.showForkBoundary
+65 -29
View File
@@ -102,6 +102,18 @@ function isStaleThreadSnapshot(current: UIMessage[], snapshot: UIMessage[]): boo
return snapshot.every((message, index) => sameMessageShape(current[index], message));
}
function latestActiveTurnId(messages: UIMessage[]): string | null {
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (message.isStreaming && message.turnId) return message.turnId;
}
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (message.role === "user" && message.turnId) return message.turnId;
}
return null;
}
const FILE_PREVIEW_DEFAULT_WIDTH = 544;
const FILE_PREVIEW_MIN_WIDTH = 360;
const FILE_PREVIEW_MAX_WIDTH = 860;
@@ -444,8 +456,7 @@ export function ThreadShell({
});
const [settings, setSettings] = useState<SettingsPayload | null>(settingsSnapshot);
const [heroGreetingKey, setHeroGreetingKey] = useState(randomHeroGreetingKey);
const [scrollToBottomSignal, setScrollToBottomSignal] = useState(0);
const [scrollToLatestUserPromptSignal, setScrollToLatestUserPromptSignal] = useState(0);
const [submittedViewportTurnId, setSubmittedViewportTurnId] = useState<string | null>(null);
const [filePreviewPath, setFilePreviewPath] = useState<string | null>(null);
const [filePreviewClosing, setFilePreviewClosing] = useState(false);
const [filePreviewWidth, setFilePreviewWidth] = useState(FILE_PREVIEW_DEFAULT_WIDTH);
@@ -457,6 +468,7 @@ export function ThreadShell({
const pendingFirstRef = useRef<PendingFirstMessage | null>(null);
const [pendingFirstTargetChatId, setPendingFirstTargetChatId] = useState<string | null>(null);
const viewportRef = useRef<ThreadViewportHandle | null>(null);
const activeViewportTurnByChatIdRef = useRef<Map<string, string>>(new Map());
const messageCacheRef = useRef<Map<string, UIMessage[]>>(new Map());
/** Last chatId we associated with the in-memory thread (for cache-on-switch). */
const prevChatIdForCacheRef = useRef<string | null>(null);
@@ -465,18 +477,20 @@ export function ThreadShell({
const appliedHistoryVersionRef = useRef<Map<string, number>>(new Map());
const pendingCanonicalHydrateRef = useRef<Set<string>>(new Set());
const sessionKeyByChatIdRef = useRef<Map<string, string>>(new Map());
const bottomScrolledChatIdRef = useRef<string | null>(null);
const initial = useMemo(() => {
if (!chatId) return historical;
return messageCacheRef.current.get(chatId) ?? historical;
}, [chatId, historical]);
const handleTurnEnd = useCallback(() => {
if (chatId) activeViewportTurnByChatIdRef.current.delete(chatId);
setSubmittedViewportTurnId(null);
setFallbackModelName(null);
onTurnEnd?.();
}, [onTurnEnd]);
}, [chatId, onTurnEnd]);
const {
messages,
messagesReady,
isStreaming,
runStartedAt,
goalState,
@@ -504,6 +518,7 @@ export function ThreadShell({
setFilePreviewClosing(false);
setFilePreviewPath(null);
setQuotedContext(null);
setSubmittedViewportTurnId(null);
}, [historyKey]);
const handleQuoteSelection = useCallback((text: string) => {
@@ -520,6 +535,28 @@ export function ThreadShell({
}, []);
const displayMessages = useMemo(() => projectWebuiThreadMessages(messages), [messages]);
const currentRunStartedAt = messagesReady ? runStartedAt : null;
const currentGoalState = messagesReady ? goalState : undefined;
const turnActive = messagesReady && (isStreaming || currentRunStartedAt !== null);
const restoredViewportTurnId = useMemo(
() => turnActive ? latestActiveTurnId(displayMessages) : null,
[displayMessages, turnActive],
);
const rememberedViewportTurnId = chatId
? activeViewportTurnByChatIdRef.current.get(chatId) ?? null
: null;
const viewportTurnId = messagesReady && turnActive
? rememberedViewportTurnId ?? restoredViewportTurnId
: null;
const activeTurnStartedHere =
viewportTurnId !== null && viewportTurnId === submittedViewportTurnId;
useEffect(() => {
if (!chatId || !messagesReady || turnActive) return;
activeViewportTurnByChatIdRef.current.delete(chatId);
setSubmittedViewportTurnId((current) =>
current === rememberedViewportTurnId ? null : current,
);
}, [chatId, messagesReady, rememberedViewportTurnId, turnActive]);
const filePreviewAvailabilityCache = useMemo(
() => new Map<string, FilePreviewAvailabilityCacheEntry>(),
[historyKey, token],
@@ -695,22 +732,14 @@ export function ThreadShell({
return client.onSessionUpdate((updatedChatId, scope) => {
if (updatedChatId !== chatId) return;
if (scope === "metadata") return;
viewportRef.current?.cancelAutoScroll();
// A turn-end thread refresh can arrive while the viewport is easing the
// final layout change. User-driven scrolling already disables following,
// so keep an active programmatic follow alive across canonical hydration.
pendingCanonicalHydrateRef.current.add(chatId);
refreshHistory();
});
}, [chatId, client, refreshHistory]);
useEffect(() => {
if (!chatId) {
bottomScrolledChatIdRef.current = null;
return;
}
if (loading || bottomScrolledChatIdRef.current === chatId) return;
bottomScrolledChatIdRef.current = chatId;
setScrollToBottomSignal((value) => value + 1);
}, [chatId, loading]);
useEffect(() => {
if (chatId) return;
setMessages(projectWebuiThreadMessages(historical));
@@ -765,8 +794,11 @@ export function ThreadShell({
}
pendingFirstRef.current = null;
setPendingFirstTargetChatId(null);
setScrollToLatestUserPromptSignal((value) => value + 1);
send(pending.content, pending.images, pending.options);
const submitted = send(pending.content, pending.images, pending.options);
if (submitted && !submitted.sideChannel) {
activeViewportTurnByChatIdRef.current.set(chatId, submitted.turnId);
setSubmittedViewportTurnId(submitted.turnId);
}
setBooting(false);
}, [chatId, pendingFirstTargetChatId, send]);
@@ -809,10 +841,13 @@ export function ThreadShell({
const handleThreadSend = useCallback(
(content: string, images?: SendAttachment[], options?: SendOptions) => {
setFallbackModelName(null);
setScrollToLatestUserPromptSignal((value) => value + 1);
send(content, images, withWorkspaceScope(options));
const submitted = send(content, images, withWorkspaceScope(options));
if (chatId && submitted && !submitted.sideChannel) {
activeViewportTurnByChatIdRef.current.set(chatId, submitted.turnId);
setSubmittedViewportTurnId(submitted.turnId);
}
},
[send, withWorkspaceScope],
[chatId, send, withWorkspaceScope],
);
const handleOpenFilePreview = useCallback((path: string) => {
@@ -927,7 +962,7 @@ export function ThreadShell({
<ThreadComposer
onSend={handleThreadSend}
disabled={!chatId}
isStreaming={isStreaming}
isStreaming={turnActive}
placeholder={
showHeroComposer
? t("thread.composer.placeholderHero")
@@ -950,8 +985,8 @@ export function ThreadShell({
skills={skills}
onStop={stop}
onTranscribeAudio={transcribeAudio}
runStartedAt={runStartedAt}
goalState={goalState}
runStartedAt={currentRunStartedAt}
goalState={currentGoalState}
workspaceScope={workspaceScope}
workspaceDefaultScope={workspaceDefaultScope}
workspaceControls={workspaceControls}
@@ -969,7 +1004,7 @@ export function ThreadShell({
<ThreadComposer
onSend={handleWelcomeSend}
disabled={booting}
isStreaming={isStreaming}
isStreaming={turnActive}
placeholder={
booting
? t("thread.composer.placeholderOpening")
@@ -990,9 +1025,9 @@ export function ThreadShell({
cliApps={cliApps}
mcpPresets={mcpPresets}
skills={skills}
runStartedAt={runStartedAt}
runStartedAt={currentRunStartedAt}
onTranscribeAudio={transcribeAudio}
goalState={goalState}
goalState={currentGoalState}
workspaceScope={workspaceScope}
workspaceDefaultScope={workspaceDefaultScope}
workspaceControls={workspaceControls}
@@ -1048,12 +1083,13 @@ export function ThreadShell({
<ThreadViewport
ref={viewportRef}
messages={displayMessages}
isStreaming={isStreaming}
isStreaming={turnActive}
emptyState={emptyState}
composer={composer}
scrollToBottomSignal={scrollToBottomSignal}
scrollToLatestUserPromptSignal={scrollToLatestUserPromptSignal}
activeTurnId={viewportTurnId}
activeTurnStartedHere={activeTurnStartedHere}
conversationKey={historyKey}
conversationReady={messagesReady}
showScrollToBottomButton={!!session}
cliApps={cliApps}
mcpPresets={mcpPresets}
+342 -240
View File
@@ -15,10 +15,14 @@ import { useTranslation } from "react-i18next";
import { PromptRail } from "@/components/thread/PromptRail";
import { ThreadMessages } from "@/components/thread/ThreadMessages";
import { isAgentActivityMember } from "@/components/thread/AgentActivityCluster";
import { ThreadCameraController } from "@/components/thread/thread-camera";
import {
ThreadMotionCoordinator,
type ThreadMotionGeometry,
} from "@/components/thread/thread-motion";
import { Button } from "@/components/ui/button";
import {
findPromptElement,
jumpToPrompt,
promptTop,
} from "@/components/thread/promptNavigation";
import { cn } from "@/lib/utils";
@@ -35,8 +39,10 @@ interface ThreadViewportProps {
composer: ReactNode;
emptyState?: ReactNode;
scrollToBottomSignal?: number;
scrollToLatestUserPromptSignal?: number;
activeTurnId?: string | null;
activeTurnStartedHere?: boolean;
conversationKey?: string | null;
conversationReady?: boolean;
showScrollToBottomButton?: boolean;
cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[];
@@ -56,7 +62,6 @@ const NEAR_TOP_PX = 96;
const DEFAULT_SCROLL_BUTTON_BOTTOM_PX = 192;
const SCROLL_BUTTON_COMPOSER_GAP_PX = 16;
const SOFT_KEYBOARD_MIN_INSET_PX = 80;
const KEYBOARD_SCROLL_FRAMES = 18;
export const INITIAL_HISTORY_WINDOW = 160;
export const HISTORY_WINDOW_INCREMENT = 120;
@@ -92,6 +97,49 @@ function isKeyboardEditableElement(element: Element | null): element is HTMLElem
].includes(element.type);
}
type ThreadScrollDirection = "backward" | "forward";
const KEYBOARD_SCROLL_DIRECTIONS: Readonly<
Partial<Record<string, ThreadScrollDirection>>
> = {
ArrowUp: "backward",
PageUp: "backward",
Home: "backward",
ArrowDown: "forward",
PageDown: "forward",
End: "forward",
};
function directionFromDelta(deltaY: number): ThreadScrollDirection | null {
return deltaY < 0 ? "backward" : deltaY > 0 ? "forward" : null;
}
function keyboardScrollDirection(
event: KeyboardEvent,
): ThreadScrollDirection | null {
if (event.key === " ") {
return event.shiftKey ? "backward" : "forward";
}
return KEYBOARD_SCROLL_DIRECTIONS[event.key] ?? null;
}
function canScrollInDirection(
element: HTMLElement,
direction: ThreadScrollDirection | null,
): boolean {
switch (direction) {
case "backward":
return element.scrollTop > 0;
case "forward":
return (
element.scrollTop
< Math.max(0, element.scrollHeight - element.clientHeight)
);
default:
return false;
}
}
function readSoftKeyboardInsetBottom(container: HTMLElement | null): number {
const viewport = window.visualViewport;
if (!viewport) return 0;
@@ -108,8 +156,10 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
composer,
emptyState,
scrollToBottomSignal = 0,
scrollToLatestUserPromptSignal = 0,
activeTurnId = null,
activeTurnStartedHere = false,
conversationKey = null,
conversationReady = true,
showScrollToBottomButton = true,
cliApps = [],
mcpPresets = [],
@@ -126,25 +176,65 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
const { t } = useTranslation();
const scrollRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const messageRegionRef = useRef<HTMLDivElement>(null);
const messageContentRef = useRef<HTMLDivElement>(null);
const composerDockRef = useRef<HTMLDivElement>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const lastConversationKeyRef = useRef<string | null>(conversationKey);
const pendingConversationScrollRef = useRef(true);
const pendingPromptJumpRef = useRef<string | null>(null);
const scrollFrameIdsRef = useRef<number[]>([]);
const programmaticPromptScrollTopRef = useRef<number | null>(null);
const handledLatestPromptSignalRef = useRef(0);
const activeTurnPromptRef = useRef<string | null>(null);
const restoreScrollAfterPrependRef =
useRef<{ height: number; top: number } | null>(null);
/** User scrolled away from the bottom; do not auto-yank until they return or we reset (new chat / send). */
const userReadingHistoryRef = useRef(false);
const composerDockHeightRef = useRef(0);
const [atBottom, setAtBottom] = useState(true);
const [composerDockHeight, setComposerDockHeight] = useState(0);
const [keyboardInsetBottom, setKeyboardInsetBottom] = useState(0);
const [hasVerticalOverflow, setHasVerticalOverflow] = useState(false);
const [visibleMessageCount, setVisibleMessageCount] =
useState(INITIAL_HISTORY_WINDOW);
const threadMotionRef = useRef<ThreadMotionCoordinator | null>(null);
if (threadMotionRef.current === null) {
const camera = new ThreadCameraController(() => scrollRef.current);
threadMotionRef.current = new ThreadMotionCoordinator({
camera,
measure: (promptId) => {
const scrollEl = scrollRef.current;
const composerDock = composerDockRef.current;
if (!scrollEl) return null;
const composerHeight = composerDock
? composerDock.getBoundingClientRect().height || composerDock.offsetHeight
: 0;
const prompt = promptId ? findPromptElement(scrollEl, promptId) : null;
const scrollHeight = scrollEl.scrollHeight;
const clientHeight = scrollEl.clientHeight;
const maxScrollTop = Math.max(0, scrollHeight - clientHeight);
return {
scrollTop: scrollEl.scrollTop,
scrollHeight,
clientHeight,
maxScrollTop,
composerHeight,
promptTop: prompt
? Math.min(
maxScrollTop,
Math.max(0, promptTop(scrollEl, prompt) - 16),
)
: null,
};
},
onGeometry: (geometry: ThreadMotionGeometry) => {
if (Math.abs(composerDockHeightRef.current - geometry.composerHeight) >= 1) {
composerDockHeightRef.current = geometry.composerHeight;
setComposerDockHeight(geometry.composerHeight);
}
const nextOverflow = geometry.scrollHeight > geometry.clientHeight + 1;
setHasVerticalOverflow((current) =>
current === nextOverflow ? current : nextOverflow,
);
},
onAutoFollow: () => setAtBottom(true),
});
}
const hasMessages = messages.length > 0;
const visibleMessages = useMemo(
() => windowMessages(messages, visibleMessageCount),
@@ -168,15 +258,8 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
const scrollViewportStyle =
keyboardInsetBottom > 0 ? { bottom: keyboardInsetBottom } : undefined;
const cancelScheduledBottomScroll = useCallback(() => {
for (const id of scrollFrameIdsRef.current) {
window.cancelAnimationFrame(id);
}
scrollFrameIdsRef.current = [];
}, []);
const markProgrammaticPromptScroll = useCallback((top: number) => {
programmaticPromptScrollTopRef.current = top;
const yieldCameraToUser = useCallback(() => {
threadMotionRef.current?.takeUserControl();
}, []);
const scrollToBottomNow = useCallback((smooth = false) => {
@@ -185,68 +268,25 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
const behavior: ScrollBehavior = smooth ? "smooth" : "auto";
if (el) {
const top = Math.max(0, el.scrollHeight - el.clientHeight);
try {
el.scrollTo?.({ top, behavior });
if (!smooth) el.scrollTop = top;
} catch {
try {
el.scrollTop = top;
} catch {
// Test DOMs can expose read-only scrollTop; browsers keep this writable.
}
if (smooth) {
threadMotionRef.current?.animateTo(top);
} else {
threadMotionRef.current?.jumpTo(top);
}
} else if (marker) {
marker.scrollIntoView({ block: "end", behavior });
}
userReadingHistoryRef.current = false;
setAtBottom(true);
}, []);
const scrollToPromptTopNow = useCallback((promptId: string) => {
const el = scrollRef.current;
if (!el) return false;
const target = findPromptElement(el, promptId);
if (!target) return false;
const top = Math.max(0, promptTop(el, target) - 16);
markProgrammaticPromptScroll(top);
try {
el.scrollTo?.({ top, behavior: "auto" });
el.scrollTop = top;
} catch {
try {
el.scrollTop = top;
} catch {
// Test DOMs can expose read-only scrollTop; browsers keep this writable.
}
}
const near = el.scrollHeight - top - el.clientHeight < NEAR_BOTTOM_PX;
userReadingHistoryRef.current = false;
setAtBottom(near);
return true;
}, [markProgrammaticPromptScroll]);
const scrollToBottom = useCallback(
(smooth = false, frames = 1, options?: { force?: boolean }) => {
(smooth = false, options?: { force?: boolean }) => {
const force = options?.force ?? false;
cancelScheduledBottomScroll();
const run = () => {
if (!force && userReadingHistoryRef.current) return;
scrollToBottomNow(smooth);
};
const scheduleNext = (remainingFrames: number) => {
if (remainingFrames <= 0) return;
const id = window.requestAnimationFrame(() => {
scrollFrameIdsRef.current = scrollFrameIdsRef.current.filter((frameId) => frameId !== id);
if (!force && userReadingHistoryRef.current) return;
scrollToBottomNow(smooth);
scheduleNext(remainingFrames - 1);
});
scrollFrameIdsRef.current.push(id);
};
run();
scheduleNext(frames - 1);
if (!force && threadMotionRef.current?.isAutoFollowPaused()) return;
threadMotionRef.current?.resumeAutoFollow();
scrollToBottomNow(smooth);
},
[cancelScheduledBottomScroll, scrollToBottomNow],
[scrollToBottomNow],
);
const loadEarlierMessages = useCallback(() => {
@@ -257,8 +297,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
top: el.scrollTop,
};
}
userReadingHistoryRef.current = true;
activeTurnPromptRef.current = null;
threadMotionRef.current?.takeUserControl();
setAtBottom(false);
if (hiddenMessageCount > 0) {
setVisibleMessageCount((count) =>
@@ -275,58 +314,68 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
const maybeLoadEarlierFromScroll = useCallback(() => {
const el = scrollRef.current;
if (!el || !hasMessages || pendingConversationScrollRef.current) return;
if (!userReadingHistoryRef.current) return;
if (!threadMotionRef.current?.isBrowsingHistory()) return;
if (el.scrollTop > NEAR_TOP_PX) return;
if (hiddenMessageCount <= 0 && !hasMoreBefore) return;
loadEarlierMessages();
}, [hasMessages, hasMoreBefore, hiddenMessageCount, loadEarlierMessages]);
const jumpToUserPrompt = useCallback((promptId: string) => {
const navigateToVisiblePrompt = useCallback((promptId: string) => {
const scrollEl = scrollRef.current;
if (scrollEl && findPromptElement(scrollEl, promptId)) {
jumpToPrompt(scrollEl, promptId);
return;
}
const prompt = scrollEl ? findPromptElement(scrollEl, promptId) : null;
if (!scrollEl || !prompt) return false;
setAtBottom(false);
const maxScrollTop = Math.max(0, scrollEl.scrollHeight - scrollEl.clientHeight);
threadMotionRef.current?.navigateHistoryTo(
Math.min(
maxScrollTop,
Math.max(0, promptTop(scrollEl, prompt) - 16),
),
);
return true;
}, []);
const jumpToUserPrompt = useCallback((promptId: string) => {
if (navigateToVisiblePrompt(promptId)) return;
const index = messages.findIndex((message) => message.id === promptId);
if (index < 0) return;
threadMotionRef.current?.takeUserControl();
pendingPromptJumpRef.current = promptId;
userReadingHistoryRef.current = true;
activeTurnPromptRef.current = null;
setAtBottom(false);
setVisibleMessageCount((count) => Math.max(count, messages.length - index));
}, [messages]);
}, [messages, navigateToVisiblePrompt]);
const cancelAutoScroll = useCallback(() => {
threadMotionRef.current?.takeUserControl();
}, []);
useImperativeHandle(
ref,
() => ({
jumpToUserPrompt,
cancelAutoScroll: cancelScheduledBottomScroll,
cancelAutoScroll,
}),
[cancelScheduledBottomScroll, jumpToUserPrompt],
[cancelAutoScroll, jumpToUserPrompt],
);
const measureComposerDock = useCallback(() => {
const el = composerDockRef.current;
if (!el) return;
const height = el.getBoundingClientRect().height || el.offsetHeight;
setComposerDockHeight((current) =>
Math.abs(current - height) < 1 ? current : height,
);
}, []);
useLayoutEffect(() => {
const updateKeyboardInset = () => {
const scrollEl = scrollRef.current;
const next = readSoftKeyboardInsetBottom(scrollEl);
const active = document.activeElement;
const composerFocused =
hasMessages && isKeyboardEditableElement(active) && Boolean(scrollEl?.contains(active));
hasMessages
&& isKeyboardEditableElement(active)
&& Boolean(scrollEl?.contains(active));
setKeyboardInsetBottom((current) =>
Math.abs(current - next) < 1 ? current : next,
);
if (composerFocused) {
userReadingHistoryRef.current = false;
scrollToBottom(false, KEYBOARD_SCROLL_FRAMES, { force: true });
// Focusing the composer establishes a new reference frame at the
// latest message. This is one immediate positioning command; viewport
// events may issue a fresh command, but no command survives into a
// later render as a train of retry frames.
scrollToBottom(false, { force: true });
}
};
updateKeyboardInset();
@@ -345,76 +394,62 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
};
}, [hasMessages, scrollToBottom]);
useLayoutEffect(() => {
if (keyboardInsetBottom > 0) {
userReadingHistoryRef.current = false;
scrollToBottom(false, KEYBOARD_SCROLL_FRAMES, { force: true });
return;
}
if (userReadingHistoryRef.current) return;
scrollToBottom(false, 4);
}, [keyboardInsetBottom, scrollToBottom]);
useEffect(() => {
const scrollEl = scrollRef.current;
if (!scrollEl) return;
const onComposerFocus = () => {
const active = document.activeElement;
if (!hasMessages || !isKeyboardEditableElement(active) || !scrollEl.contains(active)) return;
userReadingHistoryRef.current = false;
scrollToBottom(false, KEYBOARD_SCROLL_FRAMES, { force: true });
};
document.addEventListener("focusin", onComposerFocus);
return () => document.removeEventListener("focusin", onComposerFocus);
}, [hasMessages, scrollToBottom]);
useEffect(() => {
if (scrollToBottomSignal <= 0) return;
userReadingHistoryRef.current = false;
scrollToBottom(false, 8);
scrollToBottom(false, { force: true });
}, [scrollToBottomSignal, scrollToBottom]);
useLayoutEffect(() => {
if (scrollToLatestUserPromptSignal <= handledLatestPromptSignalRef.current) return;
const latest = messages[messages.length - 1];
if (!latest || latest.role !== "user") return;
handledLatestPromptSignalRef.current = scrollToLatestUserPromptSignal;
cancelScheduledBottomScroll();
activeTurnPromptRef.current = latest.id;
if (!scrollToPromptTopNow(latest.id)) activeTurnPromptRef.current = null;
}, [
cancelScheduledBottomScroll,
messages,
scrollToLatestUserPromptSignal,
scrollToPromptTopNow,
]);
useLayoutEffect(() => {
if (lastConversationKeyRef.current === conversationKey) return;
lastConversationKeyRef.current = conversationKey;
pendingConversationScrollRef.current = true;
userReadingHistoryRef.current = false;
activeTurnPromptRef.current = null;
threadMotionRef.current?.reset();
setAtBottom(true);
setVisibleMessageCount(INITIAL_HISTORY_WINDOW);
}, [conversationKey]);
useLayoutEffect(() => {
const promptId = activeTurnPromptRef.current;
if (!promptId || userReadingHistoryRef.current) return;
const promptIndex = messages.findIndex((message) => message.id === promptId);
if (promptIndex < 0) {
activeTurnPromptRef.current = null;
if (!conversationReady) {
threadMotionRef.current?.reset();
return;
}
const hasAgentOutput = messages
.slice(promptIndex + 1)
.some((message) => message.role !== "user");
if (!hasAgentOutput) return;
scrollToBottom(false, isStreaming ? 3 : 1);
}, [isStreaming, messages, scrollToBottom]);
if (!activeTurnId) {
threadMotionRef.current?.completeTurn();
return;
}
const promptIndex = messages.findIndex(
(message) => message.role === "user" && message.turnId === activeTurnId,
);
if (
activeTurnStartedHere
&& promptIndex >= 0
&& threadMotionRef.current?.snapshot().turnId !== activeTurnId
) {
// A turn submitted in this mounted viewport establishes a new prompt
// origin. A restored turn must first complete open-at-bottom instead.
pendingConversationScrollRef.current = false;
}
const prompt = promptIndex >= 0 ? messages[promptIndex] : null;
const hasOutput = promptIndex >= 0
? messages
.slice(promptIndex + 1)
.some(
(message) =>
message.role !== "user"
&& (!message.turnId || message.turnId === activeTurnId),
)
: !activeTurnStartedHere && messages.some(
(message) =>
message.role !== "user" && message.turnId === activeTurnId,
);
threadMotionRef.current?.updateTurn({
id: activeTurnId,
promptId: prompt?.id ?? null,
hasOutput,
entry: activeTurnStartedHere ? "submitted" : "restored",
});
}, [activeTurnId, activeTurnStartedHere, conversationReady, messages]);
useLayoutEffect(() => {
const pending = restoreScrollAfterPrependRef.current;
@@ -423,16 +458,11 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
restoreScrollAfterPrependRef.current = null;
if (!el) return;
const delta = el.scrollHeight - pending.height;
const nextTop = pending.top + delta;
try {
el.scrollTop = nextTop;
} catch {
try {
el.scrollTo?.({ top: nextTop, behavior: "auto" });
} catch {
// Test DOMs can expose read-only scrollTop; browsers keep this writable.
}
}
const nextTop = Math.min(
Math.max(0, el.scrollHeight - el.clientHeight),
Math.max(0, pending.top + delta),
);
threadMotionRef.current?.jumpTo(nextTop);
}, [visibleMessages.length, messages.length]);
useLayoutEffect(() => {
@@ -440,65 +470,61 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
const scrollEl = scrollRef.current;
if (!promptId || !scrollEl || !findPromptElement(scrollEl, promptId)) return;
pendingPromptJumpRef.current = null;
const frame = window.requestAnimationFrame(() => jumpToPrompt(scrollEl, promptId));
const frame = window.requestAnimationFrame(() => navigateToVisiblePrompt(promptId));
return () => window.cancelAnimationFrame(frame);
}, [visibleMessages.length]);
}, [navigateToVisiblePrompt, visibleMessages.length]);
useLayoutEffect(() => {
if (!pendingConversationScrollRef.current) return;
if (!conversationReady) return;
if (!conversationKey) {
pendingConversationScrollRef.current = false;
scrollToBottom(false, 4);
scrollToBottom(false, { force: true });
return;
}
scrollToBottom(false, 8);
scrollToBottom(false, { force: true });
if (!hasMessages) return;
pendingConversationScrollRef.current = false;
}, [conversationKey, hasMessages, messages, scrollToBottom]);
}, [
conversationKey,
conversationReady,
hasMessages,
messages,
scrollToBottom,
]);
useLayoutEffect(() => {
measureComposerDock();
}, [composer, hasMessages, measureComposerDock]);
threadMotionRef.current?.invalidateGeometry();
}, [composer, hasMessages, visibleMessages.length]);
useLayoutEffect(() => {
if (!hasMessages || userReadingHistoryRef.current) return;
const promptId = activeTurnPromptRef.current;
if (promptId && scrollToPromptTopNow(promptId)) return;
scrollToBottom(false, 2);
}, [composerDockHeight, hasMessages, scrollToBottom, scrollToPromptTopNow]);
useEffect(() => cancelScheduledBottomScroll, [cancelScheduledBottomScroll]);
useEffect(() => {
const target = composerDockRef.current;
if (!target || typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(() => measureComposerDock());
observer.observe(target);
return () => observer.disconnect();
}, [hasMessages, measureComposerDock]);
const measureVerticalOverflow = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
const next = el.scrollHeight > el.clientHeight + 1;
setHasVerticalOverflow((current) => (current === next ? current : next));
}, []);
useEffect(() => () => threadMotionRef.current?.dispose(), []);
useLayoutEffect(() => {
const el = scrollRef.current;
const content = contentRef.current;
const messageRegion = messageRegionRef.current;
const messageContent = messageContentRef.current;
const composerDock = composerDockRef.current;
if (!el) return;
measureVerticalOverflow();
const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(measureVerticalOverflow);
const invalidateGeometry = () => {
threadMotionRef.current?.invalidateGeometry();
};
invalidateGeometry();
const observer = typeof ResizeObserver === "undefined"
? null
: new ResizeObserver(invalidateGeometry);
observer?.observe(el);
if (content) observer?.observe(content);
window.addEventListener("resize", measureVerticalOverflow);
if (messageRegion) observer?.observe(messageRegion);
if (messageContent) observer?.observe(messageContent);
if (composerDock) observer?.observe(composerDock);
window.addEventListener("resize", invalidateGeometry);
return () => {
observer?.disconnect();
window.removeEventListener("resize", measureVerticalOverflow);
window.removeEventListener("resize", invalidateGeometry);
};
}, [composerDockHeight, hasMessages, measureVerticalOverflow, visibleMessages.length]);
}, [hasMessages]);
useEffect(() => {
const el = scrollRef.current;
@@ -507,48 +533,114 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
const onScroll = (allowHistoryLoad = true) => {
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
const near = distance < NEAR_BOTTOM_PX;
const programmaticPromptTop = programmaticPromptScrollTopRef.current;
const programmatic =
programmaticPromptTop !== null && Math.abs(el.scrollTop - programmaticPromptTop) < 2;
setAtBottom((current) => current === near ? current : near);
if (programmatic) {
programmaticPromptScrollTopRef.current = null;
if (near) userReadingHistoryRef.current = false;
return;
}
programmaticPromptScrollTopRef.current = null;
userReadingHistoryRef.current = !near;
if (!near) activeTurnPromptRef.current = null;
if (allowHistoryLoad && !near) maybeLoadEarlierFromScroll();
const owner = threadMotionRef.current?.observeScroll(near) ?? "automatic";
const logicallyAtBottom = owner === "automatic" || near;
setAtBottom((current) =>
current === logicallyAtBottom ? current : logicallyAtBottom,
);
if (allowHistoryLoad && owner === "user") maybeLoadEarlierFromScroll();
};
onScroll(false);
const handleScroll = () => onScroll(true);
const handleDirectionalInput = (
direction: ThreadScrollDirection | null,
) => {
if (!direction) return;
threadMotionRef.current?.handleUserScrollIntent(
canScrollInDirection(el, direction),
);
};
const handleWheel = (event: WheelEvent) => {
if (
event.defaultPrevented
|| event.ctrlKey
|| Math.abs(event.deltaY) <= Math.abs(event.deltaX)
) {
return;
}
handleDirectionalInput(directionFromDelta(event.deltaY));
};
const handlePointerDown = (event: PointerEvent) => {
if (event.button === 0 && event.target === el) yieldCameraToUser();
};
let touchStartY: number | null = null;
const handleTouchStart = (event: TouchEvent) => {
touchStartY = event.touches[0]?.clientY ?? null;
};
const handleTouchMove = (event: TouchEvent) => {
const currentY = event.touches[0]?.clientY;
const scrollDeltaY =
touchStartY !== null && currentY !== undefined
? touchStartY - currentY
: 0;
handleDirectionalInput(directionFromDelta(scrollDeltaY));
};
const handleTouchEnd = () => {
touchStartY = null;
};
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.defaultPrevented
|| event.altKey
|| event.ctrlKey
|| event.metaKey
|| isKeyboardEditableElement(event.target as Element | null)
) {
return;
}
handleDirectionalInput(keyboardScrollDirection(event));
};
el.addEventListener("scroll", handleScroll, { passive: true });
return () => el.removeEventListener("scroll", handleScroll);
}, [maybeLoadEarlierFromScroll]);
el.addEventListener("wheel", handleWheel, { passive: true });
el.addEventListener("touchstart", handleTouchStart, { passive: true });
el.addEventListener("touchmove", handleTouchMove, { passive: true });
el.addEventListener("touchend", handleTouchEnd, { passive: true });
el.addEventListener("touchcancel", handleTouchEnd, { passive: true });
el.addEventListener("pointerdown", handlePointerDown);
el.addEventListener("keydown", handleKeyDown);
return () => {
el.removeEventListener("scroll", handleScroll);
el.removeEventListener("wheel", handleWheel);
el.removeEventListener("touchstart", handleTouchStart);
el.removeEventListener("touchmove", handleTouchMove);
el.removeEventListener("touchend", handleTouchEnd);
el.removeEventListener("touchcancel", handleTouchEnd);
el.removeEventListener("pointerdown", handlePointerDown);
el.removeEventListener("keydown", handleKeyDown);
};
}, [maybeLoadEarlierFromScroll, yieldCameraToUser]);
return (
<div className="thread-viewport relative flex min-h-0 flex-1 overflow-hidden">
<div
ref={scrollRef}
className={cn(
"thread-viewport-scrollbar absolute inset-0 scroll-auto scrollbar-thin",
"thread-viewport-scrollbar absolute inset-0 scroll-auto",
"[overflow-anchor:none] [scrollbar-width:none]",
"[&::-webkit-scrollbar]:hidden",
hasVerticalOverflow ? "overflow-y-auto" : "overflow-hidden",
"[&::-webkit-scrollbar]:w-1.5",
"[&::-webkit-scrollbar-thumb]:rounded-full",
"[&::-webkit-scrollbar-thumb]:bg-muted-foreground/30",
"[&::-webkit-scrollbar-track]:bg-transparent",
)}
style={scrollViewportStyle}
>
{hasMessages ? (
<div ref={contentRef} className="mx-auto flex min-h-full w-full max-w-[64rem] flex-col">
<div
ref={contentRef}
data-testid={!hasMessages ? "thread-welcome-layout" : undefined}
data-layout={hasMessages ? "thread" : "hero"}
className={cn(
"thread-layout mx-auto grid min-h-full w-full",
hasMessages
? "max-w-[64rem]"
: "max-w-[72rem] px-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] pt-6 sm:px-4 sm:py-12",
)}
>
{hasMessages ? (
<div
ref={messageRegionRef}
data-testid="thread-message-region"
className="flex min-h-0 flex-1 flex-col justify-start px-3 pb-4 pt-4 sm:px-4"
className="row-start-1 flex min-h-0 flex-col justify-start px-3 pb-4 pt-4 sm:px-4"
>
<div className="mx-auto w-full max-w-[49.5rem]">
<div ref={messageContentRef} className="mx-auto w-full max-w-[49.5rem]">
<ThreadMessages
messages={visibleMessages}
isStreaming={isStreaming}
@@ -563,32 +655,41 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
/>
</div>
</div>
) : (
<div className="row-start-1 flex min-h-0 min-w-0 w-full items-center justify-center sm:items-end sm:pb-8">
{emptyState}
</div>
)}
<div
ref={composerDockRef}
data-testid="thread-composer-dock"
className={cn(
"row-start-2 z-10 w-full",
hasMessages ? "sticky bottom-0 bg-background" : "relative self-center",
)}
>
<div
ref={composerDockRef}
data-testid="thread-composer-dock"
className="sticky bottom-0 z-10 bg-background"
className={cn(
hasMessages
? "px-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] sm:px-4"
: "",
)}
>
<div className="px-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] sm:px-4">
<div
data-testid="thread-composer-motion"
className="mx-auto w-full max-w-[58rem]"
>
{composer}
</div>
</div>
</div>
) : (
<div ref={contentRef} className="mx-auto flex min-h-full w-full max-w-[72rem] flex-col px-3 sm:px-4">
<div className="flex w-full flex-1 flex-col items-center pb-[calc(0.75rem+env(safe-area-inset-bottom))] pt-6 sm:justify-center sm:py-12">
<div
data-testid="thread-welcome-layout"
className="relative grid w-full max-w-[58rem] flex-1 grid-rows-[minmax(min-content,1fr)_auto] gap-8 sm:block sm:flex-none"
>
<div className="flex min-h-0 min-w-0 w-full items-center justify-center sm:absolute sm:inset-x-0 sm:bottom-[calc(100%+2rem)]">
{emptyState}
</div>
<div className="w-full">{composer}</div>
</div>
</div>
</div>
)}
<div
aria-hidden
className="thread-layout-spacer row-start-3 min-h-0 overflow-hidden"
/>
</div>
<div ref={bottomRef} aria-hidden className="h-px" />
</div>
@@ -602,6 +703,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
messages={visibleMessages}
scrollRef={scrollRef}
bottomOffset={scrollButtonBottom}
onJumpToPrompt={navigateToVisiblePrompt}
/>
) : null}
@@ -613,7 +715,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
<Button
variant="outline"
size="icon"
onClick={() => scrollToBottom(true, 1, { force: true })}
onClick={() => scrollToBottom(true, { force: true })}
className={cn(
"h-8 w-8 rounded-full shadow-md",
"bg-background/90 backdrop-blur",
@@ -0,0 +1,245 @@
interface ThreadCameraMotionProfile {
/**
* Time constant for the ease-out chase. Smaller values react faster; the
* camera closes roughly 95% of an uncapped distance in three time constants.
*/
responseTimeMs: number;
/** Prevents a large completion batch from turning into a one-frame jump. */
maxSpeedPxPerSecond: number;
/** Avoids spending frames chasing sub-pixel layout noise. */
settleDistancePx: number;
/** Limits catch-up after a throttled or backgrounded animation frame. */
maxFrameDeltaMs: number;
}
const THREAD_CAMERA_FOLLOW_MOTION: Readonly<ThreadCameraMotionProfile> = {
responseTimeMs: 90,
maxSpeedPxPerSecond: 1_200,
settleDistancePx: 0.5,
maxFrameDeltaMs: 50,
};
const THREAD_CAMERA_NAVIGATION_MOTION: Readonly<ThreadCameraMotionProfile> = {
responseTimeMs: 110,
maxSpeedPxPerSecond: 12_000,
settleDistancePx: 0.5,
maxFrameDeltaMs: 50,
};
/**
* Reduced motion still preserves spatial continuity. Snapping a long thread
* to its destination removes the very context that helps users understand
* where the viewport moved; this profile shortens that motion instead.
*/
const THREAD_CAMERA_REDUCED_MOTION: Readonly<ThreadCameraMotionProfile> = {
responseTimeMs: 55,
maxSpeedPxPerSecond: 2_400,
settleDistancePx: 0.5,
maxFrameDeltaMs: 50,
};
const THREAD_CAMERA_REDUCED_NAVIGATION_MOTION: Readonly<ThreadCameraMotionProfile> = {
responseTimeMs: 45,
maxSpeedPxPerSecond: 24_000,
settleDistancePx: 0.5,
maxFrameDeltaMs: 50,
};
export interface ThreadCameraViewport {
scrollTop: number;
scrollTo?: (options?: ScrollToOptions) => void;
}
export interface ThreadCameraScheduler {
request: (callback: FrameRequestCallback) => number;
cancel: (id: number) => void;
now: () => number;
}
export type ThreadCameraFollowResult = "started" | "retargeted" | "settled";
interface ThreadCameraOptions {
scheduler?: ThreadCameraScheduler;
prefersReducedMotion?: () => boolean;
}
type ThreadCameraMotionKind = "follow" | "navigation";
/**
* A time-based ease-out chase rather than a start/end tween. The target can
* move on every streamed line without restarting a duration or adding another
* frame loop.
*/
function easeOutChase(
current: number,
target: number,
deltaSeconds: number,
profile: Pick<ThreadCameraMotionProfile, "responseTimeMs" | "maxSpeedPxPerSecond">,
): number {
const distance = target - current;
const responseSeconds = Math.max(0.001, profile.responseTimeMs / 1000);
const timeStep = Math.max(0.001, deltaSeconds);
const easeOutFraction = 1 - Math.exp(-timeStep / responseSeconds);
const uncappedStep = distance * easeOutFraction;
const maxStep = Math.max(0, profile.maxSpeedPxPerSecond) * timeStep;
const step = Math.max(-maxStep, Math.min(maxStep, uncappedStep));
return current + step;
}
function defaultScheduler(): ThreadCameraScheduler {
return {
request: (callback) => window.requestAnimationFrame(callback),
cancel: (id) => window.cancelAnimationFrame(id),
now: () => performance.now(),
};
}
function defaultPrefersReducedMotion(): boolean {
return typeof window !== "undefined"
&& typeof window.matchMedia === "function"
&& window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
export class ThreadCameraController {
private readonly getViewport: () => ThreadCameraViewport | null;
private readonly scheduler: ThreadCameraScheduler;
private readonly prefersReducedMotion: () => boolean;
private frameId: number | null = null;
private phase: "idle" | "following" = "idle";
private target = 0;
private lastTimestamp: number | null = null;
private motionKind: ThreadCameraMotionKind = "follow";
constructor(
getViewport: () => ThreadCameraViewport | null,
options: ThreadCameraOptions = {},
) {
this.getViewport = getViewport;
this.scheduler = options.scheduler ?? defaultScheduler();
this.prefersReducedMotion = options.prefersReducedMotion ?? defaultPrefersReducedMotion;
}
isFollowing(): boolean {
return this.phase === "following";
}
jumpTo(top: number): void {
const viewport = this.getViewport();
if (!viewport) return;
this.cancel();
this.target = Math.max(0, top);
this.write(viewport, this.target);
}
followTo(top: number): ThreadCameraFollowResult | null {
return this.moveTo(top, "follow");
}
navigateTo(top: number): ThreadCameraFollowResult | null {
return this.moveTo(top, "navigation");
}
private moveTo(
top: number,
motionKind: ThreadCameraMotionKind,
): ThreadCameraFollowResult | null {
const viewport = this.getViewport();
if (!viewport) return null;
const current = viewport.scrollTop;
this.target = Math.max(0, top);
this.motionKind = motionKind;
const motion = this.currentMotion(motionKind);
if (this.phase === "following") {
return "retargeted";
}
if (Math.abs(this.target - current) <= motion.settleDistancePx) {
this.write(viewport, this.target);
return "settled";
}
this.phase = "following";
this.lastTimestamp = this.scheduler.now();
this.frameId = this.scheduler.request(this.advance);
return "started";
}
cancel(): void {
if (this.frameId !== null) {
this.scheduler.cancel(this.frameId);
this.frameId = null;
}
this.phase = "idle";
this.lastTimestamp = null;
this.motionKind = "follow";
}
dispose(): void {
this.cancel();
}
private readonly advance = (timestamp: number): void => {
this.frameId = null;
const viewport = this.getViewport();
if (!viewport || this.phase !== "following") {
this.cancel();
return;
}
const motion = this.currentMotion(this.motionKind);
const previousTimestamp = this.lastTimestamp ?? timestamp - (1000 / 60);
const deltaMs = Math.min(
motion.maxFrameDeltaMs,
Math.max(1, timestamp - previousTimestamp),
);
this.lastTimestamp = timestamp;
const current = viewport.scrollTop;
const remainingDistance = this.target - current;
if (Math.abs(remainingDistance) <= motion.settleDistancePx) {
this.write(viewport, this.target);
this.phase = "idle";
this.lastTimestamp = null;
return;
}
const deltaSeconds = deltaMs / 1000;
const nextTop = easeOutChase(
current,
this.target,
deltaSeconds,
motion,
);
const settled = Math.abs(this.target - nextTop) <= motion.settleDistancePx;
this.write(viewport, settled ? this.target : nextTop);
if (settled) {
this.phase = "idle";
this.lastTimestamp = null;
return;
}
this.frameId = this.scheduler.request(this.advance);
};
private currentMotion(kind: ThreadCameraMotionKind): ThreadCameraMotionProfile {
if (kind === "navigation") {
return this.prefersReducedMotion()
? THREAD_CAMERA_REDUCED_NAVIGATION_MOTION
: THREAD_CAMERA_NAVIGATION_MOTION;
}
return this.prefersReducedMotion()
? THREAD_CAMERA_REDUCED_MOTION
: THREAD_CAMERA_FOLLOW_MOTION;
}
private write(viewport: ThreadCameraViewport, top: number): void {
try {
viewport.scrollTop = top;
} catch {
try {
viewport.scrollTo?.({ top, behavior: "auto" });
} catch {
// Test DOMs can expose read-only scrollTop; browsers keep this writable.
}
}
}
}
@@ -0,0 +1,382 @@
import type {
ThreadCameraController,
ThreadCameraFollowResult,
} from "@/components/thread/thread-camera";
type ThreadMotionMode =
| "idle"
| "anchor-prompt"
| "follow-output"
| "follow-completion"
| "navigating-history"
| "browsing-history";
type AutomaticThreadMotionMode =
| "idle"
| "anchor-prompt"
| "follow-output";
type ThreadMotionEvent =
| "navigate-history"
| "navigation-settled"
| "user-scroll"
| "boundary-scroll"
| "turn-completed"
| "resume-follow";
type ThreadMotionTransition = ThreadMotionMode | "current-automatic-mode";
const THREAD_MOTION_TRANSITIONS: Readonly<
Record<
ThreadMotionMode,
Readonly<Partial<Record<ThreadMotionEvent, ThreadMotionTransition>>>
>
> = {
idle: {
"navigate-history": "navigating-history",
"user-scroll": "browsing-history",
},
"anchor-prompt": {
"navigate-history": "navigating-history",
"user-scroll": "browsing-history",
"turn-completed": "follow-completion",
},
"follow-output": {
"navigate-history": "navigating-history",
"user-scroll": "browsing-history",
"turn-completed": "follow-completion",
},
"follow-completion": {
"navigate-history": "navigating-history",
"user-scroll": "browsing-history",
},
"navigating-history": {
"navigate-history": "navigating-history",
"navigation-settled": "browsing-history",
"user-scroll": "browsing-history",
"boundary-scroll": "browsing-history",
"resume-follow": "current-automatic-mode",
},
"browsing-history": {
"navigate-history": "navigating-history",
"resume-follow": "current-automatic-mode",
},
};
export interface ThreadMotionGeometry {
scrollTop: number;
scrollHeight: number;
clientHeight: number;
maxScrollTop: number;
composerHeight: number;
promptTop: number | null;
}
interface ThreadMotionTurn {
id: string | null;
promptId: string | null;
hasOutput: boolean;
entry?: "submitted" | "restored";
}
interface ThreadMotionSnapshot {
mode: ThreadMotionMode;
turnId: string | null;
promptId: string | null;
promptPositioned: boolean;
measurementPending: boolean;
}
export interface ThreadMotionScheduler {
request: (callback: FrameRequestCallback) => number;
cancel: (id: number) => void;
}
type ThreadMotionCamera = Pick<
ThreadCameraController,
| "cancel"
| "dispose"
| "followTo"
| "isFollowing"
| "jumpTo"
| "navigateTo"
>;
interface ThreadMotionCoordinatorOptions {
camera: ThreadMotionCamera;
measure: (promptId: string | null) => ThreadMotionGeometry | null;
onGeometry?: (geometry: ThreadMotionGeometry) => void;
onAutoFollow?: () => void;
scheduler?: ThreadMotionScheduler;
}
const GEOMETRY_EPSILON_PX = 0.5;
type ThreadScrollOwner = "automatic" | "navigation" | "user";
function defaultScheduler(): ThreadMotionScheduler {
return {
request: (callback) => window.requestAnimationFrame(callback),
cancel: (id) => window.cancelAnimationFrame(id),
};
}
/**
* Owns the policy that turns discrete layout events into continuous camera
* motion. Callers only invalidate geometry; one display frame coalesces those
* notifications, reads the authoritative layout, and retargets the camera.
*/
export class ThreadMotionCoordinator {
private readonly camera: ThreadMotionCamera;
private readonly measure: ThreadMotionCoordinatorOptions["measure"];
private readonly onGeometry?: ThreadMotionCoordinatorOptions["onGeometry"];
private readonly onAutoFollow?: ThreadMotionCoordinatorOptions["onAutoFollow"];
private readonly scheduler: ThreadMotionScheduler;
private turn: ThreadMotionTurn = {
id: null,
promptId: null,
hasOutput: false,
};
private mode: ThreadMotionMode = "idle";
private promptPositioned = false;
private measurementFrameId: number | null = null;
private geometryDirty = false;
constructor(options: ThreadMotionCoordinatorOptions) {
this.camera = options.camera;
this.measure = options.measure;
this.onGeometry = options.onGeometry;
this.onAutoFollow = options.onAutoFollow;
this.scheduler = options.scheduler ?? defaultScheduler();
}
snapshot(): ThreadMotionSnapshot {
return {
mode: this.mode,
turnId: this.turn.id,
promptId: this.turn.promptId,
promptPositioned: this.promptPositioned,
measurementPending: this.measurementFrameId !== null,
};
}
updateTurn(turn: ThreadMotionTurn): void {
if (!turn.id) {
this.completeTurn();
return;
}
const isNewTurn = this.turn.id !== turn.id;
this.turn = turn;
if (isNewTurn) {
this.camera.cancel();
this.promptPositioned = turn.entry === "restored";
this.mode = this.promptPositioned && turn.hasOutput
? "follow-output"
: "anchor-prompt";
} else if (!this.isHistoryMode() && this.promptPositioned) {
this.mode = turn.hasOutput ? "follow-output" : "anchor-prompt";
}
this.invalidateGeometry();
}
completeTurn(): void {
if (!this.turn.id) {
this.invalidateGeometry();
return;
}
// Protocol completion can share a React commit with the last large text
// batch and begins the run-drawer exit. Keep camera ownership through
// those final layout changes; only a new turn or explicit user navigation
// may end completion follow.
this.transition("turn-completed");
this.turn = { id: null, promptId: null, hasOutput: false };
this.promptPositioned = false;
this.invalidateGeometry();
}
invalidateGeometry(): void {
this.geometryDirty = true;
if (this.measurementFrameId !== null) return;
this.measurementFrameId = this.scheduler.request(this.flushGeometry);
}
takeUserControl(): void {
this.handleUserScrollIntent(true);
}
handleUserScrollIntent(canScroll: boolean): void {
const event = canScroll ? "user-scroll" : "boundary-scroll";
if (!this.transition(event)) return;
this.camera.cancel();
}
resumeAutoFollow(): void {
if (!this.transition("resume-follow")) return;
this.camera.cancel();
this.invalidateGeometry();
}
jumpTo(top: number): void {
this.camera.jumpTo(top);
}
animateTo(top: number): ThreadCameraFollowResult | null {
return this.camera.navigateTo(top);
}
navigateHistoryTo(top: number): ThreadCameraFollowResult | null {
this.camera.cancel();
this.transition("navigate-history");
const result = this.camera.navigateTo(top);
if (!result || result === "settled") {
this.transition("navigation-settled");
}
return result;
}
isAutoFollowPaused(): boolean {
return this.isHistoryMode();
}
isBrowsingHistory(): boolean {
return this.mode === "browsing-history";
}
/**
* A scroll event reports geometry; it does not prove user intent. Explicit
* input handlers call takeUserControl() before the browser scrolls. Layout,
* sticky positioning, and camera writes therefore remain automatic even
* when the browser emits an intermediate scroll event for them.
*/
observeScroll(nearBottom: boolean): ThreadScrollOwner {
switch (this.mode) {
case "navigating-history":
if (!this.camera.isFollowing()) {
this.transition("navigation-settled");
if (nearBottom) this.resumeAutoFollow();
}
return "navigation";
case "browsing-history":
if (!nearBottom) return "user";
this.resumeAutoFollow();
return "automatic";
default:
if (!nearBottom) this.invalidateGeometry();
return "automatic";
}
}
reset(): void {
if (this.measurementFrameId !== null) {
this.scheduler.cancel(this.measurementFrameId);
this.measurementFrameId = null;
}
this.geometryDirty = false;
this.camera.cancel();
this.turn = { id: null, promptId: null, hasOutput: false };
this.mode = "idle";
this.promptPositioned = false;
}
dispose(): void {
this.reset();
this.camera.dispose();
}
private isHistoryMode(): boolean {
return (
this.mode === "navigating-history"
|| this.mode === "browsing-history"
);
}
private automaticMode(): AutomaticThreadMotionMode {
if (!this.turn.id) return "idle";
return this.promptPositioned && this.turn.hasOutput
? "follow-output"
: "anchor-prompt";
}
private transition(event: ThreadMotionEvent): boolean {
const transition = THREAD_MOTION_TRANSITIONS[this.mode][event];
if (!transition) return false;
const nextMode =
transition === "current-automatic-mode"
? this.automaticMode()
: transition;
if (nextMode === this.mode) return false;
this.mode = nextMode;
return true;
}
private followGeometry(geometry: ThreadMotionGeometry): void {
const target = geometry.maxScrollTop;
const result = this.camera.followTo(target);
if (
result
&& (
Math.abs(target - geometry.scrollTop) > GEOMETRY_EPSILON_PX
|| result === "retargeted"
)
) {
this.onAutoFollow?.();
}
}
private readonly flushGeometry = (): void => {
this.measurementFrameId = null;
if (!this.geometryDirty) return;
this.geometryDirty = false;
const needsPromptGeometry =
!this.isHistoryMode()
&& this.turn.id !== null
&& !this.promptPositioned;
const geometry = this.measure(needsPromptGeometry ? this.turn.promptId : null);
if (!geometry) return;
this.onGeometry?.(geometry);
if (this.mode === "follow-completion") {
this.followGeometry(geometry);
return;
}
if (this.isHistoryMode() || !this.turn.id) return;
if (!this.turn.promptId && this.turn.entry !== "restored") {
this.mode = "anchor-prompt";
return;
}
if (!this.promptPositioned) {
if (geometry.promptTop === null) {
this.mode = "anchor-prompt";
return;
}
// Before output exists, the real lower scroll boundary is the only
// position with zero hidden downward travel. Once output exists, start
// from the prompt origin and let the follow camera reveal its growth.
this.camera.jumpTo(
this.turn.hasOutput ? geometry.promptTop : geometry.maxScrollTop,
);
this.promptPositioned = true;
} else if (
!this.turn.hasOutput
&& Math.abs(geometry.maxScrollTop - geometry.scrollTop)
> GEOMETRY_EPSILON_PX
) {
// Hero docking, the run drawer, fonts, and responsive chrome can all
// change document height while the model is still silent. Every
// authoritative geometry frame reasserts the real lower boundary so no
// stale downward scroll pocket survives a layout transition.
this.camera.jumpTo(geometry.maxScrollTop);
}
if (!this.turn.hasOutput) {
this.mode = "anchor-prompt";
return;
}
this.mode = "follow-output";
this.followGeometry(geometry);
};
}