feat(webui): polish sidebar and session transitions (#5393)

This commit is contained in:
chengyongru
2026-08-14 17:03:11 +08:00
committed by GitHub
parent 057c5e849b
commit 221e8a4e4a
17 changed files with 1152 additions and 560 deletions
File diff suppressed because it is too large Load Diff
@@ -319,7 +319,7 @@ export function AgentActivityCluster({
syncActivityScrollFade();
}, [syncActivityScrollFade]);
if (!hasVisibleActivity) return null;
if (!hasVisibleActivity && !isTurnStreaming) return null;
if (hasOnlyFileActivity) {
return (
@@ -343,6 +343,7 @@ export function AgentActivityCluster({
contentRef={activityContentRef}
fadeTop={activityScrollFade.top}
fadeBottom={activityScrollFade.bottom}
hasDetails={hasVisibleActivity}
onToggle={toggleOuter}
onScroll={onActivityScroll}
>
@@ -382,7 +383,13 @@ function activityDurationMs(
const timestamps = messages
.map((message) => message.createdAt)
.filter((value) => Number.isFinite(value));
if (!timestamps.length) return 0;
if (!timestamps.length) {
return active
&& typeof activeStartedAtMs === "number"
&& Number.isFinite(activeStartedAtMs)
? Math.max(0, now - activeStartedAtMs)
: 0;
}
const first = active && Number.isFinite(activeStartedAtMs)
? activeStartedAtMs!
: Math.min(...timestamps);
+16 -56
View File
@@ -83,7 +83,6 @@ import { useClipboardAndDrop } from "@/hooks/useClipboardAndDrop";
import { useLogoFallback } from "@/hooks/useLogoFallback";
import { useMediaQuery } from "@/hooks/useMediaQuery";
import type { SendAttachment, SendOptions } from "@/hooks/useNanobotStream";
import { usePageVisibility } from "@/hooks/usePageVisibility";
import { useVoiceRecorder, type VoiceRecorderErrorKey } from "@/hooks/useVoiceRecorder";
import type {
CliAppInfo,
@@ -207,8 +206,6 @@ interface ThreadComposerProps {
onStop?: () => void;
surfaceRef?: Ref<HTMLDivElement>;
onTranscribeAudio?: (dataUrl: string, options?: { durationMs?: number }) => Promise<string>;
/** Unix seconds from server; turn elapsed timer above input while set. */
runStartedAt?: number | null;
/** Sustained objective for this chat (WebSocket ``goal_state``). */
goalState?: GoalStateWsPayload;
workspaceScope?: WorkspaceScopePayload | null;
@@ -695,63 +692,38 @@ function mcpPresetMentionPayload(preset: McpPresetInfo): OutboundMcpPresetMentio
};
}
function RunPulseIcon() {
return (
<span className="run-pulse-icon relative flex h-4 w-4 shrink-0 items-center justify-center" aria-hidden>
<span className="run-pulse-icon__ring" />
<span className="run-pulse-icon__dot" />
</span>
);
}
function RunElapsedStrip({
startedAt,
function GoalStateStrip({
goalState,
}: {
startedAt: number | null;
goalState?: GoalStateWsPayload;
}) {
const { t } = useTranslation();
const pageVisible = usePageVisibility();
const [goalPanelOpen, setGoalPanelOpen] = useState(false);
const showTimer = startedAt != null;
const stripLabel = goalStateStripPreview(goalState, t);
const showGoal = !!stripLabel?.trim();
const active = showTimer || showGoal;
const active = !!stripLabel?.trim();
const [, setTick] = useState(0);
const stripWrapperRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const expandToggleRef = useRef<HTMLButtonElement>(null);
const stripSnapshotRef = useRef<{
startedAt: number | null;
goalState?: GoalStateWsPayload;
stripLabel: string | null;
} | null>(null);
const [panelMaxPx, setPanelMaxPx] = useState(280);
if (active) {
stripSnapshotRef.current = { startedAt, goalState, stripLabel };
stripSnapshotRef.current = { goalState, stripLabel };
}
useEffect(() => {
if (!active) setGoalPanelOpen(false);
}, [active]);
useEffect(() => {
if (startedAt == null || !pageVisible) return;
setTick((n) => n + 1);
const id = window.setInterval(() => setTick((n) => n + 1), 1000);
return () => window.clearInterval(id);
}, [pageVisible, startedAt]);
const display = active
? { startedAt, goalState, stripLabel }
? { goalState, stripLabel }
: stripSnapshotRef.current;
const displayStartedAt = display?.startedAt ?? null;
const displayGoalState = display?.goalState;
const displayStripLabel = display?.stripLabel ?? null;
const displayShowTimer = displayStartedAt != null;
const displayShowGoal = !!displayStripLabel?.trim();
const objectiveFull = displayGoalState?.objective?.trim() ?? "";
const summaryFull = displayGoalState?.ui_summary?.trim() ?? "";
@@ -819,17 +791,11 @@ function RunElapsedStrip({
};
}, [goalPanelOpen]);
const elapsed =
displayStartedAt != null ? Math.max(0, Math.floor(Date.now() / 1000 - displayStartedAt)) : 0;
const m = Math.floor(elapsed / 60);
const sec = elapsed % 60;
const shortElapsed = m > 0 ? `${m}:${sec.toString().padStart(2, "0")}` : `${sec}s`;
const timerTitle = displayShowTimer
? t("thread.composer.runRuntimeTitle", { elapsed: shortElapsed })
: null;
if (!display) return null;
const ariaParts = [timerTitle, displayShowGoal ? displayStripLabel : null].filter(Boolean);
const ariaLabel = ariaParts.join(" · ");
const ariaLabel = displayStripLabel
? t("thread.composer.goalStateStrip", { label: displayStripLabel })
: t("thread.composer.goalStateFallback");
return (
<div
@@ -838,6 +804,11 @@ function RunElapsedStrip({
data-composer-status-drawer=""
data-state={active ? "open" : "closed"}
aria-hidden={active ? undefined : true}
onTransitionEnd={(event) => {
if (active || event.target !== event.currentTarget) return;
stripSnapshotRef.current = null;
setTick((n) => n + 1);
}}
>
{goalPanelOpen && canExpandGoal && markdownBody ? (
<div
@@ -891,19 +862,9 @@ function RunElapsedStrip({
role="status"
aria-label={ariaLabel}
>
{displayShowTimer ? (
<RunPulseIcon />
) : (
<Target className="h-4 w-4 shrink-0 text-primary/75" aria-hidden />
)}
<Target className="h-4 w-4 shrink-0 text-primary/75" aria-hidden />
<span className="flex min-w-0 flex-1 items-center gap-1.5 text-[12px] font-medium text-foreground/75">
{timerTitle ? <span className="shrink-0">{timerTitle}</span> : null}
{timerTitle && displayShowGoal ? (
<span className="shrink-0 text-muted-foreground/45" aria-hidden>
·
</span>
) : null}
{displayShowGoal ? (
{displayStripLabel ? (
<span className="truncate">
{t("thread.composer.goalStateStrip", { label: displayStripLabel })}
</span>
@@ -963,7 +924,6 @@ export function ThreadComposer({
onStop,
surfaceRef,
onTranscribeAudio,
runStartedAt = null,
goalState,
workspaceScope = null,
workspaceControlsHidden = false,
@@ -2370,7 +2330,7 @@ export function ThreadComposer({
</button>
</div>
) : null}
<RunElapsedStrip startedAt={runStartedAt} goalState={goalState} />
<GoalStateStrip goalState={goalState} />
<div className="relative">
{hasMentionDecorations ? (
<ComposerCliMentionOverlay
@@ -11,6 +11,9 @@ interface ThreadMessagesProps {
temporary?: boolean;
/** When true, agent turn still in flight — keeps activity timeline expanded. */
isStreaming?: boolean;
activeTurnId?: string | null;
/** Optimistic or canonical active-turn start, in unix seconds. */
runStartedAt?: number | null;
hiddenUserMessageCount?: number;
cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[];
@@ -53,6 +56,8 @@ export function ThreadMessages({
messages,
temporary = false,
isStreaming = false,
activeTurnId = null,
runStartedAt = null,
hiddenUserMessageCount = 0,
cliApps = [],
mcpPresets = [],
@@ -74,6 +79,16 @@ export function ThreadMessages({
() => isStreaming ? currentActivityClusterIndices(units) : new Set<number>(),
[isStreaming, units],
);
const pendingTurn = useMemo(
() => pendingTurnProjection(messages, activeTurnId),
[activeTurnId, messages],
);
const pendingActivity = (
isStreaming
&& liveActivityClusterIndices.size === 0
&& pendingTurn !== null
&& !pendingTurn.hasVisibleOutput
) ? pendingTurn : null;
const unitKeys = useMemo(() => unitKeysForDisplay(units), [units]);
let nextUserIndex = hiddenUserMessageCount;
@@ -136,10 +151,68 @@ export function ThreadMessages({
/>
);
})}
{pendingActivity ? (
<div className={units.length > 0 ? "mt-5" : undefined}>
<AgentActivityCluster
messages={[]}
isTurnStreaming
hasBodyBelow={false}
startedAtMs={
runStartedAt != null
? runStartedAt * 1000
: pendingActivity.startedAtMs
}
/>
</div>
) : null}
</div>
);
}
interface PendingTurnProjection {
startedAtMs?: number;
hasVisibleOutput: boolean;
}
function pendingTurnProjection(
messages: UIMessage[],
activeTurnId: string | null,
): PendingTurnProjection | null {
let promptIndex = -1;
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (
message.role === "user"
&& message.deliveryStatus !== "failed"
&& (activeTurnId === null || message.turnId === activeTurnId)
) {
promptIndex = index;
break;
}
}
if (promptIndex < 0) return null;
const prompt = messages[promptIndex];
const hasVisibleOutput = messages.slice(promptIndex + 1).some((message) => {
if (message.role === "user") return false;
if (activeTurnId && message.turnId && message.turnId !== activeTurnId) return false;
return (
message.content.trim().length > 0
|| !!message.reasoning?.trim()
|| !!message.reasoningStreaming
|| message.kind === "trace"
|| !!message.media?.length
);
});
return {
...(typeof prompt.createdAt === "number" && Number.isFinite(prompt.createdAt)
? { startedAtMs: prompt.createdAt }
: {}),
hasVisibleOutput,
};
}
interface ThreadDisplayUnitProps {
unit: DisplayUnit;
marginTop: string;
+1 -2
View File
@@ -1458,7 +1458,6 @@ export function ThreadShell({
skills={skills}
onStop={stop}
onTranscribeAudio={transcribeAudio}
runStartedAt={currentRunStartedAt}
goalState={currentGoalState}
workspaceScope={workspaceScope}
workspaceControlsHidden={temporary}
@@ -1505,7 +1504,6 @@ export function ThreadShell({
sessions={mentionSessions}
skills={skills}
surfaceRef={composerSurfaceRef}
runStartedAt={currentRunStartedAt}
onTranscribeAudio={transcribeAudio}
goalState={currentGoalState}
workspaceScope={workspaceScope}
@@ -1579,6 +1577,7 @@ export function ThreadShell({
messages={displayMessages}
temporary={temporary}
isStreaming={turnActive}
runStartedAt={currentRunStartedAt}
emptyState={emptyState}
composer={composerPortalTarget === undefined ? composer : null}
activeTurnId={viewportTurnId}
+74 -4
View File
@@ -37,6 +37,8 @@ interface ThreadViewportProps {
messages: UIMessage[];
temporary?: boolean;
isStreaming: boolean;
/** Optimistic or canonical start time for the active turn, in unix seconds. */
runStartedAt?: number | null;
composer?: ReactNode;
emptyState?: ReactNode;
scrollToBottomSignal?: number;
@@ -64,6 +66,9 @@ const DEFAULT_SCROLL_BUTTON_BOTTOM_PX = 192;
const EXTERNAL_COMPOSER_SCROLL_BUTTON_BOTTOM_PX = 16;
const SCROLL_BUTTON_COMPOSER_GAP_PX = 16;
const SOFT_KEYBOARD_MIN_INSET_PX = 80;
const SESSION_HANDOFF_EXIT_DURATION_MS = 80;
const SESSION_HANDOFF_ENTER_DURATION_MS = 140;
const SESSION_HANDOFF_OPACITY = 0.82;
export const INITIAL_HISTORY_WINDOW = 160;
export const HISTORY_WINDOW_INCREMENT = 120;
@@ -104,6 +109,13 @@ function isThreadDisclosureTarget(target: EventTarget | null): boolean {
&& target.closest("[data-thread-disclosure]") !== null;
}
function isKeyboardControl(element: Element | null): boolean {
return element instanceof HTMLElement
&& element.closest(
"button, a[href], select, [role='button'], [role='menuitem'], [role='option']",
) !== null;
}
type ThreadScrollDirection = "backward" | "forward";
const KEYBOARD_SCROLL_DIRECTIONS: Readonly<
@@ -161,6 +173,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
messages,
temporary = false,
isStreaming,
runStartedAt = null,
composer,
emptyState,
scrollToBottomSignal = 0,
@@ -187,9 +200,12 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
const contentRef = useRef<HTMLDivElement>(null);
const messageRegionRef = useRef<HTMLDivElement>(null);
const messageContentRef = useRef<HTMLDivElement>(null);
const emptyStateRef = useRef<HTMLDivElement>(null);
const composerDockRef = useRef<HTMLDivElement>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const lastConversationKeyRef = useRef<string | null>(conversationKey);
const conversationHandoffPendingRef = useRef(false);
const conversationHandoffAnimationRef = useRef<Animation | null>(null);
const pendingConversationScrollRef = useRef(true);
const pendingPromptJumpRef = useRef<string | null>(null);
const restoreScrollAfterPrependRef =
@@ -422,11 +438,27 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
useLayoutEffect(() => {
if (lastConversationKeyRef.current === conversationKey) return;
lastConversationKeyRef.current = conversationKey;
conversationHandoffAnimationRef.current?.cancel();
conversationHandoffAnimationRef.current = null;
conversationHandoffPendingRef.current = true;
pendingConversationScrollRef.current = true;
threadMotionRef.current?.reset();
setAtBottom(true);
setVisibleMessageCount(INITIAL_HISTORY_WINDOW);
}, [conversationKey]);
const surface = hasMessages ? messageRegionRef.current : emptyStateRef.current;
const reduceMotion = typeof window.matchMedia === "function"
&& window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!surface || reduceMotion || typeof surface.animate !== "function") return;
conversationHandoffAnimationRef.current = surface.animate(
[{ opacity: 1 }, { opacity: SESSION_HANDOFF_OPACITY }],
{
duration: SESSION_HANDOFF_EXIT_DURATION_MS,
easing: "cubic-bezier(0.2, 0, 0, 1)",
fill: "forwards",
},
);
}, [conversationKey, hasMessages]);
useLayoutEffect(() => {
if (!conversationReady) {
@@ -513,11 +545,41 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
scrollToBottom,
]);
useLayoutEffect(() => {
if (!conversationReady || !conversationHandoffPendingRef.current) return;
conversationHandoffPendingRef.current = false;
conversationHandoffAnimationRef.current?.cancel();
conversationHandoffAnimationRef.current = null;
const surface = hasMessages ? messageRegionRef.current : emptyStateRef.current;
const reduceMotion = typeof window.matchMedia === "function"
&& window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!surface || reduceMotion || typeof surface.animate !== "function") return;
const animation = surface.animate(
[{ opacity: SESSION_HANDOFF_OPACITY }, { opacity: 1 }],
{
duration: SESSION_HANDOFF_ENTER_DURATION_MS,
easing: "cubic-bezier(0.2, 0, 0, 1)",
},
);
conversationHandoffAnimationRef.current = animation;
const clearAnimation = () => {
if (conversationHandoffAnimationRef.current === animation) {
conversationHandoffAnimationRef.current = null;
}
};
animation.onfinish = clearAnimation;
animation.oncancel = clearAnimation;
}, [conversationReady, hasMessages]);
useLayoutEffect(() => {
threadMotionRef.current?.invalidateGeometry();
}, [composer, hasMessages, visibleMessages.length]);
useEffect(() => () => threadMotionRef.current?.dispose(), []);
useEffect(() => () => {
conversationHandoffAnimationRef.current?.cancel();
threadMotionRef.current?.dispose();
}, []);
useLayoutEffect(() => {
const el = scrollRef.current;
@@ -530,10 +592,13 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
const invalidateGeometry = () => {
threadMotionRef.current?.invalidateGeometry();
};
invalidateGeometry();
const reconcileObservedGeometry = () => {
threadMotionRef.current?.reconcileObservedGeometry();
};
reconcileObservedGeometry();
const observer = typeof ResizeObserver === "undefined"
? null
: new ResizeObserver(invalidateGeometry);
: new ResizeObserver(reconcileObservedGeometry);
observer?.observe(el);
if (content) observer?.observe(content);
if (messageRegion) observer?.observe(messageRegion);
@@ -623,6 +688,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
yieldCameraToUser();
return;
}
if (isKeyboardControl(event.target as Element | null)) return;
handleDirectionalInput(keyboardScrollDirection(event));
};
el.addEventListener("scroll", handleScroll, { passive: true });
@@ -690,6 +756,8 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
messages={visibleMessages}
temporary={temporary}
isStreaming={isStreaming}
activeTurnId={activeTurnId}
runStartedAt={runStartedAt}
hiddenUserMessageCount={hiddenUserMessageCount}
cliApps={cliApps}
mcpPresets={mcpPresets}
@@ -704,6 +772,8 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
</div>
) : (
<div
ref={emptyStateRef}
data-testid="thread-empty-region"
className={cn(
"row-start-1 flex min-h-0 min-w-0 w-full items-center justify-center",
hasComposer && "sm:items-end sm:pb-8",
@@ -12,6 +12,7 @@ interface ThinkingReasoningShellProps {
contentRef: Ref<HTMLDivElement>;
fadeTop: boolean;
fadeBottom: boolean;
hasDetails?: boolean;
onToggle: () => void;
onScroll: () => void;
}
@@ -25,6 +26,7 @@ export function ThinkingReasoningShell({
contentRef,
fadeTop,
fadeBottom,
hasDetails = true,
onToggle,
onScroll,
}: ThinkingReasoningShellProps) {
@@ -33,80 +35,100 @@ export function ThinkingReasoningShell({
className="flex w-full max-w-[45rem] animate-in flex-col fade-in duration-300 motion-reduce:animate-none"
data-state={active ? "thinking" : "done"}
>
<button
type="button"
data-thread-disclosure=""
className="group inline-flex min-h-5 items-center self-start gap-1.5 bg-transparent p-0"
onClick={onToggle}
aria-expanded={expanded}
aria-label={label}
aria-live={active ? "polite" : undefined}
>
<span
className={cn(
"min-w-0 truncate text-[13px] font-medium leading-[18px] text-muted-foreground/70",
active && "animate-pulse motion-reduce:animate-none",
)}
{hasDetails ? (
<button
type="button"
data-thread-disclosure=""
className="group inline-flex min-h-5 items-center self-start gap-1.5 bg-transparent p-0"
onClick={onToggle}
aria-expanded={expanded}
aria-label={label}
aria-live={active ? "polite" : undefined}
>
{label}
</span>
<span
className={cn(
"inline-flex shrink-0 transition-transform [transition-duration:220ms] ease-out",
"motion-reduce:transition-none",
expanded && "rotate-180",
)}
>
<ChevronDown
<span
className={cn(
"h-3 w-3 text-muted-foreground/60 transition-colors duration-200",
"group-hover:text-muted-foreground motion-reduce:transition-none",
"min-w-0 truncate text-[13px] font-medium leading-[18px] text-muted-foreground/70",
active && "animate-pulse motion-reduce:animate-none",
)}
strokeWidth={1.8}
aria-hidden
/>
</span>
</button>
<div
{...(!expanded ? { inert: "" } : {})}
aria-hidden={!expanded}
className={cn(
"grid transition-[grid-template-rows,opacity] [transition-duration:220ms] ease-out motion-reduce:transition-none",
expanded
? "grid-rows-[1fr] opacity-100"
: "pointer-events-none grid-rows-[0fr] opacity-0",
)}
>
<div className="relative min-h-0 overflow-hidden">
<div
ref={viewportRef}
data-testid={expanded ? "agent-activity-scroll" : undefined}
data-fade-top={fadeTop}
data-fade-bottom={fadeBottom}
onScroll={onScroll}
className="mt-1.5 max-h-[180px] overflow-y-auto pr-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
<div ref={contentRef} className="flex flex-col gap-0.5">
{children}
</div>
</div>
{fadeTop ? (
<span
data-testid="activity-scroll-fade-top"
className="pointer-events-none absolute inset-x-0 top-1.5 z-10 h-3.5 bg-gradient-to-b from-background to-transparent"
{label}
</span>
<span
className={cn(
"inline-flex shrink-0 transition-transform [transition-duration:220ms] ease-out",
"motion-reduce:transition-none",
expanded && "rotate-180",
)}
>
<ChevronDown
className={cn(
"h-3 w-3 text-muted-foreground/60 transition-colors duration-200",
"group-hover:text-muted-foreground motion-reduce:transition-none",
)}
strokeWidth={1.8}
aria-hidden
/>
) : null}
{fadeBottom ? (
<span
data-testid="activity-scroll-fade-bottom"
className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-3.5 bg-gradient-to-t from-background to-transparent"
aria-hidden
/>
) : null}
</span>
</button>
) : (
<div
className="inline-flex min-h-5 items-center self-start"
role="status"
aria-label={label}
aria-live={active ? "polite" : undefined}
>
<span
className={cn(
"min-w-0 truncate text-[13px] font-medium leading-[18px] text-muted-foreground/70",
active && "animate-pulse motion-reduce:animate-none",
)}
>
{label}
</span>
</div>
</div>
)}
{hasDetails ? (
<div
{...(!expanded ? { inert: "" } : {})}
aria-hidden={!expanded}
className={cn(
"grid transition-[grid-template-rows,opacity] [transition-duration:220ms] ease-out motion-reduce:transition-none",
expanded
? "grid-rows-[1fr] opacity-100"
: "pointer-events-none grid-rows-[0fr] opacity-0",
)}
>
<div className="relative min-h-0 overflow-hidden">
<div
ref={viewportRef}
data-testid={expanded ? "agent-activity-scroll" : undefined}
data-fade-top={fadeTop}
data-fade-bottom={fadeBottom}
onScroll={onScroll}
className="mt-1.5 max-h-[180px] overflow-y-auto pr-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
<div ref={contentRef} className="flex flex-col gap-0.5">
{children}
</div>
</div>
{fadeTop ? (
<span
data-testid="activity-scroll-fade-top"
className="pointer-events-none absolute inset-x-0 top-1.5 z-10 h-3.5 bg-gradient-to-b from-background to-transparent"
aria-hidden
/>
) : null}
{fadeBottom ? (
<span
data-testid="activity-scroll-fade-bottom"
className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-3.5 bg-gradient-to-t from-background to-transparent"
aria-hidden
/>
) : null}
</div>
</div>
) : null}
</div>
);
}
+13 -4
View File
@@ -147,10 +147,10 @@ function defaultScheduler(): ThreadMotionScheduler {
}
/**
* Owns the policy that turns discrete layout events into automatic tail
* pinning or explicit camera navigation. Callers only invalidate geometry;
* one display frame coalesces those notifications and reads the authoritative
* layout before applying either policy.
* Owns the policy that turns layout events into automatic tail pinning or
* explicit camera navigation. Discrete notifications are coalesced into one
* display frame. ResizeObserver deliveries reconcile immediately because they
* already carry the browser's authoritative layout and run before paint.
*/
export class ThreadMotionCoordinator {
private readonly camera: ThreadMotionCamera;
@@ -240,6 +240,15 @@ export class ThreadMotionCoordinator {
this.measurementFrameId = this.scheduler.request(this.flushGeometry);
}
reconcileObservedGeometry(): void {
if (this.measurementFrameId !== null) {
this.scheduler.cancel(this.measurementFrameId);
this.measurementFrameId = null;
}
this.geometryDirty = true;
this.flushGeometry();
}
handleComposerInput(): void {
// Input and protocol completion can arrive in either order. Remember
// editing that starts just before turn_end so the completion drawer
@@ -220,7 +220,9 @@ export function PaneWorkbench({
const gridRef = useRef<HTMLDivElement | null>(null);
const paneRefs = useRef(new Map<string, HTMLElement>());
const lastRectsRef = useRef(new Map<string, DOMRect>());
const lastElementRectsRef = useRef(new Map<HTMLElement, DOMRect>());
const pendingRectsRef = useRef<Map<string, DOMRect> | null>(null);
const pendingElementRectsRef = useRef<Map<HTMLElement, DOMRect> | null>(null);
const animationsRef = useRef(new Map<string, Animation>());
const sourceSplitRatiosKey = splitRatios.join("\u0000");
const [previewSplitRatios, setPreviewSplitRatios] = useState(splitRatios);
@@ -284,24 +286,36 @@ export function PaneWorkbench({
return rects;
}, []);
const measurePaneElements = useCallback(() => {
const rects = new Map<HTMLElement, DOMRect>();
for (const element of paneRefs.current.values()) {
if (!element.hidden) rects.set(element, element.getBoundingClientRect());
}
return rects;
}, []);
const captureLayout = useCallback(() => {
pendingRectsRef.current = measurePanes();
pendingElementRectsRef.current = measurePaneElements();
for (const animation of animationsRef.current.values()) animation.cancel();
animationsRef.current.clear();
}, [measurePanes]);
}, [measurePaneElements, measurePanes]);
useLayoutEffect(() => {
const previousRects = pendingRectsRef.current ?? lastRectsRef.current;
const previousElementRects = pendingElementRectsRef.current ?? lastElementRectsRef.current;
pendingRectsRef.current = null;
pendingElementRectsRef.current = null;
const nextRects = measurePanes();
const nextElementRects = measurePaneElements();
const reduceMotion = typeof window.matchMedia === "function"
&& window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduceMotion) {
for (const [key, nextRect] of nextRects) {
const previousRect = previousRects.get(key);
const element = paneRefs.current.get(key);
if (!element) continue;
const previousRect = previousRects.get(key) ?? previousElementRects.get(element);
if (!previousRect) {
if (previousRects.size === 0 || typeof element.animate !== "function") continue;
const animation = element.animate(
@@ -356,7 +370,8 @@ export function PaneWorkbench({
}
}
lastRectsRef.current = nextRects;
}, [activePaneKey, effectiveLayout, measurePanes, paneOrder]);
lastElementRectsRef.current = nextElementRects;
}, [activePaneKey, effectiveLayout, measurePaneElements, measurePanes, paneOrder]);
useEffect(() => () => {
for (const animation of animationsRef.current.values()) animation.cancel();