feat(ui): add smooth chat animations and scrolling (#3758)

This commit is contained in:
José Almeida
2026-09-17 17:40:02 +02:00
committed by GitHub
parent c5dbb45502
commit 57bbae10ad
64 changed files with 2857 additions and 106 deletions
+1 -1
View File
@@ -76,7 +76,7 @@ lib/api/domains/ # API clients
- `tasks.activeTaskId`, `tasks.activeSessionId`, `workspaces.activeId`
- `repositories.byWorkspace`, `repositoryBranches.byRepository`
Quick Chat stores server conversations in `quickChat.sessions` and browser-local terminals in `quickChat.terminalTabs`; `activeKind` and terminal IDs track selection. `quick-terminal-actions.ts` owns lifecycle/fallback; terminal descriptors never enter conversation APIs or get lost in reconciliation.
`chatMotion` owns per-device chat animation preview and persistence; `useChatMotion` applies OS reduced motion. Keep it separate from `richOutputMotion` and transcript auto-scroll. Quick Chat stores server conversations in `quickChat.sessions` and browser-local terminals in `quickChat.terminalTabs`; `activeKind` and terminal IDs track selection. `quick-terminal-actions.ts` owns lifecycle/fallback; terminal descriptors never enter conversation APIs or get lost in reconciliation.
**Hydration:** Go injects `window.__KANDEV_BOOT_PAYLOAD__` into the SPA shell before React mounts. `lib/state/hydration/merge-strategies.ts` has `deepMerge()`, `mergeSessionMap()`, `mergeLoadingState()` to avoid overwriting live client state. Pass `activeSessionId` to protect active sessions.
+8
View File
@@ -2220,3 +2220,11 @@ html[data-rendering-engine="webkit"] [data-webkit-safe-motion="true"][data-state
animation: none;
}
}
@media (prefers-reduced-motion: reduce) {
[data-chat-text-motion],
[data-chat-motion-item] {
opacity: 1 !important;
transform: none !important;
}
}
@@ -61,3 +61,15 @@ it.each(["", "-1", "5001", "0.5", "abc"])("rejects invalid delay draft %s", (val
buildAppearanceUserSettingsPatch({ ...saved, sidebarHoverDelayMs: value }, saved),
).toThrow();
});
// @covers AC-UI-CHAT-MOTION-002.2
it("tracks local chat motion edits through revision and save rebasing", () => {
const saved = {
...createAppearanceSavedState("dark", "flat", true, defaultState.userSettings),
chatAnimationsEnabled: true,
};
const draft = { ...saved, chatAnimationsEnabled: false };
expect(appearanceRevision(draft)).not.toBe(appearanceRevision(saved));
expect(buildAppearanceUserSettingsPatch(draft, saved)).toEqual({});
expect(rebaseAppearanceDraft(draft, saved, saved)).toEqual(draft);
});
@@ -7,6 +7,7 @@ export type AppearanceState = {
theme: Theme;
settingsMenuMode: SettingsMenuMode;
richOutputAnimationsEnabled: boolean;
chatAnimationsEnabled: boolean;
startupPage: UserSettingsState["startupPage"];
changesPanelLayout: UserSettingsState["changesPanelLayout"];
appStatusBarEnabled: boolean;
@@ -29,6 +30,7 @@ export function createAppearanceSavedState(
| "sidebarHoverEnabled"
| "sidebarHoverDelayMs"
>,
chatAnimationsEnabled = true,
): AppearanceState {
return {
theme,
@@ -38,6 +40,7 @@ export function createAppearanceSavedState(
// Per-device, but drafted and saved with account settings under one control.
settingsMenuMode,
richOutputAnimationsEnabled,
chatAnimationsEnabled,
changesPanelLayout: userSettings.changesPanelLayout,
startupPage: userSettings.startupPage,
showMetrics: userSettings.systemMetricsDisplay.showInTopbar,
@@ -97,6 +100,7 @@ export function rebaseAppearanceDraft(
sidebarHoverDelayMs: rebase("sidebarHoverDelayMs"),
settingsMenuMode: rebase("settingsMenuMode"),
richOutputAnimationsEnabled: rebase("richOutputAnimationsEnabled"),
chatAnimationsEnabled: rebase("chatAnimationsEnabled"),
startupPage: rebase("startupPage"),
changesPanelLayout: rebase("changesPanelLayout"),
appStatusBarEnabled: rebase("appStatusBarEnabled"),
@@ -112,6 +116,7 @@ export function appearanceRevision(state: AppearanceState): string {
state.sidebarHoverDelayMs,
state.settingsMenuMode,
state.richOutputAnimationsEnabled,
state.chatAnimationsEnabled,
state.startupPage,
state.changesPanelLayout,
state.appStatusBarEnabled,
@@ -0,0 +1,49 @@
"use client";
import { useTranslation } from "react-i18next";
import { CardContent } from "@kandev/ui/card";
import { Label } from "@kandev/ui/label";
import { Switch } from "@kandev/ui/switch";
import { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/preferences";
import { SettingsCard } from "./settings-card";
export function ChatMotionSettingsCard({
enabled,
isDirty,
onChange,
}: {
enabled: boolean;
isDirty: boolean;
onChange: (enabled: boolean) => void;
}) {
const { t } = useTranslation();
return (
<SettingsCard
isDirty={isDirty}
discoveryTargetId={GENERAL_SETTINGS_TARGETS.chatMotion}
data-testid="chat-motion-settings-card"
>
<CardContent>
<div
className="flex items-center justify-between gap-4"
data-testid="chat-motion-toggle-row"
>
<div className="min-w-0 space-y-1">
<Label htmlFor="animate-chat">{t("settings:chatAnimations")}</Label>
<p id="chat-motion-description" className="max-w-3xl text-xs text-muted-foreground">
{t("settings:chatAnimationsDescription")}
</p>
</div>
<Switch
id="animate-chat"
checked={enabled}
onCheckedChange={onChange}
aria-describedby="chat-motion-description"
data-settings-dirty={isDirty}
className="data-[state=checked]:bg-transparent data-[state=unchecked]:bg-transparent dark:data-[state=unchecked]:bg-transparent data-[size=default]:h-7 data-[size=default]:w-9 [@media(pointer:coarse)]:data-[size=default]:h-11 [@media(pointer:coarse)]:data-[size=default]:w-11 cursor-pointer shrink-0 p-1 [@media(pointer:coarse)]:p-2 before:absolute before:left-1 [@media(pointer:coarse)]:before:left-2 before:top-1/2 before:h-[16.6px] before:w-7 before:-translate-y-1/2 before:rounded-full before:bg-input before:content-[''] data-[state=checked]:before:bg-primary dark:data-[state=unchecked]:before:bg-input/80 [&_[data-slot=switch-thumb]]:z-10"
/>
</div>
</CardContent>
</SettingsCard>
);
}
@@ -26,8 +26,11 @@ const storeMocks = vi.hoisted(() => ({
commitSettingsMenuMode: vi.fn(),
restoreSettingsMenuMode: vi.fn(),
previewRichOutputAnimations: vi.fn(),
previewChatAnimations: vi.fn(),
commitRichOutputAnimations: vi.fn(),
commitChatAnimations: vi.fn(),
restoreRichOutputAnimations: vi.fn(),
restoreChatAnimations: vi.fn(),
}));
vi.mock("@/lib/api", () => ({
@@ -247,8 +250,11 @@ beforeEach(() => {
storeMocks.commitSettingsMenuMode.mockReset();
storeMocks.restoreSettingsMenuMode.mockReset();
storeMocks.previewRichOutputAnimations.mockReset();
storeMocks.previewChatAnimations.mockReset();
storeMocks.commitRichOutputAnimations.mockReset();
storeMocks.commitChatAnimations.mockReset();
storeMocks.restoreRichOutputAnimations.mockReset();
storeMocks.restoreChatAnimations.mockReset();
storeMocks.state = {
userSettings: {
...defaultSettingsState.userSettings,
@@ -256,13 +262,17 @@ beforeEach(() => {
},
settingsMenu: { savedMode: "flat" },
richOutputMotion: { enabled: true, savedEnabled: true },
chatMotion: { enabled: true, savedEnabled: true },
setUserSettings: storeMocks.setUserSettings,
previewSettingsMenuMode: storeMocks.previewSettingsMenuMode,
commitSettingsMenuMode: storeMocks.commitSettingsMenuMode,
restoreSettingsMenuMode: storeMocks.restoreSettingsMenuMode,
previewRichOutputAnimations: storeMocks.previewRichOutputAnimations,
previewChatAnimations: storeMocks.previewChatAnimations,
commitRichOutputAnimations: storeMocks.commitRichOutputAnimations,
commitChatAnimations: storeMocks.commitChatAnimations,
restoreRichOutputAnimations: storeMocks.restoreRichOutputAnimations,
restoreChatAnimations: storeMocks.restoreChatAnimations,
};
});
@@ -298,6 +308,36 @@ describe("AppearanceSettings rich-output motion preference", () => {
});
});
describe("AppearanceSettings chat motion preference", () => {
it("previews and saves chat motion locally without a user-settings request", async () => {
renderAppearance();
const toggle = screen.getByRole("switch", { name: "Chat animations" });
expect(toggle.getAttribute(DATA_STATE_ATTRIBUTE)).toBe(CHECKED_STATE);
fireEvent.click(toggle);
expect(storeMocks.previewChatAnimations).toHaveBeenCalledWith(false);
expect(toggle.getAttribute(DATA_SETTINGS_DIRTY_ATTRIBUTE)).toBe("true");
fireEvent.click(await screen.findByRole("button", { name: SAVE_CHANGES_LABEL }));
await waitFor(() => expect(storeMocks.commitChatAnimations).toHaveBeenCalledWith(false));
expect(apiMocks.updateUserSettings).not.toHaveBeenCalled();
expect(storeMocks.setUserSettings).not.toHaveBeenCalled();
});
it("restores the saved chat motion preference through Reset", async () => {
renderAppearance();
const toggle = screen.getByRole("switch", { name: "Chat animations" });
fireEvent.click(toggle);
fireEvent.click(await screen.findByRole("button", { name: "Reset" }));
await waitFor(() => expect(toggle.getAttribute(DATA_STATE_ATTRIBUTE)).toBe(CHECKED_STATE));
expect(storeMocks.restoreChatAnimations).toHaveBeenCalledOnce();
});
});
describe("AppearanceSettings status bar preference", () => {
it("saves local-only Appearance changes without a user-settings request", async () => {
renderAppearance();
@@ -43,6 +43,7 @@ import { StartupPageSettingsCard } from "@/components/settings/startup-page-sett
import { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/preferences";
import { SleepInhibitionSettings } from "@/components/settings/sleep-inhibition-settings";
import { SettingsMenuModeCard } from "@/components/settings/settings-menu-mode-card";
import { ChatMotionSettingsCard } from "./chat-motion-settings-card";
import { RichOutputMotionSettingsCard } from "@/components/settings/rich-output-motion-settings-card";
import { AppearanceAccountSections } from "@/components/settings/appearance-account-sections";
import type { SettingsMenuMode } from "@/lib/settings/settings-menu-mode";
@@ -197,6 +198,9 @@ function AppearanceThemeSection({
richOutputAnimationsEnabled,
isRichOutputMotionDirty,
onRichOutputMotionChange,
chatAnimationsEnabled,
isChatMotionDirty,
onChatMotionChange,
}: {
theme: Theme;
isThemeDirty: boolean;
@@ -204,6 +208,9 @@ function AppearanceThemeSection({
richOutputAnimationsEnabled: boolean;
isRichOutputMotionDirty: boolean;
onRichOutputMotionChange: (enabled: boolean) => void;
chatAnimationsEnabled: boolean;
isChatMotionDirty: boolean;
onChatMotionChange: (enabled: boolean) => void;
}) {
const { t } = useTranslation();
return (
@@ -219,6 +226,11 @@ function AppearanceThemeSection({
isDirty={isRichOutputMotionDirty}
onChange={onRichOutputMotionChange}
/>
<ChatMotionSettingsCard
enabled={chatAnimationsEnabled}
isDirty={isChatMotionDirty}
onChange={onChatMotionChange}
/>
</div>
</SettingsSection>
);
@@ -298,8 +310,11 @@ function useAppearanceSaveContributor({
const commitMenuMode = useAppStore((state) => state.commitSettingsMenuMode);
const restoreMenuMode = useAppStore((state) => state.restoreSettingsMenuMode);
const previewRichOutputAnimations = useAppStore((state) => state.previewRichOutputAnimations);
const previewChatAnimations = useAppStore((state) => state.previewChatAnimations);
const commitRichOutputAnimations = useAppStore((state) => state.commitRichOutputAnimations);
const restoreRichOutputAnimations = useAppStore((state) => state.restoreRichOutputAnimations);
const commitChatAnimations = useAppStore((state) => state.commitChatAnimations);
const restoreChatAnimations = useAppStore((state) => state.restoreChatAnimations);
const { t } = useTranslation();
const delayValid = parseSidebarHoverDelay(draft.sidebarHoverDelayMs) !== null;
const revision = appearanceRevision(draft);
@@ -322,9 +337,8 @@ function useAppearanceSaveContributor({
commitTheme(submitted.theme);
commitMenuMode(submitted.settingsMenuMode);
commitRichOutputAnimations(submitted.richOutputAnimationsEnabled);
// A draft edited while the save was in flight keeps its preview: what was
// submitted is now persisted, but the screen should still show what the
// user is currently looking at.
commitChatAnimations(submitted.chatAnimationsEnabled);
// Keep edits made during the save visible over the submitted baseline.
if (draftRef.current.theme !== submitted.theme) {
previewTheme(draftRef.current.theme);
}
@@ -336,16 +350,15 @@ function useAppearanceSaveContributor({
) {
previewRichOutputAnimations(draftRef.current.richOutputAnimationsEnabled);
}
const latestUserSettings = storeApi.getState().userSettings;
let responseIsCurrent = false;
if (response) {
const responseOrder = compareUserSettingsRevisions(
response.settings.revision,
latestUserSettings.revision,
);
responseIsCurrent =
responseOrder === null ? latestUserSettings === settingsAtSubmit : responseOrder >= 0;
if (draftRef.current.chatAnimationsEnabled !== submitted.chatAnimationsEnabled) {
previewChatAnimations(draftRef.current.chatAnimationsEnabled);
}
const latestUserSettings = storeApi.getState().userSettings;
const responseOrder = response
? compareUserSettingsRevisions(response.settings.revision, latestUserSettings.revision)
: null;
const responseIsCurrent =
responseOrder === null ? latestUserSettings === settingsAtSubmit : responseOrder >= 0;
const nextUserSettings =
response && responseIsCurrent
? mapUserSettingsResponse(response, latestUserSettings)
@@ -355,6 +368,7 @@ function useAppearanceSaveContributor({
submitted.settingsMenuMode,
submitted.richOutputAnimationsEnabled,
nextUserSettings,
submitted.chatAnimationsEnabled,
);
setSaved(confirmed);
setDraft(rebaseAppearanceDraft(draftRef.current, submitted, confirmed, editedDuringSave));
@@ -370,6 +384,7 @@ function useAppearanceSaveContributor({
restoreTheme();
restoreMenuMode();
restoreRichOutputAnimations();
restoreChatAnimations();
},
});
}
@@ -400,6 +415,7 @@ function AppearanceSettingsSections({
previewTheme,
previewMenuMode,
previewRichOutputAnimations,
previewChatAnimations,
}: {
draft: AppearanceState;
saved: AppearanceState;
@@ -407,6 +423,7 @@ function AppearanceSettingsSections({
previewTheme: (theme: Theme) => void;
previewMenuMode: (mode: SettingsMenuMode) => void;
previewRichOutputAnimations: (enabled: boolean) => void;
previewChatAnimations: (enabled: boolean) => void;
}) {
return (
<>
@@ -417,6 +434,12 @@ function AppearanceSettingsSections({
updateDraft({ theme });
previewTheme(theme);
}}
chatAnimationsEnabled={draft.chatAnimationsEnabled}
isChatMotionDirty={draft.chatAnimationsEnabled !== saved.chatAnimationsEnabled}
onChatMotionChange={(chatAnimationsEnabled) => {
updateDraft({ chatAnimationsEnabled });
previewChatAnimations(chatAnimationsEnabled);
}}
richOutputAnimationsEnabled={draft.richOutputAnimationsEnabled}
isRichOutputMotionDirty={
draft.richOutputAnimationsEnabled !== saved.richOutputAnimationsEnabled
@@ -456,10 +479,18 @@ export function AppearanceSettings() {
const { savedTheme, previewTheme } = useTheme();
const savedMenuMode = useAppStore((state) => state.settingsMenu.savedMode);
const savedRichOutputAnimations = useAppStore((state) => state.richOutputMotion.savedEnabled);
const savedChatAnimations = useAppStore((state) => state.chatMotion.savedEnabled);
const previewMenuMode = useAppStore((state) => state.previewSettingsMenuMode);
const previewRichOutputAnimations = useAppStore((state) => state.previewRichOutputAnimations);
const previewChatAnimations = useAppStore((state) => state.previewChatAnimations);
const [saved, setSaved] = useState(() =>
createAppearanceSavedState(savedTheme, savedMenuMode, savedRichOutputAnimations, userSettings),
createAppearanceSavedState(
savedTheme,
savedMenuMode,
savedRichOutputAnimations,
userSettings,
savedChatAnimations,
),
);
const [draft, setDraft] = useState(saved);
const draftRef = useRef(draft);
@@ -475,6 +506,7 @@ export function AppearanceSettings() {
previousSaved.settingsMenuMode,
previousSaved.richOutputAnimationsEnabled,
userSettings,
previousSaved.chatAnimationsEnabled,
);
setDraft(
rebaseAppearanceDraft(
@@ -512,6 +544,7 @@ export function AppearanceSettings() {
previewTheme={previewTheme}
previewMenuMode={previewMenuMode}
previewRichOutputAnimations={previewRichOutputAnimations}
previewChatAnimations={previewChatAnimations}
/>
</div>
);
@@ -0,0 +1,112 @@
import { act, cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createContext } from "react";
const customSpanFixture = vi.hoisted(() => ({ enabled: false }));
import { MemoizedMarkdown } from "./memoized-markdown";
vi.mock("@/components/shared/markdown-components", () => ({
MarkdownFileLinkContext: createContext({}),
MarkdownTaskContext: createContext(null),
markdownComponents: {},
remarkPlugins: [
() => (tree: { children: { data?: object }[] }) => {
if (customSpanFixture.enabled) tree.children[0].data = { hName: "span" };
},
],
}));
const originalAnimate = Object.getOwnPropertyDescriptor(Element.prototype, "animate");
const motionSelector = "[data-chat-text-motion]";
afterEach(() => {
cleanup();
vi.restoreAllMocks();
customSpanFixture.enabled = false;
if (originalAnimate) Object.defineProperty(Element.prototype, "animate", originalAnimate);
else Reflect.deleteProperty(Element.prototype, "animate");
});
// @covers AC-UI-CHAT-MOTION-001.1, AC-UI-CHAT-MOTION-001.4
describe("chat Markdown reveals", () => {
it("reveals only new prose while preserving formatting, selection text and code", () => {
const animate = vi.fn(() => ({ cancel: vi.fn() }));
Object.defineProperty(Element.prototype, "animate", { value: animate, configurable: true });
const view = render(<MemoizedMarkdown content="Hello" animateText />);
expect(animate).not.toHaveBeenCalled();
view.rerender(<MemoizedMarkdown content="Hello **world** `code`" animateText />);
expect(view.container.textContent).toBe("Hello world code");
expect(view.container.querySelector("strong")?.textContent).toBe("world");
expect(view.container.querySelector("code [data-chat-text-motion]")).toBeNull();
expect(view.container.querySelectorAll(motionSelector).length).toBeGreaterThan(0);
expect(animate).toHaveBeenCalled();
const selection = window.getSelection()!;
const range = document.createRange();
range.selectNodeContents(view.container);
selection.addRange(range);
expect(selection.toString()).toBe("Hello world code");
selection.removeAllRanges();
});
it("cancels active effects on disable and does not replay on enable or rewrite", () => {
const cancel = vi.fn();
const animate = vi.fn(() => ({ cancel }));
Object.defineProperty(Element.prototype, "animate", { value: animate, configurable: true });
const view = render(<MemoizedMarkdown content="old" animateText />);
view.rerender(<MemoizedMarkdown content="old new" animateText />);
expect(animate).toHaveBeenCalled();
view.rerender(<MemoizedMarkdown content="old new" animateText={false} />);
expect(cancel).toHaveBeenCalled();
animate.mockClear();
view.rerender(<MemoizedMarkdown content="old new" animateText />);
view.rerender(<MemoizedMarkdown content="replacement" animateText />);
expect(animate).not.toHaveBeenCalled();
expect(view.container.textContent).toBe("replacement");
});
it("compacts finished ranges after a burst and leaves no timers on unmount", () => {
vi.useFakeTimers();
const view = render(<MemoizedMarkdown content="a" animateText />);
for (let i = 1; i < 40; i++)
view.rerender(<MemoizedMarkdown content={"a".repeat(i + 1)} animateText />);
expect(view.container.querySelectorAll(motionSelector).length).toBeLessThanOrEqual(1);
act(() => vi.advanceTimersByTime(160));
expect(view.container.querySelectorAll(motionSelector)).toHaveLength(0);
view.unmount();
expect(vi.getTimerCount()).toBe(0);
vi.useRealTimers();
});
});
it("retains the selected text while a reveal finishes and more text arrives", () => {
vi.useFakeTimers();
const view = render(<MemoizedMarkdown content="old" animateText />);
view.rerender(<MemoizedMarkdown content="old selected" animateText />);
const span = view.container.querySelector(motionSelector)!;
const selection = window.getSelection()!;
const range = document.createRange();
range.selectNodeContents(span);
selection.addRange(range);
act(() => vi.advanceTimersByTime(160));
expect(view.container.contains(span)).toBe(true);
view.rerender(<MemoizedMarkdown content="old selected tail" animateText />);
expect(view.container.contains(span)).toBe(true);
expect(selection.toString()).toBe(" selected");
act(() => {
selection.removeAllRanges();
document.dispatchEvent(new Event("selectionchange"));
});
expect(view.container.querySelector(motionSelector)).toBeNull();
view.unmount();
vi.useRealTimers();
});
it("preserves the caller's renderer for unmarked spans during text motion", () => {
customSpanFixture.enabled = true;
const components = {
span: ({ children }: { children?: import("react").ReactNode }) => (
<span data-custom-span>{children}</span>
),
};
const view = render(<MemoizedMarkdown content="old" components={components} animateText />);
expect(view.container.querySelector("[data-custom-span]")?.textContent).toBe("old");
view.rerender(<MemoizedMarkdown content="old new" components={components} animateText />);
expect(view.container.querySelector("[data-custom-span]")?.textContent).toBe("old new");
expect(view.container.querySelector("[data-custom-span] [data-chat-text-motion]")).not.toBeNull();
});
@@ -0,0 +1,91 @@
"use client";
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ComponentProps } from "react";
import type { ExtraProps } from "react-markdown";
import {
appendedTextRuns,
CHAT_TEXT_DURATION_MS,
splitMotionText,
type TextRun,
} from "@/lib/markdown/chat-text-motion";
import { chatTextMotionPlugin } from "@/lib/markdown/chat-text-motion-plugin";
export function ChatMotionSpan({
node,
...props
}: ComponentProps<"span"> & ExtraProps & { "data-chat-text-motion"?: number }) {
const ref = useRef<HTMLSpanElement>(null);
const receivedAt = Number(
node?.properties?.["data-chat-text-motion"] ?? props["data-chat-text-motion"],
);
useLayoutEffect(() => {
const elapsed = Math.max(0, Date.now() - receivedAt);
if (!Number.isFinite(receivedAt) || elapsed >= CHAT_TEXT_DURATION_MS || !ref.current?.animate)
return;
const animation = ref.current.animate(
[{ opacity: 0.6 + (0.4 * elapsed) / CHAT_TEXT_DURATION_MS }, { opacity: 1 }],
{ duration: CHAT_TEXT_DURATION_MS - elapsed, easing: "linear" },
);
return () => animation.cancel();
}, [receivedAt]);
return <span {...props} ref={ref} />;
}
function hasTextSelection(): boolean {
return typeof window !== "undefined" && window.getSelection()?.isCollapsed === false;
}
function useChatTextRuns(content: string, enabled: boolean) {
const [snapshot, setSnapshot] = useState({ content, enabled, runs: [] as TextRun[] });
let current = snapshot;
if (snapshot.content !== content || snapshot.enabled !== enabled) {
let runs: TextRun[] = [];
if (enabled && snapshot.enabled) {
runs = hasTextSelection()
? snapshot.runs
: appendedTextRuns(snapshot.content, content, snapshot.runs, Date.now());
}
current = { content, enabled, runs };
setSnapshot(current);
}
const runs = current.runs;
useEffect(() => {
const last = runs.at(-1);
if (!last) return;
const compact = () => {
if (hasTextSelection()) return;
document.removeEventListener("selectionchange", compact);
setSnapshot((value) => ({ ...value, runs: [] }));
};
const timer = setTimeout(
() => {
if (hasTextSelection()) document.addEventListener("selectionchange", compact);
else compact();
},
Math.max(0, last.receivedAt + CHAT_TEXT_DURATION_MS - Date.now()),
);
return () => {
clearTimeout(timer);
document.removeEventListener("selectionchange", compact);
};
}, [runs]);
return runs;
}
export function useChatMarkdownMotion(content: string, enabled: boolean) {
const runs = useChatTextRuns(content, enabled);
return useMemo(() => (runs.length ? [chatTextMotionPlugin(content, runs)] : []), [content, runs]);
}
export function ChatAnimatedText({ text, enabled }: { text: string; enabled: boolean }) {
const runs = useChatTextRuns(text, enabled);
return splitMotionText(text, 0, text, runs).map((part, index) =>
part.receivedAt === undefined ? (
part.text
) : (
<ChatMotionSpan key={index} data-chat-text-motion={part.receivedAt}>
{part.text}
</ChatMotionSpan>
),
);
}
@@ -1,6 +1,6 @@
"use client";
import { memo, useContext, useMemo } from "react";
import { createElement, memo, useContext, useMemo, type ComponentProps } from "react";
import ReactMarkdown, { type Components } from "react-markdown";
import {
MarkdownFileLinkContext,
@@ -9,6 +9,7 @@ import {
remarkPlugins,
type MarkdownFileLinkContextValue,
} from "@/components/shared/markdown-components";
import { ChatMotionSpan, useChatMarkdownMotion } from "./chat-markdown-motion";
import { normalizeCached } from "@/lib/markdown/normalize-cache";
/**
@@ -24,6 +25,7 @@ type MemoizedMarkdownProps = MarkdownFileLinkContextValue & {
content: string;
components?: Components;
taskId?: string | null;
animateText?: boolean;
};
export const MemoizedMarkdown = memo(function MemoizedMarkdown({
@@ -33,6 +35,7 @@ export const MemoizedMarkdown = memo(function MemoizedMarkdown({
fileRootAliases,
components,
taskId = null,
animateText = false,
}: MemoizedMarkdownProps) {
const inheritedContext = useContext(MarkdownFileLinkContext);
const fileLinkContext = useMemo(
@@ -50,13 +53,35 @@ export const MemoizedMarkdown = memo(function MemoizedMarkdown({
worktreePath,
],
);
const resolvedComponents = components ?? markdownComponents;
const normalized = normalizeCached(content);
const motionPlugins = useChatMarkdownMotion(normalized, animateText);
const resolvedComponents = useMemo(() => {
const base: Components = components ?? markdownComponents;
if (!animateText) return base;
const Span = base.span ?? "span";
return {
...base,
span: (props: ComponentProps<typeof ChatMotionSpan>) => {
const { node, ...attributes } = props;
if (
node?.properties?.["data-chat-text-motion"] !== undefined ||
attributes["data-chat-text-motion"] !== undefined
)
return <ChatMotionSpan {...props} />;
return typeof Span === "string" ? createElement(Span, attributes) : <Span {...props} />;
},
};
}, [animateText, components]);
return (
<MarkdownTaskContext.Provider value={taskId}>
<MarkdownFileLinkContext.Provider value={fileLinkContext}>
<ReactMarkdown remarkPlugins={remarkPlugins} components={resolvedComponents}>
{normalizeCached(content)}
<ReactMarkdown
remarkPlugins={remarkPlugins}
rehypePlugins={motionPlugins}
components={resolvedComponents}
>
{normalized}
</ReactMarkdown>
</MarkdownFileLinkContext.Provider>
</MarkdownTaskContext.Provider>
@@ -0,0 +1,11 @@
export type ArrivalSnapshot = { sessionId: string | null; ids: readonly string[]; live: boolean };
/** Only appends to an already visible, reconciled transcript are entrances. */
export function getNewArrivalIds(
previous: ArrivalSnapshot | null,
next: ArrivalSnapshot,
): ReadonlySet<string> {
if (!previous?.live || !next.live || previous.sessionId !== next.sessionId) return new Set();
if (previous.ids.some((id, index) => next.ids[index] !== id)) return new Set();
return new Set(next.ids.slice(previous.ids.length));
}
@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import { getNewArrivalIds, type ArrivalSnapshot } from "./chat-motion-state";
// @covers AC-UI-CHAT-MOTION-001.2, AC-UI-CHAT-MOTION-001.3
describe("chat arrival eligibility", () => {
const baseline: ArrivalSnapshot = { sessionId: "session", ids: ["user", "tool"], live: true };
it("finds new child messages even when the turn group stays the same", () => {
expect([
...getNewArrivalIds(baseline, { ...baseline, ids: ["user", "tool", "tool2"] }),
]).toEqual(["tool2"]);
});
it("does not animate initial history, prepends, session switches or refresh", () => {
expect(getNewArrivalIds(null, baseline).size).toBe(0);
expect(getNewArrivalIds(baseline, { ...baseline, ids: ["older", "user", "tool"] }).size).toBe(
0,
);
expect(getNewArrivalIds(baseline, { ...baseline, sessionId: "other", ids: ["new"] }).size).toBe(
0,
);
expect(
getNewArrivalIds(baseline, { ...baseline, live: false, ids: [...baseline.ids, "new"] }).size,
).toBe(0);
});
it("does not replay hidden deliveries when activated", () => {
expect(
getNewArrivalIds({ ...baseline, live: false }, { ...baseline, ids: [...baseline.ids, "new"] })
.size,
).toBe(0);
});
});
import { cleanup, render } from "@testing-library/react";
import { afterEach, vi } from "vitest";
import { ChatMotionItem, ChatMotionProvider } from "./chat-motion";
vi.mock("@/hooks/use-chat-motion", () => ({ useChatMotion: () => true }));
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
it("animates a new row once and cancels it when the transcript hides", () => {
const cancel = vi.fn();
const animate = vi.fn(() => ({ cancel }));
Object.defineProperty(Element.prototype, "animate", { configurable: true, value: animate });
const tree = (ids: string[], live = true) => (
<ChatMotionProvider sessionId="session" messages={ids.map((id) => ({ id }))} live={live}>
{ids.map((id) => (
<ChatMotionItem key={id} messageId={id}>
{id}
</ChatMotionItem>
))}
</ChatMotionProvider>
);
const view = render(tree(["old"]));
expect(animate).not.toHaveBeenCalled();
view.rerender(tree(["old", "new"]));
expect(animate).toHaveBeenCalledTimes(1);
view.rerender(tree(["old", "new"]));
expect(animate).toHaveBeenCalledTimes(1);
view.rerender(tree(["old", "new"], false));
expect(cancel).toHaveBeenCalledTimes(1);
view.rerender(tree(["old", "new"]));
expect(animate).toHaveBeenCalledTimes(1);
});
@@ -0,0 +1,87 @@
"use client";
import {
createContext,
useContext,
useLayoutEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { useChatMotion } from "@/hooks/use-chat-motion";
import { getNewArrivalIds, type ArrivalSnapshot } from "./chat-motion-state";
const ChatMotionContext = createContext({
live: false,
arrivals: new Set<string>() as ReadonlySet<string>,
receivedAt: 0,
});
export function ChatMotionProvider({
children,
sessionId,
messages,
live: eligible,
}: {
children: ReactNode;
sessionId: string | null;
messages: readonly { id: string }[];
live: boolean;
}) {
const enabled = useChatMotion();
const live = eligible && enabled;
const identity = messages.map((message) => message.id).join("\0");
const ids = useMemo(() => (identity ? identity.split("\0") : []), [identity]);
const previous = useRef<ArrivalSnapshot | null>(null);
const snapshot = useMemo(() => ({ sessionId, ids, live }), [sessionId, ids, live]);
const value = useMemo(
() => ({
live,
arrivals: getNewArrivalIds(previous.current, snapshot),
receivedAt: Date.now(),
}),
[live, snapshot],
);
useLayoutEffect(() => {
previous.current = snapshot;
}, [snapshot]);
return <ChatMotionContext.Provider value={value}>{children}</ChatMotionContext.Provider>;
}
export function useChatTextMotion(): boolean {
return useContext(ChatMotionContext).live;
}
/** The inner wrapper leaves measured row geometry and message identity intact. */
export function ChatMotionItem({
children,
messageId,
}: {
children: ReactNode;
messageId: string;
}) {
const context = useContext(ChatMotionContext);
const [arrival] = useState(() => (context.arrivals.has(messageId) ? context.receivedAt : null));
const played = useRef(false);
const ref = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
if (!context.live || arrival === null || played.current) return;
played.current = true;
const remaining = 160 - (Date.now() - arrival);
if (remaining <= 0 || !ref.current?.animate) return;
const animation = ref.current.animate(
[
{ opacity: 0, transform: "translateY(3px)" },
{ opacity: 1, transform: "translateY(0)" },
],
{ duration: remaining, easing: "ease-out" },
);
return () => animation.cancel();
}, [arrival, context.live]);
return (
<div ref={ref} data-chat-motion-item={messageId}>
{children}
</div>
);
}
@@ -0,0 +1,218 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createChatScrollMotion } from "./chat-scroll-motion";
let frames: Map<number, FrameRequestCallback>;
let now: number;
function advance(ms = 16) {
now += ms;
const pending = [...frames.values()];
frames.clear();
pending.forEach((cb) => cb(now));
}
function fixture() {
const el = document.createElement("div");
let height = 1000;
Object.defineProperties(el, {
scrollHeight: { configurable: true, get: () => height },
clientHeight: { value: 200 },
});
return {
el,
grow: (next: number) => {
height = next;
},
};
}
beforeEach(() => {
frames = new Map();
now = 0;
let id = 0;
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
frames.set(++id, cb);
return id;
});
vi.stubGlobal("cancelAnimationFrame", (id: number) => frames.delete(id));
});
afterEach(() => vi.unstubAllGlobals());
// @covers AC-UI-CHAT-MOTION-003.1, AC-UI-CHAT-MOTION-003.2, AC-UI-CHAT-MOTION-003.4
describe("interruptible chat following", () => {
it("defers geometry reads and coalesces requests into a single moving target", () => {
const { el, grow } = fixture();
const read = vi.spyOn(el, "scrollHeight", "get");
const motion = createChatScrollMotion(el, () => true, vi.fn());
motion.request();
motion.request();
expect(read).not.toHaveBeenCalled();
expect(frames.size).toBe(1);
advance();
advance();
expect(el.scrollTop).toBeGreaterThan(0);
expect(el.scrollTop).toBeLessThan(800);
grow(1400);
motion.request();
expect(frames.size).toBe(1);
for (let i = 0; i < 20; i++) advance();
expect(el.scrollTop).toBe(1200);
expect(frames.size).toBe(0);
expect(motion.isRunning()).toBe(false);
motion.dispose();
});
});
describe("continuous growth and content interactions", () => {
it("keeps moving when content grows on every frame", () => {
const { el, grow } = fixture();
const motion = createChatScrollMotion(el, () => true, vi.fn());
motion.request();
advance();
for (let i = 1; i <= 30; i++) {
const previous = el.scrollTop;
grow(1000 + i * 10);
motion.request();
advance();
expect(el.scrollTop).toBeGreaterThan(previous);
expect(frames.size).toBe(1);
}
advance(180);
expect(el.scrollTop).toBe(1100);
expect(frames.size).toBe(0);
motion.dispose();
});
it("keeps following after ordinary content clicks", () => {
const { el } = fixture();
const button = document.createElement("button");
el.append(button);
const interrupt = vi.fn();
const motion = createChatScrollMotion(el, () => true, interrupt);
motion.request();
button.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true }));
el.dispatchEvent(new MouseEvent("pointerdown"));
expect(interrupt).not.toHaveBeenCalled();
advance();
advance();
expect(el.scrollTop).toBeGreaterThan(0);
motion.dispose();
});
it("yields to a scrollbar press and removes its listener", () => {
const { el } = fixture();
Object.defineProperties(el, { clientWidth: { value: 200 }, offsetWidth: { value: 216 } });
vi.spyOn(el, "getBoundingClientRect").mockReturnValue({ left: 10, right: 226 } as DOMRect);
const interrupt = vi.fn();
const motion = createChatScrollMotion(el, () => true, interrupt);
motion.request();
el.dispatchEvent(new MouseEvent("pointerdown", { clientX: 215 }));
expect(interrupt).toHaveBeenCalledOnce();
expect(frames.size).toBe(0);
motion.dispose();
el.dispatchEvent(new MouseEvent("pointerdown", { clientX: 215 }));
expect(interrupt).toHaveBeenCalledOnce();
});
});
describe("scroll ownership", () => {
it.each(["wheel", "keydown"])("yields to %s and removes listeners on disposal", (type) => {
const { el } = fixture();
const interrupt = vi.fn();
const motion = createChatScrollMotion(el, () => true, interrupt);
motion.request();
advance();
advance();
const top = el.scrollTop;
el.dispatchEvent(
type === "keydown" ? new KeyboardEvent(type, { key: "PageUp" }) : new Event(type),
);
advance();
expect(el.scrollTop).toBe(top);
expect(frames.size).toBe(0);
expect(interrupt).toHaveBeenCalledTimes(1);
motion.dispose();
el.dispatchEvent(new Event("wheel"));
expect(interrupt).toHaveBeenCalledTimes(1);
});
it("does not treat typing as a scroll gesture", () => {
const { el } = fixture();
const interrupt = vi.fn();
const motion = createChatScrollMotion(el, () => true, interrupt);
motion.request();
el.dispatchEvent(new KeyboardEvent("keydown", { key: "a" }));
expect(interrupt).not.toHaveBeenCalled();
expect(frames.size).toBe(1);
motion.dispose();
});
it("cancels before writing when ownership changes", () => {
const { el } = fixture();
let allowed = true;
const motion = createChatScrollMotion(el, () => allowed, vi.fn());
motion.request();
advance();
allowed = false;
const top = el.scrollTop;
advance();
expect(el.scrollTop).toBe(top);
expect(frames.size).toBe(0);
motion.dispose();
});
});
describe("touch scroll intent", () => {
it("ignores taps and small finger jitter but yields to a drag", () => {
const { el } = fixture();
const interrupt = vi.fn();
const motion = createChatScrollMotion(el, () => true, interrupt);
const touch = (type: string, y: number) =>
el.dispatchEvent(new TouchEvent(type, { touches: [{ clientY: y } as Touch] }));
motion.request();
touch("touchstart", 100);
touch("touchmove", 102);
el.dispatchEvent(new TouchEvent("touchend"));
expect(interrupt).not.toHaveBeenCalled();
expect(frames.size).toBe(1);
touch("touchstart", 100);
touch("touchmove", 112);
expect(interrupt).toHaveBeenCalledOnce();
expect(frames.size).toBe(0);
touch("touchmove", 130);
expect(interrupt).toHaveBeenCalledOnce();
motion.dispose();
touch("touchstart", 100);
touch("touchmove", 130);
expect(interrupt).toHaveBeenCalledOnce();
});
});
describe("keyboard control ownership", () => {
it.each(["button", "input", "select", "textarea", "a", "editable", "role-button"])(
"keeps following when a %s handles a scroll key",
(kind) => {
const { el } = fixture();
const control = document.createElement(
kind === "editable" || kind === "role-button" ? "div" : kind,
);
if (kind === "editable") control.setAttribute("contenteditable", "true");
if (kind === "role-button") control.setAttribute("role", "button");
if (kind === "a") control.setAttribute("href", "#target");
const target = document.createElement("span");
control.append(target);
el.append(control);
const interrupt = vi.fn();
const motion = createChatScrollMotion(el, () => true, interrupt);
motion.request();
target.dispatchEvent(new KeyboardEvent("keydown", { key: " ", bubbles: true }));
expect(interrupt).not.toHaveBeenCalled();
expect(motion.isRunning()).toBe(true);
el.dispatchEvent(new KeyboardEvent("keydown", { key: "PageUp" }));
expect(interrupt).toHaveBeenCalledOnce();
motion.dispose();
},
);
});
it("leaves a prevented scroll key with its existing handler", () => {
const { el } = fixture();
const interrupt = vi.fn();
const motion = createChatScrollMotion(el, () => true, interrupt);
motion.request();
const event = new KeyboardEvent("keydown", { key: "PageUp", cancelable: true });
event.preventDefault();
el.dispatchEvent(event);
expect(interrupt).not.toHaveBeenCalled();
expect(motion.isRunning()).toBe(true);
motion.dispose();
});
@@ -0,0 +1,123 @@
export type ScrollMotion = {
request: () => void;
cancel: () => void;
dispose: () => void;
isRunning: () => boolean;
};
const activeMotions = new WeakMap<HTMLElement, ScrollMotion>();
const SCROLL_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " "]);
export function cancelChatScrollMotion(element: HTMLElement): void {
activeMotions.get(element)?.cancel();
}
function listenForScrollIntent(element: HTMLElement, interrupt: () => void): () => void {
const onKey = (event: KeyboardEvent) => {
const target = event.target;
if (
event.defaultPrevented ||
(target instanceof Element &&
target !== element &&
target.closest(
'button, input, select, textarea, a[href], [role="button"], [contenteditable]:not([contenteditable="false"])',
))
)
return;
if (SCROLL_KEYS.has(event.key)) interrupt();
};
const onPointer = (event: PointerEvent) => {
if (event.target !== element || element.offsetWidth <= element.clientWidth) return;
const bounds = element.getBoundingClientRect();
const left = bounds.left + element.clientLeft;
if (event.clientX < left || event.clientX >= left + element.clientWidth) interrupt();
};
let touchY: number | undefined;
const onTouchStart = (event: TouchEvent) => {
touchY = event.touches[0]?.clientY;
};
const onTouchEnd = () => {
touchY = undefined;
};
const onTouchMove = (event: TouchEvent) => {
const y = event.touches[0]?.clientY;
if (touchY === undefined || y === undefined || Math.abs(y - touchY) < 6) return;
touchY = undefined;
interrupt();
};
element.addEventListener("wheel", interrupt, { passive: true });
element.addEventListener("pointerdown", onPointer, { passive: true });
element.addEventListener("touchstart", onTouchStart, { passive: true });
element.addEventListener("touchmove", onTouchMove, { passive: true });
element.addEventListener("touchend", onTouchEnd);
element.addEventListener("touchcancel", onTouchEnd);
element.addEventListener("keydown", onKey);
return () => {
element.removeEventListener("wheel", interrupt);
element.removeEventListener("pointerdown", onPointer);
element.removeEventListener("touchstart", onTouchStart);
element.removeEventListener("touchmove", onTouchMove);
element.removeEventListener("touchend", onTouchEnd);
element.removeEventListener("touchcancel", onTouchEnd);
element.removeEventListener("keydown", onKey);
};
}
/** Geometry is read in frames, never in the message commit that requests follow. */
export function createChatScrollMotion(
element: HTMLElement,
canFollow: () => boolean,
onInterrupt: () => void,
): ScrollMotion {
let frame: number | null = null;
let target = -1;
let start = 0;
let startedAt = 0;
let previousFrameAt = 0;
let disposed = false;
const cancel = () => {
if (frame !== null) cancelAnimationFrame(frame);
frame = null;
target = -1;
};
const tick = (time: number) => {
frame = null;
if (!canFollow() || disposed) {
target = -1;
return;
}
const nextTarget = Math.max(0, element.scrollHeight - element.clientHeight);
if (nextTarget !== target) {
// Retarget from the previous frame so uninterrupted growth still advances.
startedAt = target === -1 ? time : previousFrameAt;
target = nextTarget;
start = element.scrollTop;
}
previousFrameAt = time;
const progress = Math.min(1, (time - startedAt) / 180);
const next = start + (target - start) * (1 - (1 - progress) ** 3);
element.scrollTop = Math.abs(target - next) < 0.5 ? target : next;
if (element.scrollTop !== target) frame = requestAnimationFrame(tick);
else target = -1;
};
const interrupt = () => {
cancel();
onInterrupt();
};
const removeListeners = listenForScrollIntent(element, interrupt);
const motion: ScrollMotion = {
request: () => {
if (!disposed && frame === null && canFollow()) frame = requestAnimationFrame(tick);
},
cancel,
isRunning: () => frame !== null,
dispose: () => {
disposed = true;
cancel();
removeListeners();
if (activeMotions.get(element) === motion) activeMotions.delete(element);
},
};
activeMotions.set(element, motion);
return motion;
}
@@ -1,5 +1,7 @@
/* eslint-disable max-lines -- pagination, scroll anchoring, and retry state share one boundary. */
import { cancelChatScrollMotion } from "./chat-scroll-motion";
import { useChatScrollMotion } from "./use-chat-scroll-motion";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { useDockviewStore } from "@/lib/state/dockview-store";
import { useAppStoreApi } from "@/components/state-provider";
@@ -49,6 +51,7 @@ const SCROLL_KEYS = new Set([
/** Writes a clamped maximum so the browser resolves the native bottom without
* forcing a synchronous scrollHeight layout read. */
export function scrollNativeToBottom(element: HTMLElement): void {
cancelChatScrollMotion(element);
element.scrollTop = NATIVE_BOTTOM_SCROLL_TOP;
}
@@ -302,7 +305,10 @@ function useScrollPositionOnPrepend(
const beginOlderLoad = useCallback(() => {
if (olderLoadPendingRef.current) return;
const el = scrollRef.current;
if (el) scrollState.current = capturePrependScrollState(el, newestItemKeyRef.current);
if (el) {
cancelChatScrollMotion(el);
scrollState.current = capturePrependScrollState(el, newestItemKeyRef.current);
}
olderLoadPendingRef.current = true;
}, [scrollRef]);
@@ -623,23 +629,58 @@ const PROGRAMMATIC_SCROLL_GUARD_MS = 1000;
* streaming in mid-animation can silently snap the transcript back to the
* bottom, cancelling the user's action.
*/
export function useAutoScroll(params: {
function useFollowIntent({
scrollRef,
isVisibleRef,
isAnimating,
userReading,
isNearBottomRef,
cancelMotion,
}: {
scrollRef: React.RefObject<HTMLDivElement | null>;
isVisibleRef: React.RefObject<boolean>;
isAnimating: () => boolean;
userReading: React.RefObject<boolean>;
isNearBottomRef: React.RefObject<boolean>;
cancelMotion: () => void;
}) {
const resyncIsNearBottom = useCallback(() => {
const el = scrollRef.current;
if (!el || !isVisibleRef.current || isAnimating()) return;
isNearBottomRef.current =
el.scrollHeight - el.scrollTop - el.clientHeight < (userReading.current ? 3 : 100);
if (isNearBottomRef.current) userReading.current = false;
}, [scrollRef, isAnimating, userReading]);
const markNotNearBottom = useCallback(() => {
cancelMotion();
isNearBottomRef.current = false;
}, [cancelMotion]);
return { resyncIsNearBottom, markNotNearBottom };
}
type AutoScrollParams = {
scrollRef: React.RefObject<HTMLDivElement | null>;
messages: Message[];
isWorking: boolean;
sessionId: string | null;
enabled: boolean;
motionEnabled?: boolean;
motionBlocked?: boolean;
hasUnreadDivider: boolean;
isProgrammaticScrollLocked: () => boolean;
isVisible?: boolean;
initialPlacementPending?: boolean;
}) {
};
export function useAutoScroll(params: AutoScrollParams) {
const {
scrollRef,
messages,
isWorking,
sessionId,
enabled,
motionEnabled = false,
motionBlocked = false,
hasUnreadDivider,
isProgrammaticScrollLocked,
isVisible = true,
@@ -659,23 +700,36 @@ export function useAutoScroll(params: {
sessionIdRef.current = sessionId;
isProgrammaticScrollLockedRef.current = isProgrammaticScrollLocked;
const resyncIsNearBottom = useCallback(() => {
const el = scrollRef.current;
if (!el || !isVisibleRef.current) return;
isNearBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 100;
}, [scrollRef]);
const markNotNearBottom = useCallback(() => {
isNearBottomRef.current = false;
}, []);
useTranscriptViewportResize({
const {
followBottom,
cancel: cancelMotion,
isAnimating,
userReading,
} = useChatScrollMotion({
scrollRef,
sessionId,
motionEnabled,
enabled,
isVisible,
initialPlacementPending,
isProgrammaticScrollLocked,
sessionId,
isNearBottomRef,
instant: scrollNativeToBottom,
isBlocked: () =>
motionBlocked ||
initialPlacementPending ||
isProgrammaticScrollLockedRef.current() ||
useDockviewStore.getState().pendingChatScrollTop !== null ||
useDockviewStore.getState().isRestoringLayout,
});
const { resyncIsNearBottom, markNotNearBottom } = useFollowIntent({
scrollRef,
isVisibleRef,
isAnimating,
userReading,
isNearBottomRef,
cancelMotion,
});
useTranscriptViewportResize({ ...params, isVisible, initialPlacementPending });
usePersistedTranscriptScroll({
scrollRef,
@@ -691,6 +745,7 @@ export function useAutoScroll(params: {
});
useAutoScrollOnContent({
followBottom,
scrollRef,
messages,
isWorking,
@@ -704,7 +759,7 @@ export function useAutoScroll(params: {
initialPlacementPending,
});
useCatchUpOnReEnable(scrollRef, messages, enabled, isNearBottomRef);
useCatchUpOnReEnable(scrollRef, messages, enabled, isNearBottomRef, followBottom);
useCatchUpOnVisible({
scrollRef,
isVisible,
@@ -853,6 +908,7 @@ function usePersistedTranscriptScroll({
}
function useAutoScrollOnContent({
followBottom,
scrollRef,
messages,
isWorking,
@@ -865,6 +921,7 @@ function useAutoScrollOnContent({
prevIsWorkingRef,
initialPlacementPending,
}: {
followBottom: () => void;
scrollRef: React.RefObject<HTMLDivElement | null>;
messages: Message[];
isWorking: boolean;
@@ -896,7 +953,7 @@ function useAutoScrollOnContent({
) {
const el = scrollRef.current;
if (el) {
scrollNativeToBottom(el);
followBottom();
isNearBottomRef.current = true;
placementDebug("work-start bottom", { sessionId });
}
@@ -907,6 +964,7 @@ function useAutoScrollOnContent({
isWorking,
sessionId,
scrollRef,
followBottom,
enabled,
isProgrammaticScrollLocked,
initialPlacementPending,
@@ -925,7 +983,7 @@ function useAutoScrollOnContent({
}) &&
enabled
) {
scrollNativeToBottom(el);
followBottom();
if (messages.length !== lastLoggedMessageCountRef.current) {
placementDebug("message-update bottom", {
sessionId,
@@ -938,6 +996,7 @@ function useAutoScrollOnContent({
messages,
sessionId,
scrollRef,
followBottom,
enabled,
isProgrammaticScrollLocked,
initialPlacementPending,
@@ -1007,6 +1066,7 @@ function useCatchUpOnReEnable(
messages: Message[],
enabled: boolean,
isNearBottomRef: React.RefObject<boolean>,
followBottom: () => void,
) {
const prevEnabledRef = useRef(enabled);
const baselineRef = useRef<{
@@ -1076,10 +1136,10 @@ function useCatchUpOnReEnable(
isAtBottom,
})
) {
scrollNativeToBottom(el);
followBottom();
isNearBottomRef.current = true;
}
}, [enabled, scrollRef, messages]);
}, [enabled, scrollRef, messages, followBottom]);
}
/**
@@ -1140,11 +1200,28 @@ function useProgrammaticScrollGuard(
export function useScrollToMessage(
scrollRef: React.RefObject<HTMLDivElement | null>,
runGuardedScroll: (performScroll: () => void) => void,
motionEnabled = true,
) {
// Bumped on every scrollToMessage call; in-flight verifiers of a superseded
// request bail on the next frame so stale work can never land the
// transcript on an older prompt after a newer one consumed.
const generationRef = useRef(0);
useLayoutEffect(() => {
if (motionEnabled || generationRef.current === 0) return;
generationRef.current += 1;
const element = scrollRef.current;
// A same-position write cancels native smooth scrolling without choosing a new target.
if (element) {
const currentTop = element.scrollTop;
element.scrollTop = currentTop;
}
}, [motionEnabled, scrollRef]);
useEffect(
() => () => {
generationRef.current += 1;
},
[],
);
return useCallback(
(messageId: string, options?: { align?: "start" | "center"; behavior?: "smooth" | "auto" }) => {
// Advance the generation BEFORE the lookup: a superseding request whose
@@ -1155,10 +1232,11 @@ export function useScrollToMessage(
const el = scrollRef.current?.querySelector<HTMLElement>(selector);
if (!el) return false;
const alignStart = options?.align === "start";
if (scrollRef.current) cancelChatScrollMotion(scrollRef.current);
runGuardedScroll(() => {
el.scrollIntoView({
block: alignStart ? "start" : "center",
behavior: options?.behavior ?? "smooth",
behavior: motionEnabled ? (options?.behavior ?? "smooth") : "auto",
});
const container = scrollRef.current;
if (!container) return;
@@ -1214,7 +1292,7 @@ export function useScrollToMessage(
});
return true;
},
[runGuardedScroll, scrollRef],
[runGuardedScroll, scrollRef, motionEnabled],
);
}
@@ -1399,6 +1477,7 @@ function applyInitialScrollPosition(params: InitialScrollApplyParams): void {
clientHeight: element.clientHeight,
});
};
cancelChatScrollMotion(element);
element.scrollTop = scrollTop;
syncNearBottom();
reportInitialPlacement({
@@ -1594,6 +1673,7 @@ type NativeScrollManagementParams = {
isWorking: boolean;
sessionId: string | null;
enabled: boolean;
motionEnabled?: boolean;
hasUnreadDivider: boolean;
/** Initial/refetch loading: the sentinel's hard block (never fires, never joins). */
messagesLoading: boolean;
@@ -1621,6 +1701,7 @@ export function useNativeScrollManagement(params: NativeScrollManagementParams)
isWorking,
sessionId,
enabled,
motionEnabled = false,
hasUnreadDivider,
messagesLoading,
historyRefreshPending = false,
@@ -1649,13 +1730,15 @@ export function useNativeScrollManagement(params: NativeScrollManagementParams)
isProgrammaticScrollLocked,
isVisible,
initialPlacementPending,
motionEnabled,
motionBlocked: messagesLoading || historyRefreshPending || isLoadingMore,
});
const runGuardedScroll = useProgrammaticScrollGuard(
scrollRef,
programmaticScrollLockRef,
resyncIsNearBottom,
);
const handleScrollToMessage = useScrollToMessage(scrollRef, runGuardedScroll);
const handleScrollToMessage = useScrollToMessage(scrollRef, runGuardedScroll, motionEnabled);
const beginOlderLoad = useScrollPositionOnPrepend(
scrollRef,
items,
@@ -180,6 +180,7 @@ function AutoScrollHarness({
messages = TEST_MESSAGES,
markRef,
enabled = true,
motionEnabled = false,
isVisible = true,
sessionId = null,
metrics,
@@ -189,6 +190,7 @@ function AutoScrollHarness({
messages?: Message[];
markRef?: { current?: () => void };
enabled?: boolean;
motionEnabled?: boolean;
isVisible?: boolean;
sessionId?: string | null;
metrics?: NativeScrollMetrics;
@@ -203,6 +205,7 @@ function AutoScrollHarness({
isWorking,
sessionId,
enabled,
motionEnabled,
hasUnreadDivider,
isProgrammaticScrollLocked: NEVER_LOCKED,
isVisible,
@@ -1742,12 +1745,14 @@ type ScrollToMessageHandle = (
function ScrollToMessageHarness({
rows,
onHandle,
motionEnabled = true,
}: {
rows: string[];
motionEnabled?: boolean;
onHandle: (handle: ScrollToMessageHandle) => void;
}) {
const scrollRef = useRef<HTMLDivElement>(null);
const handle = useScrollToMessage(scrollRef, (performScroll) => performScroll());
const handle = useScrollToMessage(scrollRef, (performScroll) => performScroll(), motionEnabled);
useLayoutEffect(() => {
onHandle(handle);
}, [handle, onHandle]);
@@ -2192,3 +2197,105 @@ describe("useScrollToMessage — absent superseder", () => {
}
});
});
it("smooth follow defers its write and yields to manual input", () => {
let time = 0;
let serial = 0;
const frames = new Map<number, FrameRequestCallback>();
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
frames.set(++serial, cb);
return serial;
});
vi.stubGlobal("cancelAnimationFrame", (id: number) => frames.delete(id));
const step = () =>
act(() => {
time += 16;
const callbacks = [...frames.values()];
frames.clear();
callbacks.forEach((cb) => cb(time));
});
const view = render(
<AutoScrollHarness isWorking={false} hasUnreadDivider={false} motionEnabled />,
);
const el = screen.getByTestId(AUTO_SCROLL_CONTAINER_TEST_ID);
setScrollMetrics(el);
el.scrollTop = 0;
view.rerender(
<AutoScrollHarness
isWorking={false}
hasUnreadDivider={false}
motionEnabled
messages={[...TEST_MESSAGES]}
/>,
);
expect(el.scrollTop).toBe(0);
step();
step();
expect(el.scrollTop).toBeGreaterThan(0);
expect(el.scrollTop).toBeLessThan(600);
const top = el.scrollTop;
act(() => el.dispatchEvent(new WheelEvent("wheel", { deltaY: -100 })));
view.rerender(
<AutoScrollHarness
isWorking={false}
hasUnreadDivider={false}
motionEnabled
messages={[...TEST_MESSAGES]}
/>,
);
step();
expect(el.scrollTop).toBe(top);
view.unmount();
vi.unstubAllGlobals();
});
it("updates explicit navigation when chat motion is disabled", () => {
let handle: ScrollToMessageHandle | null = null;
const onHandle = (next: ScrollToMessageHandle) => {
handle = next;
};
const scroll = vi.spyOn(HTMLElement.prototype, "scrollIntoView");
const view = render(<ScrollToMessageHarness rows={["nav"]} onHandle={onHandle} />);
act(() => {
handle!("nav");
});
expect(scroll).toHaveBeenLastCalledWith({ block: "center", behavior: "smooth" });
view.rerender(
<ScrollToMessageHarness rows={["nav"]} onHandle={onHandle} motionEnabled={false} />,
);
act(() => {
handle!("nav");
});
expect(scroll).toHaveBeenLastCalledWith({ block: "center", behavior: "auto" });
});
it("cancels pending navigation landing when reduced motion becomes effective", () => {
const frames: Array<() => void> = [];
vi.stubGlobal("requestAnimationFrame", (callback: () => void) => {
frames.push(callback);
return frames.length;
});
vi.stubGlobal("cancelAnimationFrame", () => {});
vi.spyOn(HTMLElement.prototype, "scrollIntoView").mockImplementation(() => {});
let handle: ScrollToMessageHandle | null = null;
const onHandle = (next: ScrollToMessageHandle) => {
handle = next;
};
const view = render(<ScrollToMessageHarness rows={["cancel"]} onHandle={onHandle} />);
const root = screen.getByTestId(SCROLL_TO_MESSAGE_ROOT);
const target = root.querySelector<HTMLElement>("#msg-cancel")!;
root.getBoundingClientRect = () => createRect(0, 400);
target.getBoundingClientRect = () => createRect(120 - root.scrollTop, 20);
act(() => {
handle!("cancel", { align: "start" });
});
view.rerender(
<ScrollToMessageHarness rows={["cancel"]} onHandle={onHandle} motionEnabled={false} />,
);
act(() => {
for (let i = 0; i < 5 && frames.length; i++) frames.shift()!();
});
expect(root.scrollTop).toBe(0);
view.unmount();
vi.unstubAllGlobals();
});
@@ -3,6 +3,9 @@
/* eslint-disable max-lines -- native transcript composition owns scrolling. */
import { useEffect, useMemo, useRef, memo, forwardRef, useImperativeHandle } from "react";
import { cancelChatScrollMotion } from "./chat-scroll-motion";
import { useChatMotion } from "@/hooks/use-chat-motion";
import { ChatMotionProvider } from "./chat-motion";
import { SessionPanelContent } from "@kandev/ui/pannel-session";
import type { Message, TaskSessionState } from "@/lib/types/http";
import type { RenderItem } from "@/hooks/use-processed-messages";
@@ -87,6 +90,7 @@ type NativeMessageListScrollParams = {
isWorking: boolean;
sessionId: string | null;
enabled: boolean;
motionEnabled: boolean;
dividerBeforeItemKey?: string | null;
anchoredBarHeight?: number;
/** Initial/refetch loading: the sentinel's hard block. */
@@ -123,6 +127,7 @@ function useNativeMessageListScroll(params: NativeMessageListScrollParams) {
isWorking,
sessionId,
enabled,
motionEnabled,
dividerBeforeItemKey,
anchoredBarHeight,
messagesLoading,
@@ -151,6 +156,7 @@ function useNativeMessageListScroll(params: NativeMessageListScrollParams) {
isWorking,
sessionId,
enabled,
motionEnabled,
hasUnreadDivider: Boolean(dividerBeforeItemKey),
messagesLoading,
historyRefreshPending,
@@ -397,6 +403,7 @@ export function useScrollToDividerOrBottom(
// the desktop anchored prompt bar still reserves its measured height.
const containerRect = el.getBoundingClientRect();
const dividerRect = dividerEl.getBoundingClientRect();
cancelChatScrollMotion(el);
el.scrollTop += dividerRect.top - containerRect.top - anchoredBarOffsetPx;
onDividerScroll?.();
didScrollToDivider.current = true;
@@ -414,6 +421,7 @@ export function useScrollToDividerOrBottom(
activationPendingRef.current = false;
return;
}
cancelChatScrollMotion(el);
el.scrollTop = el.scrollHeight;
didInitialScroll.current = true;
activationPendingRef.current = false;
@@ -494,7 +502,7 @@ function NativeMessageListBody({
launchErrorOccurredAt,
}: NativeMessageListBodyProps) {
return (
<div className="p-4">
<div className="p-4" data-chat-content>
{/* Sentinel for lazy loading older messages */}
{hasMore && <div ref={sentinelRef} className="h-px" />}
@@ -635,6 +643,7 @@ export const NativeMessageList = memo(
const streamingMessageId = getStreamingAgentMessageId(visibleMessages);
const lastTurnGroupId = useMemo(() => getLastTurnGroupId(visibleItems), [visibleItems]);
const autoScrollEnabled = useTranscriptAutoScrollEnabled(sessionId);
const motionEnabled = useChatMotion();
const { handleScrollToMessage, sentinelRef, retryLoadMore, showRecovery } =
useNativeMessageListScroll({
scrollRef,
@@ -644,6 +653,7 @@ export const NativeMessageList = memo(
isWorking,
sessionId,
enabled: autoScrollEnabled,
motionEnabled,
dividerBeforeItemKey,
anchoredBarHeight,
messagesLoading,
@@ -670,43 +680,52 @@ export const NativeMessageList = memo(
<SessionPanelContent
ref={scrollRef}
className={`relative chat-message-list p-0 ${
autoScrollEnabled ? "[overflow-anchor:auto]" : "[overflow-anchor:none]"
autoScrollEnabled &&
(!motionEnabled || messagesLoading || historyRefreshPending || isLoadingMore)
? "[overflow-anchor:auto]"
: "[overflow-anchor:none]"
}`}
>
{stickyPromptBar}
<NativeMessageListBody
items={visibleItems}
messages={visibleMessages}
footerActionMessages={visibleFooterActionMessages}
permissionsByToolCallId={permissionsByToolCallId}
childrenByParentToolCallId={childrenByParentToolCallId}
taskId={taskId}
<ChatMotionProvider
sessionId={sessionId}
isWorking={isWorking}
messagesLoading={messagesLoading}
sessionState={sessionState}
worktreePath={worktreePath}
onOpenFile={onOpenFile}
hasMore={hasMore}
isLoadingMore={isLoadingMore}
isInitialLoading={isInitialLoading}
showLoadingState={showLoadingState}
historyStatus={historyStatus}
historyError={historyError}
onRetryHistory={onRetryHistory}
retryLoadMore={retryLoadMore}
showRecovery={showRecovery}
sentinelRef={sentinelRef}
lastTurnGroupId={lastTurnGroupId}
activeTurnId={effectiveActiveTurnId}
streamingMessageId={streamingMessageId}
onScrollToMessage={handleScrollToMessage}
autoScrollEnabled={autoScrollEnabled}
dividerBeforeItemKey={dividerBeforeItemKey}
launchErrorOwned={launchErrorOwned}
launchErrorStamp={launchErrorStamp}
launchErrorOccurredAt={launchErrorOccurredAt}
/>
messages={visibleMessages}
live={isVisible && !messagesLoading && !historyRefreshPending && !isLoadingMore}
>
<NativeMessageListBody
items={visibleItems}
messages={visibleMessages}
footerActionMessages={visibleFooterActionMessages}
permissionsByToolCallId={permissionsByToolCallId}
childrenByParentToolCallId={childrenByParentToolCallId}
taskId={taskId}
sessionId={sessionId}
isWorking={isWorking}
messagesLoading={messagesLoading}
sessionState={sessionState}
worktreePath={worktreePath}
onOpenFile={onOpenFile}
hasMore={hasMore}
isLoadingMore={isLoadingMore}
isInitialLoading={isInitialLoading}
showLoadingState={showLoadingState}
historyStatus={historyStatus}
historyError={historyError}
onRetryHistory={onRetryHistory}
retryLoadMore={retryLoadMore}
showRecovery={showRecovery}
sentinelRef={sentinelRef}
lastTurnGroupId={lastTurnGroupId}
activeTurnId={effectiveActiveTurnId}
streamingMessageId={streamingMessageId}
onScrollToMessage={handleScrollToMessage}
autoScrollEnabled={autoScrollEnabled}
dividerBeforeItemKey={dividerBeforeItemKey}
launchErrorOwned={launchErrorOwned}
launchErrorStamp={launchErrorStamp}
launchErrorOccurredAt={launchErrorOccurredAt}
/>
</ChatMotionProvider>
</SessionPanelContent>
);
}),
@@ -11,6 +11,7 @@ import { isLaunchStateRegression } from "@/lib/session-state";
import { buildStartCreatedRequest } from "@/lib/services/session-launch-helpers";
import { useAppStore } from "@/components/state-provider";
import { useTask } from "@/hooks/use-task";
import { ChatMotionItem } from "./chat-motion";
import { ChatMessage } from "@/components/task/chat/messages/chat-message";
import { PermissionRequestMessage } from "@/components/task/chat/messages/permission-request-message";
import { StatusMessage } from "@/components/task/chat/messages/status-message";
@@ -487,5 +488,10 @@ export const MessageRenderer = memo(function MessageRenderer({
};
const adapter =
adapters.find((entry) => entry.matches(comment, ctx)) ?? adapters[adapters.length - 1];
return adapter.render(comment, ctx);
const content = adapter.render(comment, ctx);
return isTaskDescription ? (
content
) : (
<ChatMotionItem messageId={comment.id}>{content}</ChatMotionItem>
);
});
@@ -4,6 +4,7 @@ import { memo } from "react";
import type { Message } from "@/lib/types/http";
import { RichBlocks } from "@/components/task/chat/messages/rich-blocks";
import { MessageActions } from "@/components/task/chat/messages/message-actions";
import { useChatTextMotion } from "../chat-motion";
import { MemoizedMarkdown } from "@/components/shared/memoized-markdown";
import { MessageCommentSurface } from "./message-comment-surface";
import { useMessageFavorite } from "@/hooks/domains/session/use-message-favorite";
@@ -32,6 +33,7 @@ export const AgentMessageContent = memo(function AgentMessageContent({
isTurnActive,
}: AgentMessageContentProps) {
const { t } = useTranslation();
const animateText = useChatTextMotion();
const { isFavorite, toggleFavorite } = useMessageFavorite(comment.session_id, comment.id);
return (
<div className="flex items-start gap-2 sm:gap-3 w-full group">
@@ -54,6 +56,7 @@ export const AgentMessageContent = memo(function AgentMessageContent({
>
<div className="markdown-body max-w-none">
<MemoizedMarkdown
animateText={animateText}
content={comment.content || t("task:empty")}
taskId={comment.task_id}
worktreePath={worktreePath}
@@ -4,6 +4,8 @@ import { useState, memo } from "react";
import { IconBrain } from "@tabler/icons-react";
import type { Message } from "@/lib/types/http";
import type { RichMetadata } from "@/components/task/chat/types";
import { ChatAnimatedText } from "@/components/shared/chat-markdown-motion";
import { useChatTextMotion } from "../chat-motion";
import { MemoizedMarkdown } from "@/components/shared/memoized-markdown";
import { ExpandableRow } from "./expandable-row";
import { useTranslation } from "react-i18next";
@@ -53,6 +55,7 @@ export const ThinkingMessage = memo(function ThinkingMessage({
onOpenFile?: (path: string) => void;
}) {
const { t } = useTranslation();
const animateText = useChatTextMotion();
const [isExpanded, setIsExpanded] = useState(false);
const metadata = comment.metadata as RichMetadata | undefined;
const text = metadata?.thinking ?? comment.content;
@@ -76,7 +79,7 @@ export const ThinkingMessage = memo(function ThinkingMessage({
</span>
{isShort && (
<span className="min-w-0 break-words whitespace-normal text-xs text-muted-foreground/80">
{displayText}
<ChatAnimatedText text={displayText} enabled={animateText} />
</span>
)}
</span>
@@ -85,7 +88,7 @@ export const ThinkingMessage = memo(function ThinkingMessage({
data-testid="thinking-message-preview"
className="min-w-0 flex-1 truncate text-xs text-muted-foreground/80"
>
{preview}
<ChatAnimatedText text={preview} enabled={animateText} />
</span>
)}
</div>
@@ -97,7 +100,12 @@ export const ThinkingMessage = memo(function ThinkingMessage({
{!isShort && (
<div className="pl-4 border-l-2 border-border/30">
<div className="markdown-body max-w-none text-xs text-foreground/70 [&>*]:my-1 [&>p]:my-1 [&>ul]:my-1 [&>ol]:my-1 [&_strong]:text-foreground/80">
<MemoizedMarkdown content={text} worktreePath={worktreePath} onOpenFile={onOpenFile} />
<MemoizedMarkdown
animateText={animateText && isExpanded}
content={text}
worktreePath={worktreePath}
onOpenFile={onOpenFile}
/>
</div>
</div>
)}
@@ -0,0 +1,55 @@
import { act, cleanup, renderHook } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { useChatScrollMotion } from "./use-chat-scroll-motion";
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
it("animates allowed requests, settles on disable, and stops when hidden", () => {
let id = 0;
let time = 0;
const frames = new Map<number, FrameRequestCallback>();
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
frames.set(++id, cb);
return id;
});
vi.stubGlobal("cancelAnimationFrame", (key: number) => frames.delete(key));
const el = document.createElement("div");
Object.defineProperties(el, { scrollHeight: { value: 1000 }, clientHeight: { value: 200 } });
const options = {
scrollRef: { current: el },
motionEnabled: true,
enabled: true,
isVisible: true,
sessionId: "s",
isNearBottomRef: { current: true },
isBlocked: () => false,
instant: (element: HTMLElement) => {
element.scrollTop = 800;
},
};
const hook = renderHook(useChatScrollMotion, { initialProps: options });
act(() => hook.result.current.followBottom());
const step = () =>
act(() => {
time += 16;
const pending = [...frames.values()];
frames.clear();
pending.forEach((cb) => cb(time));
});
step();
step();
expect(el.scrollTop).toBeGreaterThan(0);
expect(el.scrollTop).toBeLessThan(800);
hook.rerender({ ...options, motionEnabled: false });
expect(el.scrollTop).toBe(800);
expect(frames.size).toBe(0);
el.scrollTop = 0;
hook.rerender(options);
act(() => hook.result.current.followBottom());
step();
hook.rerender({ ...options, isVisible: false });
step();
expect(frames.size).toBe(0);
hook.unmount();
});
@@ -0,0 +1,62 @@
import { useCallback, useLayoutEffect, useRef, type RefObject } from "react";
import { createChatScrollMotion, type ScrollMotion } from "./chat-scroll-motion";
export type ChatScrollMotionOptions = {
scrollRef: RefObject<HTMLDivElement | null>;
motionEnabled: boolean;
enabled: boolean;
isVisible: boolean;
sessionId: string | null;
isNearBottomRef: RefObject<boolean>;
isBlocked: () => boolean;
instant: (element: HTMLElement) => void;
};
export function useChatScrollMotion(options: ChatScrollMotionOptions) {
const latest = useRef(options);
latest.current = options;
const driver = useRef<ScrollMotion | null>(null);
const userReading = useRef(false);
const canFollow = useCallback(() => {
const value = latest.current;
return value.enabled && value.isVisible && !value.isBlocked() && value.isNearBottomRef.current;
}, []);
useLayoutEffect(() => {
const element = options.scrollRef.current;
if (!element || !options.motionEnabled || !options.enabled || !options.isVisible) return;
const motion = createChatScrollMotion(element, canFollow, () => {
userReading.current = true;
latest.current.isNearBottomRef.current = false;
});
driver.current = motion;
const observer = new ResizeObserver(() => {
if (canFollow()) motion.request();
});
const content = element.querySelector("[data-chat-content]");
if (content) observer.observe(content);
return () => {
const settle = motion.isRunning() && !latest.current.motionEnabled && canFollow();
observer.disconnect();
motion.dispose();
driver.current = null;
if (settle) latest.current.instant(element);
};
}, [
options.scrollRef,
options.motionEnabled,
options.enabled,
options.isVisible,
options.sessionId,
canFollow,
]);
const followBottom = useCallback(() => {
const value = latest.current;
const element = value.scrollRef.current;
if (!element || !value.enabled || !value.isVisible || value.isBlocked()) return;
userReading.current = false;
value.isNearBottomRef.current = true;
if (driver.current) driver.current.request();
else value.instant(element);
}, []);
const cancel = useCallback(() => driver.current?.cancel(), []);
const isAnimating = useCallback(() => driver.current?.isRunning() ?? false, []);
return { followBottom, cancel, isAnimating, userReading };
}
@@ -2,6 +2,7 @@
import { IconLoader2 } from "@tabler/icons-react";
import { CompositorSpin } from "@kandev/ui/compositor-spin";
import { useChatMotion } from "@/hooks/use-chat-motion";
import { useAppStore } from "@/components/state-provider";
import { selectLiveSessionForTask } from "@/lib/state/slices/session/selectors";
import { useActiveSessionRef } from "./active-session-ref-context";
@@ -20,6 +21,7 @@ type TopbarWorkingIndicatorProps = {
*/
export function TopbarWorkingIndicator({ taskId }: TopbarWorkingIndicatorProps) {
const { t } = useTranslation();
const motionEnabled = useChatMotion();
const liveSession = useAppStore((s) => selectLiveSessionForTask(s, taskId));
const { getActiveNode } = useActiveSessionRef();
@@ -28,7 +30,7 @@ export function TopbarWorkingIndicator({ taskId }: TopbarWorkingIndicatorProps)
const handleClick = () => {
const node = getActiveNode();
if (!node) return;
node.scrollIntoView({ block: "end", behavior: "smooth" });
node.scrollIntoView({ block: "end", behavior: motionEnabled ? "smooth" : "auto" });
};
return (
@@ -13,6 +13,7 @@ import { useIsUtilityConfigured } from "@/hooks/use-is-utility-configured";
import { PromptResultRecovery } from "@/components/prompt-result-recovery";
import { usePromptResultDelivery } from "@/hooks/use-prompt-result-delivery";
import { useUtilityAgentGenerator } from "@/hooks/use-utility-agent-generator";
import { useChatMotion } from "@/hooks/use-chat-motion";
import { useAppStore } from "@/components/state-provider";
import { selectOfficeAgentProfiles } from "@/lib/state/slices/office/selectors";
import { selectCommandCount } from "@/lib/state/slices/session/selectors";
@@ -504,6 +505,7 @@ function useChatAutoScroll(
* the user when they scroll away.
*/
function useCommentHashScroll(comments: TaskComment[]): void {
const motionEnabled = useChatMotion();
const targetIdRef = useRef<string | null>(null);
useEffect(() => {
if (typeof window === "undefined") return;
@@ -517,9 +519,9 @@ function useCommentHashScroll(comments: TaskComment[]): void {
if (!targetId) return;
const el = document.getElementById(targetId);
if (!el) return;
el.scrollIntoView({ behavior: "smooth", block: "center" });
el.scrollIntoView({ behavior: motionEnabled ? "smooth" : "auto", block: "center" });
targetIdRef.current = null;
}, [comments]);
}, [comments, motionEnabled]);
}
function ChatEntries({
+12 -1
View File
@@ -1151,7 +1151,18 @@ export const TaskChatPanel = memo(function TaskChatPanel({
}
}, [hasMore, firstMessageId]);
// Search can target backend rows before the visible transcript boundary.
const search = useSessionSearch(resolvedSessionId, loadMoreRaw);
const navigateSearchHit = useCallback(
(id: string) => {
if (!messageListRef.current?.scrollToMessage(id, { align: "center" })) return null;
return (
panelRef.current?.querySelector<HTMLElement>(
`.chat-message-list [id="msg-${CSS.escape(id)}"]`,
) ?? null
);
},
[messageListRef],
);
const search = useSessionSearch(resolvedSessionId, loadMoreRaw, navigateSearchHit);
const { label: agentLabel, name: agentName } = useSessionAgentIdentity(resolvedSessionId);
usePanelSearch({
containerRef: panelRef,
@@ -0,0 +1,257 @@
import { test, expect } from "../../fixtures/test-base";
import { waitForSessionDone } from "../../helpers/session";
import { SessionPage } from "../../pages/session-page";
import type { AppState } from "@/lib/state/store";
import type { Page } from "@playwright/test";
type MotionWindow = Window & {
__KANDEV_E2E_STORE__?: { getState(): AppState };
__chatMotionSamples?: { text: string; opacity: number; kind: string }[];
};
async function observeMotion(page: Page) {
await page.evaluate(() => {
const win = window as MotionWindow;
win.__chatMotionSamples = [];
const original = Element.prototype.animate;
Element.prototype.animate = function (...args: Parameters<Element["animate"]>) {
const animation = original.apply(this, args);
if (
this.hasAttribute("data-chat-motion-item") ||
this.hasAttribute("data-chat-text-motion")
) {
win.__chatMotionSamples!.push({
text: this.textContent ?? "",
opacity: Number(getComputedStyle(this).opacity),
kind: this.hasAttribute("data-chat-text-motion") ? "text" : "row",
});
}
return animation;
};
});
}
export function chatMotionScenarios(mobile: boolean) {
test("chat motion preference previews, resets and persists on this device", async ({
testPage,
}) => {
await testPage.emulateMedia({ reducedMotion: "no-preference" });
await testPage.goto("/settings/preferences/appearance");
const card = testPage.getByTestId("chat-motion-settings-card");
const toggle = card.getByRole("switch", { name: "Chat animations", exact: true });
await expect(toggle).toBeChecked();
await toggle.scrollIntoViewIfNeeded();
if (mobile) {
const box = await toggle.boundingBox();
expect(box!.height).toBeGreaterThanOrEqual(44);
expect(box!.width).toBeGreaterThanOrEqual(44);
}
await toggle.click();
const save = testPage.getByTestId("settings-floating-save");
await save.getByRole("button", { name: "Reset", exact: true }).click();
await expect(toggle).toBeChecked();
await toggle.click();
await save.getByRole("button", { name: "Save changes", exact: true }).click();
await expect(save).not.toBeVisible();
await testPage.reload();
await expect(toggle).not.toBeChecked();
await expect(
testPage.getByRole("switch", { name: "Animate rich-output charts", exact: true }),
).toBeChecked();
expect(
await testPage.evaluate(
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth,
),
).toBe(true);
await toggle.scrollIntoViewIfNeeded();
await testPage.screenshot({
path: test
.info()
.outputPath(mobile ? "chat-motion-settings-phone.png" : "chat-motion-settings-desktop.png"),
});
});
test("live prose and rows animate, history and reduced motion stay static", async ({
testPage,
apiClient,
seedData,
}) => {
await testPage.emulateMedia({ reducedMotion: "no-preference" });
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Chat motion",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
if (!task.session_id) throw new Error("Missing session");
await waitForSessionDone(apiClient, task.id, task.session_id, "motion seed should finish");
// Establish the active turn before opening history. Creating a turn in the
// observed viewport triggers a history refresh, which intentionally stays static.
await apiClient.seedAgentMessages(task.session_id, 1, "Existing turn");
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await session.waitForChatIdle();
await expect
.poll(() =>
testPage.evaluate((id) => {
const meta = (window as MotionWindow).__KANDEV_E2E_STORE__!.getState().messages
.metaBySession[id];
return meta?.historyInitialized && !meta.isLoading;
}, task.session_id!),
)
.toBe(true);
await observeMotion(testPage);
await apiClient.seedAgentMessages(task.session_id, 1, "MOTION-PROSE");
await expect(session.activeChat().getByText("MOTION-PROSE 1", { exact: true })).toBeVisible();
await expect
.poll(() =>
testPage.evaluate(() =>
(window as MotionWindow).__chatMotionSamples!.some(
(sample) => sample.kind === "row" && sample.opacity < 1,
),
),
)
.toBe(true);
// Deliver a deterministic content update through the same action as the WS handler.
await testPage.evaluate((sessionId) => {
const state = (window as MotionWindow).__KANDEV_E2E_STORE__!.getState();
const message = state.messages.bySession[sessionId].find(
(item) => item.content === "MOTION-PROSE 1",
)!;
state.updateMessage({
...message,
content: message.content + " **new suffix** `code`",
updated_at: new Date().toISOString(),
});
}, task.session_id);
await expect(session.activeChat().getByText("new suffix", { exact: true })).toBeVisible();
await expect
.poll(() =>
testPage.evaluate(() =>
(window as MotionWindow).__chatMotionSamples!.some(
(sample) =>
sample.kind === "text" && sample.text.includes("new suffix") && sample.opacity < 1,
),
),
)
.toBe(true);
await expect(session.activeChat().locator("code [data-chat-text-motion]")).toHaveCount(0);
await expect(session.activeChat().locator("[data-chat-text-motion]")).toHaveCount(0);
await testPage.emulateMedia({ reducedMotion: "reduce" });
await testPage.evaluate(() => {
(window as MotionWindow).__chatMotionSamples = [];
});
await apiClient.seedAgentMessages(task.session_id, 1, "STATIC-PROSE");
await expect(session.activeChat().getByText("STATIC-PROSE 1", { exact: true })).toBeVisible();
// The media-query CSS applies before React handles the change event.
// Even an effect started during that handoff must stay visually static.
expect(
await testPage.evaluate(() =>
(window as MotionWindow).__chatMotionSamples!.filter((sample) => sample.opacity < 1),
),
).toEqual([]);
await expect
.poll(() =>
session
.activeChat()
.evaluate(
(el) =>
el
.getAnimations({ subtree: true })
.filter(
(animation) =>
animation.effect instanceof KeyframeEffect &&
(animation.effect.target as Element | null)?.matches(
"[data-chat-motion-item], [data-chat-text-motion]",
) &&
animation.playState === "running",
).length,
),
)
.toBe(0);
await testPage.emulateMedia({ reducedMotion: "no-preference" });
await apiClient.seedAgentMessages(task.session_id, 25, "SCROLL-HISTORY");
const scroller = session.activeChat().locator(".chat-message-list");
await expect
.poll(() => scroller.evaluate((el) => el.scrollHeight - el.clientHeight))
.toBeGreaterThan(200);
await expect
.poll(() => scroller.evaluate((el) => el.scrollHeight - el.clientHeight - el.scrollTop))
.toBeLessThan(3);
const lastParagraph = session.activeChat().getByText("SCROLL-HISTORY 25", { exact: true });
if (mobile) await lastParagraph.tap();
else await lastParagraph.click();
const movement = await testPage.evaluate(async (sessionId) => {
const state = (window as MotionWindow).__KANDEV_E2E_STORE__!.getState();
const message = state.messages.bySession[sessionId].find(
(item) => item.content === "STATIC-PROSE 1",
)!;
const el = [...document.querySelectorAll<HTMLElement>(".chat-message-list")].find(
(node) => node.clientHeight > 0,
)!;
const start = el.scrollTop;
state.updateMessage({
...message,
content:
message.content +
"\n\n" +
Array.from({ length: 20 }, (_, i) => `Streaming paragraph ${i}`).join("\n\n"),
updated_at: new Date().toISOString(),
});
const samples: number[] = [];
for (let i = 0; i < 24; i++) {
await new Promise(requestAnimationFrame);
samples.push(el.scrollTop);
}
return { start, samples, target: el.scrollHeight - el.clientHeight };
}, task.session_id);
expect(movement.samples.some((top) => top > movement.start && top < movement.target - 2)).toBe(
true,
);
expect(Math.abs(movement.samples.at(-1)! - movement.target)).toBeLessThan(3);
// Real input must release follow intent before the next delivery.
const scrollEnded = scroller.evaluate(
(el) =>
new Promise<void>((resolve) => {
el.addEventListener("scrollend", () => resolve(), { once: true });
}),
);
if (mobile) {
const box = (await scroller.boundingBox())!;
const client = await testPage.context().newCDPSession(testPage);
const point = { x: box.x + box.width / 2, y: box.y + box.height / 4 };
await client.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [point] });
await client.send("Input.dispatchTouchEvent", {
type: "touchMove",
touchPoints: [{ ...point, y: point.y + box.height / 2 }],
});
await client.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
await client.detach();
} else {
await scroller.hover();
await testPage.mouse.wheel(0, -350);
}
await scrollEnded;
await expect
.poll(() => scroller.evaluate((el) => el.scrollHeight - el.clientHeight - el.scrollTop))
.toBeGreaterThan(100);
const heldTop = await scroller.evaluate((el) => el.scrollTop);
await apiClient.seedAgentMessages(task.session_id, 1, "READER-OWNED");
await expect(session.activeChat().getByText("READER-OWNED 1", { exact: true })).toBeAttached();
expect(Math.abs((await scroller.evaluate((el) => el.scrollTop)) - heldTop)).toBeLessThan(3);
await testPage.reload();
await session.waitForLoad();
expect(await session.activeChat().locator("[data-chat-text-motion]").count()).toBe(0);
await testPage.screenshot({
path: test.info().outputPath(mobile ? "chat-motion-phone.png" : "chat-motion-desktop.png"),
});
});
}
@@ -0,0 +1,2 @@
import { chatMotionScenarios } from "./chat-motion-helpers";
chatMotionScenarios(false);
@@ -268,6 +268,14 @@ test.describe("@chat last prompt scroll affordance", () => {
await expect(bar.locator("code")).toHaveText("terraform apply");
const chip = bar.getByTestId(PROMPT_MENTION_CHIP);
await expect(chip).toHaveAttribute("data-prompt-name", ANCHORED_PROMPT_NAME);
// Smooth scrolling can reveal the bar while its entrance is still running.
await bar.evaluate(async (element) => {
await Promise.all(
element
.getAnimations({ subtree: true })
.map((animation) => animation.finished.catch(() => {})),
);
});
await chip.hover();
await expect(testPage.getByText(ANCHORED_PROMPT_CONTENT, { exact: false })).toBeVisible({
timeout: 10_000,
@@ -0,0 +1,2 @@
import { chatMotionScenarios } from "./chat-motion-helpers";
chatMotionScenarios(true);
@@ -127,3 +127,51 @@ describe("useSessionSearch", () => {
expect(result.current.query).toBe("");
});
});
it("delegates search navigation to the transcript scroll owner", async () => {
const row = document.createElement("div");
row.id = "msg-owned";
document.body.append(row);
const navigate = vi.fn(() => row);
const { result, unmount } = renderHook(() => useSessionSearch("sess-1", undefined, navigate));
await act(async () => {
result.current.setActiveHit("owned");
});
expect(navigate).toHaveBeenCalledWith("owned");
unmount();
row.remove();
});
it("flashes the navigation owner's row when another mounted panel has the same ID", async () => {
const other = document.createElement("div");
const owned = document.createElement("div");
other.id = owned.id = "msg-duplicate";
document.body.append(other, owned);
const navigate = vi.fn(() => owned);
const { result, unmount } = renderHook(() => useSessionSearch("sess-1", undefined, navigate));
try {
await act(async () => result.current.setActiveHit("duplicate"));
expect(owned.classList.contains("search-flash")).toBe(true);
expect(other.classList.contains("search-flash")).toBe(false);
} finally {
unmount();
other.remove();
owned.remove();
}
});
it("does not fall back to another panel when the navigation owner has no row", async () => {
const other = document.createElement("div");
other.id = "msg-other-panel";
document.body.append(other);
const loadOlder = vi.fn(async () => 0);
const { result, unmount } = renderHook(() => useSessionSearch("sess-1", loadOlder, () => null));
try {
await act(async () => result.current.setActiveHit("other-panel"));
expect(loadOlder).toHaveBeenCalledOnce();
expect(other.classList.contains("search-flash")).toBe(false);
} finally {
unmount();
other.remove();
}
});
@@ -56,10 +56,13 @@ function useDebouncedSearch(
}
/** Focus a hit in the DOM with scroll + flash animation. */
function focusMessageElement(id: string): boolean {
const el = document.getElementById(`msg-${id}`);
function focusMessageElement(id: string, navigate?: (id: string) => HTMLElement | null): boolean {
const el = navigate ? navigate(id) : document.getElementById(`msg-${id}`);
if (!el) return false;
el.scrollIntoView({ block: "center", behavior: "smooth" });
if (!navigate) {
// Without a navigation callback there is no guard against competing chat scrolling.
el.scrollIntoView({ block: "center", behavior: "auto" });
}
el.classList.remove("search-flash");
// Force reflow so animation replays when re-clicked
void el.offsetWidth;
@@ -73,29 +76,31 @@ function useSetActiveHit(
loadOlder: (() => Promise<number>) | undefined,
setActiveHitIdState: (id: string | null) => void,
genRef: React.RefObject<number>,
navigate?: (id: string) => HTMLElement | null,
) {
return useCallback(
async (id: string | null) => {
setActiveHitIdState(id);
if (!id) return;
const myGen = ++genRef.current;
if (focusMessageElement(id)) return;
if (focusMessageElement(id, navigate)) return;
if (!loadOlder) return;
for (let i = 0; i < MAX_BACKFILL_ITERATIONS; i++) {
const loaded = await loadOlder();
// Superseded by a newer setActiveHit, or close/unmount bumped genRef.
if (genRef.current !== myGen) return;
if (loaded === 0) break;
if (focusMessageElement(id)) return;
if (focusMessageElement(id, navigate)) return;
}
},
[loadOlder, setActiveHitIdState, genRef],
[loadOlder, setActiveHitIdState, genRef, navigate],
);
}
export function useSessionSearch(
sessionId: string | null | undefined,
loadOlder?: () => Promise<number>,
navigate?: (id: string) => HTMLElement | null,
): SessionSearchHook {
const [isOpen, setIsOpen] = useState(false);
const [query, setQueryState] = useState("");
@@ -138,7 +143,7 @@ export function useSessionSearch(
// Abort any in-flight setActiveHit backfill loop.
activeHitGenRef.current++;
}, []);
const setActiveHit = useSetActiveHit(loadOlder, setActiveHitIdState, activeHitGenRef);
const setActiveHit = useSetActiveHit(loadOlder, setActiveHitIdState, activeHitGenRef, navigate);
return {
isOpen,
+47
View File
@@ -0,0 +1,47 @@
import { act, cleanup, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useChatMotion } from "./use-chat-motion";
const state = vi.hoisted(() => ({ chatMotion: { enabled: true } }));
vi.mock("@/components/state-provider", () => ({
useAppStore: (select: (value: typeof state) => unknown) => select(state),
}));
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
state.chatMotion.enabled = true;
});
// @covers AC-UI-CHAT-MOTION-002.3
describe("effective chat motion", () => {
it("reacts to OS changes without changing the saved preference and cleans up", () => {
const listeners = new Set<() => void>();
const media = {
matches: false,
addEventListener: (_: string, cb: () => void) => listeners.add(cb),
removeEventListener: (_: string, cb: () => void) => listeners.delete(cb),
};
vi.stubGlobal("matchMedia", () => media);
const hook = renderHook(useChatMotion);
expect(hook.result.current).toBe(true);
act(() => {
media.matches = true;
listeners.forEach((cb) => cb());
});
expect(hook.result.current).toBe(false);
expect(state.chatMotion.enabled).toBe(true);
act(() => {
media.matches = false;
listeners.forEach((cb) => cb());
});
expect(hook.result.current).toBe(true);
state.chatMotion.enabled = false;
hook.rerender();
expect(hook.result.current).toBe(false);
hook.unmount();
expect(listeners.size).toBe(0);
});
it("renders statically when media preference cannot be read", () => {
vi.stubGlobal("matchMedia", undefined);
expect(renderHook(useChatMotion).result.current).toBe(false);
});
});
+19
View File
@@ -0,0 +1,19 @@
"use client";
import { useEffect, useState } from "react";
import { useAppStore } from "@/components/state-provider";
/** OS motion preferences override the current preview, never its saved value. */
export function useChatMotion(): boolean {
const enabled = useAppStore((state) => state.chatMotion.enabled);
const [motionAllowed, setMotionAllowed] = useState(false);
useEffect(() => {
if (typeof window.matchMedia !== "function") return;
const media = window.matchMedia("(prefers-reduced-motion: reduce)");
const update = () => setMotionAllowed(!media.matches);
update();
media.addEventListener("change", update);
return () => media.removeEventListener("change", update);
}, []);
return enabled && motionAllowed;
}
@@ -669,6 +669,35 @@ describe("useLazyLoadSentinel — re-arm, disarm, and stale completions", () =>
});
});
describe("useLazyLoadSentinel — gesture geometry", () => {
it("rejects a gesture retry after prepend moves a stale intersection outside preload", async () => {
const scrollRef = makeScrollRef();
let geometryEligible = true;
const loadMore = vi.fn(async () => 0);
const { result } = renderHook(() =>
useLazyLoadSentinel(scrollRef, true, false, false, loadMore, {
rearmWhileIntersecting: true,
isCurrentGeometryEligible: () => geometryEligible,
}),
);
const node = document.createElement("div");
act(() => result.current.sentinelRef(node));
fire(records[0], true, node);
await act(async () => {});
expect(loadMore).toHaveBeenCalledTimes(1);
geometryEligible = false;
act(() => result.current.onUserGesture());
await act(async () => {});
expect(loadMore).toHaveBeenCalledTimes(1);
geometryEligible = true;
act(() => result.current.onUserGesture());
await act(async () => {});
expect(loadMore).toHaveBeenCalledTimes(2);
});
});
describe("useLazyLoadSentinel — failure recovery and stale completions", () => {
it("permits an onUserGesture retry while disarmed and still intersecting", async () => {
const scrollRef = makeScrollRef();
+3 -1
View File
@@ -614,8 +614,10 @@ export function useLazyLoadSentinel(
const onUserGesture = useCallback(() => {
if (!disarmedRef.current || !intersectingRef.current) return;
const { hasMore, blocked, isLoadingMore } = stateRef.current;
const { joinInFlightWhileLoading } = optionsRef.current;
const { joinInFlightWhileLoading, isCurrentGeometryEligible } = optionsRef.current;
if (!hasMore || blocked || (isLoadingMore && !joinInFlightWhileLoading)) return;
// Prepend anchoring can emit a scroll before the observer reports its exit.
if (isCurrentGeometryEligible && !isCurrentGeometryEligible()) return;
void fireLoad();
}, [fireLoad]);
@@ -0,0 +1,37 @@
import { splitMotionText, type TextRun } from "./chat-text-motion";
type MotionNode = {
type: string;
tagName?: string;
value?: string;
properties?: Record<string, unknown>;
children?: MotionNode[];
position?: { start: { offset?: number } };
};
/** A chat-only HAST transform; normalized source offsets guard decoded text. */
export function chatTextMotionPlugin(source: string, runs: TextRun[]) {
return () => (tree: MotionNode) => {
const visit = (node: MotionNode) => {
if (!node.children || node.tagName === "code" || node.tagName === "pre") return;
node.children = node.children.flatMap((child): MotionNode[] => {
if (child.type !== "text" || !child.value) {
visit(child);
return [child];
}
return splitMotionText(child.value, child.position?.start.offset, source, runs).map(
(part) =>
part.receivedAt === undefined
? { type: "text", value: part.text }
: {
type: "element",
tagName: "span",
properties: { "data-chat-text-motion": part.receivedAt },
children: [{ type: "text", value: part.text }],
},
);
});
};
visit(tree);
};
}
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { appendedTextRuns } from "./chat-text-motion";
// @covers AC-UI-CHAT-MOTION-001.1, AC-UI-CHAT-MOTION-001.4
describe("incoming prose ranges", () => {
it("animates only the appended suffix", () => {
expect(appendedTextRuns("hello", "hello world", [], 10)).toEqual([
{ start: 5, end: 11, receivedAt: 10 },
]);
});
it("preserves active runs and expires settled ranges", () => {
const runs = [{ start: 1, end: 2, receivedAt: 0 }];
expect(appendedTextRuns("ab", "abc", runs, 20)).toEqual([
...runs,
{ start: 2, end: 3, receivedAt: 20 },
]);
expect(appendedTextRuns("ab", "abc", runs, 200)).toEqual([
{ start: 2, end: 3, receivedAt: 200 },
]);
});
it("coalesces updates delivered in the same frame", () => {
const runs = appendedTextRuns("a", "ab", [], 10);
expect(appendedTextRuns("ab", "abc", runs, 12)).toEqual([{ start: 1, end: 3, receivedAt: 10 }]);
});
it("renders rewrites and truncation statically", () => {
const runs = [{ start: 0, end: 3, receivedAt: 0 }];
expect(appendedTextRuns("abc", "xyz", runs, 10)).toEqual([]);
expect(appendedTextRuns("abc", "ab", runs, 10)).toEqual([]);
});
it("keeps UTF-16 boundaries aligned to the original Unicode text", () => {
expect(appendedTextRuns("你好 👋", "你好 👋 世界", [], 1)).toEqual([
{ start: 5, end: 8, receivedAt: 1 },
]);
});
});
import { splitMotionText } from "./chat-text-motion";
it("splits a Markdown text node without fading existing inline prose", () => {
expect(
splitMotionText("hello world", 2, "**hello world**", [{ start: 7, end: 13, receivedAt: 1 }]),
).toEqual([{ text: "hello" }, { text: " world", receivedAt: 1 }]);
});
it("leaves decoded entities and unpositioned nodes static", () => {
const runs = [{ start: 0, end: 100, receivedAt: 1 }];
expect(splitMotionText("a & b", 0, "a &amp; b", runs)).toEqual([{ text: "a & b" }]);
expect(splitMotionText("text", undefined, "text", runs)).toEqual([{ text: "text" }]);
});
+42
View File
@@ -0,0 +1,42 @@
export const CHAT_TEXT_DURATION_MS = 140;
export type TextRun = { start: number; end: number; receivedAt: number };
/** Keep only recent append ranges; rewrites have no reliable reveal boundary. */
export function appendedTextRuns(
previous: string,
next: string,
runs: TextRun[],
now: number,
): TextRun[] {
if (!next.startsWith(previous)) return [];
const active = runs.filter((run) => now - run.receivedAt < CHAT_TEXT_DURATION_MS);
if (next.length === previous.length) return active;
const last = active.at(-1);
if (last && last.end === previous.length && now - last.receivedAt < 16) {
return [...active.slice(0, -1), { ...last, end: next.length }];
}
return [...active, { start: previous.length, end: next.length, receivedAt: now }];
}
export type MotionTextPart = { text: string; receivedAt?: number };
export function splitMotionText(
value: string,
offset: number | undefined,
source: string,
runs: TextRun[],
): MotionTextPart[] {
if (offset === undefined || source.slice(offset, offset + value.length) !== value)
return [{ text: value }];
const parts: MotionTextPart[] = [];
let cursor = 0;
for (const run of runs) {
const start = Math.max(cursor, run.start - offset);
const end = Math.min(value.length, run.end - offset);
if (start >= end) continue;
if (start > cursor) parts.push({ text: value.slice(cursor, start) });
parts.push({ text: value.slice(start, end), receivedAt: run.receivedAt });
cursor = end;
}
if (cursor < value.length) parts.push({ text: value.slice(cursor) });
return parts;
}
@@ -18,6 +18,7 @@ const STORAGE_DISCOVERY_IDS = [
const STABLE_CONTROL_IDS = [
"appearance-color-theme",
"appearance-rich-output-motion",
"appearance-chat-motion",
"appearance-startup-page",
"appearance-display-language",
"terminal-preferred-shell",
@@ -16,6 +16,7 @@ const TASK_BEHAVIOR_ID = "preferences-task-behavior";
export const TASK_BEHAVIOR_SETTINGS_HREF = `${PREFERENCES_SETTINGS_HREF}/task-behavior`;
export const GENERAL_SETTINGS_TARGETS = {
colorTheme: "setting-color-theme",
chatMotion: "setting-chat-motion",
richOutputMotion: "setting-rich-output-motion",
settingsMenuMode: "setting-settings-menu-mode",
startupPage: "setting-startup-page",
@@ -100,6 +101,17 @@ export const PREFERENCES_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] =
targetId: GENERAL_SETTINGS_TARGETS.richOutputMotion,
order: 12,
},
{
id: "appearance-chat-motion",
kind: "control",
labelKey: "settings:chatAnimations",
aliasesKey: "settings:discoveryAliasesChatMotion",
parentId: APPEARANCE_ID,
groupId: "preferences",
href: APPEARANCE_SETTINGS_HREF,
targetId: GENERAL_SETTINGS_TARGETS.chatMotion,
order: 12,
},
{
id: "appearance-settings-menu",
kind: "control",
+31
View File
@@ -0,0 +1,31 @@
import { beforeEach, describe, expect, it } from "vitest";
import { STORAGE_KEYS } from "./constants";
import {
DEFAULT_CHAT_ANIMATIONS_ENABLED,
readChatAnimationsEnabled,
writeChatAnimationsEnabled,
} from "./chat-motion";
describe("chat motion preference", () => {
beforeEach(() => window.localStorage.clear());
it("defaults to enabled when no preference is stored", () => {
expect(DEFAULT_CHAT_ANIMATIONS_ENABLED).toBe(true);
expect(readChatAnimationsEnabled()).toBe(true);
});
it("falls back to enabled for malformed or non-boolean storage", () => {
window.localStorage.setItem(STORAGE_KEYS.CHAT_ANIMATIONS, "{not json");
expect(readChatAnimationsEnabled()).toBe(true);
window.localStorage.setItem(STORAGE_KEYS.CHAT_ANIMATIONS, JSON.stringify("disabled"));
expect(readChatAnimationsEnabled()).toBe(true);
});
it("persists an explicit device preference", () => {
writeChatAnimationsEnabled(false);
expect(readChatAnimationsEnabled()).toBe(false);
expect(window.localStorage.getItem(STORAGE_KEYS.CHAT_ANIMATIONS)).toBe("false");
});
});
+16
View File
@@ -0,0 +1,16 @@
import { getLocalStorage, setLocalStorage } from "@/lib/local-storage";
import { STORAGE_KEYS } from "./constants";
export const DEFAULT_CHAT_ANIMATIONS_ENABLED = true;
export function readChatAnimationsEnabled(): boolean {
const stored: unknown = getLocalStorage<boolean>(
STORAGE_KEYS.CHAT_ANIMATIONS,
DEFAULT_CHAT_ANIMATIONS_ENABLED,
);
return typeof stored === "boolean" ? stored : DEFAULT_CHAT_ANIMATIONS_ENABLED;
}
export function writeChatAnimationsEnabled(enabled: boolean): void {
setLocalStorage(STORAGE_KEYS.CHAT_ANIMATIONS, enabled);
}
+2
View File
@@ -7,6 +7,8 @@ export const STORAGE_KEYS = {
SETTINGS_MENU_MODE: "kandev.settings.menuMode",
/** Branch keys left open in the persistent tree on this device. */
SETTINGS_MENU_EXPANDED: "kandev.settings.menuExpanded",
/** Whether chat text, new items, and scrolling animate on this device. */
CHAT_ANIMATIONS: "kandev.settings.chatAnimations",
/** Whether agent rich-output line and bar charts animate on this device. */
RICH_OUTPUT_ANIMATIONS: "kandev.settings.richOutputAnimations",
} as const;
+4
View File
@@ -251,6 +251,7 @@ export type AppState = KanbanSlice & {
appSidebar: (typeof defaultUIState)["appSidebar"];
settingsMenu: (typeof defaultUIState)["settingsMenu"];
richOutputMotion: (typeof defaultUIState)["richOutputMotion"];
chatMotion: (typeof defaultUIState)["chatMotion"];
acknowledgedAgentErrors: (typeof defaultUIState)["acknowledgedAgentErrors"];
dismissedAgentErrors: (typeof defaultUIState)["dismissedAgentErrors"];
@@ -639,6 +640,9 @@ export type AppState = KanbanSlice & {
previewRichOutputAnimations: UIA["previewRichOutputAnimations"];
commitRichOutputAnimations: UIA["commitRichOutputAnimations"];
restoreRichOutputAnimations: UIA["restoreRichOutputAnimations"];
previewChatAnimations: UIA["previewChatAnimations"];
commitChatAnimations: UIA["commitChatAnimations"];
restoreChatAnimations: UIA["restoreChatAnimations"];
acknowledgeAgentErrors: UIA["acknowledgeAgentErrors"];
dismissAgentError: UIA["dismissAgentError"];
} & AppStateExtraActions &
@@ -0,0 +1,43 @@
import { beforeEach, describe, expect, it } from "vitest";
import { create } from "zustand";
import { immer } from "zustand/middleware/immer";
import { STORAGE_KEYS } from "@/lib/settings/constants";
import { createUISlice } from "./ui-slice";
import type { UISlice } from "./types";
function makeStore() {
return create<UISlice>()(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
immer((...args) => ({ ...(createUISlice as any)(...args) })),
);
}
describe("chat motion preference actions", () => {
it("defaults existing installations to enabled", () => {
expect(makeStore().getState().chatMotion).toEqual({ enabled: true, savedEnabled: true });
});
beforeEach(() => window.localStorage.clear());
it("hydrates the current and saved values from device storage", () => {
window.localStorage.setItem("kandev.settings.chatAnimations", "false");
const store = makeStore();
expect(store.getState().chatMotion).toEqual({ enabled: false, savedEnabled: false });
});
it("previews, commits, and restores without persisting a preview", () => {
const store = makeStore();
store.getState().previewChatAnimations(false);
expect(store.getState().chatMotion).toEqual({ enabled: false, savedEnabled: true });
expect(window.localStorage.getItem(STORAGE_KEYS.CHAT_ANIMATIONS)).toBeNull();
store.getState().restoreChatAnimations();
expect(store.getState().chatMotion.enabled).toBe(true);
store.getState().commitChatAnimations(false);
expect(store.getState().chatMotion).toEqual({ enabled: false, savedEnabled: false });
expect(window.localStorage.getItem(STORAGE_KEYS.CHAT_ANIMATIONS)).toBe("false");
});
});
@@ -0,0 +1,29 @@
import type { StateCreator } from "zustand";
import { readChatAnimationsEnabled, writeChatAnimationsEnabled } from "@/lib/settings/chat-motion";
import type { ChatMotionState, UISlice } from "./types";
export function loadChatMotionState(): ChatMotionState {
const enabled = readChatAnimationsEnabled();
return { enabled, savedEnabled: enabled };
}
type ImmerSet = Parameters<StateCreator<UISlice, [["zustand/immer", never]], [], UISlice>>[0];
export function buildChatMotionActions(set: ImmerSet) {
return {
previewChatAnimations: (enabled: boolean) =>
set((draft) => {
draft.chatMotion.enabled = enabled;
}),
commitChatAnimations: (enabled: boolean) =>
set((draft) => {
draft.chatMotion.enabled = enabled;
draft.chatMotion.savedEnabled = enabled;
writeChatAnimationsEnabled(enabled);
}),
restoreChatAnimations: () =>
set((draft) => {
draft.chatMotion.enabled = draft.chatMotion.savedEnabled;
}),
};
}
+7
View File
@@ -274,6 +274,9 @@ export type RichOutputMotionState = {
savedEnabled: boolean;
};
/** Chat text and scroll motion, independently saved per device. */
export type ChatMotionState = { enabled: boolean; savedEnabled: boolean };
/** Unified AppSidebar collapse + per-section expand state (localStorage). */
export type AppSidebarState = {
collapsed: boolean;
@@ -336,6 +339,7 @@ export type UISliceState = {
settingsMenu: SettingsMenuState;
/** Agent rich-output chart animation preference (localStorage). */
richOutputMotion: RichOutputMotionState;
chatMotion: ChatMotionState;
/**
* Most recently dismissed `last_agent_error` stamp per sessionId. Shared by
* the chat banner and the sidebar error icon so dismissing the banner also
@@ -522,6 +526,9 @@ export type UISliceActions = {
commitRichOutputAnimations: (enabled: boolean) => void;
/** Restore the persisted rich-output chart motion preference. */
restoreRichOutputAnimations: () => void;
previewChatAnimations: (enabled: boolean) => void;
commitChatAnimations: (enabled: boolean) => void;
restoreChatAnimations: () => void;
/** Record multiple sidebar badge acknowledgements with one localStorage merge. */
acknowledgeAgentErrors: (stamps: Record<string, string>) => void;
/** Record that `stamp` has been dismissed for `sessionId`. */
+4
View File
@@ -1,3 +1,4 @@
import { buildChatMotionActions, loadChatMotionState } from "./chat-motion-actions";
import type { StateCreator } from "zustand";
import {
getStoredCollapsedSubtaskParents,
@@ -179,6 +180,7 @@ export const defaultUIState: UISliceState = {
savedMode: DEFAULT_SETTINGS_MENU_MODE,
expandedKeys: [],
},
chatMotion: { enabled: true, savedEnabled: true },
richOutputMotion: {
enabled: true,
savedEnabled: true,
@@ -378,9 +380,11 @@ export const createUISlice: StateCreator<UISlice, [["zustand/immer", never]], []
appSidebar: loadAppSidebarState(),
settingsMenu: loadSettingsMenuState(),
richOutputMotion: loadRichOutputMotionState(),
chatMotion: loadChatMotionState(),
...buildAppSidebarActions(set),
...buildSettingsMenuActions(set),
...buildRichOutputMotionActions(set),
...buildChatMotionActions(set),
...buildPreviewActions(set),
...buildMobileActions(set),
...buildBottomTerminalActions(set),
+2 -1
View File
@@ -6,7 +6,8 @@
},
"settings": {
"preventAutoStartAgentOnOpen": "開啟任務時不自動啟動代理程式",
"preventAutoStartAgentOnOpenHelp": "重新啟動後,或從工作流程最後一步開啟任務時,不會自動啟動代理程式,改為顯示「啟動代理程式」按鈕。"
"preventAutoStartAgentOnOpenHelp": "重新啟動後,或從工作流程最後一步開啟任務時,不會自動啟動代理程式,改為顯示「啟動代理程式」按鈕。",
"chatAnimationsDescription": "在此裝置上為新文字、新聊天項目和滾動新增動畫。遵循減少動態效果的偏好設定。"
},
"common": {
"nativeFolderPickerUnavailable": "原生資料夾選擇器無法使用。請重新載入 Kandev 後再試。",
+4 -1
View File
@@ -807,5 +807,8 @@
"sidebarHoverDelayError": "Enter a whole number from 0 to 5000.",
"discoveryAliasesSidebarHover": "sidebar hover reveal delay wait mouse trackpad",
"autoFocusNewTasks": "Auto-focus new tasks",
"autoFocusNewTasksHelp": "Open newly created tasks automatically. Turn this off to stay on your current view. Tasks and agents still start as requested."
"autoFocusNewTasksHelp": "Open newly created tasks automatically. Turn this off to stay on your current view. Tasks and agents still start as requested.",
"chatAnimations": "Chat animations",
"chatAnimationsDescription": "Animate incoming text, new chat items, and scrolling on this device. Respects reduced motion.",
"discoveryAliasesChatMotion": "chat animations smooth scrolling text motion"
}
+4 -1
View File
@@ -807,5 +807,8 @@
"sidebarHoverDelayError": "Ēńţēŕ à ŵĥōĺē ńũḿƀēŕ ƒŕōḿ 0 ţō 5000.",
"discoveryAliasesSidebarHover": "śĩďēƀàŕ ĥōvēŕ ŕēvēàĺ ďēĺàŷ ŵàĩţ ḿōũśē ţŕàćķƥàď",
"autoFocusNewTasks": "Àũţō-ƒōćũś ńēŵ ţàśķś",
"autoFocusNewTasksHelp": "Ōƥēń ńēŵĺŷ ćŕēàţēď ţàśķś àũţōḿàţĩćàĺĺŷ. Ţũŕń ţĥĩś ōƒƒ ţō śţàŷ ōń ŷōũŕ ćũŕŕēńţ vĩēŵ. Ţàśķś àńď àĝēńţś śţĩĺĺ śţàŕţ àś ŕēqũēśţēď."
"autoFocusNewTasksHelp": "Ōƥēń ńēŵĺŷ ćŕēàţēď ţàśķś àũţōḿàţĩćàĺĺŷ. Ţũŕń ţĥĩś ōƒƒ ţō śţàŷ ōń ŷōũŕ ćũŕŕēńţ vĩēŵ. Ţàśķś àńď àĝēńţś śţĩĺĺ śţàŕţ àś ŕēqũēśţēď.",
"chatAnimations": "Ćĥàţ àńĩḿàţĩōńś",
"chatAnimationsDescription": "Àńĩḿàţē ĩńćōḿĩńĝ ţēxţ, ńēŵ ćĥàţ ĩţēḿś, àńď śćŕōĺĺĩńĝ ōń ţĥĩś ďēvĩćē. Ŕēśƥēćţś ŕēďũćēď ḿōţĩōń.",
"discoveryAliasesChatMotion": "ćĥàţ àńĩḿàţĩōńś śḿōōţĥ śćŕōĺĺĩńĝ ţēxţ ḿōţĩōń"
}
+4 -1
View File
@@ -807,5 +807,8 @@
"sidebarHoverDelayError": "Introduza um número inteiro entre 0 e 5000.",
"discoveryAliasesSidebarHover": "barra lateral passar rato mostrar atraso espera trackpad",
"autoFocusNewTasks": "Focar automaticamente novas tarefas",
"autoFocusNewTasksHelp": "Abre automaticamente as tarefas recém-criadas. Desative esta opção para manter a vista atual. As tarefas e os agentes continuam a iniciar conforme solicitado."
"autoFocusNewTasksHelp": "Abre automaticamente as tarefas recém-criadas. Desative esta opção para manter a vista atual. As tarefas e os agentes continuam a iniciar conforme solicitado.",
"chatAnimations": "Animações do chat",
"chatAnimationsDescription": "Animar o texto recebido, os novos elementos do chat e a deslocação neste dispositivo. Respeita a preferência de movimento reduzido.",
"discoveryAliasesChatMotion": "chat animações deslocação suave texto movimento"
}
+4 -1
View File
@@ -807,5 +807,8 @@
"sidebarHoverDelayError": "请输入 0 到 5000 之间的整数。",
"discoveryAliasesSidebarHover": "侧边栏 悬停 显示 延迟 等待 鼠标 触控板",
"autoFocusNewTasks": "自动聚焦新任务",
"autoFocusNewTasksHelp": "自动打开新创建的任务。关闭此选项可保留当前视图。任务和代理仍会按要求启动。"
"autoFocusNewTasksHelp": "自动打开新创建的任务。关闭此选项可保留当前视图。任务和代理仍会按要求启动。",
"chatAnimations": "聊天动画",
"chatAnimationsDescription": "在此设备上为新文本、新聊天项目和滚动添加动画。遵循减少动态效果的偏好设置。",
"discoveryAliasesChatMotion": "聊天 动画 平滑滚动 文本 动态效果"
}
+4 -1
View File
@@ -807,5 +807,8 @@
"sidebarHoverDelayError": "請輸入 0 到 5000 之間的整數。",
"discoveryAliasesSidebarHover": "側邊欄 懸停 顯示 延遲 等待 鼠標 觸控板",
"autoFocusNewTasks": "自動聚焦新任務",
"autoFocusNewTasksHelp": "自動開啟新建立的任務。關閉此選項可保留目前視圖。任務和代理仍會按要求啓動。"
"autoFocusNewTasksHelp": "自動開啟新建立的任務。關閉此選項可保留目前視圖。任務和代理仍會按要求啓動。",
"chatAnimations": "聊天動畫",
"chatAnimationsDescription": "在此設備上為新文字、新聊天項目和滾動新增動畫。遵循減少動態效果的偏好設定。",
"discoveryAliasesChatMotion": "聊天 動畫 平滑滾動 文字 動態效果"
}
+4 -1
View File
@@ -807,5 +807,8 @@
"sidebarHoverDelayError": "請輸入 0 到 5000 之間的整數。",
"discoveryAliasesSidebarHover": "側邊欄 懸停 顯示 延遲 等待 滑鼠 觸控板",
"autoFocusNewTasks": "自動聚焦新任務",
"autoFocusNewTasksHelp": "自動開啟新建立的任務。關閉此選項可保留目前檢視。任務和代理仍會按要求啟動。"
"autoFocusNewTasksHelp": "自動開啟新建立的任務。關閉此選項可保留目前檢視。任務和代理仍會按要求啟動。",
"chatAnimations": "聊天動畫",
"chatAnimationsDescription": "在此裝置上為新文字、新聊天項目和滾動新增動畫。遵循減少動態效果的偏好設定。",
"discoveryAliasesChatMotion": "聊天 動畫 平滑滾動 文字 動態效果"
}
+163
View File
@@ -0,0 +1,163 @@
---
created: 2026-09-15
status: implemented
requirements:
- REQ-UI-CHAT-MOTION-001
- REQ-UI-CHAT-MOTION-002
- REQ-UI-CHAT-MOTION-003
system_design:
- ../../specs/ui/system-design/chat-motion.md
legacy_specs: []
---
# Implementation Plan: Chat Motion
## Overview
Deliver subtle chat motion with a default-on, per-device Appearance setting.
[Requirements](../../specs/ui/requirements/chat-motion.md) and
[system design](../../specs/ui/system-design/chat-motion.md) own the contract.
Implement text/settings first, then integrate scrolling with its existing
placement protections. Implementation was authorized in the subsequent user turn. Both work orders
are implemented and verified.
## Scope
In scope: live prose suffixes, new transcript rows, effective reduced-motion
policy, Appearance Save/Reset, smooth following and explicit chat navigation,
localized copy, desktop/mobile coverage and public settings documentation.
Out of scope: terminal/global motion, rich-output internal animation changes,
message delivery changes, backend persistence, speed settings, exit animations.
No subagents, commit, push, or PR are authorized by this package.
## Technical approach
Follow the design's existing rich-output-motion storage/actions pattern,
Appearance draft transaction, optional chat-only Markdown text-node transform,
and native transcript follow policy. New files named in work orders are proposed
implementation targets; existing files were inspected. No new package dependency
is planned. Reconcile the existing transcript pinning wording in work order 02
so live smoothing is a bounded form of following, while restoration stays instant.
## ASCII UI preview
### UI-01: Appearance, enabled and disabled (desktop and phone)
Entry: Settings > Preferences > Appearance. Existing navigation is retained.
```text
Appearance
Rich output animations [on]
Chat animations [on]
Animate incoming text, new chat items,
and scrolling on this device.
Respects reduced motion.
[Reset] [Save]
Saved off: Chat animations [off]
OS reduced motion: saved switch stays on;
effective motion is off.
```
Both viewports retain this control order. Phone help text wraps below the
label; the switch has a 44 px hit area. Existing settings page owns scrolling
and Save/Reset placement. Spacing/copy are illustrative; control semantics,
localization, hierarchy, and touch reachability are required.
### UI-02: Live chat (shared content order, existing phone full-height layout)
```text
[Session header / navigation] fixed
[Existing transcript text] scrolls
[Assistant: existing text + incoming suffix] suffix fades
[New tool row] enters once
[Composer and existing navigation controls] outside scroller
```
Phone shows one full-height Chat surface; desktop retains its Dockview panel.
Only transcript content scrolls; composer and safe-area geometry are unchanged.
History/loading restoration and motion-off states use the same layout with
immediate content. No new empty/error state is introduced. UI-01 maps to
AC-UI-CHAT-MOTION-002.1 through .4; UI-02 maps to the 001 and 003 criteria.
The work-order E2E suites prove the rendered controls and motion states.
## Work orders
| Wave | Work order | Dependencies | Status |
| --- | --- | --- | --- |
| 1 | [01: Text motion and appearance](task-01-text-and-preference.md) | None | done |
| 2 | [02: Smooth scrolling](task-02-smooth-scrolling.md) | 01 | done |
Run sequentially: shared motion policy, transcript integration, and tests overlap.
Estimated implementation: 1 to 2 working days including browser verification;
Markdown reconciliation and scroll cancellation are the main uncertainty.
## Verification strategy
Each work order contains exact targeted unit and managed Playwright commands.
Use TDD for implementation; install workspace dependencies
once if absent. Browser suites build their own fresh production assets.
Record actual command results in each work order before marking it done.
Design gates (run from repository root):
```bash
python3 scripts/list-docs.py validate
python3 scripts/lint-spec-files.test.py
python3 scripts/lint-spec-files.py --all
git diff --check -- docs/specs docs/plans/chat-motion
git status --short -- docs/plans/chat-motion
```
## Risks and decisions
Inline Markdown structure can change mid-stream: static fallback is required
when append mapping is ambiguous. Grouped rows need child identity tracking.
Scroll animation must preserve logical follow intent without ignoring manual
scroll input. Existing synchronous-placement assertions remain valid for motion
off/restoration; add deterministic bounded-settle coverage for motion on.
Per-device persistence and OS precedence are routine design choices based on
the adjacent rich-output preference, not an account setting or release flag.
No material product questions remain open. Public documentation is updated
with implementation, per docs-maintainer.
## Results
Implementation authorized and completed in the primary session on 2026-09-15.
Both work orders are complete.
- Combined targeted unit run: 198 tests across 18 files passed.
- Final lint/selection/comment rerun: 94 tests across 5 files passed.
- Cached-history anchoring fix: 63 native-scroll/lifecycle tests passed.
- TypeScript, changed-file ESLint, translations, and production Vite build passed.
- Desktop suite: 22 passed initially; motion-test readiness was corrected and
the final-build motion/settings and restoration cases passed. The hover test
now waits for the pinned bar entrance to settle and passed three consecutive
final-build runs. All 24 targeted desktop cases are covered by passing runs.
- Phone suite on the final build: all 9 passed, including cached history during
refresh, touch interruption, motion setting persistence, and navigation.
- Desktop and phone browser traces observe actual opacity and intermediate
scroll positions; unit clocks prove bounded runs and no idle frame loop.
- Public docs: 46 pages validated; specification catalog and 36 linter tests passed.
- Browser tests used the managed single-worker runner and freshly built assets.
Local socket access required an approved sandbox escalation. The first native
build used `GOCACHE=/tmp/kandev-chat-motion-go-cache`; later runs reused those
backend artifacts with `--no-build` after rebuilding the changed web bundle.
- No commit, push, or PR was performed.
## PR review remediation
Continuous content growth now advances on every frame rather than restarting at
zero progress. Ordinary clicks and phone taps retain follow intent; a touch drag
beyond 6 px or a native scrollbar press yields control. Wheel and keyboard
interruption remain unchanged. The shared desktop/phone browser case now clicks
or taps transcript prose before asserting continued smooth following.
Validation: the new continuous-growth and click unit tests failed before the fix;
the phone-tap browser regression also failed against the earlier build. The
focused scroll, lifecycle, and search unit run passed all 77 tests.
Final rebuilt browser suites: desktop chat-motion 2/2 and phone chat-motion 2/2
passed with click/tap follow preservation and wheel/touch interruption.
@@ -0,0 +1,161 @@
---
id: TASK-CHAT-MOTION-01
title: Text motion and appearance preference
status: done
wave: 1
depends_on: []
plan: plan.md
requirements:
- REQ-UI-CHAT-MOTION-001
- REQ-UI-CHAT-MOTION-002
acceptance_criteria:
- AC-UI-CHAT-MOTION-001.1
- AC-UI-CHAT-MOTION-001.2
- AC-UI-CHAT-MOTION-001.3
- AC-UI-CHAT-MOTION-001.4
- AC-UI-CHAT-MOTION-002.1
- AC-UI-CHAT-MOTION-002.2
- AC-UI-CHAT-MOTION-002.3
- AC-UI-CHAT-MOTION-002.4
system_design:
- ../../specs/ui/system-design/chat-motion.md
---
# Text motion and appearance preference
## Summary and scope
Deliver live text/row motion and the persisted Appearance control end to end.
Read the paired requirements/design and root/web AGENTS.md before TDD.
Exclude scrolling mechanics (work order 02), backend settings, and non-chat
Markdown behavior. The final scrolling promise is completed by dependent 02.
## Likely files
- Proposed `apps/web/lib/settings/chat-motion.ts`, its test, and
`lib/state/slices/ui/chat-motion-actions.ts` with tests; existing settings
constants, `ui-slice.ts`, UI types, and `app-state-types.ts` for wiring.
- Proposed `apps/web/hooks/use-chat-motion.ts` and `.test.tsx`; existing
`components/settings/appearance-settings-state.ts`, its test,
`general-settings.tsx`, its test, and settings-discovery preferences catalog.
- Existing `components/task/chat/message-list-native.tsx`,
`message-list-shared.tsx`, and message components; proposed chat arrival helper
and `chat-motion.test.tsx`. Existing `components/shared/memoized-markdown.tsx`
and tests; proposed `lib/markdown/chat-text-motion.ts` and `.test.ts`.
- `apps/web/app/globals.css`, supported `src/locales/*/settings.json`, and
proposed `e2e/tests/chat/chat-motion.spec.ts` and `mobile-chat-motion.spec.ts`.
All abbreviated paths above are relative to apps/web. Keep new helpers narrow,
use existing Markdown dependencies, and do not enlarge already oversized files
with inline algorithms. Run the Traditional Chinese converter, not hand edits.
## Implementation acceptance
1. Default/storage, Appearance preview/save/cancel/rebase, OS precedence, and
localized/discoverable switch pass unit and desktop/phone tests.
2. New inline suffixes and newly delivered rows animate once; old text, grouped
updates, historical/refetched/remounted content, and rewritten Markdown do
not replay. Unicode, copy, links, and comment selection remain correct.
3. Finite effects cancel on disable/hide/unmount; bursts do not create unbounded
spans or effect queues. Record desktop/phone visual evidence and a stream trace.
## ASCII UI preview
### UI-01: Appearance, enabled and disabled (desktop and phone)
Entry: Settings > Preferences > Appearance. Existing navigation is retained.
```text
Appearance
Rich output animations [on]
Chat animations [on]
Animate incoming text, new chat items,
and scrolling on this device.
Respects reduced motion.
[Reset] [Save]
Saved off: Chat animations [off]
OS reduced motion: saved switch stays on;
effective motion is off.
```
Both viewports retain this control order. Phone help text wraps below the
label; the switch has a 44 px hit area. Existing settings page owns scrolling
and Save/Reset placement. Spacing/copy are illustrative; control semantics,
localization, hierarchy, and touch reachability are required.
### UI-02: Live chat (shared content order, existing phone full-height layout)
```text
[Session header / navigation] fixed
[Existing transcript text] scrolls
[Assistant: existing text + incoming suffix] suffix fades
[New tool row] enters once
[Composer and existing navigation controls] outside scroller
```
Phone shows one full-height Chat surface; desktop retains its Dockview panel.
Only transcript content scrolls; composer and safe-area geometry are unchanged.
History/loading restoration and motion-off states use the same layout with
immediate content. No new empty/error state is introduced. UI-01 maps to
AC-UI-CHAT-MOTION-002.1 through .4; UI-02 maps to the 001 and 003 criteria.
The work-order E2E suites prove the rendered controls and motion states.
## Targeted verification
From repository root; initial install is needed only if dependencies are absent:
```bash
(cd apps && pnpm install --frozen-lockfile)
(cd apps/web && pnpm exec vitest run lib/settings/chat-motion.test.ts lib/state/slices/ui/chat-motion-actions.test.ts hooks/use-chat-motion.test.tsx lib/markdown/chat-text-motion.test.ts components/task/chat/chat-motion.test.tsx components/shared/memoized-markdown.test.tsx components/shared/chat-markdown-motion.test.tsx components/settings/appearance-settings-state.test.ts components/settings/general-settings.test.tsx lib/settings-discovery/catalog.test.ts)
(cd apps/web && pnpm run typecheck)
(cd apps/web && pnpm run i18n:check)
(cd apps/web && pnpm e2e:run --project chromium tests/chat/chat-motion.spec.ts)
(cd apps/web && pnpm e2e:run --project mobile-chrome tests/chat/mobile-chat-motion.spec.ts)
git diff --check
```
Add behavioral RED tests before production code. E2E covers live append versus
history/remount, grouped new tools, preference reload/cancel, runtime reduced
motion, real finite opacity, text selection, and mobile control hit testing.
Use causal stream waits and finite animation completion, never fixed sleeps.
Run affected existing comment-selection tests if changing the selection boundary.
## Dependencies and risks
No work-order dependency. Depends on existing Appearance transaction and native
message list. Risk: source-offset mismatch after Markdown normalization; use
static fallback and explicit rewrite cases. Settings save must preserve new
edits made while saving. Structural previews above match the combined plan.
## Results
Implemented the per-device default-on preference, Appearance preview/Save/Reset,
localized discovery/copy, live reduced-motion policy, prose suffix fades,
new-row entrances, and selection-safe finite text runs.
Validation completed on 2026-09-15:
- The combined targeted run passed 198 tests across 18 files.
- Final lint cleanup and selection/comment regression rerun passed 94 tests
across 5 files; changed TypeScript files lint cleanly.
- TypeScript, i18n checks (all five supported languages plus pseudo), public
documentation validation (46 pages), and specification validation passed.
- Desktop and phone settings/live-motion scenarios passed on final assets.
Phone suite: all 9 tests passed. Screenshots of both layouts were inspected.
- Browser tests measure real intermediate opacity, bounded scroll settling,
wheel/touch interruption, and disabled/reduced-motion behavior. History readiness
and finite entrance completion are causal waits; no fixed sleeps were added.
- No commit or push performed.
## Consolidated review remediation
Unmarked Markdown spans retain the caller's renderer; only marked spans enter
the reveal wrapper. The motion test fixture restores the original Web Animations
API descriptor after each test. The Traditional Chinese description uses
「項目」 for chat items, with a conversion override preserving that wording.
Validation: the custom-renderer regression failed before the fix. The combined
Markdown/scroll/search unit run passed 29 tests.
Validation commands from `apps/web`: `pnpm exec vitest run components/task/chat/chat-scroll-motion.test.ts hooks/domains/session/use-session-search.test.ts components/shared/chat-markdown-motion.test.tsx` (29 passed); `pnpm run typecheck`, `pnpm run i18n:check`, and changed-file ESLint passed.
@@ -0,0 +1,203 @@
---
id: TASK-CHAT-MOTION-02
title: Smooth scrolling and final integration
status: done
wave: 2
depends_on: [TASK-CHAT-MOTION-01]
plan: plan.md
requirements:
- REQ-UI-CHAT-MOTION-002
- REQ-UI-CHAT-MOTION-003
acceptance_criteria:
- AC-UI-CHAT-MOTION-002.3
- AC-UI-CHAT-MOTION-002.4
- AC-UI-CHAT-MOTION-003.1
- AC-UI-CHAT-MOTION-003.2
- AC-UI-CHAT-MOTION-003.3
- AC-UI-CHAT-MOTION-003.4
system_design:
- ../../specs/ui/system-design/chat-motion.md
---
# Smooth scrolling and final integration
## Summary and scope
Integrate effective chat motion with live following and explicit navigation.
Read work order 01 results and the transcript auto-scroll requirement/design.
Exclude new scroll ownership, history placement redesign, and global CSS smooth
scroll. Complete user docs and package validation after integration.
## Likely files
Existing `apps/web/components/task/chat/message-list-native-scroll.ts`, its tests,
`message-list-native.tsx`, `message-list-native.test.tsx`,
`simple/task-chat.tsx`, and `hooks/domains/session/use-session-search.ts`.
Proposed `components/task/chat/chat-scroll-motion.ts` and `.test.ts` isolate
frame scheduling and cancellation. Extend work order 01 desktop/mobile E2E files.
Update `docs/public/sessions-and-review.md` with a short Appearance control section
and reconcile `docs/specs/ui/requirements/transcript-auto-scroll.md` and its
paired design's immediate-pinning language with the new bounded-motion contract.
## Implementation acceptance
1. One retargetable driver follows live growth within the specified settling
bound, preserving manual input, auto-scroll off, anchors, and programmatic
locks. Restoration is instant and commit-time geometry reads stay absent.
2. Motion off/OS reduced motion cancels all new effects and uses immediate chat
navigation; session toggles stay independent. Desktop and phone pass the
same behavior, including touch interruption and keyboard viewport resizing.
3. Public docs describe the shipped default, per-device save/cancel behavior,
reduced-motion precedence, and distinction from auto-scroll/rich-output
settings. All package criteria have recorded results and matching designs.
## ASCII UI preview
### UI-01: Appearance, enabled and disabled (desktop and phone)
Entry: Settings > Preferences > Appearance. Existing navigation is retained.
```text
Appearance
Rich output animations [on]
Chat animations [on]
Animate incoming text, new chat items,
and scrolling on this device.
Respects reduced motion.
[Reset] [Save]
Saved off: Chat animations [off]
OS reduced motion: saved switch stays on;
effective motion is off.
```
Both viewports retain this control order. Phone help text wraps below the
label; the switch has a 44 px hit area. Existing settings page owns scrolling
and Save/Reset placement. Spacing/copy are illustrative; control semantics,
localization, hierarchy, and touch reachability are required.
### UI-02: Live chat (shared content order, existing phone full-height layout)
```text
[Session header / navigation] fixed
[Existing transcript text] scrolls
[Assistant: existing text + incoming suffix] suffix fades
[New tool row] enters once
[Composer and existing navigation controls] outside scroller
```
Phone shows one full-height Chat surface; desktop retains its Dockview panel.
Only transcript content scrolls; composer and safe-area geometry are unchanged.
History/loading restoration and motion-off states use the same layout with
immediate content. No new empty/error state is introduced. UI-01 maps to
AC-UI-CHAT-MOTION-002.1 through .4; UI-02 maps to the 001 and 003 criteria.
The work-order E2E suites prove the rendered controls and motion states.
## Targeted verification
From repository root (dependencies installed by work order 01):
```bash
(cd apps/web && pnpm exec vitest run components/task/chat/chat-scroll-motion.test.ts components/task/chat/use-chat-scroll-motion.test.tsx components/task/chat/message-list-native-scroll.test.ts components/task/chat/message-list-native.test.tsx components/task/chat/transcript-auto-scroll.test.ts hooks/domains/session/use-session-search.test.ts components/task/simple/components/topbar-working-indicator.test.tsx components/task/simple/task-chat.test.tsx)
(cd apps/web && pnpm run typecheck)
(cd apps/web && pnpm e2e:run --project chromium tests/chat/chat-motion.spec.ts tests/chat/auto-scroll-toggle.spec.ts tests/chat/last-prompt-scroll.spec.ts)
(cd apps/web && pnpm e2e:run --project mobile-chrome tests/chat/mobile-chat-motion.spec.ts tests/chat/mobile-auto-scroll-toggle.spec.ts tests/chat/mobile-last-prompt-scroll.spec.ts)
node --test scripts/validate-public-docs.test.mjs
node scripts/validate-public-docs.mjs
python3 scripts/list-docs.py validate
python3 scripts/lint-spec-files.test.py
python3 scripts/lint-spec-files.py --all
git diff --check
```
Unit RED uses a deterministic frame clock: retarget bursts, logical following
while between positions, input cancellation, late resizes, hidden/session
cleanup, and disable without moving an owned reader position. Browser tests
observe intermediate scroll positions and exact final landing, wheel/touch
interruption, manual navigation during streaming, pagination anchors, and
hidden-panel restoration. Seed overflow so assertions cannot pass trivially.
Keep existing instant placement tests explicit about their motion state.
Record sustained-stream evidence with the new driver: bounded animation count,
no idle RAF loop and no geometry reads in the synchronous message commit path.
Run the work order 01 unit suites again only where integration changes their
owned logic. Synchronize Results and statuses after all required checks pass.
## Dependencies and risks
Depends on TASK-CHAT-MOTION-01's effective motion hook and tests. Existing
scroll-write sites have different ownership: do not replace every bottom write.
Scrollbar/manual events and driver-generated events must be distinguishable;
false near-bottom changes would strand a following transcript. Existing
work-start policy is retained. No new global preference coupling is permitted.
## Results
Implemented a single retargetable frame driver, ResizeObserver growth handling,
manual-input cancellation, navigation ownership and motion-off behavior. Initial
placement, history pagination, and layout restoration retain immediate writes.
Validation completed on 2026-09-15:
- Combined targeted unit run: 198 tests passed across 18 files.
- Final native scroll/settings/selection regression run: 94 tests passed across
5 files. Tests prove one queued frame, no synchronous request geometry reads,
exact settling, input cancellation, disabled navigation, and cleanup.
- TypeScript and changed-file lint passed; documentation gates passed.
- Fresh browser bundle built successfully. All 24 desktop cases have passing
coverage (initial suite plus targeted final-build reruns); all 9 phone cases
passed together on final assets. Desktop and phone screenshots were inspected.
- Phone cached-history regression was reproduced and fixed by retaining browser
anchoring during history loading/refresh; 63 scroll/lifecycle tests and the
full phone suite passed after the fix.
- The existing pinned-prompt hover test now waits for its finite entrance
animation before hovering. It passed three consecutive runs on final assets.
- Local backend sockets required approved sandbox escalation; managed runners
used one worker and fresh frontend assets with previously built backend binaries.
- No commit or push performed.
## PR review remediation
Continuous content growth now advances on every frame rather than restarting at
zero progress. Ordinary clicks and phone taps retain follow intent; a touch drag
beyond 6 px or a native scrollbar press yields control. Wheel and keyboard
interruption remain unchanged. The shared desktop/phone browser case now clicks
or taps transcript prose before asserting continued smooth following.
Validation: the new continuous-growth and click unit tests failed before the fix;
the phone-tap browser regression also failed against the earlier build. The
focused scroll, lifecycle, and search unit run passed all 77 tests.
Final rebuilt browser suites: desktop chat-motion 2/2 and phone chat-motion 2/2
passed with click/tap follow preservation and wheel/touch interruption.
## CI pagination remediation
A delayed upward scroll from prepend anchoring could retry a stale sentinel
intersection after the sentinel had already left preload. Gesture retries now
use the same current-geometry eligibility check as observer/lifecycle retries.
This preserves the existing pagination contract; no user setting changes.
Validation: the new stale-gesture regression failed before the fix; all 36
shared-sentinel tests, all 8 mobile pagination cases, and all 8 desktop
pagination cases passed after it. Typecheck, changed-file lint, and the spec
linter also passed.
Commands: `pnpm exec vitest run hooks/use-lazy-load-sentinel.test.ts` and
`pnpm e2e:run --host --no-build --project mobile-chrome e2e/tests/chat/mobile-message-pagination.spec.ts -- --retries=0` from `apps/web`.
Desktop command: `pnpm e2e:run --host --no-build e2e/tests/chat/message-pagination.spec.ts -- --retries=0`.
## Consolidated review interaction fixes
Scroll keys consumed by interactive/editable descendants retain follow intent.
Transcript-owned scroll keys still interrupt. Search now flashes the row
returned by the owning panel's navigation callback, never a duplicate global ID.
Validation: control-key and duplicate-panel regressions failed before the fixes;
all 29 focused Markdown/scroll/search tests passed, including prevented keys and
missing owner rows.
Validation commands from `apps/web`: `pnpm exec vitest run components/task/chat/chat-scroll-motion.test.ts hooks/domains/session/use-session-search.test.ts components/shared/chat-markdown-motion.test.tsx` (29 passed); `pnpm run typecheck`, `pnpm run i18n:check`, and changed-file ESLint passed.
The browser fixture creates its active turn before loading history, so the
observed delivery is a live append rather than a history refresh. Desktop
chat-motion passed 2/2 and session-search C4 passed 1/1 on rebuilt assets.
Phone chat-motion also passed 2/2. Browser runs used `pnpm e2e:run --host --no-build` with `e2e/tests/chat/chat-motion.spec.ts`, `e2e/tests/search/session-search.spec.ts -- --grep "C4 clicking"`, and `--project mobile-chrome e2e/tests/chat/mobile-chat-motion.spec.ts`, with retries disabled.
+13
View File
@@ -255,6 +255,19 @@ the desktop/tablet bottom bar or phone Status drawer. Configure the plugin under
Provider Usage**. Kandev hides the context ring rather than presenting
impossible data when reported use exceeds the reported window.
## Control chat animations
Open **Settings > Preferences > Appearance** and change **Chat animations**.
The setting is on by default and controls incoming text, new chat items, and
smooth scrolling on this device. Choose **Save changes** to keep the choice
across reloads, or **Reset** to discard the preview.
Your device's reduced-motion preference disables these effects even when the
switch is on. Turning chat animations off keeps content visible immediately
and leaves the session's auto-scroll preference unchanged. Scroll up to read
history without being pulled back by incoming content. Rich-output chart
animations have their own Appearance setting.
## Inspect changes
Open **+ > Changes** on desktop. A repository-less task has no Git state, so Kandev closes this panel automatically.
+98
View File
@@ -0,0 +1,98 @@
---
status: active
system: ui
created: 2026-09-15
owners:
- kandev
---
# Chat Motion Requirements
## Overview
Make incoming chat content easier to follow with subtle motion and a single
Appearance control. UI owns this reusable presentation preference; message
content, delivery order, and session lifecycle remain owned by their systems.
## Requirements
### REQ-UI-CHAT-MOTION-001: Incoming content motion
**Intent:** Soften live text and item arrivals without delaying reading.
#### Acceptance criteria
- **AC-UI-CHAT-MOTION-001.1:** With motion effective, newly appended assistant
prose, including inline text in an existing paragraph and visible thinking
prose, shall fade into full readability within 200 ms of receipt. Content
shall become available immediately, without a simulated typing queue.
- **AC-UI-CHAT-MOTION-001.2:** Newly delivered user/assistant messages and tool,
thinking, plan, and rich-output rows shall enter once within 200 ms, with no
more than 4 px of vertical movement. Updating an existing row shall not
replay its entrance or fade previously readable prose.
- **AC-UI-CHAT-MOTION-001.3:** Initial history, pagination, refetch, session
changes, panel remounts, and revealing collapsed existing content shall not
replay entrances. Hidden-panel deliveries shall be readable immediately
when the panel becomes visible.
- **AC-UI-CHAT-MOTION-001.4:** Markdown formatting, file links, inline comments,
text selection, copying, search, and accessible reading order shall retain
their behavior. Code and structured output shall remain immediately readable;
their containing new row can enter once.
### REQ-UI-CHAT-MOTION-002: Appearance preference
**Intent:** Let the reader disable chat motion without disabling chat following.
#### Acceptance criteria
- **AC-UI-CHAT-MOTION-002.1:** Settings > Preferences > Appearance shall expose
“Chat animations”, enabled by default for new and existing installations
without a saved choice. Its description shall cover text, new items, and
smooth scrolling. Saving shall retain the choice on this device after reload.
- **AC-UI-CHAT-MOTION-002.2:** The control shall follow Appearance preview,
Save, Cancel, and dirty-state behavior. Cancel or leaving without saving
shall restore the saved value; edits made during a save shall remain dirty.
- **AC-UI-CHAT-MOTION-002.3:** A disabled preference or an active OS
reduced-motion preference shall suppress these text/entrance effects and
smooth programmatic chat scrolling. Changes shall take effect during an
active stream and cancel active effects without hiding content or moving a
reader-owned position. OS changes shall not overwrite the saved choice.
- **AC-UI-CHAT-MOTION-002.4:** Desktop and phone shall expose the same setting
and outcomes, with localized labels, keyboard access, visible focus, a
minimum 44 px phone/coarse-pointer hit area, and no horizontal page overflow.
Rich-output animations shall remain an independent preference.
### REQ-UI-CHAT-MOTION-003: Smooth transcript following
**Intent:** Follow live content without abrupt jumps or fighting the reader.
#### Acceptance criteria
- **AC-UI-CHAT-MOTION-003.1:** When motion is effective and existing auto-scroll
policy permits following, content growth shall move continuously toward the
latest bottom and settle within 300 ms after growth stops, within 2 px of the
target. Repeated updates shall not queue separate scroll animations.
- **AC-UI-CHAT-MOTION-003.2:** Scrolling up by wheel, touch, keyboard, or scrollbar
during motion shall stop following. Existing auto-scroll-off, unread-divider,
history-anchor, navigation, session-placement, and panel-restoration behavior
shall remain authoritative. Initial placement and restoration shall be instant.
- **AC-UI-CHAT-MOTION-003.3:** Explicit chat navigation and re-enabling auto-scroll
shall use smooth movement when effective and immediate movement otherwise.
Disabling motion shall not change the session's auto-scroll preference.
- **AC-UI-CHAT-MOTION-003.4:** Sustained streaming shall not create an ever-growing
backlog of effects or per-character elements; completed content shall not be
repeatedly animated. Hidden or unmounted transcripts shall stop motion work.
## Compatibility
This extends [transcript auto-scroll stability](transcript-auto-scroll.md).
For motion-enabled live following, “pinned” means a retained follow intent and
bounded settling under AC-UI-CHAT-MOTION-003.1. Disabled-motion following keeps
immediate pinning. Initial placement and history-anchor rules do not change.
The implementation work order reconciles that wording in the existing contract.
## Out of scope
Global UI motion controls, terminal output animation, chart-internal motion,
exit/reorder animations, typing delays, speed controls, account sync, runtime
release flags, and changes to message transport or Markdown security policy.
@@ -35,12 +35,13 @@ hand-off it is designed to protect.
- **AC-UI-TRANSCRIPT-AUTO-SCROLL-001.2:** Re-enabling auto-scroll retains the existing catch-up behavior.
- **AC-UI-TRANSCRIPT-AUTO-SCROLL-001.3:** The clarification-recovery concurrency regression waits for its asynchronous completion signal within a bounded interval instead of treating scheduler timing as a product failure.
- **AC-UI-TRANSCRIPT-AUTO-SCROLL-001.4:** **GIVEN** an overflowing native transcript at its bottom with auto-scroll disabled, **WHEN** a new message is appended, **THEN** the transcript's `scrollTop` remains at the pre-append position.
- **AC-UI-TRANSCRIPT-AUTO-SCROLL-001.5:** **GIVEN** an overflowing native transcript with auto-scroll enabled, **WHEN** new content arrives, **THEN** the transcript remains pinned to the bottom.
- **AC-UI-TRANSCRIPT-AUTO-SCROLL-001.5:** **GIVEN** an overflowing native transcript with auto-scroll enabled, **WHEN** new content arrives, **THEN** the transcript follows the bottom. With chat motion enabled, bounded settling follows [chat motion](chat-motion.md); otherwise pinning is immediate.
- **AC-UI-TRANSCRIPT-AUTO-SCROLL-001.6:** **GIVEN** clarification recovery has accepted its retry prompt, **WHEN** its asynchronous dispatch is scheduled, **THEN** the recovery call completes before the intentionally blocked prompt is released.
- **AC-UI-TRANSCRIPT-AUTO-SCROLL-001.7:** **GIVEN** an enabled transcript that is
pinned to the bottom, **WHEN** streamed content commits, **THEN** the
transcript remains pinned without a synchronous content-size read in the
message-commit path.
transcript retains bottom-follow intent without a synchronous content-size
read in the message-commit path. Enabled chat motion uses the bounded settling
contract in [chat motion](chat-motion.md).
- **AC-UI-TRANSCRIPT-AUTO-SCROLL-001.8:** **GIVEN** an enabled, overflowing
transcript that was mounted while its desktop session tab was inactive,
**WHEN** the user activates that tab after initial load or page refresh,
+156
View File
@@ -0,0 +1,156 @@
---
status: current
system: ui
requirements:
- REQ-UI-CHAT-MOTION-001
- REQ-UI-CHAT-MOTION-002
- REQ-UI-CHAT-MOTION-003
---
# Chat Motion System Design
## Boundary and mapping
| Requirement | Design section |
| --- | --- |
| REQ-UI-CHAT-MOTION-001 | Live content rendering |
| REQ-UI-CHAT-MOTION-002 | Preference and effective motion |
| REQ-UI-CHAT-MOTION-003 | Scroll integration |
UI owns presentation only. Reuse the existing native transcript; do not replace
its placement engine or broaden the shared Markdown renderer's default behavior.
See [requirements](../requirements/chat-motion.md),
[auto-scroll design](transcript-auto-scroll.md), and
[implementation plan](../../../plans/chat-motion/plan.md).
## Preference and effective motion
Preserve [ADR 0046](../../../decisions/0046-settings-route-save-coordinator.md)
and the existing route save coordinator; the row joins Appearance's contributor.
Follow `lib/settings/rich-output-motion.ts` and
`lib/state/slices/ui/rich-output-motion-actions.ts`, using a separate proposed
`chatMotion` state with `enabled` and `savedEnabled`. Add a typed local-storage
key `kandev.settings.chatAnimations`, default true, accepting booleans only.
Use the existing storage wrapper's fallback on unavailable/corrupt storage.
No backend schema, account PATCH field, or runtime registry entry is required.
Extend `AppearanceState`, saved-state construction, patch/rebase/signature
helpers, and `general-settings.tsx` preview/commit/restore handling. Preserve
in-flight-save rebasing and the existing account-save failure behavior before
committing local values. Wire slice actions through `ui-slice.ts`, UI types,
and `app-state-types.ts`. Register the row in settings discovery.
A proposed `use-chat-motion` hook combines the current preference with a live
`prefers-reduced-motion: reduce` subscription. The effective value is false
until client preference/media initialization is known, preventing a first-paint
flash for users who opted out. Keep CSS reduced-motion suppression as a second
layer. A preference/media change cancels effects and reveals text immediately;
re-enabling only affects future arrivals. Do not persist OS-derived values.
Place “Chat animations” beside the rich-output animation control. Suggested
localized description: “Animate incoming text, new chat items, and scrolling
on this device. Respects reduced motion.” Include all five supported catalogs,
generate Traditional Chinese with the repository converter, and refresh pseudo.
## Live content rendering
`message-list-native.tsx` and `MessageItem` in `message-list-shared.tsx` supply
live-arrival eligibility using stable `getItemKey` identities. Track a baseline
only after initial history/refresh settles. Reset on session change. Pagination,
refetch and hidden-panel updates advance the baseline without animating.
A new child within an existing grouped tool/turn item needs its own stable
message identity: group-key stability must not suppress that child's entrance.
Keep bookkeeping bounded to the loaded transcript, clearing removed identities.
Add a chat-scoped presentation context for eligibility and effective motion.
Enter eligible row content with opacity 0 to 1 and translateY 3 px to 0 over
160 ms, ease-out, without stagger backlog. Animate an inner presentation wrapper,
not the measured/anchored row. Avoid double animation of a parent group and child.
No height, margin, blur, bounce, or exit animation. Stable keys prevent replay.
`messages/agent-message-content.tsx` and `thinking-message.tsx` opt into an
optional chat-only text transform in `MemoizedMarkdown`; all other callers keep
its current behavior. Transform Markdown text nodes before React renders them,
using source positions in the same normalized string that is parsed, stable
message identity, and the previous normalized content to identify append-only
suffixes. Split at text-node boundaries, never raw Markdown tokens. Exclude code,
preformatted text and custom structured renderers. If source positions are
missing or an update rewrites prior content, render the affected text immediately
instead of guessing a boundary or replaying the paragraph.
Animate only newly appended prose runs, opacity 0.6 to 1 over 140 ms. Publish
all text in the same commit; no per-character spans or artificial streaming
buffer. Keep previous runs' identities stable while active and compact settled
runs without replay. While text is selected, retain existing spans and render
additional text statically; compact after the selection is released. Coalesce rapid arrivals to at most one new run per rendered frame. Preserve Unicode, links, inline emphasis, whitespace, and textContent.
Changing Markdown structure can reconcile nodes; source rewrites use the static
fallback. Do not mutate React-owned DOM or add live-region copies. Existing
comment-selection tests must prove span boundaries do not change offsets. Only
marked motion spans use the motion renderer; unmarked spans retain the caller
renderer.
## Scroll integration
`useAutoScroll` and its helpers in `message-list-native-scroll.ts` remain the
policy authority. Add one small cancelable follow driver per visible native
scroll container. Existing allowed live-follow calls request a target instead
of synchronously writing the bottom when motion is effective. Frame callbacks
read current geometry, then perform one scroll write, easing toward a moving
bottom target and landing exactly within 300 ms of the last growth. New content
updates the target of the active driver, never queues another animation. Retargeting
preserves elapsed frame time so continuous growth cannot stall scroll progress.
Use existing resize notifications for late content growth. Keep synchronous
content-size reads out of message commits (the existing stability invariant).
Follow intent is distinct from instantaneous distance to bottom while the driver
runs: its own intermediate scroll events must not incorrectly clear intent.
Actual wheel/touch/keyboard/scrollbar input cancels it and returns control to
existing near-bottom logic. Ordinary content clicks do not clear follow intent;
pointer presses interrupt only in the native scrollbar gutter. Keys handled by
interactive or editable descendants do not cancel following. Explicit scroll-to-message navigation cancels the
follow driver and retains `useProgrammaticScrollGuard`. Thread effective motion
through `handleScrollToMessage`, transcript search, and chat controls in
`simple/task-chat.tsx`, avoiding application-wide `scroll-behavior: smooth`.
Search highlighting uses the row returned by its panel-scoped navigation owner,
so duplicate message IDs in another mounted panel cannot receive the flash.
Initial placement, pagination compensation, unread positioning, and Dockview
restoration continue using immediate writes. Retain native scroll anchoring while
history is loading or refreshing; suppress it only for eligible live motion so
cached-history layout changes retain their existing placement behavior. They cancel any active driver
before applying their position. Auto-scroll disabled, panel hidden/unmounted,
session switched, and programmatic locks also cancel it. Disabling motion while
following snaps to the latest target only if existing follow policy still allows
it; otherwise it cancels without moving the viewport. Existing work-start policy
continues to decide whether a new turn requests bottom placement.
## Phone composition and accessibility
Reuse the shipped Appearance settings surface for the switch and the
full-height session chat layout for transcript motion. The curated mobile
language's dense-viewer pattern applies: Chat remains the single internal
vertical scroll owner, composer remains outside it, dynamic viewport and safe
areas remain intact. Settings retain their own scrolling page and Save/Reset
controls. This frequent reading surface needs no new drawer or navigation step.
Share preference and motion logic across viewports; use wrapped help text and
44 px coarse-pointer setting hit areas while retaining desktop density.
Touch scrolling and keyboard appearance must cancel or resize following through
the existing viewport policy. Motion wrappers remain pointer-inert and do not
change focus order, announcements, hit testing, or overflow geometry.
## Verification and failure behavior
Unit tests cover default/storage, draft save/rebase/cancel, live reduced-motion,
append classification, grouped rows, Markdown rewrites, Unicode/selection, and
frame-driver cancellation/retargeting with a deterministic frame clock. Browser
tests assert actual intermediate opacity/scroll positions and final content,
not just CSS classes. Cover sustained burst streams, long histories, user scroll
interruption, hidden tabs, session changes, and disabled motion on desktop/phone.
Record a rendered desktop/phone check and a sustained-stream trace: no idle frame
loop, no accumulating animations/spans, no new synchronous layout in commit path.
If an animation API or text-position mapping is unavailable, content renders
statically. Use existing logging/debug facilities only; no new telemetry or
security boundary is introduced. This local preference follows an established
pattern and does not need a new ADR.
@@ -30,7 +30,8 @@ restoration, and catch-up after the user enables auto-scroll again.
`message-list-native-scroll.ts` owns one helper that places the native scroll
container at its maximum vertical offset with a write-only scroll command. The
common message-update and work-start paths call this helper. They do not read
common message-update and work-start paths call this helper when chat motion
is disabled. They do not read
`scrollHeight`, `clientHeight`, a bounding rectangle, or computed style before
the write.
@@ -42,7 +43,12 @@ to the top of the scroll container.
The existing near-bottom reference remains the decision input. The helper does
not add a second animation frame, smooth scrolling, or a resize observer.
Tests instrument the common append path. A `scrollHeight` getter fails the test
When chat motion is enabled, the same policy requests the cancelable frame
controller in [Chat Motion](chat-motion.md). That controller reads geometry only
in scheduled frames. Initial placement, restoration, and prepend compensation
remain immediate and cancel any active motion before placing the viewport.
Tests instrument the immediate append path. A `scrollHeight` getter fails the test
if that path reads it. The scroll setter records the WebKit-safe offset.
## Persistent-panel visibility lifecycle
@@ -146,7 +152,9 @@ replaying an intersection observed while blocked, `useLazyLoadSentinel` invokes
the consumer's current-geometry predicate. The transcript starts pagination
only if the current sentinel and scroll root remain inside the preload region.
The same current-geometry rule applies when a stale request hands an observed
intersection to a replacement view.
intersection to a replacement view or a scroll gesture retries a disarmed
sentinel. Prepend anchoring can emit an upward scroll before the observer
reports its exit, so a cached intersection alone cannot authorize a retry.
Same-environment session switches return before arming this lifecycle.
Maximize, un-maximize, preset, and custom-layout rebuilds retain the existing