diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index 85186ef596..0c147eee0b 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -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. diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index bfba0de4de..efab72071e 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -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; + } +} diff --git a/apps/web/components/settings/appearance-settings-state.test.ts b/apps/web/components/settings/appearance-settings-state.test.ts index 1889b7c99e..f2800cbbbf 100644 --- a/apps/web/components/settings/appearance-settings-state.test.ts +++ b/apps/web/components/settings/appearance-settings-state.test.ts @@ -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); +}); diff --git a/apps/web/components/settings/appearance-settings-state.ts b/apps/web/components/settings/appearance-settings-state.ts index 5a77fe3c0b..33bcfaa5d8 100644 --- a/apps/web/components/settings/appearance-settings-state.ts +++ b/apps/web/components/settings/appearance-settings-state.ts @@ -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, diff --git a/apps/web/components/settings/chat-motion-settings-card.tsx b/apps/web/components/settings/chat-motion-settings-card.tsx new file mode 100644 index 0000000000..04e5390ea8 --- /dev/null +++ b/apps/web/components/settings/chat-motion-settings-card.tsx @@ -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 ( + + +
+
+ +

+ {t("settings:chatAnimationsDescription")} +

+
+ +
+
+
+ ); +} diff --git a/apps/web/components/settings/general-settings.test.tsx b/apps/web/components/settings/general-settings.test.tsx index 3f18679b5c..670d34f19a 100644 --- a/apps/web/components/settings/general-settings.test.tsx +++ b/apps/web/components/settings/general-settings.test.tsx @@ -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(); diff --git a/apps/web/components/settings/general-settings.tsx b/apps/web/components/settings/general-settings.tsx index d09404b32e..2eb019a3b4 100644 --- a/apps/web/components/settings/general-settings.tsx +++ b/apps/web/components/settings/general-settings.tsx @@ -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} /> + ); @@ -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} /> ); diff --git a/apps/web/components/shared/chat-markdown-motion.test.tsx b/apps/web/components/shared/chat-markdown-motion.test.tsx new file mode 100644 index 0000000000..bb5e5f6fdc --- /dev/null +++ b/apps/web/components/shared/chat-markdown-motion.test.tsx @@ -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(); + expect(animate).not.toHaveBeenCalled(); + view.rerender(); + 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(); + view.rerender(); + expect(animate).toHaveBeenCalled(); + view.rerender(); + expect(cancel).toHaveBeenCalled(); + animate.mockClear(); + view.rerender(); + view.rerender(); + 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(); + for (let i = 1; i < 40; i++) + view.rerender(); + 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(); + view.rerender(); + 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(); + 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 }) => ( + {children} + ), + }; + const view = render(); + expect(view.container.querySelector("[data-custom-span]")?.textContent).toBe("old"); + view.rerender(); + expect(view.container.querySelector("[data-custom-span]")?.textContent).toBe("old new"); + expect(view.container.querySelector("[data-custom-span] [data-chat-text-motion]")).not.toBeNull(); +}); diff --git a/apps/web/components/shared/chat-markdown-motion.tsx b/apps/web/components/shared/chat-markdown-motion.tsx new file mode 100644 index 0000000000..eccc889b8d --- /dev/null +++ b/apps/web/components/shared/chat-markdown-motion.tsx @@ -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(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 ; +} + +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 + ) : ( + + {part.text} + + ), + ); +} diff --git a/apps/web/components/shared/memoized-markdown.tsx b/apps/web/components/shared/memoized-markdown.tsx index e30005048a..4e27673bc1 100644 --- a/apps/web/components/shared/memoized-markdown.tsx +++ b/apps/web/components/shared/memoized-markdown.tsx @@ -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) => { + const { node, ...attributes } = props; + if ( + node?.properties?.["data-chat-text-motion"] !== undefined || + attributes["data-chat-text-motion"] !== undefined + ) + return ; + return typeof Span === "string" ? createElement(Span, attributes) : ; + }, + }; + }, [animateText, components]); return ( - - {normalizeCached(content)} + + {normalized} diff --git a/apps/web/components/task/chat/chat-motion-state.ts b/apps/web/components/task/chat/chat-motion-state.ts new file mode 100644 index 0000000000..0947c11adc --- /dev/null +++ b/apps/web/components/task/chat/chat-motion-state.ts @@ -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 { + 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)); +} diff --git a/apps/web/components/task/chat/chat-motion.test.tsx b/apps/web/components/task/chat/chat-motion.test.tsx new file mode 100644 index 0000000000..9f041d52d5 --- /dev/null +++ b/apps/web/components/task/chat/chat-motion.test.tsx @@ -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) => ( + ({ id }))} live={live}> + {ids.map((id) => ( + + {id} + + ))} + + ); + 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); +}); diff --git a/apps/web/components/task/chat/chat-motion.tsx b/apps/web/components/task/chat/chat-motion.tsx new file mode 100644 index 0000000000..f6c2e4387a --- /dev/null +++ b/apps/web/components/task/chat/chat-motion.tsx @@ -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() as ReadonlySet, + 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(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 {children}; +} + +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(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 ( +
+ {children} +
+ ); +} diff --git a/apps/web/components/task/chat/chat-scroll-motion.test.ts b/apps/web/components/task/chat/chat-scroll-motion.test.ts new file mode 100644 index 0000000000..4cb7ef7190 --- /dev/null +++ b/apps/web/components/task/chat/chat-scroll-motion.test.ts @@ -0,0 +1,218 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createChatScrollMotion } from "./chat-scroll-motion"; +let frames: Map; +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(); +}); diff --git a/apps/web/components/task/chat/chat-scroll-motion.ts b/apps/web/components/task/chat/chat-scroll-motion.ts new file mode 100644 index 0000000000..c09914d839 --- /dev/null +++ b/apps/web/components/task/chat/chat-scroll-motion.ts @@ -0,0 +1,123 @@ +export type ScrollMotion = { + request: () => void; + cancel: () => void; + dispose: () => void; + isRunning: () => boolean; +}; + +const activeMotions = new WeakMap(); +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; +} diff --git a/apps/web/components/task/chat/message-list-native-scroll.ts b/apps/web/components/task/chat/message-list-native-scroll.ts index 3e1b1ef2b2..f929b248b1 100644 --- a/apps/web/components/task/chat/message-list-native-scroll.ts +++ b/apps/web/components/task/chat/message-list-native-scroll.ts @@ -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; + isVisibleRef: React.RefObject; + isAnimating: () => boolean; + userReading: React.RefObject; + isNearBottomRef: React.RefObject; + 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; 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; 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, + 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, 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(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, diff --git a/apps/web/components/task/chat/message-list-native.test.tsx b/apps/web/components/task/chat/message-list-native.test.tsx index 1d97febae5..cedf3d5fe5 100644 --- a/apps/web/components/task/chat/message-list-native.test.tsx +++ b/apps/web/components/task/chat/message-list-native.test.tsx @@ -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(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(); + 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( + , + ); + const el = screen.getByTestId(AUTO_SCROLL_CONTAINER_TEST_ID); + setScrollMetrics(el); + el.scrollTop = 0; + view.rerender( + , + ); + 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( + , + ); + 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(); + act(() => { + handle!("nav"); + }); + expect(scroll).toHaveBeenLastCalledWith({ block: "center", behavior: "smooth" }); + view.rerender( + , + ); + 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(); + const root = screen.getByTestId(SCROLL_TO_MESSAGE_ROOT); + const target = root.querySelector("#msg-cancel")!; + root.getBoundingClientRect = () => createRect(0, 400); + target.getBoundingClientRect = () => createRect(120 - root.scrollTop, 20); + act(() => { + handle!("cancel", { align: "start" }); + }); + view.rerender( + , + ); + act(() => { + for (let i = 0; i < 5 && frames.length; i++) frames.shift()!(); + }); + expect(root.scrollTop).toBe(0); + view.unmount(); + vi.unstubAllGlobals(); +}); diff --git a/apps/web/components/task/chat/message-list-native.tsx b/apps/web/components/task/chat/message-list-native.tsx index fe4f4c357f..056c1898b3 100644 --- a/apps/web/components/task/chat/message-list-native.tsx +++ b/apps/web/components/task/chat/message-list-native.tsx @@ -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 ( -
+
{/* Sentinel for lazy loading older messages */} {hasMore &&
} @@ -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( {stickyPromptBar} - + messages={visibleMessages} + live={isVisible && !messagesLoading && !historyRefreshPending && !isLoadingMore} + > + + ); }), diff --git a/apps/web/components/task/chat/message-renderer.tsx b/apps/web/components/task/chat/message-renderer.tsx index 879f7608de..39cad8b054 100644 --- a/apps/web/components/task/chat/message-renderer.tsx +++ b/apps/web/components/task/chat/message-renderer.tsx @@ -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 + ) : ( + {content} + ); }); diff --git a/apps/web/components/task/chat/messages/agent-message-content.tsx b/apps/web/components/task/chat/messages/agent-message-content.tsx index 3b9cf94531..3af023fbf1 100644 --- a/apps/web/components/task/chat/messages/agent-message-content.tsx +++ b/apps/web/components/task/chat/messages/agent-message-content.tsx @@ -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 (
@@ -54,6 +56,7 @@ export const AgentMessageContent = memo(function AgentMessageContent({ >
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({ {isShort && ( - {displayText} + )} @@ -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} + )}
@@ -97,7 +100,12 @@ export const ThinkingMessage = memo(function ThinkingMessage({ {!isShort && (
- +
)} diff --git a/apps/web/components/task/chat/use-chat-scroll-motion.test.tsx b/apps/web/components/task/chat/use-chat-scroll-motion.test.tsx new file mode 100644 index 0000000000..75e6de4b95 --- /dev/null +++ b/apps/web/components/task/chat/use-chat-scroll-motion.test.tsx @@ -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(); + 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(); +}); diff --git a/apps/web/components/task/chat/use-chat-scroll-motion.ts b/apps/web/components/task/chat/use-chat-scroll-motion.ts new file mode 100644 index 0000000000..9421f6f768 --- /dev/null +++ b/apps/web/components/task/chat/use-chat-scroll-motion.ts @@ -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; + motionEnabled: boolean; + enabled: boolean; + isVisible: boolean; + sessionId: string | null; + isNearBottomRef: RefObject; + isBlocked: () => boolean; + instant: (element: HTMLElement) => void; +}; +export function useChatScrollMotion(options: ChatScrollMotionOptions) { + const latest = useRef(options); + latest.current = options; + const driver = useRef(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 }; +} diff --git a/apps/web/components/task/simple/components/topbar-working-indicator.tsx b/apps/web/components/task/simple/components/topbar-working-indicator.tsx index 17c09916bf..0cf0789b87 100644 --- a/apps/web/components/task/simple/components/topbar-working-indicator.tsx +++ b/apps/web/components/task/simple/components/topbar-working-indicator.tsx @@ -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 ( diff --git a/apps/web/components/task/simple/task-chat.tsx b/apps/web/components/task/simple/task-chat.tsx index c1f196a801..13a52ede18 100644 --- a/apps/web/components/task/simple/task-chat.tsx +++ b/apps/web/components/task/simple/task-chat.tsx @@ -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(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({ diff --git a/apps/web/components/task/task-chat-panel.tsx b/apps/web/components/task/task-chat-panel.tsx index c950677737..0e0f59d426 100644 --- a/apps/web/components/task/task-chat-panel.tsx +++ b/apps/web/components/task/task-chat-panel.tsx @@ -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( + `.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, diff --git a/apps/web/e2e/tests/chat/chat-motion-helpers.ts b/apps/web/e2e/tests/chat/chat-motion-helpers.ts new file mode 100644 index 0000000000..40923428b1 --- /dev/null +++ b/apps/web/e2e/tests/chat/chat-motion-helpers.ts @@ -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) { + 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(".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((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"), + }); + }); +} diff --git a/apps/web/e2e/tests/chat/chat-motion.spec.ts b/apps/web/e2e/tests/chat/chat-motion.spec.ts new file mode 100644 index 0000000000..ee4d8e9246 --- /dev/null +++ b/apps/web/e2e/tests/chat/chat-motion.spec.ts @@ -0,0 +1,2 @@ +import { chatMotionScenarios } from "./chat-motion-helpers"; +chatMotionScenarios(false); diff --git a/apps/web/e2e/tests/chat/last-prompt-scroll.spec.ts b/apps/web/e2e/tests/chat/last-prompt-scroll.spec.ts index 48a26880cb..73d72320b4 100644 --- a/apps/web/e2e/tests/chat/last-prompt-scroll.spec.ts +++ b/apps/web/e2e/tests/chat/last-prompt-scroll.spec.ts @@ -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, diff --git a/apps/web/e2e/tests/chat/mobile-chat-motion.spec.ts b/apps/web/e2e/tests/chat/mobile-chat-motion.spec.ts new file mode 100644 index 0000000000..cc19117cbb --- /dev/null +++ b/apps/web/e2e/tests/chat/mobile-chat-motion.spec.ts @@ -0,0 +1,2 @@ +import { chatMotionScenarios } from "./chat-motion-helpers"; +chatMotionScenarios(true); diff --git a/apps/web/hooks/domains/session/use-session-search.test.ts b/apps/web/hooks/domains/session/use-session-search.test.ts index ac45f64707..c6986ee92a 100644 --- a/apps/web/hooks/domains/session/use-session-search.test.ts +++ b/apps/web/hooks/domains/session/use-session-search.test.ts @@ -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(); + } +}); diff --git a/apps/web/hooks/domains/session/use-session-search.ts b/apps/web/hooks/domains/session/use-session-search.ts index 9906b5aa37..44383efe8a 100644 --- a/apps/web/hooks/domains/session/use-session-search.ts +++ b/apps/web/hooks/domains/session/use-session-search.ts @@ -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) | undefined, setActiveHitIdState: (id: string | null) => void, genRef: React.RefObject, + 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, + 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, diff --git a/apps/web/hooks/use-chat-motion.test.tsx b/apps/web/hooks/use-chat-motion.test.tsx new file mode 100644 index 0000000000..f755c7503f --- /dev/null +++ b/apps/web/hooks/use-chat-motion.test.tsx @@ -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); + }); +}); diff --git a/apps/web/hooks/use-chat-motion.ts b/apps/web/hooks/use-chat-motion.ts new file mode 100644 index 0000000000..508a5f3cd5 --- /dev/null +++ b/apps/web/hooks/use-chat-motion.ts @@ -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; +} diff --git a/apps/web/hooks/use-lazy-load-sentinel.test.ts b/apps/web/hooks/use-lazy-load-sentinel.test.ts index fe7c7ecf20..d087371627 100644 --- a/apps/web/hooks/use-lazy-load-sentinel.test.ts +++ b/apps/web/hooks/use-lazy-load-sentinel.test.ts @@ -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(); diff --git a/apps/web/hooks/use-lazy-load-sentinel.ts b/apps/web/hooks/use-lazy-load-sentinel.ts index 9eefd46e67..d16e997bf9 100644 --- a/apps/web/hooks/use-lazy-load-sentinel.ts +++ b/apps/web/hooks/use-lazy-load-sentinel.ts @@ -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]); diff --git a/apps/web/lib/markdown/chat-text-motion-plugin.ts b/apps/web/lib/markdown/chat-text-motion-plugin.ts new file mode 100644 index 0000000000..315edb19b8 --- /dev/null +++ b/apps/web/lib/markdown/chat-text-motion-plugin.ts @@ -0,0 +1,37 @@ +import { splitMotionText, type TextRun } from "./chat-text-motion"; + +type MotionNode = { + type: string; + tagName?: string; + value?: string; + properties?: Record; + 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); + }; +} diff --git a/apps/web/lib/markdown/chat-text-motion.test.ts b/apps/web/lib/markdown/chat-text-motion.test.ts new file mode 100644 index 0000000000..9bcc9934b8 --- /dev/null +++ b/apps/web/lib/markdown/chat-text-motion.test.ts @@ -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 & b", runs)).toEqual([{ text: "a & b" }]); + expect(splitMotionText("text", undefined, "text", runs)).toEqual([{ text: "text" }]); +}); diff --git a/apps/web/lib/markdown/chat-text-motion.ts b/apps/web/lib/markdown/chat-text-motion.ts new file mode 100644 index 0000000000..6633fccb2c --- /dev/null +++ b/apps/web/lib/markdown/chat-text-motion.ts @@ -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; +} diff --git a/apps/web/lib/settings-discovery/catalog.test.ts b/apps/web/lib/settings-discovery/catalog.test.ts index e1ec8fcb65..8411bcddf1 100644 --- a/apps/web/lib/settings-discovery/catalog.test.ts +++ b/apps/web/lib/settings-discovery/catalog.test.ts @@ -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", diff --git a/apps/web/lib/settings-discovery/catalog/preferences.ts b/apps/web/lib/settings-discovery/catalog/preferences.ts index 7a10e5aa0e..06d8665c6c 100644 --- a/apps/web/lib/settings-discovery/catalog/preferences.ts +++ b/apps/web/lib/settings-discovery/catalog/preferences.ts @@ -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", diff --git a/apps/web/lib/settings/chat-motion.test.ts b/apps/web/lib/settings/chat-motion.test.ts new file mode 100644 index 0000000000..6180e6a0e2 --- /dev/null +++ b/apps/web/lib/settings/chat-motion.test.ts @@ -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"); + }); +}); diff --git a/apps/web/lib/settings/chat-motion.ts b/apps/web/lib/settings/chat-motion.ts new file mode 100644 index 0000000000..d17876b57f --- /dev/null +++ b/apps/web/lib/settings/chat-motion.ts @@ -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( + 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); +} diff --git a/apps/web/lib/settings/constants.ts b/apps/web/lib/settings/constants.ts index 1cfe305012..2d64e0f1ed 100644 --- a/apps/web/lib/settings/constants.ts +++ b/apps/web/lib/settings/constants.ts @@ -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; diff --git a/apps/web/lib/state/app-state-types.ts b/apps/web/lib/state/app-state-types.ts index bc47c4695d..560fd7ebbf 100644 --- a/apps/web/lib/state/app-state-types.ts +++ b/apps/web/lib/state/app-state-types.ts @@ -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 & diff --git a/apps/web/lib/state/slices/ui/chat-motion-actions.test.ts b/apps/web/lib/state/slices/ui/chat-motion-actions.test.ts new file mode 100644 index 0000000000..29748ef8c8 --- /dev/null +++ b/apps/web/lib/state/slices/ui/chat-motion-actions.test.ts @@ -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()( + // 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"); + }); +}); diff --git a/apps/web/lib/state/slices/ui/chat-motion-actions.ts b/apps/web/lib/state/slices/ui/chat-motion-actions.ts new file mode 100644 index 0000000000..bc8c278c20 --- /dev/null +++ b/apps/web/lib/state/slices/ui/chat-motion-actions.ts @@ -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>[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; + }), + }; +} diff --git a/apps/web/lib/state/slices/ui/types.ts b/apps/web/lib/state/slices/ui/types.ts index e6c8da4bdb..2af3f01960 100644 --- a/apps/web/lib/state/slices/ui/types.ts +++ b/apps/web/lib/state/slices/ui/types.ts @@ -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) => void; /** Record that `stamp` has been dismissed for `sessionId`. */ diff --git a/apps/web/lib/state/slices/ui/ui-slice.ts b/apps/web/lib/state/slices/ui/ui-slice.ts index 4ef7b4559c..cf7295b670 100644 --- a/apps/web/lib/state/slices/ui/ui-slice.ts +++ b/apps/web/lib/state/slices/ui/ui-slice.ts @@ -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 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. diff --git a/docs/plans/chat-motion/task-01-text-and-preference.md b/docs/plans/chat-motion/task-01-text-and-preference.md new file mode 100644 index 0000000000..5998a21d07 --- /dev/null +++ b/docs/plans/chat-motion/task-01-text-and-preference.md @@ -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. diff --git a/docs/plans/chat-motion/task-02-smooth-scrolling.md b/docs/plans/chat-motion/task-02-smooth-scrolling.md new file mode 100644 index 0000000000..98803b800c --- /dev/null +++ b/docs/plans/chat-motion/task-02-smooth-scrolling.md @@ -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. diff --git a/docs/public/sessions-and-review.md b/docs/public/sessions-and-review.md index fe600cfe11..dfcbaafec6 100644 --- a/docs/public/sessions-and-review.md +++ b/docs/public/sessions-and-review.md @@ -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. diff --git a/docs/specs/ui/requirements/chat-motion.md b/docs/specs/ui/requirements/chat-motion.md new file mode 100644 index 0000000000..7ec62bdf35 --- /dev/null +++ b/docs/specs/ui/requirements/chat-motion.md @@ -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. diff --git a/docs/specs/ui/requirements/transcript-auto-scroll.md b/docs/specs/ui/requirements/transcript-auto-scroll.md index 5a63df47a8..a517fc084e 100644 --- a/docs/specs/ui/requirements/transcript-auto-scroll.md +++ b/docs/specs/ui/requirements/transcript-auto-scroll.md @@ -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, diff --git a/docs/specs/ui/system-design/chat-motion.md b/docs/specs/ui/system-design/chat-motion.md new file mode 100644 index 0000000000..83c881435e --- /dev/null +++ b/docs/specs/ui/system-design/chat-motion.md @@ -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. diff --git a/docs/specs/ui/system-design/transcript-auto-scroll.md b/docs/specs/ui/system-design/transcript-auto-scroll.md index 31661bd2fa..7d763e2ee7 100644 --- a/docs/specs/ui/system-design/transcript-auto-scroll.md +++ b/docs/specs/ui/system-design/transcript-auto-scroll.md @@ -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