mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-15 04:27:20 +03:00
fix(session-ui): slide the tool group count when it changes (#55617)
This commit is contained in:
committed by
GitHub
Unverified
parent
706a688994
commit
e25b7abf89
@@ -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>}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user