diff --git a/bun.lock b/bun.lock index 8df1d6456c2..70f3d01ae99 100644 --- a/bun.lock +++ b/bun.lock @@ -423,17 +423,17 @@ "devDependencies": { "@opencode-ai/ui": "workspace:*", "@solidjs/meta": "catalog:", - "@storybook/addon-a11y": "^10.2.10", - "@storybook/addon-docs": "^10.2.10", - "@storybook/addon-links": "^10.2.10", - "@storybook/addon-onboarding": "^10.2.10", - "@storybook/addon-vitest": "^10.2.10", + "@storybook/addon-a11y": "^10.2.13", + "@storybook/addon-docs": "^10.2.13", + "@storybook/addon-links": "^10.2.13", + "@storybook/addon-onboarding": "^10.2.13", + "@storybook/addon-vitest": "^10.2.13", "@tsconfig/node22": "catalog:", "@types/node": "catalog:", "@types/react": "18.0.25", "react": "18.2.0", "solid-js": "catalog:", - "storybook": "^10.2.10", + "storybook": "^10.2.13", "storybook-solidjs-vite": "^10.0.9", "typescript": "catalog:", "vite": "catalog:", @@ -1803,25 +1803,25 @@ "@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="], - "@storybook/addon-a11y": ["@storybook/addon-a11y@10.2.10", "", { "dependencies": { "@storybook/global": "^5.0.0", "axe-core": "^4.2.0" }, "peerDependencies": { "storybook": "^10.2.10" } }, "sha512-1S9pDXgvbHhBStGarCvfJ3/rfcaiAcQHRhuM3Nk4WGSIYtC1LCSRuzYdDYU0aNRpdCbCrUA7kUCbqvIE3tH+3Q=="], + "@storybook/addon-a11y": ["@storybook/addon-a11y@10.2.13", "", { "dependencies": { "@storybook/global": "^5.0.0", "axe-core": "^4.2.0" }, "peerDependencies": { "storybook": "^10.2.13" } }, "sha512-zuR1n1xgWoieEnr6E5xdTR40BI61IBQahgmsRpTvqRffL3mxAs5aFoORDmA5pZWI2LE9URdMkY85h218ijuLiw=="], - "@storybook/addon-docs": ["@storybook/addon-docs@10.2.10", "", { "dependencies": { "@mdx-js/react": "^3.0.0", "@storybook/csf-plugin": "10.2.10", "@storybook/icons": "^2.0.1", "@storybook/react-dom-shim": "10.2.10", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "ts-dedent": "^2.0.0" }, "peerDependencies": { "storybook": "^10.2.10" } }, "sha512-2wIYtdvZIzPbQ5194M5Igpy8faNbQ135nuO5ZaZ2VuttqGr+IJcGnDP42zYwbAsGs28G8ohpkbSgIzVyJWUhPQ=="], + "@storybook/addon-docs": ["@storybook/addon-docs@10.2.13", "", { "dependencies": { "@mdx-js/react": "^3.0.0", "@storybook/csf-plugin": "10.2.13", "@storybook/icons": "^2.0.1", "@storybook/react-dom-shim": "10.2.13", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "ts-dedent": "^2.0.0" }, "peerDependencies": { "storybook": "^10.2.13" } }, "sha512-puMxpJbt/CuodLIbKDxWrW1ZgADYomfNHWEKp2d2l2eJjp17rADx0h3PABuNbX+YHbJwYcDdqluSnQwMysFEOA=="], - "@storybook/addon-links": ["@storybook/addon-links@10.2.10", "", { "dependencies": { "@storybook/global": "^5.0.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.2.10" }, "optionalPeers": ["react"] }, "sha512-oo9Xx4/2OVJtptXKpqH4ySri7ZuBdiSOXlZVGejEfLa0Jeajlh/KIlREpGvzPPOqUVT7dSddWzBjJmJUyQC3ew=="], + "@storybook/addon-links": ["@storybook/addon-links@10.2.13", "", { "dependencies": { "@storybook/global": "^5.0.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.2.13" }, "optionalPeers": ["react"] }, "sha512-8wnAomGiHaUpNIc+lOzmazTrebxa64z9rihIbM/Q59vkOImHQNkGp7KP/qNgJA4GPTFtu8+fLjX2qCoAQPM0jQ=="], - "@storybook/addon-onboarding": ["@storybook/addon-onboarding@10.2.10", "", { "peerDependencies": { "storybook": "^10.2.10" } }, "sha512-DkzZQTXHp99SpHMIQ5plbbHcs4EWVzWhLXlW+icA8sBlKo5Bwj540YcOApKbqB0m/OzWprsznwN7Kv4vfvHu4w=="], + "@storybook/addon-onboarding": ["@storybook/addon-onboarding@10.2.13", "", { "peerDependencies": { "storybook": "^10.2.13" } }, "sha512-kw2GgIY67UR8YXKfuVS0k+mfWL1joNQHeSe5DlDL4+7qbgp9zfV6cRJ199BMdfRAQNMzQoxHgRUcAMAqs3Rkpw=="], - "@storybook/addon-vitest": ["@storybook/addon-vitest@10.2.10", "", { "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.1" }, "peerDependencies": { "@vitest/browser": "^3.0.0 || ^4.0.0", "@vitest/browser-playwright": "^4.0.0", "@vitest/runner": "^3.0.0 || ^4.0.0", "storybook": "^10.2.10", "vitest": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["@vitest/browser", "@vitest/browser-playwright", "@vitest/runner", "vitest"] }, "sha512-U2oHw+Ar+Xd06wDTB74VlujhIIW89OHThpJjwgqgM6NWrOC/XLllJ53ILFDyREBkMwpBD7gJQIoQpLEcKBIEhw=="], + "@storybook/addon-vitest": ["@storybook/addon-vitest@10.2.13", "", { "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.1" }, "peerDependencies": { "@vitest/browser": "^3.0.0 || ^4.0.0", "@vitest/browser-playwright": "^4.0.0", "@vitest/runner": "^3.0.0 || ^4.0.0", "storybook": "^10.2.13", "vitest": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["@vitest/browser", "@vitest/browser-playwright", "@vitest/runner", "vitest"] }, "sha512-qQD3xzxc31cQHS0loF9enGWi5sgA6zBTbaJ0HuSUNGO81iwfLSALh8L/1vrD5NfN2vlBeUMTsgv3EkCuLfe9EQ=="], "@storybook/builder-vite": ["@storybook/builder-vite@10.2.10", "", { "dependencies": { "@storybook/csf-plugin": "10.2.10", "ts-dedent": "^2.0.0" }, "peerDependencies": { "storybook": "^10.2.10", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-Wd6CYL7LvRRNiXMz977x9u/qMm7nmMw/7Dow2BybQo+Xbfy1KhVjIoZ/gOiG515zpojSozctNrJUbM0+jH1jwg=="], - "@storybook/csf-plugin": ["@storybook/csf-plugin@10.2.10", "", { "dependencies": { "unplugin": "^2.3.5" }, "peerDependencies": { "esbuild": "*", "rollup": "*", "storybook": "^10.2.10", "vite": "*", "webpack": "*" }, "optionalPeers": ["esbuild", "rollup", "vite", "webpack"] }, "sha512-aFvgaNDAnKMjuyhPK5ialT22pPqMN0XfPBNPeeNVPYztngkdKBa8WFqF/umDd47HxAjebq+vn6uId1xHyOHH3g=="], + "@storybook/csf-plugin": ["@storybook/csf-plugin@10.2.13", "", { "dependencies": { "unplugin": "^2.3.5" }, "peerDependencies": { "esbuild": "*", "rollup": "*", "storybook": "^10.2.13", "vite": "*", "webpack": "*" }, "optionalPeers": ["esbuild", "rollup", "vite", "webpack"] }, "sha512-gUCR7PmyrWYj3dIJJgxOm25dcXFolPIUPmug3z90Aaon7YPXw3pUN+dNDx8KqDJqRK1WDIB4HaefgYZIm5V7iA=="], "@storybook/global": ["@storybook/global@5.0.0", "", {}, "sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ=="], "@storybook/icons": ["@storybook/icons@2.0.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-/smVjw88yK3CKsiuR71vNgWQ9+NuY2L+e8X7IMrFjexjm6ZR8ULrV2DRkTA61aV6ryefslzHEGDInGpnNeIocg=="], - "@storybook/react-dom-shim": ["@storybook/react-dom-shim@10.2.10", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.2.10" } }, "sha512-TmBrhyLHn8B8rvDHKk5uW5BqzO1M1T+fqFNWg88NIAJOoyX4Uc90FIJjDuN1OJmWKGwB5vLmPwaKBYsTe1yS+w=="], + "@storybook/react-dom-shim": ["@storybook/react-dom-shim@10.2.13", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.2.13" } }, "sha512-ZSduoB10qTI0V9z22qeULmQLsvTs8d/rtJi03qbVxpPiMRor86AmyAaBrfhGGmWBxWQZpOGQQm6yIT2YLoPs7w=="], "@stripe/stripe-js": ["@stripe/stripe-js@8.6.1", "", {}, "sha512-UJ05U2062XDgydbUcETH1AoRQLNhigQ2KmDn1BG8sC3xfzu6JKg95Qt6YozdzFpxl1Npii/02m2LEWFt1RYjVA=="], @@ -3897,7 +3897,7 @@ "stoppable": ["stoppable@1.1.0", "", {}, "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw=="], - "storybook": ["storybook@10.2.10", "", { "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.1", "@testing-library/jest-dom": "^6.6.3", "@testing-library/user-event": "^14.6.1", "@vitest/expect": "3.2.4", "@vitest/spy": "3.2.4", "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0", "open": "^10.2.0", "recast": "^0.23.5", "semver": "^7.7.3", "use-sync-external-store": "^1.5.0", "ws": "^8.18.0" }, "peerDependencies": { "prettier": "^2 || ^3" }, "optionalPeers": ["prettier"], "bin": "./dist/bin/dispatcher.js" }, "sha512-N4U42qKgzMHS7DjqLz5bY4P7rnvJtYkWFCyKspZr3FhPUuy6CWOae3aYC2BjXkHrdug0Jyta6VxFTuB1tYUKhg=="], + "storybook": ["storybook@10.2.13", "", { "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.1", "@testing-library/jest-dom": "^6.6.3", "@testing-library/user-event": "^14.6.1", "@vitest/expect": "3.2.4", "@vitest/spy": "3.2.4", "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0", "open": "^10.2.0", "recast": "^0.23.5", "semver": "^7.7.3", "use-sync-external-store": "^1.5.0", "ws": "^8.18.0" }, "peerDependencies": { "prettier": "^2 || ^3" }, "optionalPeers": ["prettier"], "bin": "./dist/bin/dispatcher.js" }, "sha512-heMfJjOfbHvL+wlCAwFZlSxcakyJ5yQDam6e9k2RRArB1veJhRnsjO6lO1hOXjJYrqxfHA/ldIugbBVlCDqfvQ=="], "storybook-solidjs-vite": ["storybook-solidjs-vite@10.0.9", "", { "dependencies": { "@joshwooding/vite-plugin-react-docgen-typescript": "^0.6.1", "@storybook/builder-vite": "^10.0.0", "@storybook/global": "^5.0.0", "vite-plugin-solid": "^2.11.8" }, "peerDependencies": { "solid-js": "^1.9.0", "storybook": "^0.0.0-0 || ^10.0.0", "typescript": ">= 4.9.x", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["typescript"] }, "sha512-n6MwWCL9mK/qIaUutE9vhGB0X1I1hVnKin2NL+iVC5oXfAiuaABVZlr/1oEeEypsgCdyDOcbEbhJmDWmaqGpPw=="], @@ -4721,6 +4721,8 @@ "@solidjs/start/vite": ["vite@7.1.10", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA=="], + "@storybook/builder-vite/@storybook/csf-plugin": ["@storybook/csf-plugin@10.2.10", "", { "dependencies": { "unplugin": "^2.3.5" }, "peerDependencies": { "esbuild": "*", "rollup": "*", "storybook": "^10.2.10", "vite": "*", "webpack": "*" }, "optionalPeers": ["esbuild", "rollup", "vite", "webpack"] }, "sha512-aFvgaNDAnKMjuyhPK5ialT22pPqMN0XfPBNPeeNVPYztngkdKBa8WFqF/umDd47HxAjebq+vn6uId1xHyOHH3g=="], + "@tailwindcss/oxide/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], diff --git a/package.json b/package.json index bd9dbac414c..dc78d14e84f 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts", "dev:desktop": "bun --cwd packages/desktop tauri dev", "dev:web": "bun --cwd packages/app dev", + "dev:storybook": "bun --cwd packages/storybook storybook", "typecheck": "bun turbo typecheck", "prepare": "husky", "random": "echo 'Random script'", diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 8215f31bade..92a8ed297d1 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -117,6 +117,7 @@ export function MessageTimeline(props: { const dialog = useDialog() const language = useLanguage() + const rendered = createMemo(() => props.renderedUserMessages.map((message) => message.id)) const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const sessionID = createMemo(() => params.id) const info = createMemo(() => { @@ -552,16 +553,16 @@ export function MessageTimeline(props: { - - {(message) => { - const comments = createMemo(() => messageComments(sync.data.part[message.id] ?? [])) + + {(messageID) => { + const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? [])) return (
{ - props.onRegisterMessage(el, message.id) - onCleanup(() => props.onUnregisterMessage(message.id)) + props.onRegisterMessage(el, messageID) + onCleanup(() => props.onUnregisterMessage(messageID)) }} classList={{ "min-w-0 w-full max-w-full": true, @@ -600,7 +601,7 @@ export function MessageTimeline(props: { { - - -
- -
-
-
+
+ +
diff --git a/packages/storybook/package.json b/packages/storybook/package.json index 2ab92bd5f81..ff2f8577f26 100644 --- a/packages/storybook/package.json +++ b/packages/storybook/package.json @@ -10,17 +10,17 @@ "devDependencies": { "@opencode-ai/ui": "workspace:*", "@solidjs/meta": "catalog:", - "@storybook/addon-a11y": "^10.2.10", - "@storybook/addon-docs": "^10.2.10", - "@storybook/addon-links": "^10.2.10", - "@storybook/addon-onboarding": "^10.2.10", - "@storybook/addon-vitest": "^10.2.10", + "@storybook/addon-a11y": "^10.2.13", + "@storybook/addon-docs": "^10.2.13", + "@storybook/addon-links": "^10.2.13", + "@storybook/addon-onboarding": "^10.2.13", + "@storybook/addon-vitest": "^10.2.13", "@tsconfig/node22": "catalog:", "@types/node": "catalog:", "@types/react": "18.0.25", "react": "18.2.0", "solid-js": "catalog:", - "storybook": "^10.2.10", + "storybook": "^10.2.13", "storybook-solidjs-vite": "^10.0.9", "typescript": "catalog:", "vite": "catalog:" diff --git a/packages/ui/src/components/animated-number.css b/packages/ui/src/components/animated-number.css new file mode 100644 index 00000000000..022b347e968 --- /dev/null +++ b/packages/ui/src/components/animated-number.css @@ -0,0 +1,75 @@ +[data-component="animated-number"] { + display: inline-flex; + align-items: baseline; + vertical-align: baseline; + line-height: inherit; + font-variant-numeric: tabular-nums; + + [data-slot="animated-number-value"] { + display: inline-flex; + flex-direction: row-reverse; + align-items: baseline; + justify-content: flex-end; + line-height: inherit; + width: var(--animated-number-width, 1ch); + overflow: hidden; + 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-block; + width: 1ch; + height: 1em; + line-height: 1em; + overflow: hidden; + vertical-align: baseline; + -webkit-mask-image: linear-gradient( + to bottom, + transparent 0%, + #000 var(--tool-motion-mask, 18%), + #000 calc(100% - var(--tool-motion-mask, 18%)), + transparent 100% + ); + mask-image: linear-gradient( + to bottom, + transparent 0%, + #000 var(--tool-motion-mask, 18%), + #000 calc(100% - var(--tool-motion-mask, 18%)), + transparent 100% + ); + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + } + + [data-slot="animated-number-strip"] { + display: inline-flex; + flex-direction: column; + transform: translateY(calc(var(--animated-number-offset, 10) * -1em)); + transition-property: transform; + transition-duration: var(--animated-number-duration, 560ms); + transition-timing-function: var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)); + } + + [data-slot="animated-number-strip"][data-animating="false"] { + transition-duration: 0ms; + } + + [data-slot="animated-number-cell"] { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1ch; + height: 1em; + line-height: 1em; + } +} + +@media (prefers-reduced-motion: reduce) { + [data-component="animated-number"] [data-slot="animated-number-value"] { + transition-duration: 0ms; + } + + [data-component="animated-number"] [data-slot="animated-number-strip"] { + transition-duration: 0ms; + } +} diff --git a/packages/ui/src/components/animated-number.tsx b/packages/ui/src/components/animated-number.tsx new file mode 100644 index 00000000000..00bb876cb27 --- /dev/null +++ b/packages/ui/src/components/animated-number.tsx @@ -0,0 +1,100 @@ +import { For, Index, createEffect, createMemo, createSignal, on } from "solid-js" + +const TRACK = Array.from({ length: 30 }, (_, index) => index % 10) +const DURATION = 700 + +function normalize(value: number) { + return ((value % 10) + 10) % 10 +} + +function spin(from: number, to: number, direction: 1 | -1) { + if (from === to) return 0 + if (direction > 0) return (to - from + 10) % 10 + return -((from - to + 10) % 10) +} + +function Digit(props: { value: number; direction: 1 | -1 }) { + const [step, setStep] = createSignal(props.value + 10) + const [animating, setAnimating] = createSignal(false) + let last = props.value + + createEffect( + on( + () => props.value, + (next) => { + const delta = spin(last, next, props.direction) + last = next + if (!delta) { + setAnimating(false) + setStep(next + 10) + return + } + + setAnimating(true) + setStep((value) => value + delta) + }, + { defer: true }, + ), + ) + + return ( + + { + setAnimating(false) + setStep((value) => normalize(value) + 10) + }} + style={{ + "--animated-number-offset": `${step()}`, + "--animated-number-duration": `var(--tool-motion-odometer-ms, ${DURATION}ms)`, + }} + > + {(value) => {value}} + + + ) +} + +export function AnimatedNumber(props: { value: number; class?: string }) { + const target = createMemo(() => { + if (!Number.isFinite(props.value)) return 0 + return Math.max(0, Math.round(props.value)) + }) + + const [value, setValue] = createSignal(target()) + const [direction, setDirection] = createSignal<1 | -1>(1) + + createEffect( + on( + target, + (next) => { + const current = value() + if (next === current) return + + setDirection(next > current ? 1 : -1) + setValue(next) + }, + { defer: true }, + ), + ) + + 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`) + + return ( + + + {(digit) => } + + + ) +} diff --git a/packages/ui/src/components/basic-tool.tsx b/packages/ui/src/components/basic-tool.tsx index 53bdc9ce1ec..f4780578ed4 100644 --- a/packages/ui/src/components/basic-tool.tsx +++ b/packages/ui/src/components/basic-tool.tsx @@ -97,7 +97,7 @@ export function BasicTool(props: BasicToolProps) { }} > - + diff --git a/packages/ui/src/components/code.stories.tsx b/packages/ui/src/components/code.stories.tsx deleted file mode 100644 index 992fa630242..00000000000 --- a/packages/ui/src/components/code.stories.tsx +++ /dev/null @@ -1,70 +0,0 @@ -// @ts-nocheck -import * as mod from "./code" -import { create } from "../storybook/scaffold" -import { code } from "../storybook/fixtures" - -const docs = `### Overview -Syntax-highlighted code viewer with selection support and large-file virtualization. - -Use alongside \`LineComment\` and \`Diff\` in review workflows. - -### API -- Required: \`file\` with file name + contents. -- Optional: \`language\`, \`annotations\`, \`selectedLines\`, \`commentedLines\`. -- Optional callbacks: \`onRendered\`, \`onLineSelectionEnd\`. - -### Variants and states -- Supports large-file virtualization automatically. - -### Behavior -- Re-renders when \`file\` or rendering options change. -- Optional line selection integrates with selection callbacks. - -### Accessibility -- TODO: confirm keyboard find and selection behavior. - -### Theming/tokens -- Uses \`data-component="code"\` and Pierre CSS variables from \`styleVariables\`. - -` - -const story = create({ - title: "UI/Code", - mod, - args: { - file: code, - language: "ts", - }, -}) - -export default { - title: "UI/Code", - id: "components-code", - component: story.meta.component, - tags: ["autodocs"], - parameters: { - docs: { - description: { - component: docs, - }, - }, - }, -} - -export const Basic = story.Basic - -export const SelectedLines = { - args: { - enableLineSelection: true, - selectedLines: { start: 2, end: 4 }, - }, -} - -export const CommentedLines = { - args: { - commentedLines: [ - { start: 1, end: 1 }, - { start: 5, end: 6 }, - ], - }, -} diff --git a/packages/ui/src/components/diff-ssr.stories.tsx b/packages/ui/src/components/diff-ssr.stories.tsx deleted file mode 100644 index d1adce28066..00000000000 --- a/packages/ui/src/components/diff-ssr.stories.tsx +++ /dev/null @@ -1,97 +0,0 @@ -// @ts-nocheck -import { preloadMultiFileDiff } from "@pierre/diffs/ssr" -import { createResource, Show } from "solid-js" -import * as mod from "./diff-ssr" -import { createDefaultOptions } from "../pierre" -import { WorkerPoolProvider } from "../context/worker-pool" -import { getWorkerPools } from "../pierre/worker" -import { diff } from "../storybook/fixtures" - -const docs = `### Overview -Server-rendered diff hydration component for preloaded Pierre diff output. - -Use alongside server routes that preload diffs. -Pair with \`DiffChanges\` for summaries. - -### API -- Required: \`before\`, \`after\`, and \`preloadedDiff\` from \`preloadMultiFileDiff\`. -- Optional: \`diffStyle\`, \`annotations\`, \`selectedLines\`, \`commentedLines\`. - -### Variants and states -- Unified/split styles (preloaded must match the style used during preload). - -### Behavior -- Hydrates pre-rendered diff HTML into a live diff instance. -- Requires a worker pool provider for syntax highlighting. - -### Accessibility -- TODO: confirm keyboard behavior from the Pierre diff engine. - -### Theming/tokens -- Uses \`data-component="diff"\` with Pierre CSS variables and theme tokens. - -` - -const load = async () => { - return preloadMultiFileDiff({ - oldFile: diff.before, - newFile: diff.after, - options: createDefaultOptions("unified"), - }) -} - -const loadSplit = async () => { - return preloadMultiFileDiff({ - oldFile: diff.before, - newFile: diff.after, - options: createDefaultOptions("split"), - }) -} - -export default { - title: "UI/DiffSSR", - id: "components-diff-ssr", - component: mod.Diff, - tags: ["autodocs"], - parameters: { - docs: { - description: { - component: docs, - }, - }, - }, -} - -export const Basic = { - render: () => { - const [data] = createResource(load) - return ( - - Loading pre-rendered diff...
}> - {(preloaded) => ( -
- -
- )} - - - ) - }, -} - -export const Split = { - render: () => { - const [data] = createResource(loadSplit) - return ( - - Loading pre-rendered diff...}> - {(preloaded) => ( -
- -
- )} -
-
- ) - }, -} diff --git a/packages/ui/src/components/diff.stories.tsx b/packages/ui/src/components/diff.stories.tsx deleted file mode 100644 index 03bf4a0e0f0..00000000000 --- a/packages/ui/src/components/diff.stories.tsx +++ /dev/null @@ -1,96 +0,0 @@ -// @ts-nocheck -import * as mod from "./diff" -import { create } from "../storybook/scaffold" -import { diff } from "../storybook/fixtures" - -const docs = `### Overview -Render a code diff with OpenCode styling using the Pierre diff engine. - -Pair with \`DiffChanges\` for summary counts. -Use \`LineComment\` or external UI for annotation workflows. - -### API -- Required: \`before\` and \`after\` file contents (name + contents). -- Optional: \`diffStyle\` ("unified" | "split"), \`annotations\`, \`selectedLines\`, \`commentedLines\`. -- Optional interaction: \`enableLineSelection\`, \`onLineSelectionEnd\`. -- Passes through Pierre FileDiff options (see component source). - -### Variants and states -- Unified and split diff styles. -- Optional line selection + commented line highlighting. - -### Behavior -- Re-renders when \`before\`/\`after\` or diff options change. -- Line selection uses mouse drag/selection when enabled. - -### Accessibility -- TODO: confirm keyboard behavior from the Pierre diff engine. -- Provide surrounding labels or headings when used as a standalone view. - -### Theming/tokens -- Uses \`data-component="diff"\` and Pierre CSS variables from \`styleVariables\`. -- Colors derive from theme tokens (diff add/delete, background, text). - -` - -const story = create({ - title: "UI/Diff", - mod, - args: { - before: diff.before, - after: diff.after, - diffStyle: "unified", - }, -}) - -export default { - title: "UI/Diff", - id: "components-diff", - component: story.meta.component, - tags: ["autodocs"], - parameters: { - docs: { - description: { - component: docs, - }, - }, - }, - argTypes: { - diffStyle: { - control: "select", - options: ["unified", "split"], - }, - enableLineSelection: { - control: "boolean", - }, - }, -} - -export const Unified = story.Basic - -export const Split = { - args: { - diffStyle: "split", - }, -} - -export const Selectable = { - args: { - enableLineSelection: true, - }, -} - -export const SelectedLines = { - args: { - selectedLines: { start: 2, end: 4 }, - }, -} - -export const CommentedLines = { - args: { - commentedLines: [ - { start: 1, end: 1 }, - { start: 4, end: 4 }, - ], - }, -} diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index 58227f62597..3a0934c4ba3 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -561,29 +561,8 @@ cursor: pointer; [data-slot="context-tool-group-title"] { - min-width: 0; - display: flex; - align-items: center; - gap: 8px; - font-family: var(--font-family-sans); - font-size: 14px; - font-weight: var(--font-weight-medium); - line-height: var(--line-height-large); - color: var(--text-strong); - } - - [data-slot="context-tool-group-label"] { - flex-shrink: 0; - } - - [data-slot="context-tool-group-summary"] { flex-shrink: 1; min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-weight: var(--font-weight-regular); - color: var(--text-base); } [data-slot="collapsible-arrow"] { diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 6b6dfe2e50e..46cdc8c4b43 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -47,6 +47,8 @@ import { checksum } from "@opencode-ai/util/encode" import { Tooltip } from "./tooltip" import { IconButton } from "./icon-button" import { TextShimmer } from "./text-shimmer" +import { AnimatedCountList } from "./tool-count-summary" +import { ToolStatusTitle } from "./tool-status-title" interface Diagnostic { range: { @@ -468,23 +470,11 @@ function contextToolTrigger(part: ToolPart, i18n: ReturnType) { } } -function contextToolSummary(parts: ToolPart[], i18n: ReturnType) { +function contextToolSummary(parts: ToolPart[]) { const read = parts.filter((part) => part.tool === "read").length const search = parts.filter((part) => part.tool === "glob" || part.tool === "grep").length const list = parts.filter((part) => part.tool === "list").length - return [ - read - ? i18n.t(read === 1 ? "ui.messagePart.context.read.one" : "ui.messagePart.context.read.other", { count: read }) - : undefined, - search - ? i18n.t(search === 1 ? "ui.messagePart.context.search.one" : "ui.messagePart.context.search.other", { - count: search, - }) - : undefined, - list - ? i18n.t(list === 1 ? "ui.messagePart.context.list.one" : "ui.messagePart.context.list.other", { count: list }) - : undefined, - ].filter((value): value is string => !!value) + return { read, search, list } } export function registerPartComponent(type: string, component: PartComponent) { @@ -608,33 +598,53 @@ function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) { () => !!props.busy || props.parts.some((part) => part.state.status === "pending" || part.state.status === "running"), ) - const summary = createMemo(() => contextToolSummary(props.parts, i18n)) - const details = createMemo(() => summary().join(", ")) + const summary = createMemo(() => contextToolSummary(props.parts)) return (
- - {i18n.t("ui.sessionTurn.status.gatheredContext")} - - {details()} - - - } + - - - - - - {details()} - + + - + + + +
@@ -653,7 +663,7 @@ function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) {
- + @@ -1746,9 +1756,7 @@ ToolRegistry.register({
- - - + {getFilename(file().relativePath)} diff --git a/packages/ui/src/components/text-shimmer.css b/packages/ui/src/components/text-shimmer.css index 929a2d85161..f042dd2d862 100644 --- a/packages/ui/src/components/text-shimmer.css +++ b/packages/ui/src/components/text-shimmer.css @@ -1,43 +1,119 @@ [data-component="text-shimmer"] { --text-shimmer-step: 45ms; --text-shimmer-duration: 1200ms; + --text-shimmer-swap: 220ms; + --text-shimmer-index: 0; + --text-shimmer-angle: 90deg; + --text-shimmer-spread: 5.2ch; + --text-shimmer-size: 360%; + --text-shimmer-base-color: var(--text-weak); + --text-shimmer-peak-color: var(--text-strong); + --text-shimmer-sweep: linear-gradient( + var(--text-shimmer-angle), + transparent calc(50% - var(--text-shimmer-spread)), + var(--text-shimmer-peak-color) 50%, + transparent calc(50% + var(--text-shimmer-spread)) + ); + --text-shimmer-base: linear-gradient(var(--text-shimmer-base-color), var(--text-shimmer-base-color)); + + display: inline-flex; + align-items: baseline; + font: inherit; + letter-spacing: inherit; + line-height: inherit; } [data-component="text-shimmer"] [data-slot="text-shimmer-char"] { + display: inline-grid; white-space: pre; + font: inherit; + letter-spacing: inherit; + line-height: inherit; +} + +[data-component="text-shimmer"] [data-slot="text-shimmer-char-base"], +[data-component="text-shimmer"] [data-slot="text-shimmer-char-shimmer"] { + grid-area: 1 / 1; + white-space: pre; + transition: opacity var(--text-shimmer-swap) ease-out; + font: inherit; + letter-spacing: inherit; + line-height: inherit; +} + +[data-component="text-shimmer"] [data-slot="text-shimmer-char-base"] { color: inherit; + opacity: 1; +} + +[data-component="text-shimmer"] [data-slot="text-shimmer-char-shimmer"] { + color: var(--text-weaker); + opacity: 0; } -[data-component="text-shimmer"][data-active="true"] [data-slot="text-shimmer-char"] { - animation-name: text-shimmer-char; +[data-component="text-shimmer"][data-active="true"] [data-slot="text-shimmer-char-shimmer"] { + opacity: 1; +} + +[data-component="text-shimmer"] [data-slot="text-shimmer-char-shimmer"][data-run="true"] { + animation-name: text-shimmer-sweep; animation-duration: var(--text-shimmer-duration); animation-iteration-count: infinite; - animation-timing-function: ease-in-out; - animation-delay: calc(var(--text-shimmer-step) * var(--text-shimmer-index)); + animation-timing-function: linear; + animation-fill-mode: both; + animation-delay: calc(var(--text-shimmer-step) * var(--text-shimmer-index) * -1); + will-change: background-position; } -@keyframes text-shimmer-char { - 0%, - 100% { - color: var(--text-weaker); +@keyframes text-shimmer-sweep { + 0% { + background-position: + 100% 0, + 0 0; } - 30% { - color: var(--text-weak); + 100% { + background-position: + 0% 0, + 0 0; } +} - 55% { - color: var(--text-base); +@supports ((-webkit-background-clip: text) or (background-clip: text)) { + [data-component="text-shimmer"] [data-slot="text-shimmer-char-shimmer"] { + color: transparent; + -webkit-text-fill-color: transparent; + background-image: var(--text-shimmer-sweep), var(--text-shimmer-base); + background-size: + var(--text-shimmer-size) 100%, + 100% 100%; + background-position: + 100% 0, + 0 0; + background-repeat: no-repeat; + -webkit-background-clip: text; + background-clip: text; } - 75% { - color: var(--text-strong); + [data-component="text-shimmer"][data-active="true"] [data-slot="text-shimmer-char-base"] { + opacity: 0; } } @media (prefers-reduced-motion: reduce) { - [data-component="text-shimmer"] [data-slot="text-shimmer-char"] { + [data-component="text-shimmer"] [data-slot="text-shimmer-char-base"], + [data-component="text-shimmer"] [data-slot="text-shimmer-char-shimmer"] { + transition-duration: 0ms; + } + + [data-component="text-shimmer"] [data-slot="text-shimmer-char-shimmer"] { animation: none !important; color: inherit; + -webkit-text-fill-color: currentColor; + background-image: none; + } + + [data-component="text-shimmer"] [data-slot="text-shimmer-char-base"] { + opacity: 1 !important; } } diff --git a/packages/ui/src/components/text-shimmer.stories.tsx b/packages/ui/src/components/text-shimmer.stories.tsx index 4b6de34c2e9..a88a7158b11 100644 --- a/packages/ui/src/components/text-shimmer.stories.tsx +++ b/packages/ui/src/components/text-shimmer.stories.tsx @@ -1,5 +1,6 @@ // @ts-nocheck import * as mod from "./text-shimmer" +import { useArgs } from "storybook/preview-api" import { create } from "../storybook/scaffold" const docs = `### Overview @@ -9,13 +10,14 @@ Use for pending states inside buttons or list rows. ### API - Required: \`text\` string. -- Optional: \`as\`, \`active\`, \`stepMs\`, \`durationMs\`. +- Optional: \`as\`, \`active\`, \`offset\`, \`class\`. ### Variants and states - Active/inactive state via \`active\`. ### Behavior -- Characters animate with staggered delays. +- Uses a moving gradient sweep clipped to text. +- \`offset\` lets multiple shimmers run out-of-phase. ### Accessibility - Uses \`aria-label\` with the full text. @@ -25,13 +27,27 @@ Use for pending states inside buttons or list rows. ` -const story = create({ title: "UI/TextShimmer", mod, args: { text: "Loading..." } }) +const defaults = { + text: "Loading...", + active: true, + class: "text-14-medium text-text-strong", + offset: 0, +} as const + +const story = create({ title: "UI/TextShimmer", mod, args: defaults }) export default { title: "UI/TextShimmer", id: "components-text-shimmer", component: story.meta.component, tags: ["autodocs"], + args: defaults, + argTypes: { + text: { control: "text" }, + class: { control: "text" }, + active: { control: "boolean" }, + offset: { control: { type: "range", min: 0, max: 80, step: 1 } }, + }, parameters: { docs: { description: { @@ -41,7 +57,32 @@ export default { }, } -export const Basic = story.Basic +export const Basic = { + args: defaults, + render: (args) => { + const [, updateArgs] = useArgs() + const reset = () => updateArgs(defaults) + return ( +
+ + +
+ ) + }, +} export const Inactive = { args: { @@ -49,11 +90,3 @@ export const Inactive = { active: false, }, } - -export const CustomTiming = { - args: { - text: "Custom timing", - stepMs: 80, - durationMs: 1800, - }, -} diff --git a/packages/ui/src/components/text-shimmer.tsx b/packages/ui/src/components/text-shimmer.tsx index 6ee4ef4020f..c4c20b8e768 100644 --- a/packages/ui/src/components/text-shimmer.tsx +++ b/packages/ui/src/components/text-shimmer.tsx @@ -1,4 +1,4 @@ -import { For, createMemo, type ValidComponent } from "solid-js" +import { createEffect, createMemo, createSignal, onCleanup, type ValidComponent } from "solid-js" import { Dynamic } from "solid-js/web" export const TextShimmer = (props: { @@ -6,31 +6,56 @@ export const TextShimmer = (props: { class?: string as?: T active?: boolean - stepMs?: number - durationMs?: number + offset?: number }) => { - const chars = createMemo(() => Array.from(props.text)) - const active = () => props.active ?? true + const active = createMemo(() => props.active ?? true) + const offset = createMemo(() => props.offset ?? 0) + const [run, setRun] = createSignal(active()) + const swap = 220 + let timer: ReturnType | undefined + + createEffect(() => { + if (timer) { + clearTimeout(timer) + timer = undefined + } + + if (active()) { + setRun(true) + return + } + + timer = setTimeout(() => { + timer = undefined + setRun(false) + }, swap) + }) + + onCleanup(() => { + if (!timer) return + clearTimeout(timer) + }) return ( - - {(char, index) => ( - - )} - + + + + ) } diff --git a/packages/ui/src/components/tool-count-label.css b/packages/ui/src/components/tool-count-label.css new file mode 100644 index 00000000000..11a33ff5d14 --- /dev/null +++ b/packages/ui/src/components/tool-count-label.css @@ -0,0 +1,57 @@ +[data-component="tool-count-label"] { + display: inline-flex; + align-items: baseline; + white-space: nowrap; + gap: 0; + + [data-slot="tool-count-label-before"] { + display: inline-block; + white-space: pre; + line-height: inherit; + } + + [data-slot="tool-count-label-word"] { + display: inline-flex; + align-items: baseline; + white-space: pre; + line-height: inherit; + } + + [data-slot="tool-count-label-stem"] { + display: inline-block; + white-space: pre; + } + + [data-slot="tool-count-label-suffix"] { + display: inline-grid; + grid-template-columns: 0fr; + opacity: 0; + filter: blur(calc(var(--tool-motion-blur, 2px) * 0.42)); + overflow: hidden; + transform: translateX(-0.04em); + transition-property: grid-template-columns, opacity, filter, transform; + transition-duration: 250ms, 250ms, 250ms, 250ms; + transition-timing-function: + var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), ease-out, ease-out, + var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)); + } + + [data-slot="tool-count-label-suffix"][data-active="true"] { + grid-template-columns: 1fr; + opacity: 1; + filter: blur(0); + transform: translateX(0); + } + + [data-slot="tool-count-label-suffix-inner"] { + min-width: 0; + overflow: hidden; + white-space: pre; + } +} + +@media (prefers-reduced-motion: reduce) { + [data-component="tool-count-label"] [data-slot="tool-count-label-suffix"] { + transition-duration: 0ms; + } +} diff --git a/packages/ui/src/components/tool-count-label.tsx b/packages/ui/src/components/tool-count-label.tsx new file mode 100644 index 00000000000..67e861cdcb3 --- /dev/null +++ b/packages/ui/src/components/tool-count-label.tsx @@ -0,0 +1,58 @@ +import { createMemo } from "solid-js" +import { AnimatedNumber } from "./animated-number" + +function split(text: string) { + const match = /{{\s*count\s*}}/.exec(text) + if (!match) return { before: "", after: text } + if (match.index === undefined) return { before: "", after: text } + return { + before: text.slice(0, match.index), + after: text.slice(match.index + match[0].length), + } +} + +function common(one: string, other: string) { + const a = Array.from(one) + const b = Array.from(other) + let i = 0 + while (i < a.length && i < b.length && a[i] === b[i]) i++ + return { + stem: a.slice(0, i).join(""), + one: a.slice(i).join(""), + other: b.slice(i).join(""), + } +} + +export function AnimatedCountLabel(props: { count: number; one: string; other: string; class?: string }) { + const one = createMemo(() => split(props.one)) + const other = createMemo(() => split(props.other)) + const singular = createMemo(() => Math.round(props.count) === 1) + const active = createMemo(() => (singular() ? one() : other())) + const suffix = createMemo(() => common(one().after, other().after)) + const splitSuffix = createMemo( + () => + one().before === other().before && + (one().after.startsWith(other().after) || other().after.startsWith(one().after)), + ) + const before = createMemo(() => (splitSuffix() ? one().before : active().before)) + const stem = createMemo(() => (splitSuffix() ? suffix().stem : active().after)) + const tail = createMemo(() => { + if (!splitSuffix()) return "" + if (singular()) return suffix().one + return suffix().other + }) + const showTail = createMemo(() => splitSuffix() && tail().length > 0) + + return ( + + {before()} + + + {stem()} + + {tail()} + + + + ) +} diff --git a/packages/ui/src/components/tool-count-summary.css b/packages/ui/src/components/tool-count-summary.css new file mode 100644 index 00000000000..da8455267cc --- /dev/null +++ b/packages/ui/src/components/tool-count-summary.css @@ -0,0 +1,102 @@ +[data-component="tool-count-summary"] { + display: inline-flex; + align-items: baseline; + white-space: nowrap; + + [data-slot="tool-count-summary-empty"] { + display: inline-grid; + grid-template-columns: 1fr; + align-items: baseline; + opacity: 1; + filter: blur(0); + transform: translateY(0) scale(1); + overflow: hidden; + transform-origin: left center; + transition-property: grid-template-columns, opacity, filter, transform; + transition-duration: + var(--tool-motion-spring-ms, 480ms), var(--tool-motion-fade-ms, 240ms), var(--tool-motion-fade-ms, 280ms), + var(--tool-motion-spring-ms, 480ms); + transition-timing-function: + var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), ease-out, ease-out, + var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)); + } + + [data-slot="tool-count-summary-empty"][data-active="false"] { + grid-template-columns: 0fr; + opacity: 0; + filter: blur(calc(var(--tool-motion-blur, 2px) * 0.72)); + transform: translateY(0.05em) scale(0.985); + } + + [data-slot="tool-count-summary-item"] { + display: inline-grid; + grid-template-columns: 0fr; + align-items: baseline; + opacity: 0; + filter: blur(var(--tool-motion-blur, 2px)); + transform: translateY(0.06em) scale(0.985); + overflow: hidden; + transform-origin: left center; + transition-property: grid-template-columns, opacity, filter, transform; + transition-duration: + var(--tool-motion-spring-ms, 480ms), var(--tool-motion-fade-ms, 280ms), var(--tool-motion-fade-ms, 320ms), + var(--tool-motion-spring-ms, 480ms); + transition-timing-function: + var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), ease-out, ease-out, + var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)); + } + + [data-slot="tool-count-summary-item"][data-active="true"] { + grid-template-columns: 1fr; + opacity: 1; + filter: blur(0); + transform: translateY(0) scale(1); + } + + [data-slot="tool-count-summary-empty-inner"] { + min-width: 0; + overflow: hidden; + white-space: nowrap; + } + + [data-slot="tool-count-summary-item-inner"] { + display: inline-flex; + align-items: baseline; + min-width: 0; + overflow: hidden; + white-space: nowrap; + } + + [data-slot="tool-count-summary-prefix"] { + display: inline-flex; + align-items: baseline; + justify-content: flex-start; + max-width: 0; + margin-right: 0; + opacity: 0; + filter: blur(calc(var(--tool-motion-blur, 2px) * 0.55)); + overflow: hidden; + transform: translateX(-0.08em); + transition-property: opacity, filter, transform; + transition-duration: + calc(var(--tool-motion-fade-ms, 200ms) * 0.75), calc(var(--tool-motion-fade-ms, 220ms) * 0.75), + calc(var(--tool-motion-fade-ms, 220ms) * 0.6); + transition-timing-function: ease-out, ease-out, ease-out; + } + + [data-slot="tool-count-summary-prefix"][data-active="true"] { + max-width: 1ch; + margin-right: 0.45ch; + opacity: 1; + filter: blur(0); + transform: translateX(0); + } +} + +@media (prefers-reduced-motion: reduce) { + [data-component="tool-count-summary"] [data-slot="tool-count-summary-empty"], + [data-component="tool-count-summary"] [data-slot="tool-count-summary-item"], + [data-component="tool-count-summary"] [data-slot="tool-count-summary-prefix"] { + transition-duration: 0ms; + } +} diff --git a/packages/ui/src/components/tool-count-summary.stories.tsx b/packages/ui/src/components/tool-count-summary.stories.tsx new file mode 100644 index 00000000000..2a0cd0c12d1 --- /dev/null +++ b/packages/ui/src/components/tool-count-summary.stories.tsx @@ -0,0 +1,305 @@ +// @ts-nocheck +import { createSignal, onCleanup, For } from "solid-js" +import { AnimatedCountList, type CountItem } from "./tool-count-summary" +import { ToolStatusTitle } from "./tool-status-title" + +export default { + title: "UI/AnimatedCountList", + id: "components-animated-count-list", + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: `### Overview +Animated count list that smoothly transitions items in/out as counts change. + +Uses \`grid-template-columns: 0fr → 1fr\` for width animations and the odometer +digit roller for count transitions. Shown here with \`ToolStatusTitle\` exactly +as it appears in the context tool group on the session page.`, + }, + }, + }, +} + +const LOCALES = { + en: { + label: "English", + active: "Exploring", + done: "Explored", + read: { one: "{{count}} read", other: "{{count}} reads" }, + search: { one: "{{count}} search", other: "{{count}} searches" }, + list: { one: "{{count}} list", other: "{{count}} lists" }, + }, + fr: { + label: "Français", + active: "Exploration", + done: "Exploré", + read: { one: "{{count}} lecture", other: "{{count}} lectures" }, + search: { one: "{{count}} recherche", other: "{{count}} recherches" }, + list: { one: "{{count}} liste", other: "{{count}} listes" }, + }, + ja: { + label: "日本語", + active: "探索中", + done: "探索済み", + read: { one: "{{count}} 件の読み取り", other: "{{count}} 件の読み取り" }, + search: { one: "{{count}} 件の検索", other: "{{count}} 件の検索" }, + list: { one: "{{count}} 件のリスト", other: "{{count}} 件のリスト" }, + }, + ko: { + label: "한국어", + active: "탐색 중", + done: "탐색됨", + read: { one: "{{count}}개 읽음", other: "{{count}}개 읽음" }, + search: { one: "{{count}}개 검색", other: "{{count}}개 검색" }, + list: { one: "{{count}}개 목록", other: "{{count}}개 목록" }, + }, + de: { + label: "Deutsch", + active: "Erkunden", + done: "Erkundet", + read: { one: "{{count}} Lesevorgang", other: "{{count}} Lesevorgänge" }, + search: { one: "{{count}} Suche", other: "{{count}} Suchen" }, + list: { one: "{{count}} Liste", other: "{{count}} Listen" }, + }, + es: { + label: "Español", + active: "Explorando", + done: "Explorado", + read: { one: "{{count}} lectura", other: "{{count}} lecturas" }, + search: { one: "{{count}} búsqueda", other: "{{count}} búsquedas" }, + list: { one: "{{count}} lista", other: "{{count}} listas" }, + }, + th: { + label: "ไทย", + active: "กำลังสำรวจ", + done: "สำรวจแล้ว", + read: { one: "อ่าน {{count}} รายการ", other: "อ่าน {{count}} รายการ" }, + search: { one: "ค้นหา {{count}} รายการ", other: "ค้นหา {{count}} รายการ" }, + list: { one: "รายการ {{count}} รายการ", other: "รายการ {{count}} รายการ" }, + }, + ar: { + label: "العربية", + active: "استكشاف", + done: "تم الاستكشاف", + read: { one: "{{count}} قراءة", other: "{{count}} قراءات" }, + search: { one: "{{count}} بحث", other: "{{count}} عمليات بحث" }, + list: { one: "{{count}} قائمة", other: "{{count}} قوائم" }, + }, +} as const + +type LocaleKey = keyof typeof LOCALES + +function rand(min: number, max: number) { + return Math.floor(Math.random() * (max - min + 1)) + min +} + +const btn = (accent?: boolean) => + ({ + padding: "6px 14px", + "border-radius": "6px", + border: "1px solid var(--color-divider, #333)", + background: accent ? "var(--color-danger-fill, #c33)" : "var(--color-fill-element, #222)", + color: "var(--color-text, #eee)", + cursor: "pointer", + "font-size": "13px", + }) as const + +const smallBtn = (active?: boolean) => + ({ + padding: "4px 12px", + "border-radius": "6px", + border: active ? "1px solid var(--color-accent, #58f)" : "1px solid var(--color-divider, #333)", + background: active ? "var(--color-accent, #58f)" : "var(--color-fill-element, #222)", + color: "var(--color-text, #eee)", + cursor: "pointer", + "font-size": "12px", + }) as const + +export const Playground = { + render: () => { + const [reads, setReads] = createSignal(0) + const [searches, setSearches] = createSignal(0) + const [lists, setLists] = createSignal(0) + const [active, setActive] = createSignal(false) + const [locale, setLocale] = createSignal("en") + const [reducedMotion, setReducedMotion] = createSignal(false) + + const l = () => LOCALES[locale()] + + let timeouts: ReturnType[] = [] + + const clearAll = () => { + for (const t of timeouts) clearTimeout(t) + timeouts = [] + } + + onCleanup(clearAll) + + const startSim = () => { + clearAll() + setReads(0) + setSearches(0) + setLists(0) + setActive(true) + const steps = rand(3, 10) + let elapsed = 0 + + for (let i = 0; i < steps; i++) { + const delay = rand(300, 800) + elapsed += delay + const t = setTimeout(() => { + const pick = rand(0, 2) + if (pick === 0) setReads((n) => n + 1) + else if (pick === 1) setSearches((n) => n + 1) + else setLists((n) => n + 1) + }, elapsed) + timeouts.push(t) + } + + const end = setTimeout(() => setActive(false), elapsed + 100) + timeouts.push(end) + } + + const stopSim = () => { + clearAll() + setActive(false) + } + + const reset = () => { + stopSim() + setReads(0) + setSearches(0) + setLists(0) + } + + const items = (): CountItem[] => [ + { key: "read", count: reads(), one: l().read.one, other: l().read.other }, + { key: "search", count: searches(), one: l().search.one, other: l().search.other }, + { key: "list", count: lists(), one: l().list.one, other: l().list.other }, + ] + + return ( +
+ {reducedMotion() && ( + + )} + + {/* Matches context-tool-group-trigger layout from message-part.tsx */} + + + + + + + + + + {/* Language picker */} +
+ + {(key) => ( + + )} + +
+ +
+ + + +
+ +
+ + + +
+ +
+ {locale()} · motion: {reducedMotion() ? "reduced" : "normal"} · active: {active() ? "true" : "false"} · reads:{" "} + {reads()} · searches: {searches()} · lists: {lists()} +
+
+ ) + }, +} + +export const Empty = { + render: () => ( + + + + + ), +} + +export const Done = { + render: () => ( + + + + + + + ), +} diff --git a/packages/ui/src/components/tool-count-summary.tsx b/packages/ui/src/components/tool-count-summary.tsx new file mode 100644 index 00000000000..a5cb5b40d22 --- /dev/null +++ b/packages/ui/src/components/tool-count-summary.tsx @@ -0,0 +1,52 @@ +import { Index, createMemo } from "solid-js" +import { AnimatedCountLabel } from "./tool-count-label" + +export type CountItem = { + key: string + count: number + one: string + other: string +} + +export function AnimatedCountList(props: { items: CountItem[]; fallback?: string; class?: string }) { + const visible = createMemo(() => props.items.filter((item) => item.count > 0)) + const fallback = createMemo(() => props.fallback ?? "") + const showEmpty = createMemo(() => visible().length === 0 && fallback().length > 0) + + return ( + + + {fallback()} + + + + {(item, index) => { + const active = createMemo(() => item().count > 0) + const hasPrev = createMemo(() => { + for (let i = index - 1; i >= 0; i--) { + if (props.items[i].count > 0) return true + } + return false + }) + + return ( + <> + + , + + + + + + + + ) + }} + + + ) +} diff --git a/packages/ui/src/components/tool-status-title.css b/packages/ui/src/components/tool-status-title.css new file mode 100644 index 00000000000..d4415bd2daf --- /dev/null +++ b/packages/ui/src/components/tool-status-title.css @@ -0,0 +1,89 @@ +[data-component="tool-status-title"] { + display: inline-flex; + align-items: baseline; + white-space: nowrap; + text-align: start; + + [data-slot="tool-status-suffix"] { + display: inline-flex; + align-items: baseline; + white-space: nowrap; + } + + [data-slot="tool-status-prefix"] { + white-space: nowrap; + flex-shrink: 0; + } + + [data-slot="tool-status-swap"], + [data-slot="tool-status-tail"] { + display: inline-grid; + overflow: hidden; + justify-items: start; + transition: width var(--tool-motion-spring-ms, 480ms) var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)); + } + + [data-slot="tool-status-active"], + [data-slot="tool-status-done"] { + grid-area: 1 / 1; + white-space: nowrap; + justify-self: start; + text-align: start; + transition-property: opacity, filter, transform; + transition-duration: + var(--tool-motion-fade-ms, 240ms), calc(var(--tool-motion-fade-ms, 240ms) * 0.8), + calc(var(--tool-motion-fade-ms, 240ms) * 0.8); + transition-timing-function: ease-out, ease-out, ease-out; + } + + &[data-ready="false"] { + [data-slot="tool-status-swap"], + [data-slot="tool-status-tail"] { + transition-duration: 0ms; + } + + [data-slot="tool-status-active"], + [data-slot="tool-status-done"] { + transition-duration: 0ms; + } + } + + [data-slot="tool-status-active"] { + opacity: 0; + filter: blur(calc(var(--tool-motion-blur, 2px) * 0.45)); + transform: translateY(0.03em); + } + + [data-slot="tool-status-done"] { + color: var(--text-strong); + opacity: 1; + filter: blur(0); + transform: translateY(0); + } + + &[data-active="true"] { + [data-slot="tool-status-active"] { + opacity: 1; + filter: blur(0); + transform: translateY(0); + } + + [data-slot="tool-status-done"] { + opacity: 0; + filter: blur(calc(var(--tool-motion-blur, 2px) * 0.45)); + transform: translateY(0.03em); + } + } +} + +@media (prefers-reduced-motion: reduce) { + [data-component="tool-status-title"] [data-slot="tool-status-swap"], + [data-component="tool-status-title"] [data-slot="tool-status-tail"] { + transition-duration: 0ms; + } + + [data-component="tool-status-title"] [data-slot="tool-status-active"], + [data-component="tool-status-title"] [data-slot="tool-status-done"] { + transition-duration: 0ms; + } +} diff --git a/packages/ui/src/components/tool-status-title.tsx b/packages/ui/src/components/tool-status-title.tsx new file mode 100644 index 00000000000..4cf8f15abd1 --- /dev/null +++ b/packages/ui/src/components/tool-status-title.tsx @@ -0,0 +1,138 @@ +import { Show, createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js" +import { TextShimmer } from "./text-shimmer" + +function common(active: string, done: string) { + const a = Array.from(active) + const b = Array.from(done) + let i = 0 + while (i < a.length && i < b.length && a[i] === b[i]) i++ + return { + prefix: a.slice(0, i).join(""), + active: a.slice(i).join(""), + done: b.slice(i).join(""), + } +} + +function contentWidth(el: HTMLSpanElement | undefined) { + if (!el) return 0 + const range = document.createRange() + range.selectNodeContents(el) + return Math.ceil(range.getBoundingClientRect().width) +} + +export function ToolStatusTitle(props: { + active: boolean + activeText: string + doneText: string + class?: string + split?: boolean +}) { + const split = createMemo(() => common(props.activeText, props.doneText)) + const suffix = createMemo( + () => (props.split ?? true) && split().prefix.length >= 2 && split().active.length > 0 && split().done.length > 0, + ) + const prefixLen = createMemo(() => Array.from(split().prefix).length) + const activeTail = createMemo(() => (suffix() ? split().active : props.activeText)) + const doneTail = createMemo(() => (suffix() ? split().done : props.doneText)) + + const [width, setWidth] = createSignal("auto") + const [ready, setReady] = createSignal(false) + let activeRef: HTMLSpanElement | undefined + let doneRef: HTMLSpanElement | undefined + let frame: number | undefined + let readyFrame: number | undefined + + const measure = () => { + const target = props.active ? activeRef : doneRef + const px = contentWidth(target) + if (px > 0) setWidth(`${px}px`) + } + + const schedule = () => { + if (typeof requestAnimationFrame !== "function") { + measure() + return + } + if (frame !== undefined) cancelAnimationFrame(frame) + frame = requestAnimationFrame(() => { + frame = undefined + measure() + }) + } + + const finish = () => { + if (typeof requestAnimationFrame !== "function") { + setReady(true) + return + } + if (readyFrame !== undefined) cancelAnimationFrame(readyFrame) + readyFrame = requestAnimationFrame(() => { + readyFrame = undefined + setReady(true) + }) + } + + createEffect( + on( + [() => props.active, activeTail, doneTail, suffix], + () => schedule(), + ), + ) + + onMount(() => { + measure() + const fonts = typeof document !== "undefined" ? document.fonts : undefined + if (!fonts) { + finish() + return + } + fonts.ready.finally(() => { + measure() + finish() + }) + }) + + onCleanup(() => { + if (frame !== undefined) cancelAnimationFrame(frame) + if (readyFrame !== undefined) cancelAnimationFrame(readyFrame) + }) + + return ( + + + + + + + + + + } + > + + + + + + + + + + + + + +
+ + ) +} diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index f822371f709..078d731374b 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -7,6 +7,7 @@ @import "katex/dist/katex.min.css" layer(base); @import "../components/accordion.css" layer(components); +@import "../components/animated-number.css" layer(components); @import "../components/app-icon.css" layer(components); @import "../components/avatar.css" layer(components); @import "../components/basic-tool.css" layer(components); @@ -49,6 +50,9 @@ @import "../components/tabs.css" layer(components); @import "../components/tag.css" layer(components); @import "../components/text-shimmer.css" layer(components); +@import "../components/tool-count-label.css" layer(components); +@import "../components/tool-count-summary.css" layer(components); +@import "../components/tool-status-title.css" layer(components); @import "../components/toast.css" layer(components); @import "../components/tooltip.css" layer(components); @import "../components/typewriter.css" layer(components);