fix(webui): tighten interactive motion

This commit is contained in:
chengyongru 2026-08-06 18:26:13 +08:00
parent a95fd0ee82
commit d39d01f275
9 changed files with 64 additions and 18 deletions

View File

@ -307,7 +307,7 @@ export function AgentActivityCluster({
} }
if (!wasStreaming || userToggledOuter) return undefined; if (!wasStreaming || userToggledOuter) return undefined;
setCompletionHoldOpen(true); setCompletionHoldOpen(true);
const timeout = window.setTimeout(() => setCompletionHoldOpen(false), 900); const timeout = window.setTimeout(() => setCompletionHoldOpen(false), 300);
return () => window.clearTimeout(timeout); return () => window.clearTimeout(timeout);
}, [isTurnStreaming, userToggledOuter]); }, [isTurnStreaming, userToggledOuter]);

View File

@ -2226,7 +2226,7 @@ export function ThreadComposer({
role="alert" role="alert"
className={cn( className={cn(
"mx-3 mb-1 max-h-10 overflow-hidden rounded-md border border-destructive/40 bg-destructive/8 px-2.5 py-1", "mx-3 mb-1 max-h-10 overflow-hidden rounded-md border border-destructive/40 bg-destructive/8 px-2.5 py-1",
"text-[11.5px] font-medium text-destructive transition-[max-height,margin,padding,opacity] duration-500 ease-out", "text-[11.5px] font-medium text-destructive transition-[max-height,margin,padding,opacity] [transition-duration:220ms] ease-out motion-reduce:transition-none",
voiceErrorFading && "mb-0 max-h-0 border-transparent py-0 opacity-0", voiceErrorFading && "mb-0 max-h-0 border-transparent py-0 opacity-0",
)} )}
> >

View File

@ -1455,7 +1455,7 @@ export function ThreadShell({
{t("thread.loadingConversation")} {t("thread.loadingConversation")}
</div> </div>
) : ( ) : (
<div className="flex w-full flex-col items-center text-center animate-in fade-in-0 slide-in-from-bottom-2 duration-500"> <div className="flex w-full flex-col items-center text-center animate-in fade-in-0 slide-in-from-bottom-2 [animation-duration:220ms] motion-reduce:animate-none">
<HeroGreeting text={t(heroGreetingKey)} /> <HeroGreeting text={t(heroGreetingKey)} />
</div> </div>
); );

View File

@ -97,6 +97,11 @@ function isKeyboardEditableElement(element: Element | null): element is HTMLElem
].includes(element.type); ].includes(element.type);
} }
function isThreadDisclosureTarget(target: EventTarget | null): boolean {
return target instanceof Element
&& target.closest("[data-thread-disclosure]") !== null;
}
type ThreadScrollDirection = "backward" | "forward"; type ThreadScrollDirection = "backward" | "forward";
const KEYBOARD_SCROLL_DIRECTIONS: Readonly< const KEYBOARD_SCROLL_DIRECTIONS: Readonly<
@ -572,7 +577,12 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
handleDirectionalInput(directionFromDelta(event.deltaY)); handleDirectionalInput(directionFromDelta(event.deltaY));
}; };
const handlePointerDown = (event: PointerEvent) => { const handlePointerDown = (event: PointerEvent) => {
if (event.button === 0 && event.target === el) yieldCameraToUser(); if (
event.button === 0
&& (event.target === el || isThreadDisclosureTarget(event.target))
) {
yieldCameraToUser();
}
}; };
let lastTouchY: number | null = null; let lastTouchY: number | null = null;
const handleTouchStart = (event: TouchEvent) => { const handleTouchStart = (event: TouchEvent) => {
@ -600,6 +610,13 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
) { ) {
return; return;
} }
if (
(event.key === "Enter" || event.key === " ")
&& isThreadDisclosureTarget(event.target)
) {
yieldCameraToUser();
return;
}
handleDirectionalInput(keyboardScrollDirection(event)); handleDirectionalInput(keyboardScrollDirection(event));
}; };
el.addEventListener("scroll", handleScroll, { passive: true }); el.addEventListener("scroll", handleScroll, { passive: true });

View File

