Smooth WebUI streaming with state-driven viewport motion (#4696)
This commit is contained in:
@@ -100,3 +100,4 @@ temp/
|
|||||||
exp/
|
exp/
|
||||||
.playwright-mcp/
|
.playwright-mcp/
|
||||||
bridge/node_modules/
|
bridge/node_modules/
|
||||||
|
webui/.verify-*
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ interface MarkdownTextProps {
|
|||||||
children: string;
|
children: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
streaming?: boolean;
|
streaming?: boolean;
|
||||||
|
preserveStreamingLayout?: boolean;
|
||||||
onOpenFilePreview?: (path: string) => void;
|
onOpenFilePreview?: (path: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,11 +75,13 @@ export function MarkdownText({
|
|||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
streaming = false,
|
streaming = false,
|
||||||
|
preserveStreamingLayout = false,
|
||||||
onOpenFilePreview,
|
onOpenFilePreview,
|
||||||
}: MarkdownTextProps) {
|
}: MarkdownTextProps) {
|
||||||
const renderedSource = children;
|
const renderedSource = children;
|
||||||
const renderPhase = streaming ? "streaming" : "complete";
|
const renderPhase = streaming ? "streaming" : "complete";
|
||||||
const highlightCode = !streaming;
|
const highlightCode = !streaming;
|
||||||
|
const renderWithStreamingLayout = streaming || preserveStreamingLayout;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (streaming) void preloadMarkdownText();
|
if (streaming) void preloadMarkdownText();
|
||||||
@@ -103,7 +106,7 @@ export function MarkdownText({
|
|||||||
source={renderedSource}
|
source={renderedSource}
|
||||||
className={className}
|
className={className}
|
||||||
highlightCode={highlightCode}
|
highlightCode={highlightCode}
|
||||||
streaming={streaming}
|
streaming={renderWithStreamingLayout}
|
||||||
onOpenFilePreview={onOpenFilePreview}
|
onOpenFilePreview={onOpenFilePreview}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|||||||
@@ -248,13 +248,6 @@ const rehypePlugins: NonNullable<StreamdownProps["rehypePlugins"]> = [rehypeKate
|
|||||||
|
|
||||||
const DIRECT_LINKS = { enabled: false } as const;
|
const DIRECT_LINKS = { enabled: false } as const;
|
||||||
const SAFE_MARKDOWN_PROTOCOL = /^(https?|ircs?|mailto|xmpp)$/i;
|
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. */
|
/** Preserve react-markdown's URL policy when rendering through Streamdown. */
|
||||||
const safeMarkdownUrl: NonNullable<StreamdownProps["urlTransform"]> = (url) => {
|
const safeMarkdownUrl: NonNullable<StreamdownProps["urlTransform"]> = (url) => {
|
||||||
@@ -727,9 +720,8 @@ export default function MarkdownTextRenderer({
|
|||||||
<Streamdown
|
<Streamdown
|
||||||
mode={streaming ? "streaming" : "static"}
|
mode={streaming ? "streaming" : "static"}
|
||||||
parseIncompleteMarkdown
|
parseIncompleteMarkdown
|
||||||
isAnimating={streaming}
|
isAnimating={false}
|
||||||
animated={streaming ? STREAMING_ANIMATION : false}
|
animated={false}
|
||||||
caret={streaming ? "block" : undefined}
|
|
||||||
linkSafety={DIRECT_LINKS}
|
linkSafety={DIRECT_LINKS}
|
||||||
urlTransform={safeMarkdownUrl}
|
urlTransform={safeMarkdownUrl}
|
||||||
remarkPlugins={remarkPlugins}
|
remarkPlugins={remarkPlugins}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import {
|
|||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { copyTextToClipboard } from "@/lib/clipboard";
|
import { copyTextToClipboard } from "@/lib/clipboard";
|
||||||
import { formatTurnLatency } from "@/lib/format";
|
import { fmtDateTime, formatMessageEndTime } from "@/lib/format";
|
||||||
import { toMediaAttachment } from "@/lib/media";
|
import { toMediaAttachment } from "@/lib/media";
|
||||||
import { matchingSlashCommand } from "@/lib/slash-command";
|
import { matchingSlashCommand } from "@/lib/slash-command";
|
||||||
import { parseQuotedUserMessage } from "@/lib/user-message-quote";
|
import { parseQuotedUserMessage } from "@/lib/user-message-quote";
|
||||||
@@ -239,13 +239,19 @@ export function MessageBubble({
|
|||||||
const showCopyButton = showCopyAction && showAssistantActions;
|
const showCopyButton = showCopyAction && showAssistantActions;
|
||||||
const showForkButton = showAssistantActions && !!onForkFromHere;
|
const showForkButton = showAssistantActions && !!onForkFromHere;
|
||||||
const forkLabel = t("message.forkFromHere");
|
const forkLabel = t("message.forkFromHere");
|
||||||
const latencyMs = message.latencyMs;
|
const completedAt = message.completedAt;
|
||||||
const showLatencyFooter =
|
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"
|
message.role === "assistant"
|
||||||
&& latencyMs != null
|
|
||||||
&& !message.isStreaming
|
|
||||||
&& (!empty || hasReasoning || media.length > 0);
|
&& (!empty || hasReasoning || media.length > 0);
|
||||||
const showAssistantFooterRow = showCopyButton || showForkButton || showLatencyFooter;
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("w-full text-[15px]", baseAnim)} style={{ lineHeight: "var(--cjk-line-height)" }}>
|
<div className={cn("w-full text-[15px]", baseAnim)} style={{ lineHeight: "var(--cjk-line-height)" }}>
|
||||||
{hasReasoning ? (
|
{hasReasoning ? (
|
||||||
@@ -266,17 +272,32 @@ export function MessageBubble({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<div data-assistant-selectable={message.isStreaming ? undefined : "true"}>
|
<div data-assistant-selectable={message.isStreaming ? undefined : "true"}>
|
||||||
|
{/* A mode switch rebuilds Streamdown's subtree and moves the scroll anchor. */}
|
||||||
<MarkdownText
|
<MarkdownText
|
||||||
streaming={!!message.isStreaming}
|
streaming={!!message.isStreaming}
|
||||||
|
preserveStreamingLayout
|
||||||
onOpenFilePreview={onOpenFilePreview}
|
onOpenFilePreview={onOpenFilePreview}
|
||||||
>
|
>
|
||||||
{message.content}
|
{message.content}
|
||||||
</MarkdownText>
|
</MarkdownText>
|
||||||
</div>
|
</div>
|
||||||
{media.length > 0 ? <MessageMedia media={media} align="left" /> : null}
|
{media.length > 0 ? <MessageMedia media={media} align="left" /> : null}
|
||||||
{showAssistantFooterRow ? (
|
</>
|
||||||
|
)}
|
||||||
|
{showAssistantFooterSlot ? (
|
||||||
<TooltipProvider delayDuration={220} skipDelayDuration={80}>
|
<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">
|
<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 ? (
|
{showCopyButton ? (
|
||||||
<MessageCopyButton content={message.content} />
|
<MessageCopyButton content={message.content} />
|
||||||
) : null}
|
) : null}
|
||||||
@@ -299,19 +320,19 @@ export function MessageBubble({
|
|||||||
<TooltipContent side="top" align="center">{forkLabel}</TooltipContent>
|
<TooltipContent side="top" align="center">{forkLabel}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null}
|
) : null}
|
||||||
{showLatencyFooter ? (
|
{showCompletedAt ? (
|
||||||
<span
|
<time
|
||||||
|
data-assistant-completed-at
|
||||||
|
dateTime={new Date(completedAt!).toISOString()}
|
||||||
className="text-[11px] leading-none text-muted-foreground/70 tabular-nums"
|
className="text-[11px] leading-none text-muted-foreground/70 tabular-nums"
|
||||||
title={t("message.turnLatencyTitle")}
|
title={completedAtTitle}
|
||||||
>
|
>
|
||||||
{formatTurnLatency(latencyMs)}
|
{completedAtLabel}
|
||||||
</span>
|
</time>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { cn } from "@/lib/utils";
|
|||||||
import type { UIMessage } from "@/lib/types";
|
import type { UIMessage } from "@/lib/types";
|
||||||
import {
|
import {
|
||||||
findPromptElement,
|
findPromptElement,
|
||||||
jumpToPrompt,
|
|
||||||
type PromptAnchor,
|
type PromptAnchor,
|
||||||
promptTop,
|
promptTop,
|
||||||
userPromptAnchors,
|
userPromptAnchors,
|
||||||
@@ -13,6 +12,7 @@ import {
|
|||||||
interface PromptRailProps {
|
interface PromptRailProps {
|
||||||
bottomOffset: number;
|
bottomOffset: number;
|
||||||
messages: UIMessage[];
|
messages: UIMessage[];
|
||||||
|
onJumpToPrompt: (promptId: string) => void;
|
||||||
scrollRef: RefObject<HTMLDivElement>;
|
scrollRef: RefObject<HTMLDivElement>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,6 +46,7 @@ const HOVER_MARKER_WIDTHS_PX = [28, 22, 16, 11];
|
|||||||
export function PromptRail({
|
export function PromptRail({
|
||||||
bottomOffset,
|
bottomOffset,
|
||||||
messages,
|
messages,
|
||||||
|
onJumpToPrompt,
|
||||||
scrollRef,
|
scrollRef,
|
||||||
}: PromptRailProps) {
|
}: PromptRailProps) {
|
||||||
const railRef = useRef<HTMLDivElement>(null);
|
const railRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -159,7 +160,7 @@ export function PromptRail({
|
|||||||
key={marker.ids.join("|")}
|
key={marker.ids.join("|")}
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`Jump to prompt: ${marker.label}`}
|
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)}
|
onBlur={() => setFocusedMarkerIndex(null)}
|
||||||
onFocus={() => setFocusedMarkerIndex(index)}
|
onFocus={() => setFocusedMarkerIndex(index)}
|
||||||
onPointerEnter={() => setFocusedMarkerIndex(index)}
|
onPointerEnter={() => setFocusedMarkerIndex(index)}
|
||||||
|
|||||||
@@ -584,8 +584,6 @@ function RunElapsedStrip({
|
|||||||
const stripLabel = goalStateStripPreview(goalState, t);
|
const stripLabel = goalStateStripPreview(goalState, t);
|
||||||
const showGoal = !!stripLabel?.trim();
|
const showGoal = !!stripLabel?.trim();
|
||||||
const active = showTimer || showGoal;
|
const active = showTimer || showGoal;
|
||||||
const [renderStrip, setRenderStrip] = useState(active);
|
|
||||||
const [leaving, setLeaving] = useState(false);
|
|
||||||
const [, setTick] = useState(0);
|
const [, setTick] = useState(0);
|
||||||
const stripWrapperRef = useRef<HTMLDivElement>(null);
|
const stripWrapperRef = useRef<HTMLDivElement>(null);
|
||||||
const panelRef = useRef<HTMLDivElement>(null);
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -602,20 +600,8 @@ function RunElapsedStrip({
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (active) {
|
if (!active) setGoalPanelOpen(false);
|
||||||
setRenderStrip(true);
|
}, [active]);
|
||||||
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]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (startedAt == null || !pageVisible) return;
|
if (startedAt == null || !pageVisible) return;
|
||||||
@@ -699,8 +685,6 @@ function RunElapsedStrip({
|
|||||||
};
|
};
|
||||||
}, [goalPanelOpen]);
|
}, [goalPanelOpen]);
|
||||||
|
|
||||||
if (!renderStrip || !display) return null;
|
|
||||||
|
|
||||||
const elapsed =
|
const elapsed =
|
||||||
displayStartedAt != null ? Math.max(0, Math.floor(Date.now() / 1000 - displayStartedAt)) : 0;
|
displayStartedAt != null ? Math.max(0, Math.floor(Date.now() / 1000 - displayStartedAt)) : 0;
|
||||||
const m = Math.floor(elapsed / 60);
|
const m = Math.floor(elapsed / 60);
|
||||||
@@ -716,8 +700,10 @@ function RunElapsedStrip({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={stripWrapperRef}
|
ref={stripWrapperRef}
|
||||||
className="composer-status-strip relative z-30"
|
className="composer-status-drawer relative z-30"
|
||||||
data-state={leaving ? "exit" : "enter"}
|
data-composer-status-drawer=""
|
||||||
|
data-state={active ? "open" : "closed"}
|
||||||
|
aria-hidden={active ? undefined : true}
|
||||||
>
|
>
|
||||||
{goalPanelOpen && canExpandGoal && markdownBody ? (
|
{goalPanelOpen && canExpandGoal && markdownBody ? (
|
||||||
<div
|
<div
|
||||||
@@ -764,8 +750,10 @@ function RunElapsedStrip({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
<div className="composer-status-drawer-clip">
|
||||||
|
{display ? (
|
||||||
<div
|
<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]"
|
className="composer-status-drawer-content flex min-h-[36px] items-center gap-2 px-3 py-2"
|
||||||
role="status"
|
role="status"
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
>
|
>
|
||||||
@@ -810,6 +798,8 @@ function RunElapsedStrip({
|
|||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,7 +92,13 @@ export function ThreadMessages({
|
|||||||
unit.type === "activity"
|
unit.type === "activity"
|
||||||
&& next?.type === "message"
|
&& next?.type === "message"
|
||||||
&& next.message.role === "assistant";
|
&& 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 =
|
const userPromptId =
|
||||||
unit.type === "message" && unit.message.role === "user"
|
unit.type === "message" && unit.message.role === "user"
|
||||||
? unit.message.id
|
? unit.message.id
|
||||||
@@ -110,6 +116,7 @@ export function ThreadMessages({
|
|||||||
marginTop={marginTop}
|
marginTop={marginTop}
|
||||||
userPromptId={userPromptId}
|
userPromptId={userPromptId}
|
||||||
hasBodyBelow={hasBodyBelow}
|
hasBodyBelow={hasBodyBelow}
|
||||||
|
deferOffscreenRender={deferOffscreenRender}
|
||||||
isTurnStreaming={liveActivityClusterIndices.has(index)}
|
isTurnStreaming={liveActivityClusterIndices.has(index)}
|
||||||
forkIndex={forkIndex}
|
forkIndex={forkIndex}
|
||||||
showForkBoundary={index === forkBoundaryAfterUnitIndex}
|
showForkBoundary={index === forkBoundaryAfterUnitIndex}
|
||||||
@@ -131,6 +138,7 @@ interface ThreadDisplayUnitProps {
|
|||||||
marginTop: string;
|
marginTop: string;
|
||||||
userPromptId?: string;
|
userPromptId?: string;
|
||||||
hasBodyBelow: boolean;
|
hasBodyBelow: boolean;
|
||||||
|
deferOffscreenRender: boolean;
|
||||||
isTurnStreaming: boolean;
|
isTurnStreaming: boolean;
|
||||||
forkIndex?: number;
|
forkIndex?: number;
|
||||||
showForkBoundary: boolean;
|
showForkBoundary: boolean;
|
||||||
@@ -147,6 +155,7 @@ const ThreadDisplayUnit = memo(function ThreadDisplayUnit({
|
|||||||
marginTop,
|
marginTop,
|
||||||
userPromptId,
|
userPromptId,
|
||||||
hasBodyBelow,
|
hasBodyBelow,
|
||||||
|
deferOffscreenRender,
|
||||||
isTurnStreaming,
|
isTurnStreaming,
|
||||||
forkIndex,
|
forkIndex,
|
||||||
showForkBoundary,
|
showForkBoundary,
|
||||||
@@ -157,17 +166,20 @@ const ThreadDisplayUnit = memo(function ThreadDisplayUnit({
|
|||||||
onOpenFilePreview,
|
onOpenFilePreview,
|
||||||
onForkFromMessage,
|
onForkFromMessage,
|
||||||
}: ThreadDisplayUnitProps) {
|
}: 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(() => {
|
const onForkFromHere = useCallback(() => {
|
||||||
if (forkIndex !== undefined) onForkFromMessage?.(forkIndex);
|
if (forkIndex !== undefined) onForkFromMessage?.(forkIndex);
|
||||||
}, [forkIndex, onForkFromMessage]);
|
}, [forkIndex, onForkFromMessage]);
|
||||||
const deferOffscreenRender = unit.type === "activity"
|
|
||||||
? !isTurnStreaming
|
|
||||||
: unit.message.role === "assistant" && !unit.message.isStreaming;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={`${marginTop}${deferOffscreenRender ? " thread-render-unit" : ""}`}
|
className={`${marginTop}${stableDeferOffscreenRender ? " thread-render-unit" : ""}`}
|
||||||
data-user-prompt-id={userPromptId}
|
data-user-prompt-id={userPromptId}
|
||||||
>
|
>
|
||||||
{unit.type === "activity" ? (
|
{unit.type === "activity" ? (
|
||||||
@@ -206,6 +218,7 @@ function threadDisplayUnitPropsEqual(
|
|||||||
&& previous.marginTop === next.marginTop
|
&& previous.marginTop === next.marginTop
|
||||||
&& previous.userPromptId === next.userPromptId
|
&& previous.userPromptId === next.userPromptId
|
||||||
&& previous.hasBodyBelow === next.hasBodyBelow
|
&& previous.hasBodyBelow === next.hasBodyBelow
|
||||||
|
&& previous.deferOffscreenRender === next.deferOffscreenRender
|
||||||
&& previous.isTurnStreaming === next.isTurnStreaming
|
&& previous.isTurnStreaming === next.isTurnStreaming
|
||||||
&& previous.forkIndex === next.forkIndex
|
&& previous.forkIndex === next.forkIndex
|
||||||
&& previous.showForkBoundary === next.showForkBoundary
|
&& previous.showForkBoundary === next.showForkBoundary
|
||||||
|
|||||||
@@ -102,6 +102,18 @@ function isStaleThreadSnapshot(current: UIMessage[], snapshot: UIMessage[]): boo
|
|||||||
return snapshot.every((message, index) => sameMessageShape(current[index], message));
|
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_DEFAULT_WIDTH = 544;
|
||||||
const FILE_PREVIEW_MIN_WIDTH = 360;
|
const FILE_PREVIEW_MIN_WIDTH = 360;
|
||||||
const FILE_PREVIEW_MAX_WIDTH = 860;
|
const FILE_PREVIEW_MAX_WIDTH = 860;
|
||||||
@@ -444,8 +456,7 @@ export function ThreadShell({
|
|||||||
});
|
});
|
||||||
const [settings, setSettings] = useState<SettingsPayload | null>(settingsSnapshot);
|
const [settings, setSettings] = useState<SettingsPayload | null>(settingsSnapshot);
|
||||||
const [heroGreetingKey, setHeroGreetingKey] = useState(randomHeroGreetingKey);
|
const [heroGreetingKey, setHeroGreetingKey] = useState(randomHeroGreetingKey);
|
||||||
const [scrollToBottomSignal, setScrollToBottomSignal] = useState(0);
|
const [submittedViewportTurnId, setSubmittedViewportTurnId] = useState<string | null>(null);
|
||||||
const [scrollToLatestUserPromptSignal, setScrollToLatestUserPromptSignal] = useState(0);
|
|
||||||
const [filePreviewPath, setFilePreviewPath] = useState<string | null>(null);
|
const [filePreviewPath, setFilePreviewPath] = useState<string | null>(null);
|
||||||
const [filePreviewClosing, setFilePreviewClosing] = useState(false);
|
const [filePreviewClosing, setFilePreviewClosing] = useState(false);
|
||||||
const [filePreviewWidth, setFilePreviewWidth] = useState(FILE_PREVIEW_DEFAULT_WIDTH);
|
const [filePreviewWidth, setFilePreviewWidth] = useState(FILE_PREVIEW_DEFAULT_WIDTH);
|
||||||
@@ -457,6 +468,7 @@ export function ThreadShell({
|
|||||||
const pendingFirstRef = useRef<PendingFirstMessage | null>(null);
|
const pendingFirstRef = useRef<PendingFirstMessage | null>(null);
|
||||||
const [pendingFirstTargetChatId, setPendingFirstTargetChatId] = useState<string | null>(null);
|
const [pendingFirstTargetChatId, setPendingFirstTargetChatId] = useState<string | null>(null);
|
||||||
const viewportRef = useRef<ThreadViewportHandle | null>(null);
|
const viewportRef = useRef<ThreadViewportHandle | null>(null);
|
||||||
|
const activeViewportTurnByChatIdRef = useRef<Map<string, string>>(new Map());
|
||||||
const messageCacheRef = useRef<Map<string, UIMessage[]>>(new Map());
|
const messageCacheRef = useRef<Map<string, UIMessage[]>>(new Map());
|
||||||
/** Last chatId we associated with the in-memory thread (for cache-on-switch). */
|
/** Last chatId we associated with the in-memory thread (for cache-on-switch). */
|
||||||
const prevChatIdForCacheRef = useRef<string | null>(null);
|
const prevChatIdForCacheRef = useRef<string | null>(null);
|
||||||
@@ -465,18 +477,20 @@ export function ThreadShell({
|
|||||||
const appliedHistoryVersionRef = useRef<Map<string, number>>(new Map());
|
const appliedHistoryVersionRef = useRef<Map<string, number>>(new Map());
|
||||||
const pendingCanonicalHydrateRef = useRef<Set<string>>(new Set());
|
const pendingCanonicalHydrateRef = useRef<Set<string>>(new Set());
|
||||||
const sessionKeyByChatIdRef = useRef<Map<string, string>>(new Map());
|
const sessionKeyByChatIdRef = useRef<Map<string, string>>(new Map());
|
||||||
const bottomScrolledChatIdRef = useRef<string | null>(null);
|
|
||||||
|
|
||||||
const initial = useMemo(() => {
|
const initial = useMemo(() => {
|
||||||
if (!chatId) return historical;
|
if (!chatId) return historical;
|
||||||
return messageCacheRef.current.get(chatId) ?? historical;
|
return messageCacheRef.current.get(chatId) ?? historical;
|
||||||
}, [chatId, historical]);
|
}, [chatId, historical]);
|
||||||
const handleTurnEnd = useCallback(() => {
|
const handleTurnEnd = useCallback(() => {
|
||||||
|
if (chatId) activeViewportTurnByChatIdRef.current.delete(chatId);
|
||||||
|
setSubmittedViewportTurnId(null);
|
||||||
setFallbackModelName(null);
|
setFallbackModelName(null);
|
||||||
onTurnEnd?.();
|
onTurnEnd?.();
|
||||||
}, [onTurnEnd]);
|
}, [chatId, onTurnEnd]);
|
||||||
const {
|
const {
|
||||||
messages,
|
messages,
|
||||||
|
messagesReady,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
runStartedAt,
|
runStartedAt,
|
||||||
goalState,
|
goalState,
|
||||||
@@ -504,6 +518,7 @@ export function ThreadShell({
|
|||||||
setFilePreviewClosing(false);
|
setFilePreviewClosing(false);
|
||||||
setFilePreviewPath(null);
|
setFilePreviewPath(null);
|
||||||
setQuotedContext(null);
|
setQuotedContext(null);
|
||||||
|
setSubmittedViewportTurnId(null);
|
||||||
}, [historyKey]);
|
}, [historyKey]);
|
||||||
|
|
||||||
const handleQuoteSelection = useCallback((text: string) => {
|
const handleQuoteSelection = useCallback((text: string) => {
|
||||||
@@ -520,6 +535,28 @@ export function ThreadShell({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const displayMessages = useMemo(() => projectWebuiThreadMessages(messages), [messages]);
|
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(
|
const filePreviewAvailabilityCache = useMemo(
|
||||||
() => new Map<string, FilePreviewAvailabilityCacheEntry>(),
|
() => new Map<string, FilePreviewAvailabilityCacheEntry>(),
|
||||||
[historyKey, token],
|
[historyKey, token],
|
||||||
@@ -695,22 +732,14 @@ export function ThreadShell({
|
|||||||
return client.onSessionUpdate((updatedChatId, scope) => {
|
return client.onSessionUpdate((updatedChatId, scope) => {
|
||||||
if (updatedChatId !== chatId) return;
|
if (updatedChatId !== chatId) return;
|
||||||
if (scope === "metadata") 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);
|
pendingCanonicalHydrateRef.current.add(chatId);
|
||||||
refreshHistory();
|
refreshHistory();
|
||||||
});
|
});
|
||||||
}, [chatId, client, 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(() => {
|
useEffect(() => {
|
||||||
if (chatId) return;
|
if (chatId) return;
|
||||||
setMessages(projectWebuiThreadMessages(historical));
|
setMessages(projectWebuiThreadMessages(historical));
|
||||||
@@ -765,8 +794,11 @@ export function ThreadShell({
|
|||||||
}
|
}
|
||||||
pendingFirstRef.current = null;
|
pendingFirstRef.current = null;
|
||||||
setPendingFirstTargetChatId(null);
|
setPendingFirstTargetChatId(null);
|
||||||
setScrollToLatestUserPromptSignal((value) => value + 1);
|
const submitted = send(pending.content, pending.images, pending.options);
|
||||||
send(pending.content, pending.images, pending.options);
|
if (submitted && !submitted.sideChannel) {
|
||||||
|
activeViewportTurnByChatIdRef.current.set(chatId, submitted.turnId);
|
||||||
|
setSubmittedViewportTurnId(submitted.turnId);
|
||||||
|
}
|
||||||
setBooting(false);
|
setBooting(false);
|
||||||
}, [chatId, pendingFirstTargetChatId, send]);
|
}, [chatId, pendingFirstTargetChatId, send]);
|
||||||
|
|
||||||
@@ -809,10 +841,13 @@ export function ThreadShell({
|
|||||||
const handleThreadSend = useCallback(
|
const handleThreadSend = useCallback(
|
||||||
(content: string, images?: SendAttachment[], options?: SendOptions) => {
|
(content: string, images?: SendAttachment[], options?: SendOptions) => {
|
||||||
setFallbackModelName(null);
|
setFallbackModelName(null);
|
||||||
setScrollToLatestUserPromptSignal((value) => value + 1);
|
const submitted = send(content, images, withWorkspaceScope(options));
|
||||||
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) => {
|
const handleOpenFilePreview = useCallback((path: string) => {
|
||||||
@@ -927,7 +962,7 @@ export function ThreadShell({
|
|||||||
<ThreadComposer
|
<ThreadComposer
|
||||||
onSend={handleThreadSend}
|
onSend={handleThreadSend}
|
||||||
disabled={!chatId}
|
disabled={!chatId}
|
||||||
isStreaming={isStreaming}
|
isStreaming={turnActive}
|
||||||
placeholder={
|
placeholder={
|
||||||
showHeroComposer
|
showHeroComposer
|
||||||
? t("thread.composer.placeholderHero")
|
? t("thread.composer.placeholderHero")
|
||||||
@@ -950,8 +985,8 @@ export function ThreadShell({
|
|||||||
skills={skills}
|
skills={skills}
|
||||||
onStop={stop}
|
onStop={stop}
|
||||||
onTranscribeAudio={transcribeAudio}
|
onTranscribeAudio={transcribeAudio}
|
||||||
runStartedAt={runStartedAt}
|
runStartedAt={currentRunStartedAt}
|
||||||
goalState={goalState}
|
goalState={currentGoalState}
|
||||||
workspaceScope={workspaceScope}
|
workspaceScope={workspaceScope}
|
||||||
workspaceDefaultScope={workspaceDefaultScope}
|
workspaceDefaultScope={workspaceDefaultScope}
|
||||||
workspaceControls={workspaceControls}
|
workspaceControls={workspaceControls}
|
||||||
@@ -969,7 +1004,7 @@ export function ThreadShell({
|
|||||||
<ThreadComposer
|
<ThreadComposer
|
||||||
onSend={handleWelcomeSend}
|
onSend={handleWelcomeSend}
|
||||||
disabled={booting}
|
disabled={booting}
|
||||||
isStreaming={isStreaming}
|
isStreaming={turnActive}
|
||||||
placeholder={
|
placeholder={
|
||||||
booting
|
booting
|
||||||
? t("thread.composer.placeholderOpening")
|
? t("thread.composer.placeholderOpening")
|
||||||
@@ -990,9 +1025,9 @@ export function ThreadShell({
|
|||||||
cliApps={cliApps}
|
cliApps={cliApps}
|
||||||
mcpPresets={mcpPresets}
|
mcpPresets={mcpPresets}
|
||||||
skills={skills}
|
skills={skills}
|
||||||
runStartedAt={runStartedAt}
|
runStartedAt={currentRunStartedAt}
|
||||||
onTranscribeAudio={transcribeAudio}
|
onTranscribeAudio={transcribeAudio}
|
||||||
goalState={goalState}
|
goalState={currentGoalState}
|
||||||
workspaceScope={workspaceScope}
|
workspaceScope={workspaceScope}
|
||||||
workspaceDefaultScope={workspaceDefaultScope}
|
workspaceDefaultScope={workspaceDefaultScope}
|
||||||
workspaceControls={workspaceControls}
|
workspaceControls={workspaceControls}
|
||||||
@@ -1048,12 +1083,13 @@ export function ThreadShell({
|
|||||||
<ThreadViewport
|
<ThreadViewport
|
||||||
ref={viewportRef}
|
ref={viewportRef}
|
||||||
messages={displayMessages}
|
messages={displayMessages}
|
||||||
isStreaming={isStreaming}
|
isStreaming={turnActive}
|
||||||
emptyState={emptyState}
|
emptyState={emptyState}
|
||||||
composer={composer}
|
composer={composer}
|
||||||
scrollToBottomSignal={scrollToBottomSignal}
|
activeTurnId={viewportTurnId}
|
||||||
scrollToLatestUserPromptSignal={scrollToLatestUserPromptSignal}
|
activeTurnStartedHere={activeTurnStartedHere}
|
||||||
conversationKey={historyKey}
|
conversationKey={historyKey}
|
||||||
|
conversationReady={messagesReady}
|
||||||
showScrollToBottomButton={!!session}
|
showScrollToBottomButton={!!session}
|
||||||
cliApps={cliApps}
|
cliApps={cliApps}
|
||||||
mcpPresets={mcpPresets}
|
mcpPresets={mcpPresets}
|
||||||
|
|||||||
@@ -15,10 +15,14 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { PromptRail } from "@/components/thread/PromptRail";
|
import { PromptRail } from "@/components/thread/PromptRail";
|
||||||
import { ThreadMessages } from "@/components/thread/ThreadMessages";
|
import { ThreadMessages } from "@/components/thread/ThreadMessages";
|
||||||
import { isAgentActivityMember } from "@/components/thread/AgentActivityCluster";
|
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 { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
findPromptElement,
|
findPromptElement,
|
||||||
jumpToPrompt,
|
|
||||||
promptTop,
|
promptTop,
|
||||||
} from "@/components/thread/promptNavigation";
|
} from "@/components/thread/promptNavigation";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -35,8 +39,10 @@ interface ThreadViewportProps {
|
|||||||
composer: ReactNode;
|
composer: ReactNode;
|
||||||
emptyState?: ReactNode;
|
emptyState?: ReactNode;
|
||||||
scrollToBottomSignal?: number;
|
scrollToBottomSignal?: number;
|
||||||
scrollToLatestUserPromptSignal?: number;
|
activeTurnId?: string | null;
|
||||||
|
activeTurnStartedHere?: boolean;
|
||||||
conversationKey?: string | null;
|
conversationKey?: string | null;
|
||||||
|
conversationReady?: boolean;
|
||||||
showScrollToBottomButton?: boolean;
|
showScrollToBottomButton?: boolean;
|
||||||
cliApps?: CliAppInfo[];
|
cliApps?: CliAppInfo[];
|
||||||
mcpPresets?: McpPresetInfo[];
|
mcpPresets?: McpPresetInfo[];
|
||||||
@@ -56,7 +62,6 @@ const NEAR_TOP_PX = 96;
|
|||||||
const DEFAULT_SCROLL_BUTTON_BOTTOM_PX = 192;
|
const DEFAULT_SCROLL_BUTTON_BOTTOM_PX = 192;
|
||||||
const SCROLL_BUTTON_COMPOSER_GAP_PX = 16;
|
const SCROLL_BUTTON_COMPOSER_GAP_PX = 16;
|
||||||
const SOFT_KEYBOARD_MIN_INSET_PX = 80;
|
const SOFT_KEYBOARD_MIN_INSET_PX = 80;
|
||||||
const KEYBOARD_SCROLL_FRAMES = 18;
|
|
||||||
export const INITIAL_HISTORY_WINDOW = 160;
|
export const INITIAL_HISTORY_WINDOW = 160;
|
||||||
export const HISTORY_WINDOW_INCREMENT = 120;
|
export const HISTORY_WINDOW_INCREMENT = 120;
|
||||||
|
|
||||||
@@ -92,6 +97,49 @@ function isKeyboardEditableElement(element: Element | null): element is HTMLElem
|
|||||||
].includes(element.type);
|
].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 {
|
function readSoftKeyboardInsetBottom(container: HTMLElement | null): number {
|
||||||
const viewport = window.visualViewport;
|
const viewport = window.visualViewport;
|
||||||
if (!viewport) return 0;
|
if (!viewport) return 0;
|
||||||
@@ -108,8 +156,10 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
composer,
|
composer,
|
||||||
emptyState,
|
emptyState,
|
||||||
scrollToBottomSignal = 0,
|
scrollToBottomSignal = 0,
|
||||||
scrollToLatestUserPromptSignal = 0,
|
activeTurnId = null,
|
||||||
|
activeTurnStartedHere = false,
|
||||||
conversationKey = null,
|
conversationKey = null,
|
||||||
|
conversationReady = true,
|
||||||
showScrollToBottomButton = true,
|
showScrollToBottomButton = true,
|
||||||
cliApps = [],
|
cliApps = [],
|
||||||
mcpPresets = [],
|
mcpPresets = [],
|
||||||
@@ -126,25 +176,65 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const contentRef = useRef<HTMLDivElement>(null);
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const messageRegionRef = useRef<HTMLDivElement>(null);
|
||||||
|
const messageContentRef = useRef<HTMLDivElement>(null);
|
||||||
const composerDockRef = useRef<HTMLDivElement>(null);
|
const composerDockRef = useRef<HTMLDivElement>(null);
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
const lastConversationKeyRef = useRef<string | null>(conversationKey);
|
const lastConversationKeyRef = useRef<string | null>(conversationKey);
|
||||||
const pendingConversationScrollRef = useRef(true);
|
const pendingConversationScrollRef = useRef(true);
|
||||||
const pendingPromptJumpRef = useRef<string | null>(null);
|
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 =
|
const restoreScrollAfterPrependRef =
|
||||||
useRef<{ height: number; top: number } | null>(null);
|
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 composerDockHeightRef = useRef(0);
|
||||||
const userReadingHistoryRef = useRef(false);
|
|
||||||
const [atBottom, setAtBottom] = useState(true);
|
const [atBottom, setAtBottom] = useState(true);
|
||||||
const [composerDockHeight, setComposerDockHeight] = useState(0);
|
const [composerDockHeight, setComposerDockHeight] = useState(0);
|
||||||
const [keyboardInsetBottom, setKeyboardInsetBottom] = useState(0);
|
const [keyboardInsetBottom, setKeyboardInsetBottom] = useState(0);
|
||||||
const [hasVerticalOverflow, setHasVerticalOverflow] = useState(false);
|
const [hasVerticalOverflow, setHasVerticalOverflow] = useState(false);
|
||||||
const [visibleMessageCount, setVisibleMessageCount] =
|
const [visibleMessageCount, setVisibleMessageCount] =
|
||||||
useState(INITIAL_HISTORY_WINDOW);
|
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 hasMessages = messages.length > 0;
|
||||||
const visibleMessages = useMemo(
|
const visibleMessages = useMemo(
|
||||||
() => windowMessages(messages, visibleMessageCount),
|
() => windowMessages(messages, visibleMessageCount),
|
||||||
@@ -168,15 +258,8 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
const scrollViewportStyle =
|
const scrollViewportStyle =
|
||||||
keyboardInsetBottom > 0 ? { bottom: keyboardInsetBottom } : undefined;
|
keyboardInsetBottom > 0 ? { bottom: keyboardInsetBottom } : undefined;
|
||||||
|
|
||||||
const cancelScheduledBottomScroll = useCallback(() => {
|
const yieldCameraToUser = useCallback(() => {
|
||||||
for (const id of scrollFrameIdsRef.current) {
|
threadMotionRef.current?.takeUserControl();
|
||||||
window.cancelAnimationFrame(id);
|
|
||||||
}
|
|
||||||
scrollFrameIdsRef.current = [];
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const markProgrammaticPromptScroll = useCallback((top: number) => {
|
|
||||||
programmaticPromptScrollTopRef.current = top;
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const scrollToBottomNow = useCallback((smooth = false) => {
|
const scrollToBottomNow = useCallback((smooth = false) => {
|
||||||
@@ -185,68 +268,25 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
const behavior: ScrollBehavior = smooth ? "smooth" : "auto";
|
const behavior: ScrollBehavior = smooth ? "smooth" : "auto";
|
||||||
if (el) {
|
if (el) {
|
||||||
const top = Math.max(0, el.scrollHeight - el.clientHeight);
|
const top = Math.max(0, el.scrollHeight - el.clientHeight);
|
||||||
try {
|
if (smooth) {
|
||||||
el.scrollTo?.({ top, behavior });
|
threadMotionRef.current?.animateTo(top);
|
||||||
if (!smooth) el.scrollTop = top;
|
} else {
|
||||||
} catch {
|
threadMotionRef.current?.jumpTo(top);
|
||||||
try {
|
|
||||||
el.scrollTop = top;
|
|
||||||
} catch {
|
|
||||||
// Test DOMs can expose read-only scrollTop; browsers keep this writable.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else if (marker) {
|
} else if (marker) {
|
||||||
marker.scrollIntoView({ block: "end", behavior });
|
marker.scrollIntoView({ block: "end", behavior });
|
||||||
}
|
}
|
||||||
userReadingHistoryRef.current = false;
|
|
||||||
setAtBottom(true);
|
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(
|
const scrollToBottom = useCallback(
|
||||||
(smooth = false, frames = 1, options?: { force?: boolean }) => {
|
(smooth = false, options?: { force?: boolean }) => {
|
||||||
const force = options?.force ?? false;
|
const force = options?.force ?? false;
|
||||||
cancelScheduledBottomScroll();
|
if (!force && threadMotionRef.current?.isAutoFollowPaused()) return;
|
||||||
const run = () => {
|
threadMotionRef.current?.resumeAutoFollow();
|
||||||
if (!force && userReadingHistoryRef.current) return;
|
|
||||||
scrollToBottomNow(smooth);
|
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);
|
|
||||||
},
|
},
|
||||||
[cancelScheduledBottomScroll, scrollToBottomNow],
|
[scrollToBottomNow],
|
||||||
);
|
);
|
||||||
|
|
||||||
const loadEarlierMessages = useCallback(() => {
|
const loadEarlierMessages = useCallback(() => {
|
||||||
@@ -257,8 +297,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
top: el.scrollTop,
|
top: el.scrollTop,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
userReadingHistoryRef.current = true;
|
threadMotionRef.current?.takeUserControl();
|
||||||
activeTurnPromptRef.current = null;
|
|
||||||
setAtBottom(false);
|
setAtBottom(false);
|
||||||
if (hiddenMessageCount > 0) {
|
if (hiddenMessageCount > 0) {
|
||||||
setVisibleMessageCount((count) =>
|
setVisibleMessageCount((count) =>
|
||||||
@@ -275,58 +314,68 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
const maybeLoadEarlierFromScroll = useCallback(() => {
|
const maybeLoadEarlierFromScroll = useCallback(() => {
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
if (!el || !hasMessages || pendingConversationScrollRef.current) return;
|
if (!el || !hasMessages || pendingConversationScrollRef.current) return;
|
||||||
if (!userReadingHistoryRef.current) return;
|
if (!threadMotionRef.current?.isBrowsingHistory()) return;
|
||||||
if (el.scrollTop > NEAR_TOP_PX) return;
|
if (el.scrollTop > NEAR_TOP_PX) return;
|
||||||
if (hiddenMessageCount <= 0 && !hasMoreBefore) return;
|
if (hiddenMessageCount <= 0 && !hasMoreBefore) return;
|
||||||
loadEarlierMessages();
|
loadEarlierMessages();
|
||||||
}, [hasMessages, hasMoreBefore, hiddenMessageCount, loadEarlierMessages]);
|
}, [hasMessages, hasMoreBefore, hiddenMessageCount, loadEarlierMessages]);
|
||||||
|
|
||||||
const jumpToUserPrompt = useCallback((promptId: string) => {
|
const navigateToVisiblePrompt = useCallback((promptId: string) => {
|
||||||
const scrollEl = scrollRef.current;
|
const scrollEl = scrollRef.current;
|
||||||
if (scrollEl && findPromptElement(scrollEl, promptId)) {
|
const prompt = scrollEl ? findPromptElement(scrollEl, promptId) : null;
|
||||||
jumpToPrompt(scrollEl, promptId);
|
if (!scrollEl || !prompt) return false;
|
||||||
return;
|
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);
|
const index = messages.findIndex((message) => message.id === promptId);
|
||||||
if (index < 0) return;
|
if (index < 0) return;
|
||||||
|
threadMotionRef.current?.takeUserControl();
|
||||||
pendingPromptJumpRef.current = promptId;
|
pendingPromptJumpRef.current = promptId;
|
||||||
userReadingHistoryRef.current = true;
|
|
||||||
activeTurnPromptRef.current = null;
|
|
||||||
setAtBottom(false);
|
setAtBottom(false);
|
||||||
setVisibleMessageCount((count) => Math.max(count, messages.length - index));
|
setVisibleMessageCount((count) => Math.max(count, messages.length - index));
|
||||||
}, [messages]);
|
}, [messages, navigateToVisiblePrompt]);
|
||||||
|
|
||||||
|
const cancelAutoScroll = useCallback(() => {
|
||||||
|
threadMotionRef.current?.takeUserControl();
|
||||||
|
}, []);
|
||||||
|
|
||||||
useImperativeHandle(
|
useImperativeHandle(
|
||||||
ref,
|
ref,
|
||||||
() => ({
|
() => ({
|
||||||
jumpToUserPrompt,
|
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(() => {
|
useLayoutEffect(() => {
|
||||||
const updateKeyboardInset = () => {
|
const updateKeyboardInset = () => {
|
||||||
const scrollEl = scrollRef.current;
|
const scrollEl = scrollRef.current;
|
||||||
const next = readSoftKeyboardInsetBottom(scrollEl);
|
const next = readSoftKeyboardInsetBottom(scrollEl);
|
||||||
const active = document.activeElement;
|
const active = document.activeElement;
|
||||||
const composerFocused =
|
const composerFocused =
|
||||||
hasMessages && isKeyboardEditableElement(active) && Boolean(scrollEl?.contains(active));
|
hasMessages
|
||||||
|
&& isKeyboardEditableElement(active)
|
||||||
|
&& Boolean(scrollEl?.contains(active));
|
||||||
setKeyboardInsetBottom((current) =>
|
setKeyboardInsetBottom((current) =>
|
||||||
Math.abs(current - next) < 1 ? current : next,
|
Math.abs(current - next) < 1 ? current : next,
|
||||||
);
|
);
|
||||||
if (composerFocused) {
|
if (composerFocused) {
|
||||||
userReadingHistoryRef.current = false;
|
// Focusing the composer establishes a new reference frame at the
|
||||||
scrollToBottom(false, KEYBOARD_SCROLL_FRAMES, { force: true });
|
// 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();
|
updateKeyboardInset();
|
||||||
@@ -345,76 +394,62 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
};
|
};
|
||||||
}, [hasMessages, scrollToBottom]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (scrollToBottomSignal <= 0) return;
|
if (scrollToBottomSignal <= 0) return;
|
||||||
userReadingHistoryRef.current = false;
|
scrollToBottom(false, { force: true });
|
||||||
scrollToBottom(false, 8);
|
|
||||||
}, [scrollToBottomSignal, scrollToBottom]);
|
}, [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(() => {
|
useLayoutEffect(() => {
|
||||||
if (lastConversationKeyRef.current === conversationKey) return;
|
if (lastConversationKeyRef.current === conversationKey) return;
|
||||||
lastConversationKeyRef.current = conversationKey;
|
lastConversationKeyRef.current = conversationKey;
|
||||||
pendingConversationScrollRef.current = true;
|
pendingConversationScrollRef.current = true;
|
||||||
userReadingHistoryRef.current = false;
|
threadMotionRef.current?.reset();
|
||||||
activeTurnPromptRef.current = null;
|
|
||||||
setAtBottom(true);
|
setAtBottom(true);
|
||||||
setVisibleMessageCount(INITIAL_HISTORY_WINDOW);
|
setVisibleMessageCount(INITIAL_HISTORY_WINDOW);
|
||||||
}, [conversationKey]);
|
}, [conversationKey]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const promptId = activeTurnPromptRef.current;
|
if (!conversationReady) {
|
||||||
if (!promptId || userReadingHistoryRef.current) return;
|
threadMotionRef.current?.reset();
|
||||||
const promptIndex = messages.findIndex((message) => message.id === promptId);
|
|
||||||
if (promptIndex < 0) {
|
|
||||||
activeTurnPromptRef.current = null;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const hasAgentOutput = messages
|
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)
|
.slice(promptIndex + 1)
|
||||||
.some((message) => message.role !== "user");
|
.some(
|
||||||
if (!hasAgentOutput) return;
|
(message) =>
|
||||||
scrollToBottom(false, isStreaming ? 3 : 1);
|
message.role !== "user"
|
||||||
}, [isStreaming, messages, scrollToBottom]);
|
&& (!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(() => {
|
useLayoutEffect(() => {
|
||||||
const pending = restoreScrollAfterPrependRef.current;
|
const pending = restoreScrollAfterPrependRef.current;
|
||||||
@@ -423,16 +458,11 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
restoreScrollAfterPrependRef.current = null;
|
restoreScrollAfterPrependRef.current = null;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
const delta = el.scrollHeight - pending.height;
|
const delta = el.scrollHeight - pending.height;
|
||||||
const nextTop = pending.top + delta;
|
const nextTop = Math.min(
|
||||||
try {
|
Math.max(0, el.scrollHeight - el.clientHeight),
|
||||||
el.scrollTop = nextTop;
|
Math.max(0, pending.top + delta),
|
||||||
} catch {
|
);
|
||||||
try {
|
threadMotionRef.current?.jumpTo(nextTop);
|
||||||
el.scrollTo?.({ top: nextTop, behavior: "auto" });
|
|
||||||
} catch {
|
|
||||||
// Test DOMs can expose read-only scrollTop; browsers keep this writable.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [visibleMessages.length, messages.length]);
|
}, [visibleMessages.length, messages.length]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
@@ -440,65 +470,61 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
const scrollEl = scrollRef.current;
|
const scrollEl = scrollRef.current;
|
||||||
if (!promptId || !scrollEl || !findPromptElement(scrollEl, promptId)) return;
|
if (!promptId || !scrollEl || !findPromptElement(scrollEl, promptId)) return;
|
||||||
pendingPromptJumpRef.current = null;
|
pendingPromptJumpRef.current = null;
|
||||||
const frame = window.requestAnimationFrame(() => jumpToPrompt(scrollEl, promptId));
|
const frame = window.requestAnimationFrame(() => navigateToVisiblePrompt(promptId));
|
||||||
return () => window.cancelAnimationFrame(frame);
|
return () => window.cancelAnimationFrame(frame);
|
||||||
}, [visibleMessages.length]);
|
}, [navigateToVisiblePrompt, visibleMessages.length]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!pendingConversationScrollRef.current) return;
|
if (!pendingConversationScrollRef.current) return;
|
||||||
|
if (!conversationReady) return;
|
||||||
if (!conversationKey) {
|
if (!conversationKey) {
|
||||||
pendingConversationScrollRef.current = false;
|
pendingConversationScrollRef.current = false;
|
||||||
scrollToBottom(false, 4);
|
scrollToBottom(false, { force: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
scrollToBottom(false, 8);
|
scrollToBottom(false, { force: true });
|
||||||
if (!hasMessages) return;
|
if (!hasMessages) return;
|
||||||
pendingConversationScrollRef.current = false;
|
pendingConversationScrollRef.current = false;
|
||||||
}, [conversationKey, hasMessages, messages, scrollToBottom]);
|
}, [
|
||||||
|
conversationKey,
|
||||||
|
conversationReady,
|
||||||
|
hasMessages,
|
||||||
|
messages,
|
||||||
|
scrollToBottom,
|
||||||
|
]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
measureComposerDock();
|
threadMotionRef.current?.invalidateGeometry();
|
||||||
}, [composer, hasMessages, measureComposerDock]);
|
}, [composer, hasMessages, visibleMessages.length]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useEffect(() => () => threadMotionRef.current?.dispose(), []);
|
||||||
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));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
const content = contentRef.current;
|
const content = contentRef.current;
|
||||||
|
const messageRegion = messageRegionRef.current;
|
||||||
|
const messageContent = messageContentRef.current;
|
||||||
|
const composerDock = composerDockRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
||||||
measureVerticalOverflow();
|
const invalidateGeometry = () => {
|
||||||
const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(measureVerticalOverflow);
|
threadMotionRef.current?.invalidateGeometry();
|
||||||
|
};
|
||||||
|
invalidateGeometry();
|
||||||
|
const observer = typeof ResizeObserver === "undefined"
|
||||||
|
? null
|
||||||
|
: new ResizeObserver(invalidateGeometry);
|
||||||
observer?.observe(el);
|
observer?.observe(el);
|
||||||
if (content) observer?.observe(content);
|
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 () => {
|
return () => {
|
||||||
observer?.disconnect();
|
observer?.disconnect();
|
||||||
window.removeEventListener("resize", measureVerticalOverflow);
|
window.removeEventListener("resize", invalidateGeometry);
|
||||||
};
|
};
|
||||||
}, [composerDockHeight, hasMessages, measureVerticalOverflow, visibleMessages.length]);
|
}, [hasMessages]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
@@ -507,48 +533,114 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
const onScroll = (allowHistoryLoad = true) => {
|
const onScroll = (allowHistoryLoad = true) => {
|
||||||
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
|
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||||
const near = distance < NEAR_BOTTOM_PX;
|
const near = distance < NEAR_BOTTOM_PX;
|
||||||
const programmaticPromptTop = programmaticPromptScrollTopRef.current;
|
const owner = threadMotionRef.current?.observeScroll(near) ?? "automatic";
|
||||||
const programmatic =
|
const logicallyAtBottom = owner === "automatic" || near;
|
||||||
programmaticPromptTop !== null && Math.abs(el.scrollTop - programmaticPromptTop) < 2;
|
setAtBottom((current) =>
|
||||||
setAtBottom((current) => current === near ? current : near);
|
current === logicallyAtBottom ? current : logicallyAtBottom,
|
||||||
if (programmatic) {
|
);
|
||||||
programmaticPromptScrollTopRef.current = null;
|
if (allowHistoryLoad && owner === "user") maybeLoadEarlierFromScroll();
|
||||||
if (near) userReadingHistoryRef.current = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
programmaticPromptScrollTopRef.current = null;
|
|
||||||
userReadingHistoryRef.current = !near;
|
|
||||||
if (!near) activeTurnPromptRef.current = null;
|
|
||||||
if (allowHistoryLoad && !near) maybeLoadEarlierFromScroll();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
onScroll(false);
|
onScroll(false);
|
||||||
const handleScroll = () => onScroll(true);
|
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 });
|
el.addEventListener("scroll", handleScroll, { passive: true });
|
||||||
return () => el.removeEventListener("scroll", handleScroll);
|
el.addEventListener("wheel", handleWheel, { passive: true });
|
||||||
}, [maybeLoadEarlierFromScroll]);
|
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 (
|
return (
|
||||||
<div className="thread-viewport relative flex min-h-0 flex-1 overflow-hidden">
|
<div className="thread-viewport relative flex min-h-0 flex-1 overflow-hidden">
|
||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
className={cn(
|
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",
|
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}
|
style={scrollViewportStyle}
|
||||||
>
|
>
|
||||||
{hasMessages ? (
|
|
||||||
<div ref={contentRef} className="mx-auto flex min-h-full w-full max-w-[64rem] flex-col">
|
|
||||||
<div
|
<div
|
||||||
data-testid="thread-message-region"
|
ref={contentRef}
|
||||||
className="flex min-h-0 flex-1 flex-col justify-start px-3 pb-4 pt-4 sm:px-4"
|
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",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div className="mx-auto w-full max-w-[49.5rem]">
|
{hasMessages ? (
|
||||||
|
<div
|
||||||
|
ref={messageRegionRef}
|
||||||
|
data-testid="thread-message-region"
|
||||||
|
className="row-start-1 flex min-h-0 flex-col justify-start px-3 pb-4 pt-4 sm:px-4"
|
||||||
|
>
|
||||||
|
<div ref={messageContentRef} className="mx-auto w-full max-w-[49.5rem]">
|
||||||
<ThreadMessages
|
<ThreadMessages
|
||||||
messages={visibleMessages}
|
messages={visibleMessages}
|
||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
@@ -563,32 +655,41 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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
|
<div
|
||||||
ref={composerDockRef}
|
ref={composerDockRef}
|
||||||
data-testid="thread-composer-dock"
|
data-testid="thread-composer-dock"
|
||||||
className="sticky bottom-0 z-10 bg-background"
|
className={cn(
|
||||||
|
"row-start-2 z-10 w-full",
|
||||||
|
hasMessages ? "sticky bottom-0 bg-background" : "relative self-center",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
hasMessages
|
||||||
|
? "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]"
|
||||||
>
|
>
|
||||||
<div className="px-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] sm:px-4">
|
|
||||||
{composer}
|
{composer}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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
|
<div
|
||||||
data-testid="thread-welcome-layout"
|
aria-hidden
|
||||||
className="relative grid w-full max-w-[58rem] flex-1 grid-rows-[minmax(min-content,1fr)_auto] gap-8 sm:block sm:flex-none"
|
className="thread-layout-spacer row-start-3 min-h-0 overflow-hidden"
|
||||||
>
|
/>
|
||||||
<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>
|
||||||
<div className="w-full">{composer}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div ref={bottomRef} aria-hidden className="h-px" />
|
<div ref={bottomRef} aria-hidden className="h-px" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -602,6 +703,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
messages={visibleMessages}
|
messages={visibleMessages}
|
||||||
scrollRef={scrollRef}
|
scrollRef={scrollRef}
|
||||||
bottomOffset={scrollButtonBottom}
|
bottomOffset={scrollButtonBottom}
|
||||||
|
onJumpToPrompt={navigateToVisiblePrompt}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -613,7 +715,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => scrollToBottom(true, 1, { force: true })}
|
onClick={() => scrollToBottom(true, { force: true })}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-8 w-8 rounded-full shadow-md",
|
"h-8 w-8 rounded-full shadow-md",
|
||||||
"bg-background/90 backdrop-blur",
|
"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);
|
||||||
|
};
|
||||||
|
}
|
||||||
+60
-27
@@ -265,33 +265,6 @@
|
|||||||
@apply list-none pl-0;
|
@apply list-none pl-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Show a caret only while the full markdown renderer is still loading. */
|
|
||||||
@keyframes streaming-caret-blink {
|
|
||||||
0%, 45% { opacity: 1; }
|
|
||||||
55%, 100% { opacity: 0; }
|
|
||||||
}
|
|
||||||
.streaming-text-fallback::after {
|
|
||||||
content: "";
|
|
||||||
display: inline-block;
|
|
||||||
width: 1.5px;
|
|
||||||
height: 1em;
|
|
||||||
margin-left: 3px;
|
|
||||||
vertical-align: -0.12em;
|
|
||||||
border-radius: 1px;
|
|
||||||
background: hsl(var(--foreground) / 0.8);
|
|
||||||
animation: streaming-caret-blink 1s step-end infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
[data-sd-animate] {
|
|
||||||
animation: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.streaming-text-fallback::after {
|
|
||||||
animation: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* CJK-friendly line-height: prose paragraphs default to 1.625 which is
|
/* CJK-friendly line-height: prose paragraphs default to 1.625 which is
|
||||||
tight for Chinese/Japanese/Korean characters. Bump to 1.8 for better
|
tight for Chinese/Japanese/Korean characters. Bump to 1.8 for better
|
||||||
readability when the browser detects a CJK primary font. */
|
readability when the browser detects a CJK primary font. */
|
||||||
@@ -371,6 +344,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The composer is one persistent grid item in both landing and thread
|
||||||
|
* layouts. On desktop, the landing's trailing flexible row holds it at the
|
||||||
|
* visual center; collapsing only that row lets the same element dock at the
|
||||||
|
* bottom without a remount or a transform clone.
|
||||||
|
*/
|
||||||
|
.thread-layout {
|
||||||
|
grid-template-rows: minmax(min-content, 1fr) auto 0fr;
|
||||||
|
transition:
|
||||||
|
grid-template-rows 900ms cubic-bezier(0.33, 1, 0.68, 1);
|
||||||
|
}
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.thread-layout[data-layout="hero"] {
|
||||||
|
grid-template-rows: minmax(min-content, 1fr) auto 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes composer-status-strip-enter {
|
@keyframes composer-status-strip-enter {
|
||||||
0% {
|
0% {
|
||||||
max-height: 0;
|
max-height: 0;
|
||||||
@@ -410,6 +400,39 @@
|
|||||||
.composer-status-strip[data-state="exit"] {
|
.composer-status-strip[data-state="exit"] {
|
||||||
animation: composer-status-strip-exit 180ms ease-in both;
|
animation: composer-status-strip-exit 180ms ease-in both;
|
||||||
}
|
}
|
||||||
|
.composer-status-drawer {
|
||||||
|
--composer-status-drawer-duration: 600ms;
|
||||||
|
--composer-status-drawer-easing: ease-out;
|
||||||
|
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 0fr;
|
||||||
|
pointer-events: none;
|
||||||
|
transition:
|
||||||
|
grid-template-rows var(--composer-status-drawer-duration)
|
||||||
|
var(--composer-status-drawer-easing);
|
||||||
|
}
|
||||||
|
.composer-status-drawer[data-state="open"] {
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.composer-status-drawer-clip {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.composer-status-drawer-content {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
transform-origin: bottom center;
|
||||||
|
transition:
|
||||||
|
opacity var(--composer-status-drawer-duration)
|
||||||
|
var(--composer-status-drawer-easing),
|
||||||
|
transform var(--composer-status-drawer-duration)
|
||||||
|
var(--composer-status-drawer-easing);
|
||||||
|
}
|
||||||
|
.composer-status-drawer[data-state="open"] .composer-status-drawer-content {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
@keyframes run-pulse-dot {
|
@keyframes run-pulse-dot {
|
||||||
0%,
|
0%,
|
||||||
100% {
|
100% {
|
||||||
@@ -474,6 +497,16 @@
|
|||||||
.composer-status-strip[data-state] {
|
.composer-status-strip[data-state] {
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
|
.composer-status-drawer {
|
||||||
|
transition-duration: 220ms;
|
||||||
|
}
|
||||||
|
.composer-status-drawer-content {
|
||||||
|
transform: translateY(2px);
|
||||||
|
transition-duration: 140ms, 220ms;
|
||||||
|
}
|
||||||
|
.thread-layout {
|
||||||
|
transition-duration: 360ms;
|
||||||
|
}
|
||||||
.run-pulse-icon,
|
.run-pulse-icon,
|
||||||
.run-pulse-icon * {
|
.run-pulse-icon * {
|
||||||
animation: none;
|
animation: none;
|
||||||
|
|||||||
@@ -227,9 +227,9 @@ function pruneReasoningOnlyPlaceholders(prev: UIMessage[]): UIMessage[] {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function stampLastAssistantLatency(
|
function stampLastAssistantCompletion(
|
||||||
prev: UIMessage[],
|
prev: UIMessage[],
|
||||||
latencyMs: number,
|
completion: Pick<UIMessage, "latencyMs" | "completedAt">,
|
||||||
turnId?: string,
|
turnId?: string,
|
||||||
): UIMessage[] {
|
): UIMessage[] {
|
||||||
for (let i = prev.length - 1; i >= 0; i -= 1) {
|
for (let i = prev.length - 1; i >= 0; i -= 1) {
|
||||||
@@ -239,7 +239,7 @@ function stampLastAssistantLatency(
|
|||||||
&& m.kind !== "trace"
|
&& m.kind !== "trace"
|
||||||
&& (!turnId || !m.turnId || m.turnId === turnId)
|
&& (!turnId || !m.turnId || m.turnId === turnId)
|
||||||
) {
|
) {
|
||||||
const merged: UIMessage = { ...m, latencyMs, isStreaming: false };
|
const merged: UIMessage = { ...m, ...completion, isStreaming: false };
|
||||||
return [...prev.slice(0, i), merged, ...prev.slice(i + 1)];
|
return [...prev.slice(0, i), merged, ...prev.slice(i + 1)];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -488,6 +488,12 @@ export interface SendOptions {
|
|||||||
continueActiveTurn?: boolean;
|
continueActiveTurn?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SubmittedTurn {
|
||||||
|
turnId: string;
|
||||||
|
userMessageId: string;
|
||||||
|
sideChannel: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
function eventExtendsModelActivity(ev: InboundEvent): boolean {
|
function eventExtendsModelActivity(ev: InboundEvent): boolean {
|
||||||
if (
|
if (
|
||||||
ev.event === "delta"
|
ev.event === "delta"
|
||||||
@@ -520,12 +526,18 @@ export function useNanobotStream(
|
|||||||
onTurnEnd?: () => void,
|
onTurnEnd?: () => void,
|
||||||
): {
|
): {
|
||||||
messages: UIMessage[];
|
messages: UIMessage[];
|
||||||
|
/** Whether ``messages`` belongs to the current ``chatId`` after a session switch. */
|
||||||
|
messagesReady: boolean;
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
/** Unix epoch seconds when the current user turn started (WebSocket ``goal_status``). */
|
/** Unix epoch seconds when the current user turn started (WebSocket ``goal_status``). */
|
||||||
runStartedAt: number | null;
|
runStartedAt: number | null;
|
||||||
/** Latest sustained goal for this ``chatId`` (``goal_state`` WS events). */
|
/** Latest sustained goal for this ``chatId`` (``goal_state`` WS events). */
|
||||||
goalState: GoalStateWsPayload | undefined;
|
goalState: GoalStateWsPayload | undefined;
|
||||||
send: (content: string, images?: SendAttachment[], options?: SendOptions) => void;
|
send: (
|
||||||
|
content: string,
|
||||||
|
images?: SendAttachment[],
|
||||||
|
options?: SendOptions,
|
||||||
|
) => SubmittedTurn | null;
|
||||||
transcribeAudio: (dataUrl: string, options?: { durationMs?: number }) => Promise<string>;
|
transcribeAudio: (dataUrl: string, options?: { durationMs?: number }) => Promise<string>;
|
||||||
stop: () => void;
|
stop: () => void;
|
||||||
setMessages: React.Dispatch<React.SetStateAction<UIMessage[]>>;
|
setMessages: React.Dispatch<React.SetStateAction<UIMessage[]>>;
|
||||||
@@ -537,12 +549,16 @@ export function useNanobotStream(
|
|||||||
dismissStreamError: () => void;
|
dismissStreamError: () => void;
|
||||||
} {
|
} {
|
||||||
const { client } = useClient();
|
const { client } = useClient();
|
||||||
|
const initialRunStartedAt = chatId ? client.getRunStartedAt(chatId) : null;
|
||||||
const [messages, setMessages] = useState<UIMessage[]>(initialMessages);
|
const [messages, setMessages] = useState<UIMessage[]>(initialMessages);
|
||||||
|
const [messageOwnerChatId, setMessageOwnerChatId] = useState(chatId);
|
||||||
/** If history ends in unfinished agent activity, keep the loading spinner alive. */
|
/** If history ends in unfinished agent activity, keep the loading spinner alive. */
|
||||||
const initialStreaming = hasPendingAgentActivity(initialMessages);
|
const initialStreaming = hasPendingAgentActivity(initialMessages);
|
||||||
const [isStreaming, setIsStreaming] = useState(initialStreaming || hasPendingToolCalls);
|
const [isStreaming, setIsStreaming] = useState(
|
||||||
|
initialStreaming || hasPendingToolCalls || initialRunStartedAt !== null,
|
||||||
|
);
|
||||||
/** Unix epoch seconds when the current user turn started; cleared on ``idle``. */
|
/** Unix epoch seconds when the current user turn started; cleared on ``idle``. */
|
||||||
const [runStartedAt, setRunStartedAt] = useState<number | null>(null);
|
const [runStartedAt, setRunStartedAt] = useState<number | null>(initialRunStartedAt);
|
||||||
const [goalState, setGoalState] = useState<GoalStateWsPayload | undefined>(undefined);
|
const [goalState, setGoalState] = useState<GoalStateWsPayload | undefined>(undefined);
|
||||||
const [streamError, setStreamError] = useState<StreamError | null>(null);
|
const [streamError, setStreamError] = useState<StreamError | null>(null);
|
||||||
const buffer = useRef<StreamBuffer | null>(null);
|
const buffer = useRef<StreamBuffer | null>(null);
|
||||||
@@ -826,12 +842,16 @@ export function useNanobotStream(
|
|||||||
// ``initialMessages`` update: a brand-new chat can receive an empty/404
|
// ``initialMessages`` update: a brand-new chat can receive an empty/404
|
||||||
// history response after the optimistic first message has already rendered.
|
// history response after the optimistic first message has already rendered.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const restoredRunStartedAt = chatId ? client.getRunStartedAt(chatId) : null;
|
||||||
setMessages(initialMessages);
|
setMessages(initialMessages);
|
||||||
|
setMessageOwnerChatId(chatId);
|
||||||
setIsStreaming(
|
setIsStreaming(
|
||||||
hasPendingAgentActivity(initialMessages) || hasPendingToolCalls,
|
hasPendingAgentActivity(initialMessages)
|
||||||
|
|| hasPendingToolCalls
|
||||||
|
|| restoredRunStartedAt !== null,
|
||||||
);
|
);
|
||||||
setStreamError(null);
|
setStreamError(null);
|
||||||
setRunStartedAt(chatId ? client.getRunStartedAt(chatId) : null);
|
setRunStartedAt(restoredRunStartedAt);
|
||||||
setGoalState(chatId ? client.getGoalState(chatId) : undefined);
|
setGoalState(chatId ? client.getGoalState(chatId) : undefined);
|
||||||
buffer.current = null;
|
buffer.current = null;
|
||||||
activeAssistantRef.current = null;
|
activeAssistantRef.current = null;
|
||||||
@@ -929,8 +949,10 @@ export function useNanobotStream(
|
|||||||
if (ev.event === "goal_status") {
|
if (ev.event === "goal_status") {
|
||||||
if (ev.status === "running" && typeof ev.started_at === "number") {
|
if (ev.status === "running" && typeof ev.started_at === "number") {
|
||||||
setRunStartedAt(ev.started_at);
|
setRunStartedAt(ev.started_at);
|
||||||
|
setIsStreaming(true);
|
||||||
} else {
|
} else {
|
||||||
setRunStartedAt(null);
|
setRunStartedAt(null);
|
||||||
|
setIsStreaming(false);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -944,16 +966,22 @@ export function useNanobotStream(
|
|||||||
// pending debounce timer and stop the loading indicator immediately.
|
// pending debounce timer and stop the loading indicator immediately.
|
||||||
cancelStreamEndTimer();
|
cancelStreamEndTimer();
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
|
const completedAt = Date.now();
|
||||||
setMessages((prev) => {
|
setMessages((prev) => {
|
||||||
let finalized = prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m));
|
let finalized = prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m));
|
||||||
finalized = pruneReasoningOnlyPlaceholders(finalized);
|
finalized = pruneReasoningOnlyPlaceholders(finalized);
|
||||||
if (typeof ev.latency_ms === "number" && ev.latency_ms >= 0) {
|
const latencyMs =
|
||||||
finalized = stampLastAssistantLatency(
|
typeof ev.latency_ms === "number" && ev.latency_ms >= 0
|
||||||
|
? Math.round(ev.latency_ms)
|
||||||
|
: undefined;
|
||||||
|
finalized = stampLastAssistantCompletion(
|
||||||
finalized,
|
finalized,
|
||||||
Math.round(ev.latency_ms),
|
{
|
||||||
|
...(latencyMs !== undefined ? { latencyMs } : {}),
|
||||||
|
completedAt,
|
||||||
|
},
|
||||||
ev.turn_id,
|
ev.turn_id,
|
||||||
);
|
);
|
||||||
}
|
|
||||||
buffer.current = null;
|
buffer.current = null;
|
||||||
activeAssistantRef.current = null;
|
activeAssistantRef.current = null;
|
||||||
clearActivitySegment();
|
clearActivitySegment();
|
||||||
@@ -1176,11 +1204,11 @@ export function useNanobotStream(
|
|||||||
|
|
||||||
const send = useCallback(
|
const send = useCallback(
|
||||||
(content: string, images?: SendAttachment[], options?: SendOptions) => {
|
(content: string, images?: SendAttachment[], options?: SendOptions) => {
|
||||||
if (!chatId) return;
|
if (!chatId) return null;
|
||||||
const hasAttachments = !!images && images.length > 0;
|
const hasAttachments = !!images && images.length > 0;
|
||||||
// Text is optional when files are attached — the agent will still see
|
// Text is optional when files are attached — the agent will still see
|
||||||
// them via ``media`` paths.
|
// them via ``media`` paths.
|
||||||
if (!hasAttachments && !content.trim()) return;
|
if (!hasAttachments && !content.trim()) return null;
|
||||||
|
|
||||||
const sideChannel = options?.sideChannel === true;
|
const sideChannel = options?.sideChannel === true;
|
||||||
const finalizeActiveTurn = options?.finalizeActiveTurn === true;
|
const finalizeActiveTurn = options?.finalizeActiveTurn === true;
|
||||||
@@ -1194,6 +1222,7 @@ export function useNanobotStream(
|
|||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
}
|
}
|
||||||
const turnId = crypto.randomUUID();
|
const turnId = crypto.randomUUID();
|
||||||
|
const userMessageId = crypto.randomUUID();
|
||||||
if (sideChannel) sideChannelTurnIdsRef.current.add(turnId);
|
if (sideChannel) sideChannelTurnIdsRef.current.add(turnId);
|
||||||
const previews = hasAttachments ? images!.map((i) => i.preview) : undefined;
|
const previews = hasAttachments ? images!.map((i) => i.preview) : undefined;
|
||||||
setMessages((prev) => {
|
setMessages((prev) => {
|
||||||
@@ -1213,7 +1242,7 @@ export function useNanobotStream(
|
|||||||
return [
|
return [
|
||||||
...(sideChannel || continueActiveTurn ? base : pruneReasoningOnlyPlaceholders(base)),
|
...(sideChannel || continueActiveTurn ? base : pruneReasoningOnlyPlaceholders(base)),
|
||||||
{
|
{
|
||||||
id: crypto.randomUUID(),
|
id: userMessageId,
|
||||||
role: "user",
|
role: "user",
|
||||||
content: outboundContent,
|
content: outboundContent,
|
||||||
turnId,
|
turnId,
|
||||||
@@ -1234,6 +1263,7 @@ export function useNanobotStream(
|
|||||||
delete wireOptions.finalizeActiveTurn;
|
delete wireOptions.finalizeActiveTurn;
|
||||||
delete wireOptions.continueActiveTurn;
|
delete wireOptions.continueActiveTurn;
|
||||||
client.sendMessage(chatId, outboundContent, wireMedia, wireOptions);
|
client.sendMessage(chatId, outboundContent, wireMedia, wireOptions);
|
||||||
|
return { turnId, userMessageId, sideChannel };
|
||||||
},
|
},
|
||||||
[cancelStreamEndTimer, chatId, clearActivitySegment, client, flushPendingStreamEvents],
|
[cancelStreamEndTimer, chatId, clearActivitySegment, client, flushPendingStreamEvents],
|
||||||
);
|
);
|
||||||
@@ -1261,6 +1291,7 @@ export function useNanobotStream(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
messages,
|
messages,
|
||||||
|
messagesReady: messageOwnerChatId === chatId,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
runStartedAt,
|
runStartedAt,
|
||||||
goalState,
|
goalState,
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ const RELATIVE_THRESHOLDS: [number, Intl.RelativeTimeFormatUnit][] = [
|
|||||||
|
|
||||||
const relativeTimeFormatters = new Map<string, Intl.RelativeTimeFormat>();
|
const relativeTimeFormatters = new Map<string, Intl.RelativeTimeFormat>();
|
||||||
const dateTimeFormatters = new Map<string, Intl.DateTimeFormat>();
|
const dateTimeFormatters = new Map<string, Intl.DateTimeFormat>();
|
||||||
|
const clockTimeFormatters = new Map<string, Intl.DateTimeFormat>();
|
||||||
|
|
||||||
function activeLocale(locale?: string): string {
|
function activeLocale(locale?: string): string {
|
||||||
return locale || i18n.resolvedLanguage || i18n.language || currentLocale();
|
return locale || i18n.resolvedLanguage || i18n.language || currentLocale();
|
||||||
@@ -95,6 +96,25 @@ function dateTimeFormatter(locale: string): Intl.DateTimeFormat {
|
|||||||
return formatter;
|
return formatter;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clockTimeFormatter(locale: string): Intl.DateTimeFormat {
|
||||||
|
const existing = clockTimeFormatters.get(locale);
|
||||||
|
if (existing) return existing;
|
||||||
|
const formatter = new Intl.DateTimeFormat(locale, {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
clockTimeFormatters.set(locale, formatter);
|
||||||
|
return formatter;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSameLocalCalendarDay(left: Date, right: Date): boolean {
|
||||||
|
return (
|
||||||
|
left.getFullYear() === right.getFullYear()
|
||||||
|
&& left.getMonth() === right.getMonth()
|
||||||
|
&& left.getDate() === right.getDate()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function relativeTime(
|
export function relativeTime(
|
||||||
value: string | number | null | undefined,
|
value: string | number | null | undefined,
|
||||||
locale?: string,
|
locale?: string,
|
||||||
@@ -120,6 +140,22 @@ export function fmtDateTime(
|
|||||||
return date ? dateTimeFormatter(activeLocale(locale)).format(date) : "";
|
return date ? dateTimeFormatter(activeLocale(locale)).format(date) : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a completion timestamp in the browser's local timezone.
|
||||||
|
* Today's messages stay compact; older messages include their date for orientation.
|
||||||
|
*/
|
||||||
|
export function formatMessageEndTime(
|
||||||
|
value: number | null | undefined,
|
||||||
|
locale?: string,
|
||||||
|
): string {
|
||||||
|
const date = parseDate(value);
|
||||||
|
if (!date) return "";
|
||||||
|
const loc = activeLocale(locale);
|
||||||
|
return isSameLocalCalendarDay(date, new Date())
|
||||||
|
? clockTimeFormatter(loc).format(date)
|
||||||
|
: dateTimeFormatter(loc).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
/** Human-readable turn duration (wall-clock), locale-aware via ``Intl`` (seconds/minutes). */
|
/** Human-readable turn duration (wall-clock), locale-aware via ``Intl`` (seconds/minutes). */
|
||||||
export function formatTurnLatency(ms: number, locale?: string): string {
|
export function formatTurnLatency(ms: number, locale?: string): string {
|
||||||
const loc = activeLocale(locale);
|
const loc = activeLocale(locale);
|
||||||
|
|||||||
@@ -24,6 +24,44 @@ export function normalizeLegacyLongTaskMessages(messages: UIMessage[]): UIMessag
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replay timestamps an assistant row when its first output is recorded, while
|
||||||
|
* latency covers the whole turn. Derive the end from the matching user start
|
||||||
|
* so the displayed time cannot double-count the pre-output interval.
|
||||||
|
*/
|
||||||
|
function deriveAssistantCompletionTimes(messages: UIMessage[]): UIMessage[] {
|
||||||
|
const userStartedAtByTurn = new Map<string, number>();
|
||||||
|
let latestUserStartedAt: number | undefined;
|
||||||
|
|
||||||
|
return messages.map((message) => {
|
||||||
|
if (message.role === "user") {
|
||||||
|
if (Number.isFinite(message.createdAt)) {
|
||||||
|
latestUserStartedAt = message.createdAt;
|
||||||
|
if (message.turnId) userStartedAtByTurn.set(message.turnId, message.createdAt);
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
message.role !== "assistant"
|
||||||
|
|| message.kind === "trace"
|
||||||
|
|| message.completedAt !== undefined
|
||||||
|
|| message.latencyMs === undefined
|
||||||
|
|| !Number.isFinite(message.latencyMs)
|
||||||
|
|| message.latencyMs < 0
|
||||||
|
) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startedAt = message.turnId
|
||||||
|
? userStartedAtByTurn.get(message.turnId)
|
||||||
|
: message.source
|
||||||
|
? undefined
|
||||||
|
: latestUserStartedAt;
|
||||||
|
if (startedAt === undefined) return message;
|
||||||
|
return { ...message, completedAt: startedAt + message.latencyMs };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function projectWebuiThreadMessages(messages: UIMessage[]): UIMessage[] {
|
export function projectWebuiThreadMessages(messages: UIMessage[]): UIMessage[] {
|
||||||
const normalized = scrubSubagentUiMessages(normalizeLegacyLongTaskMessages(messages));
|
const normalized = scrubSubagentUiMessages(normalizeLegacyLongTaskMessages(messages));
|
||||||
const hiddenTurns = new Set(normalized.flatMap((message) => (
|
const hiddenTurns = new Set(normalized.flatMap((message) => (
|
||||||
@@ -31,10 +69,11 @@ export function projectWebuiThreadMessages(messages: UIMessage[]): UIMessage[] {
|
|||||||
? [message.turnId]
|
? [message.turnId]
|
||||||
: []
|
: []
|
||||||
)));
|
)));
|
||||||
return normalized.filter((message) => (
|
const visible = normalized.filter((message) => (
|
||||||
!isSystemCommandTurnId(message.turnId)
|
!isSystemCommandTurnId(message.turnId)
|
||||||
&& (!message.turnId || !hiddenTurns.has(message.turnId))
|
&& (!message.turnId || !hiddenTurns.has(message.turnId))
|
||||||
&& !(message.role === "user" && isModelCommandText(message.content))
|
&& !(message.role === "user" && isModelCommandText(message.content))
|
||||||
&& !(message.role === "assistant" && isModelCommandResponseText(message.content))
|
&& !(message.role === "assistant" && isModelCommandResponseText(message.content))
|
||||||
));
|
));
|
||||||
|
return deriveAssistantCompletionTimes(visible);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ export interface UIMessage {
|
|||||||
reasoningStreaming?: boolean;
|
reasoningStreaming?: boolean;
|
||||||
/** End-to-end wall time for this assistant turn (persisted ``latency_ms`` / ``turn_end``). */
|
/** End-to-end wall time for this assistant turn (persisted ``latency_ms`` / ``turn_end``). */
|
||||||
latencyMs?: number;
|
latencyMs?: number;
|
||||||
|
/** Client epoch milliseconds when the definitive ``turn_end`` was received. */
|
||||||
|
completedAt?: number;
|
||||||
/** Lightweight provenance for proactive assistant messages. */
|
/** Lightweight provenance for proactive assistant messages. */
|
||||||
source?: UIMessageSource;
|
source?: UIMessageSource;
|
||||||
/** Stable protocol metadata for grouping all activity emitted by one user turn. */
|
/** Stable protocol metadata for grouping all activity emitted by one user turn. */
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { setAppLanguage } from "@/i18n";
|
import { setAppLanguage } from "@/i18n";
|
||||||
import { fmtDateTime, formatTurnLatency, relativeTime } from "@/lib/format";
|
import {
|
||||||
|
fmtDateTime,
|
||||||
|
formatMessageEndTime,
|
||||||
|
formatTurnLatency,
|
||||||
|
relativeTime,
|
||||||
|
} from "@/lib/format";
|
||||||
|
|
||||||
describe("localized format helpers", () => {
|
describe("localized format helpers", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -62,6 +67,34 @@ describe("localized format helpers", () => {
|
|||||||
expect(english).not.toBe(french);
|
expect(english).not.toBe(french);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows only the local clock time for messages completed today", async () => {
|
||||||
|
const value = Date.parse("2026-04-18T08:34:56Z");
|
||||||
|
const date = new Date(value);
|
||||||
|
|
||||||
|
await setAppLanguage("zh-CN");
|
||||||
|
|
||||||
|
expect(formatMessageEndTime(value)).toBe(
|
||||||
|
new Intl.DateTimeFormat("zh-CN", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
}).format(date),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds the local date for messages completed before today", async () => {
|
||||||
|
const value = Date.parse("2026-04-16T08:34:56Z");
|
||||||
|
const date = new Date(value);
|
||||||
|
|
||||||
|
await setAppLanguage("zh-CN");
|
||||||
|
|
||||||
|
expect(formatMessageEndTime(value)).toBe(
|
||||||
|
new Intl.DateTimeFormat("zh-CN", {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeStyle: "short",
|
||||||
|
}).format(date),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("formats turn latency with locale-aware units", async () => {
|
it("formats turn latency with locale-aware units", async () => {
|
||||||
await setAppLanguage("en");
|
await setAppLanguage("en");
|
||||||
const subMinute = formatTurnLatency(2400, "en");
|
const subMinute = formatTurnLatency(2400, "en");
|
||||||
|
|||||||
@@ -424,39 +424,36 @@ describe("MarkdownTextRenderer", () => {
|
|||||||
expect(container.firstElementChild).not.toHaveClass("space-y-0");
|
expect(container.firstElementChild).not.toHaveClass("space-y-0");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses Streamdown's incremental reveal while content is streaming", () => {
|
it("keeps streaming parsing without a competing reveal animation", () => {
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<MarkdownTextRenderer streaming>春天</MarkdownTextRenderer>,
|
<MarkdownTextRenderer streaming>春天</MarkdownTextRenderer>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(container.firstElementChild).toHaveClass(
|
expect(container).toHaveTextContent("春天");
|
||||||
|
expect(container.firstElementChild).not.toHaveClass(
|
||||||
"[&>*:last-child]:after:content-[var(--streamdown-caret)]",
|
"[&>*:last-child]:after:content-[var(--streamdown-caret)]",
|
||||||
);
|
);
|
||||||
const animatedUnits = container.querySelectorAll<HTMLElement>("[data-sd-animate]");
|
expect(container.querySelector("[data-sd-animate]")).not.toBeInTheDocument();
|
||||||
expect(animatedUnits).toHaveLength(1);
|
|
||||||
expect(animatedUnits[0]).toHaveTextContent("春天");
|
|
||||||
expect(animatedUnits[0].getAttribute("style")).toContain("--sd-duration: 180ms");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("removes animation markup when a streamed response completes", async () => {
|
it("does not add animation markup when a streamed response completes", () => {
|
||||||
const { container, rerender } = render(
|
const { container, rerender } = render(
|
||||||
<MarkdownTextRenderer streaming>春天</MarkdownTextRenderer>,
|
<MarkdownTextRenderer streaming>春天</MarkdownTextRenderer>,
|
||||||
);
|
);
|
||||||
expect(container.querySelector("[data-sd-animate]")).toBeInTheDocument();
|
expect(container.querySelector("[data-sd-animate]")).not.toBeInTheDocument();
|
||||||
|
|
||||||
rerender(<MarkdownTextRenderer>春天</MarkdownTextRenderer>);
|
rerender(<MarkdownTextRenderer>春天</MarkdownTextRenderer>);
|
||||||
|
|
||||||
await waitFor(() => {
|
expect(container).toHaveTextContent("春天");
|
||||||
expect(container.querySelector("[data-sd-animate]")).not.toBeInTheDocument();
|
expect(container.querySelector("[data-sd-animate]")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
it("does not create one DOM node per CJK character for long responses", () => {
|
it("does not create one DOM node per CJK character for long responses", () => {
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<MarkdownTextRenderer streaming>{"长".repeat(6_001)}</MarkdownTextRenderer>,
|
<MarkdownTextRenderer streaming>{"长".repeat(6_001)}</MarkdownTextRenderer>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(container.querySelectorAll("[data-sd-animate]")).toHaveLength(1);
|
expect(container.querySelector("[data-sd-animate]")).not.toBeInTheDocument();
|
||||||
expect(container.querySelector("[data-nanobot-stream-unit]")).not.toBeInTheDocument();
|
expect(container.querySelector("[data-nanobot-stream-unit]")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ vi.mock("@/components/MarkdownTextRenderer", () => ({
|
|||||||
<div
|
<div
|
||||||
data-testid="markdown-renderer"
|
data-testid="markdown-renderer"
|
||||||
data-highlight-code={String(highlightCode)}
|
data-highlight-code={String(highlightCode)}
|
||||||
|
data-streaming-layout={String(streaming)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
@@ -106,6 +107,33 @@ describe("MarkdownText", () => {
|
|||||||
expect(rendererMountSpy).toHaveBeenCalledTimes(1);
|
expect(rendererMountSpy).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("can complete without replacing the streaming renderer layout", async () => {
|
||||||
|
const source = "A layout-stable answer";
|
||||||
|
const { rerender } = render(
|
||||||
|
<MarkdownText streaming preserveStreamingLayout>{source}</MarkdownText>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
expect(screen.getByTestId("markdown-renderer")).toHaveAttribute(
|
||||||
|
"data-streaming-layout",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
|
||||||
|
rerender(<MarkdownText preserveStreamingLayout>{source}</MarkdownText>);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("markdown-renderer")).toHaveAttribute(
|
||||||
|
"data-streaming-layout",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("markdown-renderer")).toHaveAttribute(
|
||||||
|
"data-highlight-code",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("defers syntax highlighting until the final render", async () => {
|
it("defers syntax highlighting until the final render", async () => {
|
||||||
rendererSpy.mockClear();
|
rendererSpy.mockClear();
|
||||||
const largeCode = `\`\`\`ts\n${"const value = 1;\n".repeat(1_100)}\`\`\``;
|
const largeCode = `\`\`\`ts\n${"const value = 1;\n".repeat(1_100)}\`\`\``;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { MessageBubble } from "@/components/MessageBubble";
|
import { MessageBubble } from "@/components/MessageBubble";
|
||||||
|
import { fmtDateTime, formatMessageEndTime } from "@/lib/format";
|
||||||
import type {
|
import type {
|
||||||
CliAppInfo,
|
CliAppInfo,
|
||||||
McpPresetInfo,
|
McpPresetInfo,
|
||||||
@@ -298,6 +299,52 @@ describe("MessageBubble", () => {
|
|||||||
expect(onForkFromHere).toHaveBeenCalledTimes(1);
|
expect(onForkFromHere).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows the assistant completion time in the former latency slot", () => {
|
||||||
|
const completedAt = Date.UTC(2026, 6, 25, 12, 34, 56);
|
||||||
|
const { container } = render(
|
||||||
|
<MessageBubble
|
||||||
|
message={{
|
||||||
|
id: "a-completed-at",
|
||||||
|
role: "assistant",
|
||||||
|
content: "Finished answer",
|
||||||
|
latencyMs: 13_000,
|
||||||
|
completedAt,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument();
|
||||||
|
const time = container.querySelector("[data-assistant-completed-at]");
|
||||||
|
expect(time).toHaveTextContent(formatMessageEndTime(completedAt));
|
||||||
|
expect(time).toHaveAttribute("dateTime", new Date(completedAt).toISOString());
|
||||||
|
expect(time).toHaveAttribute("title", fmtDateTime(completedAt));
|
||||||
|
expect(time).toHaveClass(
|
||||||
|
"text-[11px]",
|
||||||
|
"leading-none",
|
||||||
|
"text-muted-foreground/70",
|
||||||
|
"tabular-nums",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not infer completion time from the assistant creation timestamp", () => {
|
||||||
|
const createdAt = Date.UTC(2026, 6, 25, 12, 34, 0);
|
||||||
|
const latencyMs = 13_000;
|
||||||
|
const { container } = render(
|
||||||
|
<MessageBubble
|
||||||
|
message={{
|
||||||
|
id: "a-replayed-completion",
|
||||||
|
role: "assistant",
|
||||||
|
content: "Replayed answer",
|
||||||
|
latencyMs,
|
||||||
|
createdAt,
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.querySelector("[data-assistant-completed-at]")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("renders installed CLI app mentions inside sent user messages", () => {
|
it("renders installed CLI app mentions inside sent user messages", () => {
|
||||||
const message: UIMessage = {
|
const message: UIMessage = {
|
||||||
id: "u-cli",
|
id: "u-cli",
|
||||||
@@ -482,6 +529,37 @@ describe("MessageBubble", () => {
|
|||||||
expect(screen.queryByRole("button", { name: "Copy" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: "Copy" })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps assistant footer geometry mounted across stream completion", () => {
|
||||||
|
const streaming: UIMessage = {
|
||||||
|
id: "a-footer-stable",
|
||||||
|
role: "assistant",
|
||||||
|
content: "Stable answer",
|
||||||
|
isStreaming: true,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
};
|
||||||
|
const { container, rerender } = render(<MessageBubble message={streaming} />);
|
||||||
|
|
||||||
|
const reservedFooter = container.querySelector("[data-assistant-footer]");
|
||||||
|
expect(reservedFooter).not.toBeNull();
|
||||||
|
expect(reservedFooter).toHaveAttribute("data-state", "reserved");
|
||||||
|
expect(reservedFooter).toHaveClass("mt-2", "min-h-8", "opacity-0");
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<MessageBubble
|
||||||
|
message={{
|
||||||
|
...streaming,
|
||||||
|
isStreaming: false,
|
||||||
|
completedAt: Date.now(),
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const visibleFooter = container.querySelector("[data-assistant-footer]");
|
||||||
|
expect(visibleFooter).toBe(reservedFooter);
|
||||||
|
expect(visibleFooter).toHaveAttribute("data-state", "visible");
|
||||||
|
expect(visibleFooter).toHaveClass("mt-2", "min-h-8", "opacity-100");
|
||||||
|
});
|
||||||
|
|
||||||
it("does not show copy when showCopyAction is false", () => {
|
it("does not show copy when showCopyAction is false", () => {
|
||||||
const message: UIMessage = {
|
const message: UIMessage = {
|
||||||
id: "a-mid",
|
id: "a-mid",
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ThreadCameraController,
|
||||||
|
type ThreadCameraScheduler,
|
||||||
|
type ThreadCameraViewport,
|
||||||
|
} from "@/components/thread/thread-camera";
|
||||||
|
|
||||||
|
function cameraHarness(prefersReducedMotion = false) {
|
||||||
|
let now = 0;
|
||||||
|
let nextFrameId = 1;
|
||||||
|
const frames = new Map<number, FrameRequestCallback>();
|
||||||
|
const viewport: ThreadCameraViewport = {
|
||||||
|
scrollTop: 0,
|
||||||
|
};
|
||||||
|
const scheduler: ThreadCameraScheduler = {
|
||||||
|
request: vi.fn((callback) => {
|
||||||
|
const id = nextFrameId;
|
||||||
|
nextFrameId += 1;
|
||||||
|
frames.set(id, callback);
|
||||||
|
return id;
|
||||||
|
}),
|
||||||
|
cancel: vi.fn((id) => {
|
||||||
|
frames.delete(id);
|
||||||
|
}),
|
||||||
|
now: () => now,
|
||||||
|
};
|
||||||
|
const camera = new ThreadCameraController(() => viewport, {
|
||||||
|
scheduler,
|
||||||
|
prefersReducedMotion: () => prefersReducedMotion,
|
||||||
|
});
|
||||||
|
const advance = (deltaMs: number) => {
|
||||||
|
now += deltaMs;
|
||||||
|
const pending = [...frames.entries()];
|
||||||
|
frames.clear();
|
||||||
|
for (const [, callback] of pending) callback(now);
|
||||||
|
};
|
||||||
|
return { camera, viewport, scheduler, frames, advance };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ThreadCameraController", () => {
|
||||||
|
it("responds immediately, then eases out as a static target gets closer", () => {
|
||||||
|
const { camera, viewport, advance } = cameraHarness();
|
||||||
|
|
||||||
|
camera.followTo(60);
|
||||||
|
advance(16);
|
||||||
|
const firstStep = viewport.scrollTop;
|
||||||
|
advance(16);
|
||||||
|
const secondStep = viewport.scrollTop - firstStep;
|
||||||
|
advance(16);
|
||||||
|
const thirdStep = viewport.scrollTop - firstStep - secondStep;
|
||||||
|
|
||||||
|
expect(firstStep).toBeGreaterThan(0);
|
||||||
|
expect(secondStep).toBeGreaterThan(0);
|
||||||
|
expect(thirdStep).toBeGreaterThan(0);
|
||||||
|
expect(secondStep).toBeLessThan(firstStep);
|
||||||
|
expect(thirdStep).toBeLessThan(secondStep);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retargets an active follow without adding another loop", () => {
|
||||||
|
const { camera, viewport, frames, advance } = cameraHarness();
|
||||||
|
|
||||||
|
expect(camera.followTo(100)).toBe("started");
|
||||||
|
expect(frames).toHaveLength(1);
|
||||||
|
advance(16);
|
||||||
|
expect(frames).toHaveLength(1);
|
||||||
|
|
||||||
|
expect(camera.followTo(180)).toBe("retargeted");
|
||||||
|
expect(frames).toHaveLength(1);
|
||||||
|
for (let frame = 0; frame < 120; frame += 1) advance(16);
|
||||||
|
expect(viewport.scrollTop).toBe(180);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tracks repeated target growth as one monotonic camera movement", () => {
|
||||||
|
const { camera, viewport, advance } = cameraHarness();
|
||||||
|
|
||||||
|
camera.followTo(80);
|
||||||
|
advance(16);
|
||||||
|
const first = viewport.scrollTop;
|
||||||
|
camera.followTo(140);
|
||||||
|
advance(16);
|
||||||
|
const second = viewport.scrollTop;
|
||||||
|
camera.followTo(220);
|
||||||
|
advance(16);
|
||||||
|
const third = viewport.scrollTop;
|
||||||
|
|
||||||
|
expect(first).toBeGreaterThan(0);
|
||||||
|
expect(second).toBeGreaterThan(first);
|
||||||
|
expect(third).toBeGreaterThan(second);
|
||||||
|
expect(camera.isFollowing()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a faster motion profile for explicit long-distance navigation", () => {
|
||||||
|
const follow = cameraHarness();
|
||||||
|
const navigation = cameraHarness();
|
||||||
|
|
||||||
|
follow.camera.followTo(1_000);
|
||||||
|
navigation.camera.navigateTo(1_000);
|
||||||
|
follow.advance(16);
|
||||||
|
navigation.advance(16);
|
||||||
|
|
||||||
|
expect(navigation.viewport.scrollTop).toBeGreaterThan(follow.viewport.scrollTop);
|
||||||
|
expect(navigation.viewport.scrollTop).toBeLessThan(1_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives an immediate jump command priority over an active follow", () => {
|
||||||
|
const { camera, viewport, scheduler, frames } = cameraHarness();
|
||||||
|
|
||||||
|
camera.followTo(240);
|
||||||
|
expect(frames).toHaveLength(1);
|
||||||
|
camera.jumpTo(40);
|
||||||
|
|
||||||
|
expect(camera.isFollowing()).toBe(false);
|
||||||
|
expect(viewport.scrollTop).toBe(40);
|
||||||
|
expect(scheduler.cancel).toHaveBeenCalledTimes(1);
|
||||||
|
expect(frames).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves spatial continuity with a shorter reduced-motion chase", () => {
|
||||||
|
const regular = cameraHarness();
|
||||||
|
const reduced = cameraHarness(true);
|
||||||
|
|
||||||
|
expect(regular.camera.followTo(240)).toBe("started");
|
||||||
|
expect(reduced.camera.followTo(240)).toBe("started");
|
||||||
|
|
||||||
|
regular.advance(16);
|
||||||
|
reduced.advance(16);
|
||||||
|
|
||||||
|
expect(reduced.viewport.scrollTop).toBeGreaterThan(regular.viewport.scrollTop);
|
||||||
|
expect(reduced.viewport.scrollTop).toBeLessThan(240);
|
||||||
|
expect(reduced.camera.isFollowing()).toBe(true);
|
||||||
|
|
||||||
|
for (let frame = 0; frame < 60; frame += 1) reduced.advance(16);
|
||||||
|
expect(reduced.camera.isFollowing()).toBe(false);
|
||||||
|
expect(reduced.viewport.scrollTop).toBe(240);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1073,13 +1073,59 @@ describe("ThreadComposer", () => {
|
|||||||
const status = screen.getByRole("status");
|
const status = screen.getByRole("status");
|
||||||
expect(status).toHaveTextContent(/Running/);
|
expect(status).toHaveTextContent(/Running/);
|
||||||
expect(status).toHaveTextContent(/2:05/);
|
expect(status).toHaveTextContent(/2:05/);
|
||||||
expect(status.parentElement).toHaveClass("composer-status-strip");
|
expect(status).toHaveClass("composer-status-drawer-content");
|
||||||
expect(status.parentElement).toHaveAttribute("data-state", "enter");
|
expect(status.closest("[data-composer-status-drawer]")).toHaveAttribute(
|
||||||
|
"data-state",
|
||||||
|
"open",
|
||||||
|
);
|
||||||
expect(status.querySelector(".run-pulse-icon")).not.toBeNull();
|
expect(status.querySelector(".run-pulse-icon")).not.toBeNull();
|
||||||
|
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("opens and closes the run timer through one persistent drawer", () => {
|
||||||
|
const { container, rerender } = render(
|
||||||
|
<ThreadComposer
|
||||||
|
onSend={vi.fn()}
|
||||||
|
placeholder="Type your message..."
|
||||||
|
runStartedAt={null}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const drawer = container.querySelector("[data-composer-status-drawer]");
|
||||||
|
expect(drawer).not.toBeNull();
|
||||||
|
expect(drawer).toHaveAttribute("data-state", "closed");
|
||||||
|
expect(drawer).toHaveAttribute("aria-hidden", "true");
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<ThreadComposer
|
||||||
|
onSend={vi.fn()}
|
||||||
|
placeholder="Type your message..."
|
||||||
|
runStartedAt={Math.floor(Date.now() / 1000)}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.querySelector("[data-composer-status-drawer]")).toBe(drawer);
|
||||||
|
expect(drawer).toHaveAttribute("data-state", "open");
|
||||||
|
expect(drawer).not.toHaveAttribute("aria-hidden");
|
||||||
|
const status = screen.getByRole("status");
|
||||||
|
expect(status).toHaveClass("composer-status-drawer-content");
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<ThreadComposer
|
||||||
|
onSend={vi.fn()}
|
||||||
|
placeholder="Type your message..."
|
||||||
|
runStartedAt={null}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.querySelector("[data-composer-status-drawer]")).toBe(drawer);
|
||||||
|
expect(drawer).toHaveAttribute("data-state", "closed");
|
||||||
|
expect(drawer).toHaveAttribute("aria-hidden", "true");
|
||||||
|
expect(screen.queryByRole("status")).not.toBeInTheDocument();
|
||||||
|
expect(drawer?.querySelector('[role="status"]')).toBe(status);
|
||||||
|
});
|
||||||
|
|
||||||
it("opens an upward anchored goal panel with markdown content when expand is clicked", async () => {
|
it("opens an upward anchored goal panel with markdown content when expand is clicked", async () => {
|
||||||
const longObjective =
|
const longObjective =
|
||||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyz0123456789GoalTail";
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyz0123456789GoalTail";
|
||||||
|
|||||||
@@ -7,6 +7,21 @@ import {
|
|||||||
} from "@/lib/thread-display-compat";
|
} from "@/lib/thread-display-compat";
|
||||||
import type { UIMessage } from "@/lib/types";
|
import type { UIMessage } from "@/lib/types";
|
||||||
|
|
||||||
|
const STARTED_AT = Date.UTC(2026, 6, 25, 12, 34, 0);
|
||||||
|
|
||||||
|
function message(
|
||||||
|
role: UIMessage["role"],
|
||||||
|
overrides: Partial<Omit<UIMessage, "role">> = {},
|
||||||
|
): UIMessage {
|
||||||
|
return {
|
||||||
|
id: role,
|
||||||
|
role,
|
||||||
|
content: role,
|
||||||
|
createdAt: STARTED_AT,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("normalizeLegacyLongTaskMessages", () => {
|
describe("normalizeLegacyLongTaskMessages", () => {
|
||||||
it("maps legacy long_task rows to trace lines", () => {
|
it("maps legacy long_task rows to trace lines", () => {
|
||||||
const legacy = {
|
const legacy = {
|
||||||
@@ -23,17 +38,19 @@ describe("normalizeLegacyLongTaskMessages", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("removes model and silent-command turns without hiding concurrent replies", () => {
|
it("removes model and silent-command turns without hiding concurrent replies", () => {
|
||||||
const message = (
|
|
||||||
id: string,
|
|
||||||
role: UIMessage["role"],
|
|
||||||
content: string,
|
|
||||||
turnId?: string,
|
|
||||||
): UIMessage => ({ id, role, content, createdAt: 1, turnId });
|
|
||||||
const visible = projectWebuiThreadMessages([
|
const visible = projectWebuiThreadMessages([
|
||||||
message("model", "user", "/model fast", "model-turn"),
|
message("user", { id: "model", content: "/model fast", turnId: "model-turn" }),
|
||||||
message("model-reply", "assistant", "Switched model preset to fast.", "model-turn"),
|
message("assistant", {
|
||||||
message("silent", "user", "/restart", "webui-system:restart"),
|
id: "model-reply",
|
||||||
message("reply", "assistant", "This unrelated reply stays visible.", "other-turn"),
|
content: "Switched model preset to fast.",
|
||||||
|
turnId: "model-turn",
|
||||||
|
}),
|
||||||
|
message("user", { id: "silent", content: "/restart", turnId: "webui-system:restart" }),
|
||||||
|
message("assistant", {
|
||||||
|
id: "reply",
|
||||||
|
content: "This unrelated reply stays visible.",
|
||||||
|
turnId: "other-turn",
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(visible.map(({ content }) => content)).toEqual([
|
expect(visible.map(({ content }) => content)).toEqual([
|
||||||
@@ -47,3 +64,74 @@ describe("normalizeLegacyLongTaskMessages", () => {
|
|||||||
expect(deriveTitle("## Model\n- Current model: `gpt-5.5`", "New chat")).toBe("New chat");
|
expect(deriveTitle("## Model\n- Current model: `gpt-5.5`", "New chat")).toBe("New chat");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("projectWebuiThreadMessages", () => {
|
||||||
|
it("derives replayed completion time from the matching user turn", () => {
|
||||||
|
const firstOutputAt = STARTED_AT + 5_000;
|
||||||
|
const latencyMs = 13_000;
|
||||||
|
const visible = projectWebuiThreadMessages([
|
||||||
|
message("user", { turnId: "turn-1" }),
|
||||||
|
message("assistant", {
|
||||||
|
turnId: "turn-1",
|
||||||
|
createdAt: firstOutputAt,
|
||||||
|
latencyMs,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(visible[1]?.completedAt).toBe(STARTED_AT + latencyMs);
|
||||||
|
expect(visible[1]?.completedAt).not.toBe(firstOutputAt + latencyMs);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves the exact completion time received for a live turn", () => {
|
||||||
|
const completedAt = STARTED_AT + 13_000;
|
||||||
|
const visible = projectWebuiThreadMessages([
|
||||||
|
message("user", { turnId: "turn-1" }),
|
||||||
|
message("assistant", {
|
||||||
|
turnId: "turn-1",
|
||||||
|
latencyMs: 13_000,
|
||||||
|
completedAt,
|
||||||
|
createdAt: STARTED_AT + 5_000,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(visible[1]?.completedAt).toBe(completedAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not borrow a timestamp from a different turn", () => {
|
||||||
|
const visible = projectWebuiThreadMessages([
|
||||||
|
message("user", { turnId: "turn-1" }),
|
||||||
|
message("assistant", {
|
||||||
|
turnId: "turn-2",
|
||||||
|
latencyMs: 13_000,
|
||||||
|
createdAt: STARTED_AT + 60_000,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(visible[1]?.completedAt).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the nearest user start for legacy rows without turn metadata", () => {
|
||||||
|
const visible = projectWebuiThreadMessages([
|
||||||
|
message("user"),
|
||||||
|
message("assistant", {
|
||||||
|
latencyMs: 13_000,
|
||||||
|
createdAt: STARTED_AT + 5_000,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(visible[1]?.completedAt).toBe(STARTED_AT + 13_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not attach a previous user turn to proactive messages", () => {
|
||||||
|
const visible = projectWebuiThreadMessages([
|
||||||
|
message("user"),
|
||||||
|
message("assistant", {
|
||||||
|
source: { kind: "cron" },
|
||||||
|
latencyMs: 13_000,
|
||||||
|
createdAt: STARTED_AT + 26 * 60_000,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(visible[1]?.completedAt).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
ThreadMessages,
|
ThreadMessages,
|
||||||
unitKeysForDisplay,
|
unitKeysForDisplay,
|
||||||
} from "@/components/thread/ThreadMessages";
|
} from "@/components/thread/ThreadMessages";
|
||||||
|
import { preloadMarkdownText } from "@/components/MarkdownText";
|
||||||
import type { UIMessage } from "@/lib/types";
|
import type { UIMessage } from "@/lib/types";
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -15,6 +16,118 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("ThreadMessages", () => {
|
describe("ThreadMessages", () => {
|
||||||
|
it("does not move a mounted tail answer into offscreen rendering on the next turn", () => {
|
||||||
|
const completed: UIMessage[] = [
|
||||||
|
{ id: "u1", role: "user", content: "question", createdAt: 1 },
|
||||||
|
{ id: "a1", role: "assistant", content: "latest answer", createdAt: 2 },
|
||||||
|
];
|
||||||
|
const { rerender } = render(
|
||||||
|
<ThreadMessages messages={completed} isStreaming={false} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("latest answer").closest(".thread-render-unit")).toBeNull();
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<ThreadMessages
|
||||||
|
messages={[
|
||||||
|
...completed,
|
||||||
|
{ id: "u2", role: "user", content: "next question", createdAt: 3 },
|
||||||
|
]}
|
||||||
|
isStreaming
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("latest answer").closest(".thread-render-unit")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still defers historical non-tail answers on their initial render", () => {
|
||||||
|
render(
|
||||||
|
<ThreadMessages
|
||||||
|
messages={[
|
||||||
|
{ id: "u1", role: "user", content: "old question", createdAt: 1 },
|
||||||
|
{ id: "a1", role: "assistant", content: "historical answer", createdAt: 2 },
|
||||||
|
{ id: "u2", role: "user", content: "latest question", createdAt: 3 },
|
||||||
|
]}
|
||||||
|
isStreaming={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("historical answer").closest(".thread-render-unit")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves an answer's markdown tree across completion and the next prompt", async () => {
|
||||||
|
await preloadMarkdownText();
|
||||||
|
const turnId = "turn-1";
|
||||||
|
const streaming: UIMessage[] = [
|
||||||
|
{
|
||||||
|
id: "u1",
|
||||||
|
role: "user",
|
||||||
|
content: "question",
|
||||||
|
createdAt: 1,
|
||||||
|
turnId,
|
||||||
|
turnPhase: "prompt",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "live-answer",
|
||||||
|
role: "assistant",
|
||||||
|
content: "stable final answer",
|
||||||
|
createdAt: 2,
|
||||||
|
isStreaming: true,
|
||||||
|
turnId,
|
||||||
|
turnPhase: "answer",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const { container, rerender } = render(
|
||||||
|
<ThreadMessages messages={streaming} isStreaming />,
|
||||||
|
);
|
||||||
|
await waitFor(
|
||||||
|
() => expect(container.querySelector(".markdown-content")).not.toBeNull(),
|
||||||
|
{ timeout: 3_000 },
|
||||||
|
);
|
||||||
|
const paragraph = screen.getByText("stable final answer").closest("p");
|
||||||
|
expect(paragraph).not.toBeNull();
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<ThreadMessages
|
||||||
|
messages={[
|
||||||
|
streaming[0],
|
||||||
|
{
|
||||||
|
...streaming[1],
|
||||||
|
id: "canonical-answer",
|
||||||
|
isStreaming: false,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
isStreaming={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("stable final answer").closest("p")).toBe(paragraph);
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<ThreadMessages
|
||||||
|
messages={[
|
||||||
|
streaming[0],
|
||||||
|
{
|
||||||
|
...streaming[1],
|
||||||
|
id: "canonical-answer",
|
||||||
|
isStreaming: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "u2",
|
||||||
|
role: "user",
|
||||||
|
content: "next question",
|
||||||
|
createdAt: 3,
|
||||||
|
turnId: "turn-2",
|
||||||
|
turnPhase: "prompt",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
isStreaming
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("stable final answer").closest("p")).toBe(paragraph);
|
||||||
|
});
|
||||||
|
|
||||||
it("offers a follow-up action for text selected within one completed answer", async () => {
|
it("offers a follow-up action for text selected within one completed answer", async () => {
|
||||||
const onQuoteSelection = vi.fn();
|
const onQuoteSelection = vi.fn();
|
||||||
render(
|
render(
|
||||||
|
|||||||
@@ -0,0 +1,443 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ThreadMotionCoordinator,
|
||||||
|
type ThreadMotionGeometry,
|
||||||
|
type ThreadMotionScheduler,
|
||||||
|
} from "@/components/thread/thread-motion";
|
||||||
|
|
||||||
|
function motionHarness(initial?: Partial<ThreadMotionGeometry>) {
|
||||||
|
let nextFrameId = 1;
|
||||||
|
let cameraFollowing = false;
|
||||||
|
const frames = new Map<number, FrameRequestCallback>();
|
||||||
|
let geometry: ThreadMotionGeometry = {
|
||||||
|
scrollTop: 400,
|
||||||
|
scrollHeight: 1_900,
|
||||||
|
clientHeight: 500,
|
||||||
|
maxScrollTop: 1_400,
|
||||||
|
composerHeight: 120,
|
||||||
|
promptTop: 404,
|
||||||
|
...initial,
|
||||||
|
};
|
||||||
|
const scheduler: ThreadMotionScheduler = {
|
||||||
|
request: vi.fn((callback) => {
|
||||||
|
const id = nextFrameId;
|
||||||
|
nextFrameId += 1;
|
||||||
|
frames.set(id, callback);
|
||||||
|
return id;
|
||||||
|
}),
|
||||||
|
cancel: vi.fn((id) => {
|
||||||
|
frames.delete(id);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const camera = {
|
||||||
|
cancel: vi.fn(() => {
|
||||||
|
cameraFollowing = false;
|
||||||
|
}),
|
||||||
|
dispose: vi.fn(),
|
||||||
|
jumpTo: vi.fn(),
|
||||||
|
followTo: vi.fn(() => "started" as const),
|
||||||
|
isFollowing: vi.fn(() => cameraFollowing),
|
||||||
|
navigateTo: vi.fn(() => {
|
||||||
|
cameraFollowing = true;
|
||||||
|
return "started" as const;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const onGeometry = vi.fn();
|
||||||
|
const onAutoFollow = vi.fn();
|
||||||
|
const measure = vi.fn(() => geometry);
|
||||||
|
const coordinator = new ThreadMotionCoordinator({
|
||||||
|
camera,
|
||||||
|
measure,
|
||||||
|
onGeometry,
|
||||||
|
onAutoFollow,
|
||||||
|
scheduler,
|
||||||
|
});
|
||||||
|
const advanceFrame = () => {
|
||||||
|
const pending = [...frames.entries()];
|
||||||
|
frames.clear();
|
||||||
|
for (const [, callback] of pending) callback(16);
|
||||||
|
};
|
||||||
|
const setGeometry = (next: Partial<ThreadMotionGeometry>) => {
|
||||||
|
const updated = { ...geometry, ...next };
|
||||||
|
geometry = {
|
||||||
|
...updated,
|
||||||
|
maxScrollTop: next.maxScrollTop
|
||||||
|
?? Math.max(0, updated.scrollHeight - updated.clientHeight),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const setCameraFollowing = (following: boolean) => {
|
||||||
|
cameraFollowing = following;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
camera,
|
||||||
|
coordinator,
|
||||||
|
frames,
|
||||||
|
measure,
|
||||||
|
onAutoFollow,
|
||||||
|
onGeometry,
|
||||||
|
scheduler,
|
||||||
|
advanceFrame,
|
||||||
|
setCameraFollowing,
|
||||||
|
setGeometry,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ThreadMotionCoordinator", () => {
|
||||||
|
it("coalesces discrete invalidations into one authoritative geometry frame", () => {
|
||||||
|
const {
|
||||||
|
camera,
|
||||||
|
coordinator,
|
||||||
|
frames,
|
||||||
|
measure,
|
||||||
|
scheduler,
|
||||||
|
advanceFrame,
|
||||||
|
} = motionHarness();
|
||||||
|
|
||||||
|
coordinator.updateTurn({
|
||||||
|
id: "turn-1",
|
||||||
|
promptId: "prompt-1",
|
||||||
|
hasOutput: true,
|
||||||
|
});
|
||||||
|
coordinator.invalidateGeometry();
|
||||||
|
coordinator.invalidateGeometry();
|
||||||
|
|
||||||
|
expect(scheduler.request).toHaveBeenCalledTimes(1);
|
||||||
|
expect(frames).toHaveLength(1);
|
||||||
|
expect(measure).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
advanceFrame();
|
||||||
|
|
||||||
|
expect(measure).toHaveBeenCalledTimes(1);
|
||||||
|
expect(measure).toHaveBeenCalledWith("prompt-1");
|
||||||
|
expect(camera.jumpTo).toHaveBeenCalledWith(404);
|
||||||
|
expect(camera.followTo).toHaveBeenCalledWith(1_400);
|
||||||
|
expect(coordinator.snapshot()).toMatchObject({
|
||||||
|
mode: "follow-output",
|
||||||
|
promptPositioned: true,
|
||||||
|
measurementPending: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retargets repeated output growth without restarting camera ownership", () => {
|
||||||
|
const {
|
||||||
|
camera,
|
||||||
|
coordinator,
|
||||||
|
advanceFrame,
|
||||||
|
setGeometry,
|
||||||
|
} = motionHarness();
|
||||||
|
coordinator.updateTurn({
|
||||||
|
id: "turn-1",
|
||||||
|
promptId: "prompt-1",
|
||||||
|
hasOutput: true,
|
||||||
|
});
|
||||||
|
advanceFrame();
|
||||||
|
camera.cancel.mockClear();
|
||||||
|
camera.followTo.mockClear();
|
||||||
|
|
||||||
|
setGeometry({ scrollTop: 1_400, scrollHeight: 1_928 });
|
||||||
|
coordinator.invalidateGeometry();
|
||||||
|
coordinator.invalidateGeometry();
|
||||||
|
advanceFrame();
|
||||||
|
setGeometry({ scrollTop: 1_410, scrollHeight: 1_944 });
|
||||||
|
coordinator.invalidateGeometry();
|
||||||
|
advanceFrame();
|
||||||
|
|
||||||
|
expect(camera.followTo.mock.calls).toEqual([[1_428], [1_444]]);
|
||||||
|
expect(camera.cancel).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"geometry-before-completion",
|
||||||
|
"completion-before-geometry",
|
||||||
|
] as const)("keeps following final layout for %s ordering", (ordering) => {
|
||||||
|
const {
|
||||||
|
camera,
|
||||||
|
coordinator,
|
||||||
|
advanceFrame,
|
||||||
|
setGeometry,
|
||||||
|
} = motionHarness();
|
||||||
|
coordinator.updateTurn({
|
||||||
|
id: "turn-1",
|
||||||
|
promptId: "prompt-1",
|
||||||
|
hasOutput: true,
|
||||||
|
});
|
||||||
|
advanceFrame();
|
||||||
|
camera.cancel.mockClear();
|
||||||
|
camera.followTo.mockClear();
|
||||||
|
|
||||||
|
const applyFinalGeometry = () => {
|
||||||
|
setGeometry({
|
||||||
|
scrollTop: 1_400,
|
||||||
|
scrollHeight: 2_400,
|
||||||
|
});
|
||||||
|
coordinator.invalidateGeometry();
|
||||||
|
};
|
||||||
|
if (ordering === "geometry-before-completion") applyFinalGeometry();
|
||||||
|
coordinator.completeTurn();
|
||||||
|
if (ordering === "completion-before-geometry") applyFinalGeometry();
|
||||||
|
|
||||||
|
expect(coordinator.snapshot().mode).toBe("follow-completion");
|
||||||
|
expect(camera.cancel).not.toHaveBeenCalled();
|
||||||
|
advanceFrame();
|
||||||
|
expect(camera.followTo).toHaveBeenLastCalledWith(1_900);
|
||||||
|
|
||||||
|
setGeometry({
|
||||||
|
scrollTop: 1_900,
|
||||||
|
scrollHeight: 2_450,
|
||||||
|
});
|
||||||
|
coordinator.invalidateGeometry();
|
||||||
|
advanceFrame();
|
||||||
|
expect(camera.followTo).toHaveBeenLastCalledWith(1_950);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps turn identity stable when canonical replay replaces DOM ids", () => {
|
||||||
|
const {
|
||||||
|
camera,
|
||||||
|
coordinator,
|
||||||
|
measure,
|
||||||
|
advanceFrame,
|
||||||
|
setGeometry,
|
||||||
|
} = motionHarness();
|
||||||
|
coordinator.updateTurn({
|
||||||
|
id: "turn-stable",
|
||||||
|
promptId: "optimistic-prompt",
|
||||||
|
hasOutput: true,
|
||||||
|
});
|
||||||
|
advanceFrame();
|
||||||
|
camera.jumpTo.mockClear();
|
||||||
|
measure.mockClear();
|
||||||
|
|
||||||
|
setGeometry({ scrollHeight: 2_020, promptTop: 120 });
|
||||||
|
coordinator.updateTurn({
|
||||||
|
id: "turn-stable",
|
||||||
|
promptId: "canonical-prompt",
|
||||||
|
hasOutput: true,
|
||||||
|
});
|
||||||
|
advanceFrame();
|
||||||
|
|
||||||
|
expect(measure).toHaveBeenCalledWith(null);
|
||||||
|
expect(camera.jumpTo).not.toHaveBeenCalled();
|
||||||
|
expect(camera.followTo).toHaveBeenLastCalledWith(1_520);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("continues from the bottom when a running turn is restored before prompt identity", () => {
|
||||||
|
const {
|
||||||
|
camera,
|
||||||
|
coordinator,
|
||||||
|
advanceFrame,
|
||||||
|
} = motionHarness({
|
||||||
|
scrollTop: 1_400,
|
||||||
|
});
|
||||||
|
|
||||||
|
coordinator.updateTurn({
|
||||||
|
id: "turn-restored",
|
||||||
|
promptId: null,
|
||||||
|
hasOutput: true,
|
||||||
|
entry: "restored",
|
||||||
|
});
|
||||||
|
advanceFrame();
|
||||||
|
|
||||||
|
expect(camera.jumpTo).not.toHaveBeenCalled();
|
||||||
|
expect(camera.followTo).toHaveBeenCalledWith(1_400);
|
||||||
|
expect(coordinator.snapshot()).toMatchObject({
|
||||||
|
mode: "follow-output",
|
||||||
|
promptPositioned: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("continues measuring while browsing history but does not steal the viewport", () => {
|
||||||
|
const {
|
||||||
|
camera,
|
||||||
|
coordinator,
|
||||||
|
onGeometry,
|
||||||
|
advanceFrame,
|
||||||
|
setGeometry,
|
||||||
|
} = motionHarness();
|
||||||
|
coordinator.updateTurn({
|
||||||
|
id: "turn-1",
|
||||||
|
promptId: "prompt-1",
|
||||||
|
hasOutput: true,
|
||||||
|
});
|
||||||
|
advanceFrame();
|
||||||
|
camera.followTo.mockClear();
|
||||||
|
onGeometry.mockClear();
|
||||||
|
|
||||||
|
coordinator.takeUserControl();
|
||||||
|
setGeometry({ scrollTop: 200, scrollHeight: 2_100 });
|
||||||
|
coordinator.invalidateGeometry();
|
||||||
|
advanceFrame();
|
||||||
|
|
||||||
|
expect(onGeometry).toHaveBeenCalledTimes(1);
|
||||||
|
expect(camera.followTo).not.toHaveBeenCalled();
|
||||||
|
expect(coordinator.snapshot().mode).toBe("browsing-history");
|
||||||
|
|
||||||
|
coordinator.resumeAutoFollow();
|
||||||
|
advanceFrame();
|
||||||
|
expect(camera.followTo).toHaveBeenCalledWith(1_600);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats scroll events as observations until explicit user intent takes control", () => {
|
||||||
|
const {
|
||||||
|
camera,
|
||||||
|
coordinator,
|
||||||
|
advanceFrame,
|
||||||
|
setGeometry,
|
||||||
|
} = motionHarness({
|
||||||
|
scrollTop: 700,
|
||||||
|
scrollHeight: 1_200,
|
||||||
|
clientHeight: 500,
|
||||||
|
maxScrollTop: 700,
|
||||||
|
});
|
||||||
|
coordinator.updateTurn({
|
||||||
|
id: "turn-1",
|
||||||
|
promptId: "prompt-1",
|
||||||
|
hasOutput: false,
|
||||||
|
});
|
||||||
|
advanceFrame();
|
||||||
|
camera.jumpTo.mockClear();
|
||||||
|
|
||||||
|
setGeometry({
|
||||||
|
scrollTop: 700,
|
||||||
|
scrollHeight: 1_280,
|
||||||
|
});
|
||||||
|
expect(coordinator.observeScroll(false)).toBe("automatic");
|
||||||
|
expect(coordinator.snapshot().mode).toBe("anchor-prompt");
|
||||||
|
advanceFrame();
|
||||||
|
expect(camera.jumpTo).toHaveBeenCalledWith(780);
|
||||||
|
|
||||||
|
coordinator.takeUserControl();
|
||||||
|
expect(coordinator.observeScroll(false)).toBe("user");
|
||||||
|
expect(coordinator.snapshot().mode).toBe("browsing-history");
|
||||||
|
|
||||||
|
expect(coordinator.observeScroll(true)).toBe("automatic");
|
||||||
|
expect(coordinator.snapshot().mode).toBe("anchor-prompt");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves history browsing when an active turn is cleared", () => {
|
||||||
|
const {
|
||||||
|
camera,
|
||||||
|
coordinator,
|
||||||
|
advanceFrame,
|
||||||
|
} = motionHarness();
|
||||||
|
coordinator.updateTurn({
|
||||||
|
id: "turn-1",
|
||||||
|
promptId: "prompt-1",
|
||||||
|
hasOutput: true,
|
||||||
|
});
|
||||||
|
advanceFrame();
|
||||||
|
coordinator.takeUserControl();
|
||||||
|
camera.followTo.mockClear();
|
||||||
|
|
||||||
|
coordinator.updateTurn({
|
||||||
|
id: null,
|
||||||
|
promptId: null,
|
||||||
|
hasOutput: false,
|
||||||
|
});
|
||||||
|
advanceFrame();
|
||||||
|
|
||||||
|
expect(coordinator.snapshot().mode).toBe("browsing-history");
|
||||||
|
expect(camera.followTo).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps history navigation independent from active turn completion", () => {
|
||||||
|
const {
|
||||||
|
camera,
|
||||||
|
coordinator,
|
||||||
|
advanceFrame,
|
||||||
|
} = motionHarness();
|
||||||
|
coordinator.updateTurn({
|
||||||
|
id: "turn-1",
|
||||||
|
promptId: "prompt-1",
|
||||||
|
hasOutput: true,
|
||||||
|
});
|
||||||
|
advanceFrame();
|
||||||
|
coordinator.navigateHistoryTo(900);
|
||||||
|
camera.cancel.mockClear();
|
||||||
|
|
||||||
|
coordinator.completeTurn();
|
||||||
|
|
||||||
|
expect(camera.cancel).not.toHaveBeenCalled();
|
||||||
|
expect(coordinator.snapshot().mode).toBe("navigating-history");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves from rail navigation to history browsing on user intent", () => {
|
||||||
|
const {
|
||||||
|
camera,
|
||||||
|
coordinator,
|
||||||
|
setCameraFollowing,
|
||||||
|
} = motionHarness();
|
||||||
|
|
||||||
|
coordinator.handleUserScrollIntent(false);
|
||||||
|
expect(coordinator.snapshot().mode).toBe("idle");
|
||||||
|
expect(camera.cancel).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
coordinator.navigateHistoryTo(900);
|
||||||
|
expect(camera.navigateTo).toHaveBeenCalledWith(900);
|
||||||
|
expect(coordinator.snapshot().mode).toBe("navigating-history");
|
||||||
|
expect(coordinator.observeScroll(false)).toBe("navigation");
|
||||||
|
|
||||||
|
camera.cancel.mockClear();
|
||||||
|
coordinator.handleUserScrollIntent(false);
|
||||||
|
expect(camera.cancel).toHaveBeenCalledTimes(1);
|
||||||
|
expect(coordinator.snapshot().mode).toBe("browsing-history");
|
||||||
|
expect(coordinator.observeScroll(false)).toBe("user");
|
||||||
|
|
||||||
|
coordinator.navigateHistoryTo(700);
|
||||||
|
camera.cancel.mockClear();
|
||||||
|
coordinator.takeUserControl();
|
||||||
|
expect(camera.cancel).toHaveBeenCalledTimes(1);
|
||||||
|
expect(coordinator.snapshot().mode).toBe("browsing-history");
|
||||||
|
|
||||||
|
camera.cancel.mockClear();
|
||||||
|
coordinator.takeUserControl();
|
||||||
|
expect(camera.cancel).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
coordinator.navigateHistoryTo(600);
|
||||||
|
setCameraFollowing(false);
|
||||||
|
expect(coordinator.observeScroll(false)).toBe("navigation");
|
||||||
|
expect(coordinator.snapshot().mode).toBe("browsing-history");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pins a waiting prompt to the exact lower boundary across all layout changes", () => {
|
||||||
|
const {
|
||||||
|
camera,
|
||||||
|
coordinator,
|
||||||
|
advanceFrame,
|
||||||
|
setGeometry,
|
||||||
|
} = motionHarness({
|
||||||
|
scrollHeight: 930,
|
||||||
|
clientHeight: 500,
|
||||||
|
maxScrollTop: 430,
|
||||||
|
});
|
||||||
|
coordinator.updateTurn({
|
||||||
|
id: "turn-1",
|
||||||
|
promptId: "prompt-1",
|
||||||
|
hasOutput: false,
|
||||||
|
});
|
||||||
|
advanceFrame();
|
||||||
|
expect(camera.jumpTo).toHaveBeenLastCalledWith(430);
|
||||||
|
|
||||||
|
setGeometry({
|
||||||
|
composerHeight: 148,
|
||||||
|
scrollHeight: 958,
|
||||||
|
promptTop: 404,
|
||||||
|
});
|
||||||
|
coordinator.invalidateGeometry();
|
||||||
|
advanceFrame();
|
||||||
|
|
||||||
|
expect(camera.jumpTo.mock.calls).toEqual([[430], [458]]);
|
||||||
|
expect(camera.followTo).not.toHaveBeenCalled();
|
||||||
|
expect(coordinator.snapshot().mode).toBe("anchor-prompt");
|
||||||
|
|
||||||
|
setGeometry({
|
||||||
|
scrollTop: 458,
|
||||||
|
scrollHeight: 990,
|
||||||
|
});
|
||||||
|
coordinator.invalidateGeometry();
|
||||||
|
advanceFrame();
|
||||||
|
|
||||||
|
expect(camera.jumpTo.mock.calls).toEqual([[430], [458], [490]]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import type { ReactNode } from "react";
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { preloadMarkdownText } from "@/components/MarkdownText";
|
import { preloadMarkdownText } from "@/components/MarkdownText";
|
||||||
|
import { ThreadCameraController } from "@/components/thread/thread-camera";
|
||||||
import { ThreadShell } from "@/components/thread/ThreadShell";
|
import { ThreadShell } from "@/components/thread/ThreadShell";
|
||||||
import { CLI_APPS_CHANGED_EVENT } from "@/lib/cli-app-events";
|
import { CLI_APPS_CHANGED_EVENT } from "@/lib/cli-app-events";
|
||||||
import { ClientProvider } from "@/providers/ClientProvider";
|
import { ClientProvider } from "@/providers/ClientProvider";
|
||||||
@@ -18,6 +19,7 @@ function makeClient() {
|
|||||||
(modelName: string | null, modelPreset?: string | null) => void
|
(modelName: string | null, modelPreset?: string | null) => void
|
||||||
>();
|
>();
|
||||||
const sessionUpdateHandlers = new Set<(chatId: string, scope?: string) => void>();
|
const sessionUpdateHandlers = new Set<(chatId: string, scope?: string) => void>();
|
||||||
|
const runStartedAtByChatId = new Map<string, number>();
|
||||||
const goalStateByChatId = new Map<string, import("@/lib/types").GoalStateWsPayload>();
|
const goalStateByChatId = new Map<string, import("@/lib/types").GoalStateWsPayload>();
|
||||||
return {
|
return {
|
||||||
status: "open" as const,
|
status: "open" as const,
|
||||||
@@ -31,7 +33,7 @@ function makeClient() {
|
|||||||
runtimeModelHandlers.delete(handler);
|
runtimeModelHandlers.delete(handler);
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
getRunStartedAt: () => null,
|
getRunStartedAt: (chatId: string) => runStartedAtByChatId.get(chatId) ?? null,
|
||||||
getGoalState: (chatId: string) => goalStateByChatId.get(chatId),
|
getGoalState: (chatId: string) => goalStateByChatId.get(chatId),
|
||||||
onChat: (chatId: string, handler: (ev: import("@/lib/types").InboundEvent) => void) => {
|
onChat: (chatId: string, handler: (ev: import("@/lib/types").InboundEvent) => void) => {
|
||||||
let handlers = chatHandlers.get(chatId);
|
let handlers = chatHandlers.get(chatId);
|
||||||
@@ -60,6 +62,18 @@ function makeClient() {
|
|||||||
for (const h of errorHandlers) h(err);
|
for (const h of errorHandlers) h(err);
|
||||||
},
|
},
|
||||||
_emitChat(chatId: string, ev: import("@/lib/types").InboundEvent) {
|
_emitChat(chatId: string, ev: import("@/lib/types").InboundEvent) {
|
||||||
|
if (
|
||||||
|
ev.event === "goal_status"
|
||||||
|
&& ev.status === "running"
|
||||||
|
&& typeof ev.started_at === "number"
|
||||||
|
) {
|
||||||
|
runStartedAtByChatId.set(chatId, ev.started_at);
|
||||||
|
} else if (
|
||||||
|
(ev.event === "goal_status" && ev.status === "idle")
|
||||||
|
|| ev.event === "turn_end"
|
||||||
|
) {
|
||||||
|
runStartedAtByChatId.delete(chatId);
|
||||||
|
}
|
||||||
if (ev.event === "goal_state") {
|
if (ev.event === "goal_state") {
|
||||||
goalStateByChatId.set(chatId, ev.goal_state);
|
goalStateByChatId.set(chatId, ev.goal_state);
|
||||||
}
|
}
|
||||||
@@ -142,6 +156,36 @@ function httpJson(body: unknown) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ThreadResizeObserverInstance {
|
||||||
|
elements: Element[];
|
||||||
|
callback: ResizeObserverCallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stubThreadResizeObserver() {
|
||||||
|
const original = globalThis.ResizeObserver;
|
||||||
|
const observers: ThreadResizeObserverInstance[] = [];
|
||||||
|
class MockResizeObserver {
|
||||||
|
elements: Element[] = [];
|
||||||
|
callback: ResizeObserverCallback;
|
||||||
|
|
||||||
|
constructor(callback: ResizeObserverCallback) {
|
||||||
|
this.callback = callback;
|
||||||
|
observers.push(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
observe(element: Element) {
|
||||||
|
this.elements.push(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {}
|
||||||
|
}
|
||||||
|
vi.stubGlobal("ResizeObserver", MockResizeObserver);
|
||||||
|
return {
|
||||||
|
observers,
|
||||||
|
restore: () => vi.stubGlobal("ResizeObserver", original),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function modelSettings(model: string, provider: string): SettingsPayload {
|
function modelSettings(model: string, provider: string): SettingsPayload {
|
||||||
return {
|
return {
|
||||||
agent: {
|
agent: {
|
||||||
@@ -1351,6 +1395,40 @@ describe("ThreadShell", () => {
|
|||||||
await waitFor(() => expect(screen.getByText("live assistant reply")).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText("live assistant reply")).toBeInTheDocument());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("restores the stop control when returning to a running chat", async () => {
|
||||||
|
const client = makeClient();
|
||||||
|
const view = (chatId: string) => wrap(
|
||||||
|
client,
|
||||||
|
<ThreadShell
|
||||||
|
session={session(chatId)}
|
||||||
|
title={`Chat ${chatId}`}
|
||||||
|
onToggleSidebar={() => {}}
|
||||||
|
onNewChat={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const { rerender } = render(view("chat-a"));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
client._emitChat("chat-a", {
|
||||||
|
event: "goal_status",
|
||||||
|
chat_id: "chat-a",
|
||||||
|
status: "running",
|
||||||
|
started_at: Date.now() / 1000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect(screen.getByRole("button", { name: "Stop response" })).toBeInTheDocument();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
rerender(view("chat-b"));
|
||||||
|
});
|
||||||
|
expect(screen.queryByRole("button", { name: "Stop response" })).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
rerender(view("chat-a"));
|
||||||
|
});
|
||||||
|
expect(screen.getByRole("button", { name: "Stop response" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps live fork replies when a canonical refresh is missing an earlier assistant answer", async () => {
|
it("keeps live fork replies when a canonical refresh is missing an earlier assistant answer", async () => {
|
||||||
const client = makeClient();
|
const client = makeClient();
|
||||||
let historyCalls = 0;
|
let historyCalls = 0;
|
||||||
@@ -1526,9 +1604,7 @@ describe("ThreadShell", () => {
|
|||||||
|
|
||||||
it("does not scroll again when canonical history refreshes after a session update", async () => {
|
it("does not scroll again when canonical history refreshes after a session update", async () => {
|
||||||
const client = makeClient();
|
const client = makeClient();
|
||||||
const scrollTo = vi.fn();
|
const jumpTo = vi.spyOn(ThreadCameraController.prototype, "jumpTo");
|
||||||
const originalScrollTo = HTMLElement.prototype.scrollTo;
|
|
||||||
HTMLElement.prototype.scrollTo = scrollTo;
|
|
||||||
let historyCalls = 0;
|
let historyCalls = 0;
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
"fetch",
|
"fetch",
|
||||||
@@ -1569,13 +1645,13 @@ describe("ThreadShell", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByText("question")).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText("question")).toBeInTheDocument());
|
||||||
await waitFor(() => expect(scrollTo).toHaveBeenCalled());
|
await waitFor(() => expect(jumpTo).toHaveBeenCalled());
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
for (let i = 0; i < 8; i += 1) {
|
for (let i = 0; i < 8; i += 1) {
|
||||||
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()));
|
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
scrollTo.mockClear();
|
jumpTo.mockClear();
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
client._emitSessionUpdate("chat-a");
|
client._emitSessionUpdate("chat-a");
|
||||||
@@ -1583,9 +1659,112 @@ describe("ThreadShell", () => {
|
|||||||
|
|
||||||
await waitFor(() => expect(historyCalls).toBe(2));
|
await waitFor(() => expect(historyCalls).toBe(2));
|
||||||
await waitFor(() => expect(screen.getByText("canonical answer")).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText("canonical answer")).toBeInTheDocument());
|
||||||
expect(scrollTo).not.toHaveBeenCalled();
|
expect(jumpTo).not.toHaveBeenCalled();
|
||||||
} finally {
|
} finally {
|
||||||
HTMLElement.prototype.scrollTo = originalScrollTo;
|
jumpTo.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps an active completion follow alive while canonical history refreshes", async () => {
|
||||||
|
const resizeObserver = stubThreadResizeObserver();
|
||||||
|
const client = makeClient();
|
||||||
|
let historyCalls = 0;
|
||||||
|
const pendingRefresh = new Promise<ReturnType<typeof httpJson>>(() => {});
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async (input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url.includes("websocket%3Achat-follow/webui-thread")) {
|
||||||
|
historyCalls += 1;
|
||||||
|
if (historyCalls > 1) return pendingRefresh;
|
||||||
|
return httpJson(
|
||||||
|
transcriptFromSimpleMessages([
|
||||||
|
{ role: "user", content: "old question" },
|
||||||
|
{ role: "assistant", content: "old answer" },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
json: async () => ({}),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const requestFrame = vi.spyOn(window, "requestAnimationFrame");
|
||||||
|
const cancelFrame = vi.spyOn(window, "cancelAnimationFrame");
|
||||||
|
try {
|
||||||
|
const { container } = render(
|
||||||
|
wrap(
|
||||||
|
client,
|
||||||
|
<ThreadShell
|
||||||
|
session={session("chat-follow")}
|
||||||
|
title="Chat follow"
|
||||||
|
onToggleSidebar={() => {}}
|
||||||
|
onNewChat={() => {}}
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText("old answer")).toBeInTheDocument());
|
||||||
|
const scroller = container.querySelector(".thread-viewport-scrollbar") as HTMLElement;
|
||||||
|
Object.defineProperties(scroller, {
|
||||||
|
scrollHeight: { configurable: true, value: 2_000 },
|
||||||
|
clientHeight: { configurable: true, value: 500 },
|
||||||
|
scrollTop: { configurable: true, writable: true, value: 1_500 },
|
||||||
|
scrollTo: {
|
||||||
|
configurable: true,
|
||||||
|
value: ({ top }: ScrollToOptions) => {
|
||||||
|
if (typeof top === "number") scroller.scrollTop = top;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("Message input"), {
|
||||||
|
target: { value: "new question" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||||
|
await waitFor(() => expect(screen.getByText("new question")).toBeInTheDocument());
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
client._emitChat("chat-follow", {
|
||||||
|
event: "delta",
|
||||||
|
chat_id: "chat-follow",
|
||||||
|
text: "new answer",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(screen.getByText("new answer")).toBeInTheDocument());
|
||||||
|
|
||||||
|
requestFrame.mockImplementation(() => 9_001);
|
||||||
|
cancelFrame.mockImplementation(() => undefined);
|
||||||
|
cancelFrame.mockClear();
|
||||||
|
Object.defineProperty(scroller, "scrollHeight", {
|
||||||
|
configurable: true,
|
||||||
|
value: 2_120,
|
||||||
|
});
|
||||||
|
const messageContent = screen.getByTestId("thread-message-region").firstElementChild;
|
||||||
|
const contentObserver = resizeObserver.observers.find(
|
||||||
|
(observer) => observer.elements.includes(messageContent!),
|
||||||
|
);
|
||||||
|
expect(contentObserver).toBeDefined();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
contentObserver!.callback([], contentObserver as unknown as ResizeObserver);
|
||||||
|
});
|
||||||
|
expect(requestFrame).toHaveBeenCalled();
|
||||||
|
cancelFrame.mockClear();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
client._emitSessionUpdate("chat-follow", "thread");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(cancelFrame).not.toHaveBeenCalledWith(9_001);
|
||||||
|
expect(historyCalls).toBe(2);
|
||||||
|
} finally {
|
||||||
|
requestFrame.mockRestore();
|
||||||
|
cancelFrame.mockRestore();
|
||||||
|
resizeObserver.restore();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1649,12 +1828,7 @@ describe("ThreadShell", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByText("loaded answer")).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText("loaded answer")).toBeInTheDocument());
|
||||||
await waitFor(() =>
|
await waitFor(() => expect(scroller.scrollTop).toBe(1800));
|
||||||
expect(scrollTo).toHaveBeenCalledWith({
|
|
||||||
top: 1800,
|
|
||||||
behavior: "auto",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("opens slash commands on the blank welcome page", async () => {
|
it("opens slash commands on the blank welcome page", async () => {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1569,6 +1569,33 @@ describe("useNanobotStream", () => {
|
|||||||
expect(result.current.messages[0].turnPhase).toBe("user");
|
expect(result.current.messages[0].turnPhase).toBe("user");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns the submitted turn identity used by the optimistic row and wire frame", () => {
|
||||||
|
const fake = fakeClient();
|
||||||
|
const { result } = renderHook(
|
||||||
|
() => useNanobotStream("chat-submitted-turn", EMPTY_MESSAGES),
|
||||||
|
{ wrapper: wrap(fake.client) },
|
||||||
|
);
|
||||||
|
|
||||||
|
let submitted: ReturnType<typeof result.current.send> = null;
|
||||||
|
act(() => {
|
||||||
|
submitted = result.current.send("bind the camera");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(submitted).not.toBeNull();
|
||||||
|
expect(submitted?.sideChannel).toBe(false);
|
||||||
|
expect(result.current.messages[0]).toMatchObject({
|
||||||
|
id: submitted?.userMessageId,
|
||||||
|
turnId: submitted?.turnId,
|
||||||
|
role: "user",
|
||||||
|
});
|
||||||
|
expect(fake.client.sendMessage).toHaveBeenCalledWith(
|
||||||
|
"chat-submitted-turn",
|
||||||
|
"bind the camera",
|
||||||
|
undefined,
|
||||||
|
expect.objectContaining({ turnId: submitted?.turnId }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("adds optimistic user file attachments as media", () => {
|
it("adds optimistic user file attachments as media", () => {
|
||||||
const fake = fakeClient();
|
const fake = fakeClient();
|
||||||
const { result } = renderHook(() => useNanobotStream("chat-file-send", EMPTY_MESSAGES), {
|
const { result } = renderHook(() => useNanobotStream("chat-file-send", EMPTY_MESSAGES), {
|
||||||
@@ -2097,7 +2124,10 @@ describe("useNanobotStream", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stamps latency on the last assistant bubble from turn_end", () => {
|
it("stamps completion time and latency on the last assistant bubble from turn_end", () => {
|
||||||
|
const completedAt = Date.UTC(2026, 6, 25, 12, 34, 56);
|
||||||
|
const dateNow = vi.spyOn(Date, "now").mockReturnValue(completedAt);
|
||||||
|
try {
|
||||||
const fake = fakeClient();
|
const fake = fakeClient();
|
||||||
const { result } = renderHook(() => useNanobotStream("chat-lat", EMPTY_MESSAGES), {
|
const { result } = renderHook(() => useNanobotStream("chat-lat", EMPTY_MESSAGES), {
|
||||||
wrapper: wrap(fake.client),
|
wrapper: wrap(fake.client),
|
||||||
@@ -2119,8 +2149,14 @@ describe("useNanobotStream", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const lastAssistant = [...result.current.messages].reverse().find((m) => m.role === "assistant");
|
const lastAssistant = [...result.current.messages]
|
||||||
|
.reverse()
|
||||||
|
.find((m) => m.role === "assistant");
|
||||||
expect(lastAssistant?.latencyMs).toBe(2400);
|
expect(lastAssistant?.latencyMs).toBe(2400);
|
||||||
|
expect(lastAssistant?.completedAt).toBe(completedAt);
|
||||||
|
} finally {
|
||||||
|
dateNow.mockRestore();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("tracks goal_status running and clears on idle", () => {
|
it("tracks goal_status running and clears on idle", () => {
|
||||||
@@ -2130,6 +2166,7 @@ describe("useNanobotStream", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.runStartedAt).toBeNull();
|
expect(result.current.runStartedAt).toBeNull();
|
||||||
|
expect(result.current.isStreaming).toBe(false);
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
fake.emit("chat-g", {
|
fake.emit("chat-g", {
|
||||||
@@ -2140,6 +2177,7 @@ describe("useNanobotStream", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
expect(result.current.runStartedAt).toBe(1700);
|
expect(result.current.runStartedAt).toBe(1700);
|
||||||
|
expect(result.current.isStreaming).toBe(true);
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
fake.emit("chat-g", {
|
fake.emit("chat-g", {
|
||||||
@@ -2149,6 +2187,7 @@ describe("useNanobotStream", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
expect(result.current.runStartedAt).toBeNull();
|
expect(result.current.runStartedAt).toBeNull();
|
||||||
|
expect(result.current.isStreaming).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clears runStartedAt on turn_end even without idle", () => {
|
it("clears runStartedAt on turn_end even without idle", () => {
|
||||||
@@ -2166,6 +2205,7 @@ describe("useNanobotStream", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
expect(result.current.runStartedAt).toBe(1700);
|
expect(result.current.runStartedAt).toBe(1700);
|
||||||
|
expect(result.current.isStreaming).toBe(true);
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
fake.emit("chat-g", {
|
fake.emit("chat-g", {
|
||||||
@@ -2174,6 +2214,7 @@ describe("useNanobotStream", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
expect(result.current.runStartedAt).toBeNull();
|
expect(result.current.runStartedAt).toBeNull();
|
||||||
|
expect(result.current.isStreaming).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("restores runStartedAt after switching away and back when goal_status was recorded without a subscriber", () => {
|
it("restores runStartedAt after switching away and back when goal_status was recorded without a subscriber", () => {
|
||||||
@@ -2195,9 +2236,11 @@ describe("useNanobotStream", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
expect(result.current.runStartedAt).toBe(4242);
|
expect(result.current.runStartedAt).toBe(4242);
|
||||||
|
expect(result.current.isStreaming).toBe(true);
|
||||||
|
|
||||||
rerender({ chatId: "chat-b" });
|
rerender({ chatId: "chat-b" });
|
||||||
expect(result.current.runStartedAt).toBeNull();
|
expect(result.current.runStartedAt).toBeNull();
|
||||||
|
expect(result.current.isStreaming).toBe(false);
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
fake.emit("chat-a", {
|
fake.emit("chat-a", {
|
||||||
@@ -2210,6 +2253,7 @@ describe("useNanobotStream", () => {
|
|||||||
|
|
||||||
rerender({ chatId: "chat-a" });
|
rerender({ chatId: "chat-a" });
|
||||||
expect(result.current.runStartedAt).toBe(9001);
|
expect(result.current.runStartedAt).toBe(9001);
|
||||||
|
expect(result.current.isStreaming).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("tracks goal_state per chat and restores after switching sessions", () => {
|
it("tracks goal_state per chat and restores after switching sessions", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user