fix(session-ui): slide the tool group count when it changes (#55617)

This commit is contained in:
Luke Parker
2026-10-16 11:10:33 +10:00
committed by GitHub
Unverified
parent 706a688994
commit e25b7abf89
5 changed files with 237 additions and 35 deletions
@@ -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 })
@@ -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 = (
<CurrentSessionProviders document={storyDocument(parts())}>
<CurrentContextToolGroup
parts={parts()}
busy={false}
open={state.open}
onOpenChange={(open) => setState("open", open)}
/>
</CurrentSessionProviders>
)
return (
<section class="mx-auto flex w-full max-w-[860px] flex-col gap-4 p-6">
<div class="flex gap-3">
<button type="button" onClick={() => setState("count", (count) => count + 1)}>
Add read
</button>
<button type="button" onClick={() => setState("count", (count) => count - 1)}>
Remove read
</button>
<button type="button" onClick={() => setState("attached", (attached) => !attached)}>
{state.attached ? "Detach" : "Attach"}
</button>
</div>
<Show when={state.attached}>{group}</Show>
</section>
)
},
}
// 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<string, boolean> })
const [state, setState] = createStore<{ open: boolean; files: Record<string, boolean> }>({ open: true, files: {} })
return (
<section
@@ -39,6 +39,7 @@ import { checksum } from "@opencode/util/encode"
import { Tooltip } from "@opencode/ui/tooltip"
import { IconButton } from "@opencode/ui/icon-button"
import { TextShimmer } from "@opencode/ui/text-shimmer"
import { AnimatedNumber } from "@opencode/ui/animated-number"
import { changedFileDiff, patchFileGroups } from "../components/apply-patch-file"
import { animate } from "motion"
import { SessionProgressIndicatorV2 } from "../v2/components/session-progress-indicator-v2"
@@ -497,7 +498,7 @@ export function CurrentContextToolGroup(props: {
if (!names() && !thoughts) {
const text = i18n.t("ui.messagePart.context.updates")
return { text, title: "", before: text, count: "", between: "", after: "" }
return { text, title: "", before: text, count: undefined, between: "", after: "" }
}
const title = names() || i18n.plural("ui.messagePart.context.thought", thoughts)
@@ -509,13 +510,13 @@ export function CurrentContextToolGroup(props: {
const countIndex = before.indexOf(countText)
const after = text.slice(index + title.length).trim()
if (countIndex === -1) return { text, title, before, count: "", between: "", after }
if (countIndex === -1) return { text, title, before, count: undefined, between: "", after }
return {
text,
title,
before: before.slice(0, countIndex).trim(),
count: countText,
count,
between: before.slice(countIndex + countText.length).trim(),
after,
}
@@ -617,7 +618,11 @@ export function CurrentContextToolGroup(props: {
{(before) => <span data-slot="context-tool-group-prefix">{before()}</span>}
</Show>
<Show when={label().count}>
{(count) => <span data-slot="context-tool-group-count">{count()}</span>}
{(count) => (
<span data-slot="context-tool-group-count">
<AnimatedNumber value={count()} />
</span>
)}
</Show>
<Show when={label().between}>
{(between) => <span data-slot="context-tool-group-prefix">{between()}</span>}
+17 -4
View File
@@ -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;
}
+55 -26
View File
@@ -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 (
<span data-slot="animated-number-digit" data-animating={animating() ? "true" : "false"}>
<span data-slot="animated-number-static">{props.value}</span>
<span data-slot="animated-number-static" data-digit={props.value} />
<span
ref={strip}
data-slot="animated-number-strip"
onTransitionEnd={() => {
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)`,
}}
>
<For each={TRACK}>{(value) => <span data-slot="animated-number-cell">{value}</span>}</For>
<For each={TRACK}>{(value) => <span data-slot="animated-number-cell" data-digit={value} />}</For>
</span>
</span>
)
@@ -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 (
<span data-component="animated-number" class={props.class} aria-label={label()}>
<span data-slot="animated-number-value" style={{ "--animated-number-width": width() }}>
<Index each={digits()}>{(digit) => <Digit value={digit()} direction={direction()} />}</Index>
<span data-component="animated-number" class={props.class}>
{/* The value is the only text: screen readers and copy read it once, in order. CSS draws the digits. */}
<span class="sr-only">{label()}</span>
<span
ref={box}
data-slot="animated-number-value"
data-resizing={state.resizing ? "true" : "false"}
aria-hidden="true"
style={{ "--animated-number-width": `${label().length}ch` }}
onTransitionEnd={(event) => {
if (event.target === event.currentTarget) setState({ places: label().length + 1, resizing: false })
}}
>
<For each={places()}>
{(place) => <Digit value={Math.floor(value() / 10 ** place) % 10} direction={direction()} />}
</For>
</span>
</span>
)