diff --git a/packages/chat-ui/src/strings.ts b/packages/chat-ui/src/strings.ts index 84361b88ca..9bccec3206 100644 --- a/packages/chat-ui/src/strings.ts +++ b/packages/chat-ui/src/strings.ts @@ -68,6 +68,7 @@ export const CHAT_STRINGS = { emptyTimelineDescription: "Say something to get the conversation going.", emptyAgentTimelineDescription: "They're ready — send the first message to get started.", + jumpToLatestAction: "Jump to latest", mentionEmpty: "No matches", mentionAgentsGroupLabel: "Agents", mentionPeopleGroupLabel: "People", diff --git a/packages/chat-ui/src/styles.css b/packages/chat-ui/src/styles.css index c782a362f5..634499cdc8 100644 --- a/packages/chat-ui/src/styles.css +++ b/packages/chat-ui/src/styles.css @@ -737,6 +737,77 @@ padding: 0.75rem clamp(0.85rem, 3vw, 1.75rem) 1rem; } +/* Positions jump-to-latest over the live tail without putting it in the + scroll flow. Claims the same flex:1 slot `.chat-timeline` used to as + `.chat-main`'s column child. */ +.chat-timeline-shell { + position: relative; + display: flex; + flex: 1; + min-width: 0; + min-height: 0; + flex-direction: column; +} + +.chat-jump-to-latest { + position: absolute; + z-index: 2; + left: 50%; + bottom: 0.75rem; + display: inline-flex; + min-height: 2.5rem; + align-items: center; + justify-content: center; + gap: 0.35rem; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--background); + padding: 0 0.85rem; + color: var(--foreground); + font: inherit; + font-size: 0.8125rem; + font-weight: 600; + box-shadow: 0 2px 10px color-mix(in srgb, black 14%, transparent); + cursor: pointer; + transform: translateX(-50%); +} + +.chat-jump-to-latest svg { + width: 0.9rem; + height: 0.9rem; +} + +.chat-jump-to-latest:hover { + background: color-mix(in srgb, var(--foreground) 4%, var(--background)); +} + +.chat-jump-to-latest:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 2px; +} + +.chat-jump-to-latest:active { + transform: translateX(-50%) scale(0.97); +} + +@media (prefers-reduced-motion: no-preference) { + @keyframes chat-jump-to-latest-enter { + from { + opacity: 0; + transform: translateX(-50%) translateY(4px); + } + to { + opacity: 1; + transform: translateX(-50%) translateY(0); + } + } + + .chat-jump-to-latest { + animation: chat-jump-to-latest-enter var(--duration-standard, 200ms) + var(--chat-ease); + } +} + /* The async-mint setup state: three staggered orange squares (the brand's zero-radius language) pulsing while the launches finish. `flex: 1` claims the rest of `.chat-main`'s column instead of sitting diff --git a/packages/chat-ui/src/timeline.tsx b/packages/chat-ui/src/timeline.tsx index 2f161db9d4..e38a8d410f 100644 --- a/packages/chat-ui/src/timeline.tsx +++ b/packages/chat-ui/src/timeline.tsx @@ -34,6 +34,7 @@ import { groupTimelineParts } from "./tool-activity"; import { ToolActivityGroup } from "./tool-activity-view"; import { ArrowBendUpLeft, + ArrowDown, ChatCircle, Clock, Copy, @@ -2200,7 +2201,10 @@ export function WorkbenchTimeline({ // Starts pinned (true) unless a restored snapshot says otherwise — a // workbench's first-ever render always lands at the bottom, but remounting // after Settings closes restores exactly how the reader left it. - const pinnedRef = useRef(scrollRestore?.pinned ?? true); + const [pinnedToLatest, setPinnedToLatest] = useState( + scrollRestore?.pinned ?? true, + ); + const pinnedRef = useRef(pinnedToLatest); // Kept current every render (never a dependency) so the unmount cleanup // below always calls the host's latest callback, not a stale one closed @@ -2215,7 +2219,19 @@ export function WorkbenchTimeline({ if (container === null) return; const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight; - pinnedRef.current = distanceFromBottom <= BOTTOM_PIN_THRESHOLD_PX; + const nextPinned = distanceFromBottom <= BOTTOM_PIN_THRESHOLD_PX; + pinnedRef.current = nextPinned; + setPinnedToLatest((current) => + current === nextPinned ? current : nextPinned, + ); + }; + + const jumpToLatest = () => { + const container = containerRef.current; + if (container === null) return; + pinnedRef.current = true; + setPinnedToLatest(true); + container.scrollTop = container.scrollHeight; }; useEffect(() => { @@ -2317,87 +2333,105 @@ export function WorkbenchTimeline({ } return ( -
- {items.map((item, index) => { - if (joinRuns.absorbedIds.has(item.id)) return null; - const previous = index > 0 ? items[index - 1] : undefined; - const showDayDivider = - previous === undefined || - !isSameCalendarDay( - new Date(previous.createdAt), - new Date(item.createdAt), +
+
+ {items.map((item, index) => { + if (joinRuns.absorbedIds.has(item.id)) return null; + const previous = index > 0 ? items[index - 1] : undefined; + const showDayDivider = + previous === undefined || + !isSameCalendarDay( + new Date(previous.createdAt), + new Date(item.createdAt), + ); + // Keyed by `clientId` (falling back to `id`) when present: a + // pending send and the confirmed message that later reconciles + // it (CL-6251's wire `clientId`) share this key, so React + // updates the same DOM node in place — avatar, header and all — + // rather than unmounting a "sending" bubble and mounting an + // unrelated "confirmed" one, which is what used to read as an + // unsent→sent swap (CL-6251, reopened). + const key = item.clientId ?? item.id; + if (item.streaming === true) { + return ( + + ); + } + const showHeader = !isGroupedWithPrevious( + item, + previous, + showDayDivider, ); - // Keyed by `clientId` (falling back to `id`) when present: a - // pending send and the confirmed message that later reconciles - // it (CL-6251's wire `clientId`) share this key, so React - // updates the same DOM node in place — avatar, header and all — - // rather than unmounting a "sending" bubble and mounting an - // unrelated "confirmed" one, which is what used to read as an - // unsent→sent swap (CL-6251, reopened). - const key = item.clientId ?? item.id; - if (item.streaming === true) { + const collapsedJoinText = joinRuns.textByLeadId.get(item.id); return ( - ); - } - const showHeader = !isGroupedWithPrevious( - item, - previous, - showDayDivider, - ); - const collapsedJoinText = joinRuns.textByLeadId.get(item.id); - return ( - - ); - })} - {footer} + })} + {footer} +
+ {pinnedToLatest ? null : ( + + )}
); } diff --git a/packages/chat-ui/test/timeline-jump-to-latest.test.tsx b/packages/chat-ui/test/timeline-jump-to-latest.test.tsx new file mode 100644 index 0000000000..fcbb6e8833 --- /dev/null +++ b/packages/chat-ui/test/timeline-jump-to-latest.test.tsx @@ -0,0 +1,150 @@ +// Jump-to-latest (CL-6424): when the reader has scrolled up from the live +// tail past `BOTTOM_PIN_THRESHOLD_PX` (40px), the timeline offers a control +// that re-pins and jumps to the bottom. There is no such control while +// pinned, on an empty timeline, or when the reader is still within the +// pin threshold. +import { afterEach, describe, expect, test } from "bun:test"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import type { Root } from "react-dom/client"; + +import type { MessageItem } from "../src/api"; +import { WorkbenchTimeline } from "../src/timeline"; + +const realResizeObserver = globalThis.ResizeObserver; + +class NoopResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} + +afterEach(() => { + globalThis.ResizeObserver = realResizeObserver; +}); + +function items(): MessageItem[] { + return [ + { + id: "m1", + createdAt: "2026-01-01T00:00:00.000Z", + parts: [{ kind: "text", text: "hello" }], + sender: { name: "Researcher", address: "researcher@agents.example" }, + }, + ]; +} + +let container: HTMLDivElement | null = null; +let root: Root | null = null; + +afterEach(() => { + if (root !== null) act(() => root?.unmount()); + container?.remove(); + container = null; + root = null; +}); + +async function mount(props?: { + readonly items?: MessageItem[]; + readonly scrollRestore?: { + readonly scrollTop: number; + readonly pinned: boolean; + }; +}) { + globalThis.ResizeObserver = + NoopResizeObserver as unknown as typeof ResizeObserver; + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + await act(async () => { + root?.render( + , + ); + }); + const scrollEl = container.querySelector( + ".chat-timeline", + ) as HTMLElement | null; + if (scrollEl !== null) { + Object.defineProperty(scrollEl, "scrollHeight", { + value: 900, + configurable: true, + }); + Object.defineProperty(scrollEl, "clientHeight", { + value: 200, + configurable: true, + }); + } + return { container, scrollEl }; +} + +function jumpControl(from: HTMLElement): HTMLButtonElement | null { + return from.querySelector(".chat-jump-to-latest"); +} + +describe("WorkbenchTimeline jump-to-latest (CL-6424)", () => { + test("a pinned reader sees no jump-to-latest control", async () => { + const { container: el } = await mount(); + expect(jumpControl(el)).toBeNull(); + }); + + test("scrolling up past the 40px pin threshold reveals jump-to-latest", async () => { + const { container: el, scrollEl } = await mount(); + expect(scrollEl).not.toBeNull(); + if (scrollEl === null) return; + act(() => { + scrollEl.scrollTop = 100; + scrollEl.dispatchEvent(new Event("scroll", { bubbles: true })); + }); + const button = jumpControl(el); + expect(button).not.toBeNull(); + expect(button?.textContent).toContain("Jump to latest"); + }); + + test("staying within 40px of the tail keeps the control hidden", async () => { + const { container: el, scrollEl } = await mount(); + expect(scrollEl).not.toBeNull(); + if (scrollEl === null) return; + act(() => { + // distanceFromBottom = 900 - 660 - 200 = 40, still pinned. + scrollEl.scrollTop = 660; + scrollEl.dispatchEvent(new Event("scroll", { bubbles: true })); + }); + expect(jumpControl(el)).toBeNull(); + }); + + test("clicking jump-to-latest re-pins to the bottom and hides the control", async () => { + const { container: el, scrollEl } = await mount(); + expect(scrollEl).not.toBeNull(); + if (scrollEl === null) return; + act(() => { + scrollEl.scrollTop = 100; + scrollEl.dispatchEvent(new Event("scroll", { bubbles: true })); + }); + const button = jumpControl(el); + expect(button).not.toBeNull(); + act(() => { + button?.click(); + }); + expect(scrollEl.scrollTop).toBe(900); + expect(jumpControl(el)).toBeNull(); + }); + + test("an unpinned scroll restore shows jump-to-latest on mount", async () => { + const { container: el } = await mount({ + scrollRestore: { scrollTop: 100, pinned: false }, + }); + expect(jumpControl(el)).not.toBeNull(); + }); + + test("an empty timeline never offers jump-to-latest", async () => { + const { container: el, scrollEl } = await mount({ items: [] }); + expect(scrollEl).toBeNull(); + expect(jumpControl(el)).toBeNull(); + expect(el.textContent).toContain("No messages yet"); + }); +});