@ -41,7 +41,7 @@ function ReasoningMarker({ streaming }: { streaming: boolean }) {
useEffect(() => { useEffect(() => {
if (wasStreamingRef.current && !streaming) { if (wasStreamingRef.current && !streaming) {
setJustCompleted(true); setJustCompleted(true);
const timeout = window.setTimeout(() => setJustCompleted(false), 650); const timeout = window.setTimeout(() => setJustCompleted(false), 300);
wasStreamingRef.current = streaming; wasStreamingRef.current = streaming;
return () => window.clearTimeout(timeout); return () => window.clearTimeout(timeout);
} }

View File

@ -35,6 +35,7 @@ export function ThinkingReasoningShell({
> >
<button <button
type="button" type="button"
data-thread-disclosure=""
className="group inline-flex min-h-5 items-center self-start gap-1.5 bg-transparent p-0" className="group inline-flex min-h-5 items-center self-start gap-1.5 bg-transparent p-0"
onClick={onToggle} onClick={onToggle}
aria-expanded={expanded} aria-expanded={expanded}
@ -51,8 +52,8 @@ export function ThinkingReasoningShell({
</span> </span>
<span <span
className={cn( className={cn(
"inline-flex shrink-0 transition-transform [transition-duration:600ms] ease-out", "inline-flex shrink-0 transition-transform [transition-duration:220ms] ease-out",
"motion-reduce:[transition-duration:220ms]", "motion-reduce:transition-none",
expanded && "rotate-180", expanded && "rotate-180",
)} )}
> >
@ -69,7 +70,7 @@ export function ThinkingReasoningShell({
<div <div
className={cn( className={cn(
"grid transition-[grid-template-rows,opacity] [transition-duration:600ms] ease-out motion-reduce:[transition-duration:220ms]", "grid transition-[grid-template-rows,opacity] [transition-duration:220ms] ease-out motion-reduce:transition-none",
expanded expanded
? "grid-rows-[1fr] opacity-100" ? "grid-rows-[1fr] opacity-100"
: "pointer-events-none grid-rows-[0fr] opacity-0", : "pointer-events-none grid-rows-[0fr] opacity-0",

View File

@ -142,7 +142,7 @@
pointer-events: none; pointer-events: none;
background-color: rgb(236 141 49); background-color: rgb(236 141 49);
opacity: 0; opacity: 0;
transition: opacity 600ms ease-in-out; transition: opacity 220ms ease-out;
} }
.composer-model-badge[data-fallback="true"]::before { .composer-model-badge[data-fallback="true"]::before {
@ -352,8 +352,7 @@
*/ */
.thread-layout { .thread-layout {
grid-template-rows: minmax(min-content, 1fr) auto 0fr; grid-template-rows: minmax(min-content, 1fr) auto 0fr;
transition: transition: grid-template-rows 220ms ease-out;
grid-template-rows 900ms cubic-bezier(0.33, 1, 0.68, 1);
} }
.thread-layout[data-layout="thread"] { .thread-layout[data-layout="thread"] {
grid-template-rows: minmax(0, 1fr) auto 0fr; grid-template-rows: minmax(0, 1fr) auto 0fr;
@ -404,7 +403,7 @@
animation: composer-status-strip-exit 180ms ease-in both; animation: composer-status-strip-exit 180ms ease-in both;
} }
.composer-status-drawer { .composer-status-drawer {
--composer-status-drawer-duration: 600ms; --composer-status-drawer-duration: 220ms;
--composer-status-drawer-easing: ease-out; --composer-status-drawer-easing: ease-out;
display: grid; display: grid;
@ -501,14 +500,14 @@
animation: none; animation: none;
} }
.composer-status-drawer { .composer-status-drawer {
transition-duration: 220ms; transition-duration: 0.01ms;
} }
.composer-status-drawer-content { .composer-status-drawer-content {
transform: translateY(2px); transform: none;
transition-duration: 140ms, 220ms; transition-duration: 120ms, 0.01ms;
} }
.thread-layout { .thread-layout {
transition-duration: 360ms; transition-duration: 0.01ms;
} }
.run-pulse-icon, .run-pulse-icon,
.run-pulse-icon * { .run-pulse-icon * {

View File

@ -385,7 +385,7 @@ describe("AgentActivityCluster", () => {
expect(screen.getByTestId("agent-activity-scroll")).toBeInTheDocument(); expect(screen.getByTestId("agent-activity-scroll")).toBeInTheDocument();
act(() => { act(() => {
vi.advanceTimersByTime(901); vi.advanceTimersByTime(301);
}); });
expect(screen.queryByTestId("agent-activity-scroll")).not.toBeInTheDocument(); expect(screen.queryByTestId("agent-activity-scroll")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Thought" })).toHaveAttribute( expect(screen.getByRole("button", { name: "Thought" })).toHaveAttribute(
@ -413,12 +413,13 @@ describe("AgentActivityCluster", () => {
); );
const button = screen.getByRole("button", { name: "Thought" }); const button = screen.getByRole("button", { name: "Thought" });
expect(button).toHaveAttribute("data-thread-disclosure");
const chevron = button.querySelector("svg"); const chevron = button.querySelector("svg");
expect(chevron).toBeInTheDocument(); expect(chevron).toBeInTheDocument();
expect(chevron).toHaveClass("transition-colors", "duration-200"); expect(chevron).toHaveClass("transition-colors", "duration-200");
expect(chevron?.parentElement).toHaveClass( expect(chevron?.parentElement).toHaveClass(
"transition-transform", "transition-transform",
"[transition-duration:600ms]", "[transition-duration:220ms]",
); );
}); });

View File

@ -215,6 +215,34 @@ function ViewportWithPromptNavigator({ messages }: { messages: UIMessage[] }) {
} }
describe("ThreadViewport", () => { describe("ThreadViewport", () => {
it("keeps reasoning disclosure anchored for pointer and keyboard toggles", () => {
const takeUserControl = vi.spyOn(
ThreadMotionCoordinator.prototype,
"takeUserControl",
);
render(
<ThreadViewport
messages={[{
id: "reasoning-1",
role: "assistant",
content: "",
reasoning: "A completed thought",
createdAt: 1,
}]}
isStreaming={false}
composer={<div>composer</div>}
/>,
);
const disclosure = screen.getByRole("button", { name: "Thought" });
fireEvent.pointerDown(disclosure, { button: 0 });
expect(takeUserControl).toHaveBeenCalledTimes(1);
takeUserControl.mockClear();
fireEvent.keyDown(disclosure, { key: "Enter" });
expect(takeUserControl).toHaveBeenCalledTimes(1);
});
it("top-aligns short threads in the message rendering area", () => { it("top-aligns short threads in the message rendering area", () => {
render( render(
<ThreadViewport <ThreadViewport