diff --git a/packages/session-ui/component-tests/tool-group.spec.ts b/packages/session-ui/component-tests/tool-group.spec.ts index 8581f474212..095203ef318 100644 --- a/packages/session-ui/component-tests/tool-group.spec.ts +++ b/packages/session-ui/component-tests/tool-group.spec.ts @@ -1,5 +1,31 @@ +import type { Locator } from "@playwright/test" import { expect, story } from "../../storybook/playwright/story" +// The digits drawn inside the count's clipped width, left to right. Each slot shows whichever visible glyph lines up +// with it, so a slot mid-roll reads "_", and the spare leading zero outside the width is skipped. +const shown = (count: Locator) => + count.locator('[data-slot="animated-number-value"]').evaluate((value) => { + const clip = value.getBoundingClientRect() + + return Array.from(value.querySelectorAll('[data-slot="animated-number-digit"]'), (slot) => ({ + slot, + rect: slot.getBoundingClientRect(), + })) + .filter((item) => item.rect.left >= clip.left - 0.5 && item.rect.right <= clip.right + 0.5) + .toSorted((a, b) => a.rect.left - b.rect.left) + .map( + (item) => + Array.from(item.slot.querySelectorAll("[data-digit]")) + .find( + (glyph) => + getComputedStyle(glyph).visibility === "visible" && + Math.abs(glyph.getBoundingClientRect().top - item.rect.top) < 0.5, + ) + ?.getAttribute("data-digit") ?? "_", + ) + .join("") + }) + for (const reasoningDefaultOpen of [false, true]) { story( `keeps ordered thoughts and tool-only counts with reasoning ${reasoningDefaultOpen ? "expanded" : "collapsed"}`, @@ -109,6 +135,94 @@ story("summarizes subagents as Agent while retaining their card titles", async ( await expect(group.locator('[data-component="task-tool-title"]')).toHaveText(["General", "Explore"]) }) +for (const direction of ["ltr", "rtl"]) { + story(`reads and shows a two-digit count once, in order, in ${direction}`, async ({ mount, page }) => { + const root = await mount("current-tool-group--sticky-header", { globals: { direction } }) + const count = root.locator('[data-component="context-tool-group-trigger"] [data-slot="context-tool-group-count"]') + await expect(count).toHaveText("37") + expect(await count.ariaSnapshot()).toBe('- text: "37"') + // The clipped width follows the web font's metrics. + await page.evaluate(() => document.fonts.ready.then(() => undefined)) + expect(await shown(count)).toBe("37") + }) +} + +story("does not animate the count on mount or when its font changes", async ({ mount }) => { + const root = await mount("current-tool-group--sticky-header") + const count = root.locator('[data-component="context-tool-group-trigger"] [data-slot="context-tool-group-count"]') + await expect(count).toHaveText("37") + expect(await count.evaluate((element) => element.getAnimations({ subtree: true }).length)).toBe(0) + + // Like a late web font swap or a zoom change: the digit width changes without the count changing. + await count.evaluate((element) => { + element.style.fontSize = "20px" + }) + expect(await count.evaluate((element) => element.getAnimations({ subtree: true }).length)).toBe(0) +}) + +story("settles count changes made while detached, without rolling", async ({ mount }) => { + const root = await mount("current-tool-group--count-rollover") + const count = root.locator('[data-component="context-tool-group-trigger"] [data-slot="context-tool-group-count"]') + await expect(count).toHaveText("9") + + // A background session keeps adding tools while its cached timeline is detached. + await root.getByRole("button", { name: "Detach" }).click() + + for (let added = 0; added < 12; added++) await root.getByRole("button", { name: "Add read" }).click() + + await root.getByRole("button", { name: "Attach" }).click() + + await expect(count).toHaveText("21") + expect(await count.evaluate((element) => element.getAnimations({ subtree: true }).length)).toBe(0) + await expect(count.locator('[data-animating="true"]')).toHaveCount(0) + expect(await shown(count)).toBe("21") +}) + +story("keeps a digit rolling when the count changes again mid-roll", async ({ mount, page }) => { + const root = await mount("current-tool-group--count-rollover") + const count = root.locator('[data-component="context-tool-group-trigger"] [data-slot="context-tool-group-count"]') + // A slow roll keeps the second change inside the first roll on a loaded machine. + await page.addStyleTag({ content: ":root { --tool-motion-odometer-ms: 5000ms }" }) + await expect(count).toHaveText("9") + + await root.getByRole("button", { name: "Add read" }).click() + await root.getByRole("button", { name: "Add read" }).click() + await expect(count).toHaveText("11") + // 9 -> 10 starts the tens digit rolling; 10 -> 11 leaves it unchanged, so it finishes its roll instead of jumping. + await expect(count.locator('[data-animating="true"]')).toHaveCount(2) +}) + +story("rolls the leading digit in and out when the count gains or loses a digit", async ({ mount }) => { + const root = await mount("current-tool-group--count-rollover") + const count = root.locator('[data-component="context-tool-group-trigger"] [data-slot="context-tool-group-count"]') + const rolling = count.locator('[data-animating="true"]') + await expect(count).toHaveText("9") + + await root.getByRole("button", { name: "Add read" }).click() + // The tens digit rolls in from zero beside the ones digit instead of appearing. + await expect(rolling).toHaveCount(2) + expect(await count.ariaSnapshot()).toBe('- text: "10"') + await expect(rolling).toHaveCount(0) + + await root.getByRole("button", { name: "Remove read" }).click() + // The tens digit rolls back to zero before it leaves instead of vanishing. + await expect(rolling).toHaveCount(2) + expect(await count.ariaSnapshot()).toBe('- text: "9"') + await expect(rolling).toHaveCount(0) + await expect(count).toHaveText("9") +}) + +story("rolls the count and exposes only its current value while rolling", async ({ mount }) => { + const root = await mount("current-tool-group--mixed-reasoning") + const count = root.locator('[data-component="context-tool-group-trigger"] [data-slot="context-tool-group-count"]') + await expect(count).toHaveText("4") + await root.getByRole("button", { name: "Append follow-up read" }).click() + await expect(count.locator('[data-animating="true"]')).toHaveCount(1) + // One read mid-roll: a retrying assertion would pass once the strip hides again. + expect(await count.ariaSnapshot()).toBe('- text: "5"') + await expect(count).toHaveText("5") +}) + for (const width of [840, 390]) { story(`keeps grouped cards inside their trigger bounds at ${width}px`, async ({ mount, page }) => { await page.setViewportSize({ width, height: 600 }) diff --git a/packages/session-ui/src/tools/tool-group.stories.tsx b/packages/session-ui/src/tools/tool-group.stories.tsx index 2b6c9f273f8..fd580918dcb 100644 --- a/packages/session-ui/src/tools/tool-group.stories.tsx +++ b/packages/session-ui/src/tools/tool-group.stories.tsx @@ -109,6 +109,47 @@ export const MixedReasoning = { }, } +export const CountRollover = { + render: () => { + const [state, setState] = createStore({ open: false, count: 9, attached: true }) + + const parts = createMemo(() => + Array.from({ length: state.count }, (_, index) => + storyTool(`rollover_read_${index}`, "read", "completed", { path: `src/file-${index}.ts` }), + ), + ) + + // Created once, like a cached session timeline: detaching removes it from the page but keeps it updating. + const group = ( + + setState("open", open)} + /> + + ) + + return ( +
+
+ + + +
+ {group} +
+ ) + }, +} + // Mirrors a long exploration turn in a timeline viewport with the session title header above it. const stickyCommands = [ "git log --oneline 09c318094c..upstream/v2 -- packages/tui/src/plugin/structure.ts packages/tui/src/plugin/render.tsx", @@ -168,7 +209,7 @@ export const StickyHeader = { args: { height: 720 }, argTypes: { height: { control: { type: "range", min: 320, max: 1200, step: 20 } } }, render: (args: { height: number }) => { - const [state, setState] = createStore({ open: true, files: {} as Record }) + const [state, setState] = createStore<{ open: boolean; files: Record }>({ open: true, files: {} }) return (
{before()}} - {(count) => {count()}} + {(count) => ( + + + + )} {(between) => {between()}} diff --git a/packages/ui/src/components/animated-number.css b/packages/ui/src/components/animated-number.css index be0c4fb9aa8..60fa626ed72 100644 --- a/packages/ui/src/components/animated-number.css +++ b/packages/ui/src/components/animated-number.css @@ -7,17 +7,23 @@ [data-slot="animated-number-value"] { display: inline-flex; - flex-direction: row-reverse; + /* Digits keep numeric order in RTL documents. */ + direction: ltr; align-items: baseline; justify-content: flex-end; line-height: inherit; width: var(--animated-number-width, 1ch); overflow: hidden; + } + + [data-slot="animated-number-value"][data-resizing="true"] { transition: width var(--tool-motion-spring-ms, 560ms) var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)); } [data-slot="animated-number-digit"] { display: inline-grid; + /* Digits outside the width are clipped at the start edge, not squeezed. */ + flex-shrink: 0; width: 1ch; height: 1em; line-height: 1em; @@ -56,6 +62,11 @@ line-height: 1em; } + [data-slot="animated-number-static"]::before, + [data-slot="animated-number-cell"]::before { + content: attr(data-digit); + } + [data-slot="animated-number-digit"][data-animating="true"] [data-slot="animated-number-static"] { visibility: hidden; } @@ -63,8 +74,10 @@ [data-slot="animated-number-strip"] { display: inline-flex; flex-direction: column; - margin-top: calc(var(--animated-number-offset, 10) * -1em); - transition-property: margin-top; + /* Not transform: a composited roll lands on whole pixels, then the digit shifts as it settles. */ + position: relative; + top: calc(var(--animated-number-offset, 10) * -1em); + transition-property: top; transition-duration: var(--animated-number-duration, 560ms); transition-timing-function: var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)); } @@ -76,7 +89,7 @@ } @media (prefers-reduced-motion: reduce) { - [data-component="animated-number"] [data-slot="animated-number-value"] { + [data-component="animated-number"] [data-slot="animated-number-value"][data-resizing] { transition-duration: 0ms; } diff --git a/packages/ui/src/components/animated-number.tsx b/packages/ui/src/components/animated-number.tsx index 560cab5dfc2..4eb88cff206 100644 --- a/packages/ui/src/components/animated-number.tsx +++ b/packages/ui/src/components/animated-number.tsx @@ -1,4 +1,4 @@ -import { For, Index, createEffect, createMemo, on } from "solid-js" +import { For, createEffect, createMemo, on } from "solid-js" import { createStore } from "solid-js/store" const TRACK = Array.from({ length: 30 }, (_, index) => index % 10) @@ -26,6 +26,7 @@ function Digit(props: { value: number; direction: 1 | -1 }) { const step = () => state.step const animating = () => state.animating let last = props.value + let strip: HTMLSpanElement | undefined createEffect( on( @@ -34,15 +35,19 @@ function Digit(props: { value: number; direction: 1 | -1 }) { const delta = spin(last, next, props.direction) last = next - if (!delta) { - setState("animating", false) - setState("step", next + 10) + // An unchanged digit keeps any roll it is in the middle of. + if (!delta) return + + const target = step() + delta + + // A detached digit never finishes a roll, and rapid changes can run off the track, so both settle at once. + if (!strip?.isConnected || target < 0 || target >= TRACK.length) { + setState({ animating: false, step: next + 10 }) return } - setState("animating", true) - setState("step", (value) => value + delta) + setState({ animating: true, step: target }) }, { defer: true }, ), @@ -50,8 +55,9 @@ function Digit(props: { value: number; direction: 1 | -1 }) { return ( - {props.value} + { setState("animating", false) @@ -62,7 +68,7 @@ function Digit(props: { value: number; direction: 1 | -1 }) { "--animated-number-duration": `var(--tool-motion-odometer-ms, ${DURATION}ms)`, }} > - {(value) => {value}} + {(value) => } ) @@ -75,13 +81,18 @@ export function AnimatedNumber(props: { value: number; class?: string }) { return Math.max(0, Math.round(props.value)) }) - const [state, setState] = createStore({ + // A spare leading zero waits outside the clipped width to roll in. A digit that leaves stays until the width has + // shrunk past it, so it rolls to zero instead of vanishing. + const [state, setState] = createStore<{ value: number; direction: 1 | -1; places: number; resizing: boolean }>({ value: target(), - direction: 1 as 1 | -1, + direction: 1, + places: String(target()).length + 1, + resizing: false, }) const value = () => state.value const direction = () => state.direction + let box: HTMLSpanElement | undefined createEffect( on( @@ -91,8 +102,22 @@ export function AnimatedNumber(props: { value: number; class?: string }) { if (next === current) return - setState("direction", next > current ? 1 : -1) - setState("value", next) + const digits = String(next).length + + // A detached number cannot ease its width and would never hear it end, so it settles at once. + if (!box?.isConnected) { + setState({ direction: next > current ? 1 : -1, value: next, places: digits + 1, resizing: false }) + + return + } + + // Only a change to the digit count eases the width, so a late font swap or zoom never animates it. + setState({ + direction: next > current ? 1 : -1, + value: next, + places: Math.max(state.places, digits + 1), + resizing: state.resizing || digits !== String(current).length, + }) }, { defer: true }, ), @@ -100,22 +125,26 @@ export function AnimatedNumber(props: { value: number; class?: string }) { const label = createMemo(() => value().toString()) - const digits = createMemo(() => - Array.from(label(), (char) => { - const code = char.charCodeAt(0) - 48 - - if (code < 0 || code > 9) return 0 - - return code - }).reverse(), - ) - - const width = createMemo(() => `${digits().length}ch`) + // Place values, most significant first. Keying by place keeps each digit rolling as digits appear and leave. + const places = createMemo(() => Array.from({ length: state.places }, (_, index) => state.places - 1 - index)) return ( - - - {(digit) => } + + {/* The value is the only text: screen readers and copy read it once, in order. CSS draws the digits. */} + {label()} + )