fix(webui): tighten interactive motion

This commit is contained in:
chengyongru
2026-08-06 18:28:45 +08:00
committed by chengyongru
parent a95fd0ee82
commit 223b911e7e
9 changed files with 64 additions and 18 deletions
@@ -307,7 +307,7 @@ export function AgentActivityCluster({
}
if (!wasStreaming || userToggledOuter) return undefined;
setCompletionHoldOpen(true);
const timeout = window.setTimeout(() => setCompletionHoldOpen(false), 900);
const timeout = window.setTimeout(() => setCompletionHoldOpen(false), 300);
return () => window.clearTimeout(timeout);
}, [isTurnStreaming, userToggledOuter]);
@@ -2226,7 +2226,7 @@ export function ThreadComposer({
role="alert"
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",
"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",
)}
>
+1 -1
View File
@@ -1455,7 +1455,7 @@ export function ThreadShell({
{t("thread.loadingConversation")}
</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)} />
</div>
);
+18 -1
View File
@@ -97,6 +97,11 @@ function isKeyboardEditableElement(element: Element | null): element is HTMLElem
].includes(element.type);
}
function isThreadDisclosureTarget(target: EventTarget | null): boolean {
return target instanceof Element
&& target.closest("[data-thread-disclosure]") !== null;
}
type ThreadScrollDirection = "backward" | "forward";
const KEYBOARD_SCROLL_DIRECTIONS: Readonly<
@@ -572,7 +577,12 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
handleDirectionalInput(directionFromDelta(event.deltaY));
};
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;
const handleTouchStart = (event: TouchEvent) => {
@@ -600,6 +610,13 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
) {
return;
}
if (
(event.key === "Enter" || event.key === " ")
&& isThreadDisclosureTarget(event.target)
) {
yieldCameraToUser();
return;
}
handleDirectionalInput(keyboardScrollDirection(event));
};
el.addEventListener("scroll", handleScroll, { passive: true });
@@ -41,7 +41,7 @@ function ReasoningMarker({ streaming }: { streaming: boolean }) {
useEffect(() => {
if (wasStreamingRef.current && !streaming) {
setJustCompleted(true);
const timeout = window.setTimeout(() => setJustCompleted(false), 650);
const timeout = window.setTimeout(() => setJustCompleted(false), 300);
wasStreamingRef.current = streaming;
return () => window.clearTimeout(timeout);
}
@@ -35,6 +35,7 @@ export function ThinkingReasoningShell({
>
<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}
@@ -51,8 +52,8 @@ export function ThinkingReasoningShell({
</span>
<span
className={cn(
"inline-flex shrink-0 transition-transform [transition-duration:600ms] ease-out",
"motion-reduce:[transition-duration:220ms]",
"inline-flex shrink-0 transition-transform [transition-duration:220ms] ease-out",
"motion-reduce:transition-none",
expanded && "rotate-180",
)}
>
@@ -69,7 +70,7 @@ export function ThinkingReasoningShell({
<div
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
? "grid-rows-[1fr] opacity-100"
: "pointer-events-none grid-rows-[0fr] opacity-0",
+7 -8
View File
@@ -142,7 +142,7 @@
pointer-events: none;
background-color: rgb(236 141 49);
opacity: 0;
transition: opacity 600ms ease-in-out;
transition: opacity 220ms ease-out;
}
.composer-model-badge[data-fallback="true"]::before {
@@ -352,8 +352,7 @@
*/
.thread-layout {
grid-template-rows: minmax(min-content, 1fr) auto 0fr;
transition:
grid-template-rows 900ms cubic-bezier(0.33, 1, 0.68, 1);
transition: grid-template-rows 220ms ease-out;
}
.thread-layout[data-layout="thread"] {
grid-template-rows: minmax(0, 1fr) auto 0fr;
@@ -404,7 +403,7 @@
animation: composer-status-strip-exit 180ms ease-in both;
}
.composer-status-drawer {
--composer-status-drawer-duration: 600ms;
--composer-status-drawer-duration: 220ms;
--composer-status-drawer-easing: ease-out;
display: grid;
@@ -501,14 +500,14 @@
animation: none;
}
.composer-status-drawer {
transition-duration: 220ms;
transition-duration: 0.01ms;
}
.composer-status-drawer-content {
transform: translateY(2px);
transition-duration: 140ms, 220ms;
transform: none;
transition-duration: 120ms, 0.01ms;
}
.thread-layout {
transition-duration: 360ms;
transition-duration: 0.01ms;
}
.run-pulse-icon,
.run-pulse-icon * {
@@ -385,7 +385,7 @@ describe("AgentActivityCluster", () => {
expect(screen.getByTestId("agent-activity-scroll")).toBeInTheDocument();
act(() => {
vi.advanceTimersByTime(901);
vi.advanceTimersByTime(301);
});
expect(screen.queryByTestId("agent-activity-scroll")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Thought" })).toHaveAttribute(
@@ -413,12 +413,13 @@ describe("AgentActivityCluster", () => {
);
const button = screen.getByRole("button", { name: "Thought" });
expect(button).toHaveAttribute("data-thread-disclosure");
const chevron = button.querySelector("svg");
expect(chevron).toBeInTheDocument();
expect(chevron).toHaveClass("transition-colors", "duration-200");
expect(chevron?.parentElement).toHaveClass(
"transition-transform",
"[transition-duration:600ms]",
"[transition-duration:220ms]",
);
});
+28
View File
@@ -215,6 +215,34 @@ function ViewportWithPromptNavigator({ messages }: { messages: UIMessage[] }) {
}
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", () => {
render(
<ThreadViewport