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