Animation Smorgasbord (#15637)

Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
This commit is contained in:
Kit Langton
2026-03-02 17:24:32 -05:00
committed by GitHub
parent 78069369e2
commit 9d7852b5c3
62 changed files with 5231 additions and 710 deletions

View File

@@ -423,17 +423,18 @@
"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",
"@tailwindcss/vite": "catalog:",
"@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:",
@@ -461,6 +462,9 @@
"marked-katex-extension": "5.1.6",
"marked-shiki": "catalog:",
"morphdom": "2.7.8",
"motion": "12.34.3",
"motion-dom": "12.34.3",
"motion-utils": "12.29.2",
"remeda": "catalog:",
"shiki": "catalog:",
"solid-js": "catalog:",
@@ -1803,25 +1807,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=="],
@@ -3325,6 +3329,12 @@
"morphdom": ["morphdom@2.7.8", "", {}, "sha512-D/fR4xgGUyVRbdMGU6Nejea1RFzYxYtyurG4Fbv2Fi/daKlWKuXGLOdXtl+3eIwL110cI2hz1ZojGICjjFLgTg=="],
"motion": ["motion@12.34.3", "", { "dependencies": { "framer-motion": "^12.34.3", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-xZIkBGO7v/Uvm+EyaqYd+9IpXu0sZqLywVlGdCFrrMiaO9JI4Kx51mO9KlHSWwll+gZUVY5OJsWgYI5FywJ/tw=="],
"motion-dom": ["motion-dom@12.34.3", "", { "dependencies": { "motion-utils": "^12.29.2" } }, "sha512-sYgFe+pR9aIM7o4fhs2aXtOI+oqlUd33N9Yoxcgo1Fv7M20sRkHtCmzE/VRNIcq7uNJ+qio+Xubt1FXH3pQ+eQ=="],
"motion-utils": ["motion-utils@12.29.2", "", {}, "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A=="],
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
@@ -3897,7 +3907,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 +4731,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=="],
@@ -4887,6 +4899,8 @@
"miniflare/zod": ["zod@3.22.3", "", {}, "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug=="],
"motion/framer-motion": ["framer-motion@12.34.3", "", { "dependencies": { "motion-dom": "^12.34.3", "motion-utils": "^12.29.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-v81ecyZKYO/DfpTwHivqkxSUBzvceOpoI+wLfgCgoUIKxlFKEXdg0oR9imxwXumT4SFy8vRk9xzJ5l3/Du/55Q=="],
"mssql/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
"nitro/h3": ["h3@2.0.1-rc.5", "", { "dependencies": { "rou3": "^0.7.9", "srvx": "^0.9.1" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"] }, "sha512-qkohAzCab0nLzXNm78tBjZDvtKMTmtygS8BJLT3VPczAQofdqlFXDPkXdLMJN4r05+xqneG8snZJ0HgkERCZTg=="],

View File

@@ -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'",

View File

@@ -1,4 +1,5 @@
import { useFilteredList } from "@opencode-ai/ui/hooks"
import { useSpring } from "@opencode-ai/ui/motion-spring"
import { createEffect, on, Component, Show, onCleanup, Switch, Match, createMemo, createSignal } from "solid-js"
import { createStore } from "solid-js/store"
import { createFocusSignal } from "@solid-primitives/active-element"
@@ -255,6 +256,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
pendingAutoAccept: false,
})
const buttonsSpring = useSpring(
() => (store.mode === "normal" ? 1 : 0),
{ visualDuration: 0.2, bounce: 0 },
)
const commentCount = createMemo(() => {
if (store.mode === "shell") return 0
return prompt.context.items().filter((item) => !!item.comment?.trim()).length
@@ -1250,10 +1256,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<div
aria-hidden={store.mode !== "normal"}
class="flex items-center gap-1 transition-all duration-200 ease-out"
classList={{
"opacity-100 translate-y-0 scale-100 pointer-events-auto": store.mode === "normal",
"opacity-0 translate-y-2 scale-95 pointer-events-none": store.mode !== "normal",
class="flex items-center gap-1"
style={{
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
}}
>
<TooltipKeybind
@@ -1266,6 +1271,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
type="button"
variant="ghost"
class="size-8 p-0"
style={{
opacity: buttonsSpring(),
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
}}
onClick={pick}
disabled={store.mode !== "normal"}
tabIndex={store.mode === "normal" ? undefined : -1}
@@ -1303,6 +1313,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
icon={working() ? "stop" : "arrow-up"}
variant="primary"
class="size-8"
style={{
opacity: buttonsSpring(),
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
}}
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
/>
</Tooltip>
@@ -1355,14 +1370,21 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<Show when={store.mode === "normal" || store.mode === "shell"}>
<DockTray attach="top">
<div class="px-1.75 pt-5.5 pb-2 flex items-center gap-2 min-w-0">
<div class="flex items-center gap-1.5 min-w-0 flex-1">
<Show when={store.mode === "shell"}>
<div class="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0" style={{ padding: "0 4px 0 8px" }}>
<span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span>
<div class="size-4 shrink-0" />
</div>
</Show>
<Show when={store.mode === "normal"}>
<div class="flex items-center gap-1.5 min-w-0 flex-1 relative">
<div
class="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0 absolute inset-y-0 left-0"
style={{
padding: "0 4px 0 8px",
opacity: 1 - buttonsSpring(),
transform: `scale(${0.95 + (1 - buttonsSpring()) * 0.05})`,
filter: `blur(${buttonsSpring() * 2}px)`,
"pointer-events": buttonsSpring() < 0.5 ? "auto" : "none",
}}
>
<span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span>
<div class="size-4 shrink-0" />
</div>
<div class="flex items-center gap-1.5 min-w-0 flex-1">
<TooltipKeybind
placement="top"
gutter={4}
@@ -1376,7 +1398,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onSelect={local.agent.set}
class="capitalize max-w-[160px]"
valueClass="truncate text-13-regular"
triggerStyle={{ height: "28px" }}
triggerStyle={{
height: "28px",
opacity: buttonsSpring(),
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
}}
variant="ghost"
/>
</TooltipKeybind>
@@ -1394,7 +1422,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
variant="ghost"
size="normal"
class="min-w-0 max-w-[320px] text-13-regular group"
style={{ height: "28px" }}
style={{
height: "28px",
opacity: buttonsSpring(),
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
}}
onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}
>
<Show when={local.model.current()?.provider?.id}>
@@ -1423,7 +1457,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
triggerProps={{
variant: "ghost",
size: "normal",
style: { height: "28px" },
style: {
height: "28px",
opacity: buttonsSpring(),
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
},
class: "min-w-0 max-w-[320px] text-13-regular group",
}}
>
@@ -1455,11 +1495,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
class="capitalize max-w-[160px]"
valueClass="truncate text-13-regular"
triggerStyle={{ height: "28px" }}
triggerStyle={{
height: "28px",
opacity: buttonsSpring(),
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
}}
variant="ghost"
/>
</TooltipKeybind>
</Show>
</div>
</div>
<div class="shrink-0">
<RadioGroup

View File

@@ -1,5 +1,6 @@
import { Show, createEffect, createMemo } from "solid-js"
import { Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { useParams } from "@solidjs/router"
import { useSpring } from "@opencode-ai/ui/motion-spring"
import { PromptInput } from "@/components/prompt-input"
import { useLanguage } from "@/context/language"
import { usePrompt } from "@/context/prompt"
@@ -18,6 +19,23 @@ export function SessionComposerRegion(props: {
onSubmit: () => void
onResponseSubmit: () => void
setPromptDockRef: (el: HTMLDivElement) => void
visualDuration?: number
bounce?: number
dockOpenVisualDuration?: number
dockOpenBounce?: number
dockCloseVisualDuration?: number
dockCloseBounce?: number
drawerExpandVisualDuration?: number
drawerExpandBounce?: number
drawerCollapseVisualDuration?: number
drawerCollapseBounce?: number
subtitleDuration?: number
subtitleTravel?: number
subtitleEdge?: number
countDuration?: number
countMask?: number
countMaskHeight?: number
countWidthDuration?: number
}) {
const params = useParams()
const prompt = usePrompt()
@@ -43,6 +61,40 @@ export function SessionComposerRegion(props: {
setSessionHandoff(sessionKey(), { prompt: previewPrompt() })
})
const open = createMemo(() => props.state.dock() && !props.state.closing())
const config = createMemo(() =>
open()
? {
visualDuration: props.dockOpenVisualDuration ?? props.visualDuration ?? 0.3,
bounce: props.dockOpenBounce ?? props.bounce ?? 0,
}
: {
visualDuration: props.dockCloseVisualDuration ?? props.visualDuration ?? 0.3,
bounce: props.dockCloseBounce ?? props.bounce ?? 0,
},
)
const progress = useSpring(
() => (open() ? 1 : 0),
config,
)
const value = createMemo(() => Math.max(0, Math.min(1, progress())))
const [height, setHeight] = createSignal(320)
const dock = createMemo(() => props.state.dock() || value() > 0.001)
const full = createMemo(() => Math.max(78, height()))
const [contentRef, setContentRef] = createSignal<HTMLDivElement>()
createEffect(() => {
const el = contentRef()
if (!el) return
const update = () => {
setHeight(el.getBoundingClientRect().height)
}
update()
const observer = new ResizeObserver(update)
observer.observe(el)
onCleanup(() => observer.disconnect())
})
return (
<div
ref={props.setPromptDockRef}
@@ -87,30 +139,46 @@ export function SessionComposerRegion(props: {
</div>
}
>
<Show when={props.state.dock()}>
<Show when={dock()}>
<div
classList={{
"transition-[max-height,opacity,transform] duration-[400ms] ease-out overflow-hidden": true,
"max-h-[320px]": !props.state.closing(),
"max-h-0 pointer-events-none": props.state.closing(),
"opacity-0 translate-y-9": props.state.closing() || props.state.opening(),
"opacity-100 translate-y-0": !props.state.closing() && !props.state.opening(),
"overflow-hidden": true,
"pointer-events-none": value() < 0.98,
}}
style={{
"max-height": `${full() * value()}px`,
}}
>
<SessionTodoDock
todos={props.state.todos()}
title={language.t("session.todo.title")}
collapseLabel={language.t("session.todo.collapse")}
expandLabel={language.t("session.todo.expand")}
/>
<div ref={setContentRef}>
<SessionTodoDock
todos={props.state.todos()}
title={language.t("session.todo.title")}
collapseLabel={language.t("session.todo.collapse")}
expandLabel={language.t("session.todo.expand")}
dockProgress={value()}
visualDuration={props.visualDuration}
bounce={props.bounce}
expandVisualDuration={props.drawerExpandVisualDuration}
expandBounce={props.drawerExpandBounce}
collapseVisualDuration={props.drawerCollapseVisualDuration}
collapseBounce={props.drawerCollapseBounce}
subtitleDuration={props.subtitleDuration}
subtitleTravel={props.subtitleTravel}
subtitleEdge={props.subtitleEdge}
countDuration={props.countDuration}
countMask={props.countMask}
countMaskHeight={props.countMaskHeight}
countWidthDuration={props.countWidthDuration}
/>
</div>
</div>
</Show>
<div
classList={{
"relative z-10": true,
"transition-[margin] duration-[400ms] ease-out": true,
"-mt-9": props.state.dock() && !props.state.closing(),
"mt-0": !props.state.dock() || props.state.closing(),
}}
style={{
"margin-top": `${-36 * value()}px`,
}}
>
<PromptInput

View File

@@ -29,7 +29,11 @@ export function createSessionComposerBlocked() {
})
}
export function createSessionComposerState() {
export function createSessionComposerState(
options?: {
closeMs?: number | (() => number)
},
) {
const params = useParams()
const sdk = useSDK()
const sync = useSync()
@@ -96,12 +100,19 @@ export function createSessionComposerState() {
let timer: number | undefined
let raf: number | undefined
const closeMs = () => {
const value = options?.closeMs
if (typeof value === "function") return Math.max(0, value())
if (typeof value === "number") return Math.max(0, value)
return 400
}
const scheduleClose = () => {
if (timer) window.clearTimeout(timer)
timer = window.setTimeout(() => {
setStore({ dock: false, closing: false })
timer = undefined
}, 400)
}, closeMs())
}
createEffect(

View File

@@ -1,8 +1,12 @@
import type { Todo } from "@opencode-ai/sdk/v2"
import { AnimatedNumber } from "@opencode-ai/ui/animated-number"
import { Checkbox } from "@opencode-ai/ui/checkbox"
import { DockTray } from "@opencode-ai/ui/dock-surface"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
import { useSpring } from "@opencode-ai/ui/motion-spring"
import { TextReveal } from "@opencode-ai/ui/text-reveal"
import { TextStrikethrough } from "@opencode-ai/ui/text-strikethrough"
import { Index, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
function dot(status: Todo["status"]) {
@@ -30,19 +34,35 @@ function dot(status: Todo["status"]) {
)
}
export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseLabel: string; expandLabel: string }) {
export function SessionTodoDock(props: {
todos: Todo[]
title: string
collapseLabel: string
expandLabel: string
dockProgress?: number
visualDuration?: number
bounce?: number
expandVisualDuration?: number
expandBounce?: number
collapseVisualDuration?: number
collapseBounce?: number
subtitleDuration?: number
subtitleTravel?: number
subtitleEdge?: number
countDuration?: number
countMask?: number
countMaskHeight?: number
countWidthDuration?: number
}) {
const [store, setStore] = createStore({
collapsed: false,
})
const toggle = () => setStore("collapsed", (value) => !value)
const summary = createMemo(() => {
const total = props.todos.length
if (total === 0) return ""
const completed = props.todos.filter((todo) => todo.status === "completed").length
return `${completed} of ${total} ${props.title.toLowerCase()} completed`
})
const total = createMemo(() => props.todos.length)
const done = createMemo(() => props.todos.filter((todo) => todo.status === "completed").length)
const label = createMemo(() => `${done()} of ${total()} ${props.title.toLowerCase()} completed`)
const active = createMemo(
() =>
@@ -53,56 +73,134 @@ export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseL
)
const preview = createMemo(() => active()?.content ?? "")
const config = createMemo(() =>
store.collapsed
? {
visualDuration: props.collapseVisualDuration ?? props.visualDuration ?? 0.3,
bounce: props.collapseBounce ?? props.bounce ?? 0,
}
: {
visualDuration: props.expandVisualDuration ?? props.visualDuration ?? 0.3,
bounce: props.expandBounce ?? props.bounce ?? 0,
},
)
const collapse = useSpring(() => (store.collapsed ? 1 : 0), config)
const dock = createMemo(() => Math.max(0, Math.min(1, props.dockProgress ?? 1)))
const shut = createMemo(() => 1 - dock())
const value = createMemo(() => Math.max(0, Math.min(1, collapse())))
const hide = createMemo(() => Math.max(value(), shut()))
const off = createMemo(() => hide() > 0.98)
const turn = createMemo(() => Math.max(0, Math.min(1, value())))
const [height, setHeight] = createSignal(320)
const full = createMemo(() => Math.max(78, height()))
let contentRef: HTMLDivElement | undefined
createEffect(() => {
const el = contentRef
if (!el) return
const update = () => {
setHeight(el.getBoundingClientRect().height)
}
update()
const observer = new ResizeObserver(update)
observer.observe(el)
onCleanup(() => observer.disconnect())
})
return (
<DockTray
data-component="session-todo-dock"
classList={{
"h-[78px]": store.collapsed,
style={{
"overflow-x": "visible",
"overflow-y": "hidden",
"max-height": `${Math.max(78, full() - value() * (full() - 78))}px`,
}}
>
<div
data-action="session-todo-toggle"
class="pl-3 pr-2 py-2 flex items-center gap-2"
role="button"
tabIndex={0}
onClick={toggle}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
toggle()
}}
>
<span class="text-14-regular text-text-strong cursor-default">{summary()}</span>
<Show when={store.collapsed}>
<div class="ml-1 flex-1 min-w-0">
<Show when={preview()}>
<div class="text-14-regular text-text-base truncate cursor-default">{preview()}</div>
</Show>
<div ref={contentRef}>
<div
data-action="session-todo-toggle"
class="pl-3 pr-2 py-2 flex items-center gap-2 overflow-visible"
role="button"
tabIndex={0}
onClick={toggle}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
toggle()
}}
>
<span
class="text-14-regular text-text-strong cursor-default inline-flex items-baseline shrink-0 whitespace-nowrap overflow-visible"
aria-label={label()}
style={{
"--tool-motion-odometer-ms": `${props.countDuration ?? 600}ms`,
"--tool-motion-mask": `${props.countMask ?? 18}%`,
"--tool-motion-mask-height": `${props.countMaskHeight ?? 0}px`,
"--tool-motion-spring-ms": `${props.countWidthDuration ?? 560}ms`,
opacity: `${Math.max(0, Math.min(1, 1 - shut()))}`,
filter: `blur(${Math.max(0, Math.min(1, shut())) * 2}px)`,
}}
>
<AnimatedNumber value={done()} />
<span class="mx-1">of</span>
<AnimatedNumber value={total()} />
<span>&nbsp;{props.title.toLowerCase()} completed</span>
</span>
<div
data-slot="session-todo-preview"
class="ml-1 min-w-0 overflow-hidden"
style={{
flex: "1 1 auto",
"max-width": "100%",
}}
>
<TextReveal
class="text-14-regular text-text-base cursor-default"
text={store.collapsed ? preview() : undefined}
duration={props.subtitleDuration ?? 600}
travel={props.subtitleTravel ?? 25}
edge={props.subtitleEdge ?? 17}
spring="cubic-bezier(0.34, 1, 0.64, 1)"
springSoft="cubic-bezier(0.34, 1, 0.64, 1)"
growOnly
truncate
/>
</div>
<div class="ml-auto">
<IconButton
data-action="session-todo-toggle-button"
data-collapsed={store.collapsed ? "true" : "false"}
icon="chevron-down"
size="normal"
variant="ghost"
style={{ transform: `rotate(${turn() * 180}deg)` }}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.stopPropagation()
toggle()
}}
aria-label={store.collapsed ? props.expandLabel : props.collapseLabel}
/>
</div>
</Show>
<div classList={{ "ml-auto": !store.collapsed, "ml-1": store.collapsed }}>
<IconButton
data-action="session-todo-toggle-button"
icon="chevron-down"
size="normal"
variant="ghost"
classList={{ "rotate-180": store.collapsed }}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.stopPropagation()
toggle()
}}
aria-label={store.collapsed ? props.expandLabel : props.collapseLabel}
/>
</div>
</div>
<div data-slot="session-todo-list" hidden={store.collapsed}>
<TodoList todos={props.todos} open={!store.collapsed} />
<div
data-slot="session-todo-list"
aria-hidden={store.collapsed || off()}
classList={{
"pointer-events-none": hide() > 0.1,
}}
style={{
visibility: off() ? "hidden" : "visible",
opacity: `${Math.max(0, Math.min(1, 1 - hide()))}`,
filter: `blur(${Math.max(0, Math.min(1, hide())) * 2}px)`,
}}
>
<TodoList todos={props.todos} open={!store.collapsed} />
</div>
</div>
</DockTray>
)
@@ -171,33 +269,43 @@ function TodoList(props: { todos: Todo[]; open: boolean }) {
}, 250)
}}
>
<For each={props.todos}>
<Index each={props.todos}>
{(todo) => (
<Checkbox
readOnly
checked={todo.status === "completed"}
indeterminate={todo.status === "in_progress"}
data-in-progress={todo.status === "in_progress" ? "" : undefined}
icon={dot(todo.status)}
style={{ "--checkbox-align": "flex-start", "--checkbox-offset": "1px" }}
checked={todo().status === "completed"}
indeterminate={todo().status === "in_progress"}
data-in-progress={todo().status === "in_progress" ? "" : undefined}
data-state={todo().status}
icon={dot(todo().status)}
style={{
"--checkbox-align": "flex-start",
"--checkbox-offset": "1px",
transition:
"opacity 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), filter 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))",
opacity: todo().status === "pending" ? "0.94" : "1",
filter: todo().status === "pending" ? "blur(0.3px)" : "blur(0px)",
}}
>
<span
<TextStrikethrough
active={todo().status === "completed" || todo().status === "cancelled"}
text={todo().content}
class="text-14-regular min-w-0 break-words"
classList={{
"text-text-weak": todo.status === "completed" || todo.status === "cancelled",
"text-text-strong": todo.status !== "completed" && todo.status !== "cancelled",
}}
style={{
"line-height": "var(--line-height-normal)",
"text-decoration":
todo.status === "completed" || todo.status === "cancelled" ? "line-through" : undefined,
transition:
"color 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), opacity 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), filter 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))",
color:
todo().status === "completed" || todo().status === "cancelled"
? "var(--text-weak)"
: "var(--text-strong)",
opacity: todo().status === "pending" ? "0.92" : "1",
filter: todo().status === "pending" ? "blur(0.3px)" : "blur(0px)",
}}
>
{todo.content}
</span>
/>
</Checkbox>
)}
</For>
</Index>
</div>
<div
class="pointer-events-none absolute top-0 left-0 right-0 h-4 transition-opacity duration-150"

View File

@@ -10,8 +10,9 @@ import { Dialog } from "@opencode-ai/ui/dialog"
import { InlineInput } from "@opencode-ai/ui/inline-input"
import { SessionTurn } from "@opencode-ai/ui/session-turn"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import type { Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2"
import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2"
import { showToast } from "@opencode-ai/ui/toast"
import { Binary } from "@opencode-ai/util/binary"
import { getFilename } from "@opencode-ai/util/path"
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
import { SessionContextUsage } from "@/components/session-context-usage"
@@ -31,6 +32,9 @@ type MessageComment = {
}
}
const emptyMessages: MessageType[] = []
const idle = { type: "idle" as const }
const messageComments = (parts: Part[]): MessageComment[] =>
parts.flatMap((part) => {
if (part.type !== "text" || !(part as TextPart).synthetic) return []
@@ -213,8 +217,34 @@ 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 sessionMessages = createMemo(() => {
const id = sessionID()
if (!id) return emptyMessages
return sync.data.message[id] ?? emptyMessages
})
const pending = createMemo(() =>
sessionMessages().findLast(
(item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number",
),
)
const activeMessageID = createMemo(() => {
const parentID = pending()?.parentID
if (!parentID) return
const messages = sessionMessages()
const result = Binary.search(messages, parentID, (message) => message.id)
const message = result.found ? messages[result.index] : messages.find((item) => item.id === parentID)
if (!message || message.role !== "user") return
return message.id
})
const sessionStatus = createMemo(() => {
const id = sessionID()
if (!id) return idle
return sync.data.session_status[id] ?? idle
})
const info = createMemo(() => {
const id = sessionID()
if (!id) return
@@ -651,17 +681,23 @@ export function MessageTimeline(props: {
</Button>
</div>
</Show>
<For each={staging.messages()}>
{(message) => {
const comments = createMemo(() => messageComments(sync.data.part[message.id] ?? []))
<For each={rendered()}>
{(messageID) => {
const active = createMemo(() => activeMessageID() === messageID)
const queued = createMemo(() => {
const item = pending()
if (!item || active()) return false
return messageID > item.id
})
const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []))
const commentCount = createMemo(() => comments().length)
return (
<div
id={props.anchor(message.id)}
data-message-id={message.id}
id={props.anchor(messageID)}
data-message-id={messageID}
ref={(el) => {
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,
@@ -701,7 +737,10 @@ export function MessageTimeline(props: {
</Show>
<SessionTurn
sessionID={sessionID() ?? ""}
messageID={message.id}
messageID={messageID}
active={active()}
queued={queued()}
status={active() ? sessionStatus() : undefined}
showReasoningSummaries={settings.general.showReasoningSummaries()}
shellToolDefaultOpen={settings.general.shellToolPartsExpanded()}
editToolDefaultOpen={settings.general.editToolPartsExpanded()}

View File

@@ -1,9 +1,12 @@
import { defineMain } from "storybook-solidjs-vite"
import path from "node:path"
import { fileURLToPath } from "node:url"
import tailwindcss from "@tailwindcss/vite"
const here = path.dirname(fileURLToPath(import.meta.url))
const ui = path.resolve(here, "../../ui")
const app = path.resolve(here, "../../app/src")
const mocks = path.resolve(here, "./mocks")
export default defineMain({
framework: {
@@ -21,15 +24,41 @@ export default defineMain({
async viteFinal(config) {
const { mergeConfig, searchForWorkspaceRoot } = await import("vite")
return mergeConfig(config, {
plugins: [tailwindcss()],
resolve: {
dedupe: ["solid-js", "solid-js/web", "@solidjs/meta"],
alias: [
{ find: "@solidjs/router", replacement: path.resolve(mocks, "solid-router.tsx") },
{ find: /^@\/context\/local$/, replacement: path.resolve(mocks, "app/context/local.ts") },
{ find: /^@\/context\/file$/, replacement: path.resolve(mocks, "app/context/file.ts") },
{ find: /^@\/context\/prompt$/, replacement: path.resolve(mocks, "app/context/prompt.ts") },
{ find: /^@\/context\/layout$/, replacement: path.resolve(mocks, "app/context/layout.ts") },
{ find: /^@\/context\/sdk$/, replacement: path.resolve(mocks, "app/context/sdk.ts") },
{ find: /^@\/context\/sync$/, replacement: path.resolve(mocks, "app/context/sync.ts") },
{ find: /^@\/context\/comments$/, replacement: path.resolve(mocks, "app/context/comments.ts") },
{ find: /^@\/context\/command$/, replacement: path.resolve(mocks, "app/context/command.ts") },
{ find: /^@\/context\/permission$/, replacement: path.resolve(mocks, "app/context/permission.ts") },
{ find: /^@\/context\/language$/, replacement: path.resolve(mocks, "app/context/language.ts") },
{ find: /^@\/context\/platform$/, replacement: path.resolve(mocks, "app/context/platform.ts") },
{ find: /^@\/context\/global-sync$/, replacement: path.resolve(mocks, "app/context/global-sync.ts") },
{ find: /^@\/hooks\/use-providers$/, replacement: path.resolve(mocks, "app/hooks/use-providers.ts") },
{
find: /^@\/components\/dialog-select-model$/,
replacement: path.resolve(mocks, "app/components/dialog-select-model.tsx"),
},
{
find: /^@\/components\/dialog-select-model-unpaid$/,
replacement: path.resolve(mocks, "app/components/dialog-select-model-unpaid.tsx"),
},
{ find: "@", replacement: app },
],
},
worker: {
format: "es",
},
server: {
fs: {
allow: [searchForWorkspaceRoot(process.cwd()), ui],
allow: [searchForWorkspaceRoot(process.cwd()), ui, app, mocks],
},
},
})

View File

@@ -0,0 +1,3 @@
export function DialogSelectModelUnpaid() {
return <div data-component="dialog-select-model-unpaid">Select model</div>
}

View File

@@ -0,0 +1,7 @@
import { splitProps } from "solid-js"
export function ModelSelectorPopover(props: { triggerAs: any; triggerProps?: Record<string, unknown>; children: any }) {
const [local] = splitProps(props, ["triggerAs", "triggerProps", "children"])
const Trigger = local.triggerAs
return <Trigger {...(local.triggerProps ?? {})}>{local.children}</Trigger>
}

View File

@@ -0,0 +1,22 @@
const keybinds: Record<string, string> = {
"file.attach": "mod+u",
"prompt.mode.shell": "mod+shift+x",
"prompt.mode.normal": "mod+shift+e",
"permissions.autoaccept": "mod+shift+a",
"agent.cycle": "mod+.",
"model.choose": "mod+m",
"model.variant.cycle": "mod+shift+m",
}
export function useCommand() {
return {
options: [],
register() {
return () => undefined
},
trigger() {},
keybind(id: string) {
return keybinds[id]
},
}
}

View File

@@ -0,0 +1,34 @@
import { createSignal } from "solid-js"
type Comment = {
id: string
file: string
selection: { start: number; end: number }
comment: string
time: number
}
const [list, setList] = createSignal<Comment[]>([])
const [focus, setFocus] = createSignal<{ file: string; id: string } | null>(null)
const [active, setActive] = createSignal<{ file: string; id: string } | null>(null)
export function useComments() {
return {
all: list,
replace(next: Comment[]) {
setList(next)
},
remove(file: string, id: string) {
setList((current) => current.filter((item) => !(item.file === file && item.id === id)))
},
clear() {
setList([])
setFocus(null)
setActive(null)
},
focus,
setFocus,
active,
setActive,
}
}

View File

@@ -0,0 +1,47 @@
export type FileSelection = {
startLine: number
startChar: number
endLine: number
endChar: number
}
export type SelectedLineRange = {
start: number
end: number
}
export function selectionFromLines(selection?: SelectedLineRange): FileSelection | undefined {
if (!selection) return undefined
return {
startLine: selection.start,
startChar: 0,
endLine: selection.end,
endChar: 0,
}
}
const pool = [
"src/session/timeline.tsx",
"src/session/composer.tsx",
"src/components/prompt-input.tsx",
"src/components/session-todo-dock.tsx",
"README.md",
]
export function useFile() {
return {
tab(path: string) {
return `file:${path}`
},
pathFromTab(tab: string) {
if (!tab.startsWith("file:")) return ""
return tab.slice(5)
},
load: async () => undefined,
async searchFilesAndDirectories(query: string) {
const text = query.trim().toLowerCase()
if (!text) return pool
return pool.filter((path) => path.toLowerCase().includes(text))
},
}
}

View File

@@ -0,0 +1,42 @@
import { createStore } from "solid-js/store"
const provider = {
all: [
{
id: "anthropic",
models: {
"claude-3-7-sonnet": {
id: "claude-3-7-sonnet",
name: "Claude 3.7 Sonnet",
cost: { input: 1, output: 1 },
},
},
},
],
connected: ["anthropic"],
default: { anthropic: "claude-3-7-sonnet" },
}
const [store, setStore] = createStore({
todo: {} as Record<string, any[]>,
provider,
session: [] as any[],
config: { permission: {} },
})
export function useGlobalSync() {
return {
data: {
provider,
session_todo: store.todo,
},
child() {
return [store, setStore] as const
},
todo: {
set(sessionID: string, todos: any[]) {
setStore("todo", sessionID, todos)
},
},
}
}

View File

@@ -0,0 +1,74 @@
const dict: Record<string, string> = {
"session.todo.title": "Todos",
"session.todo.collapse": "Collapse todos",
"session.todo.expand": "Expand todos",
"prompt.loading": "Loading prompt...",
"prompt.placeholder.normal": "Ask anything...",
"prompt.placeholder.simple": "Ask anything...",
"prompt.placeholder.shell": "Run a shell command...",
"prompt.placeholder.summarizeComment": "Summarize this comment",
"prompt.placeholder.summarizeComments": "Summarize these comments",
"prompt.action.attachFile": "Attach file",
"prompt.action.send": "Send",
"prompt.action.stop": "Stop",
"prompt.attachment.remove": "Remove attachment",
"prompt.dropzone.label": "Drop image to attach",
"prompt.dropzone.file.label": "Drop file to attach",
"prompt.mode.shell": "Shell",
"prompt.mode.normal": "Prompt",
"dialog.model.select.title": "Select model",
"common.default": "Default",
"common.key.esc": "Esc",
"command.category.file": "File",
"command.category.session": "Session",
"command.agent.cycle": "Cycle agent",
"command.model.choose": "Choose model",
"command.model.variant.cycle": "Cycle model variant",
"command.prompt.mode.shell": "Switch to shell mode",
"command.prompt.mode.normal": "Switch to prompt mode",
"command.permissions.autoaccept.enable": "Enable auto-accept",
"command.permissions.autoaccept.disable": "Disable auto-accept",
"prompt.example.1": "Refactor this function and keep behavior the same",
"prompt.example.2": "Find the root cause of this error",
"prompt.example.3": "Write tests for this module",
"prompt.example.4": "Explain this diff",
"prompt.example.5": "Optimize this query",
"prompt.example.6": "Clean up this component",
"prompt.example.7": "Summarize the recent changes",
"prompt.example.8": "Add accessibility checks",
"prompt.example.9": "Review this API design",
"prompt.example.10": "Generate migration notes",
"prompt.example.11": "Patch this bug",
"prompt.example.12": "Make this animation smoother",
"prompt.example.13": "Improve error handling",
"prompt.example.14": "Document this feature",
"prompt.example.15": "Refine these styles",
"prompt.example.16": "Check edge cases",
"prompt.example.17": "Help me write a commit message",
"prompt.example.18": "Reduce re-renders in this component",
"prompt.example.19": "Verify keyboard navigation",
"prompt.example.20": "Make this copy clearer",
"prompt.example.21": "Add telemetry for this flow",
"prompt.example.22": "Compare these two implementations",
"prompt.example.23": "Create a minimal reproduction",
"prompt.example.24": "Suggest naming improvements",
"prompt.example.25": "What should we test next?",
}
function render(template: string, params?: Record<string, unknown>) {
if (!params) return template
return template.replace(/\{\{([^}]+)\}\}/g, (_, key: string) => {
const value = params[key.trim()]
if (value === undefined || value === null) return ""
return String(value)
})
}
export function useLanguage() {
return {
locale: () => "en" as const,
t(key: string, params?: Record<string, unknown>) {
return render(dict[key] ?? key, params)
},
}
}

View File

@@ -0,0 +1,41 @@
import { createSignal } from "solid-js"
const [all, setAll] = createSignal<string[]>([])
const [active, setActive] = createSignal<string | undefined>(undefined)
const [reviewOpen, setReviewOpen] = createSignal(false)
const tabs = {
all,
active,
open(tab: string) {
setAll((current) => (current.includes(tab) ? current : [...current, tab]))
},
setActive(tab: string) {
if (!all().includes(tab)) {
tabs.open(tab)
}
setActive(tab)
},
}
const view = {
reviewPanel: {
opened: reviewOpen,
open() {
setReviewOpen(true)
},
},
}
export function useLayout() {
return {
tabs: () => tabs,
view: () => view,
fileTree: {
setTab() {},
},
handoff: {
setTabs() {},
},
}
}

View File

@@ -0,0 +1,41 @@
import { createSignal } from "solid-js"
const model = {
id: "claude-3-7-sonnet",
name: "Claude 3.7 Sonnet",
provider: { id: "anthropic" },
variants: { fast: {}, thinking: {} },
}
const agents = [{ name: "build" }, { name: "review" }, { name: "plan" }]
const [agent, setAgent] = createSignal(agents[0].name)
const [variant, setVariant] = createSignal<string | undefined>(undefined)
export function useLocal() {
return {
slug: () => "c3Rvcnk=",
agent: {
list: () => agents,
current: () => agents.find((item) => item.name === agent()) ?? agents[0],
set(value?: string) {
if (!value) {
setAgent(agents[0].name)
return
}
const hit = agents.find((item) => item.name === value)
setAgent(hit?.name ?? agents[0].name)
},
},
model: {
current: () => model,
variant: {
list: () => Object.keys(model.variants),
current: () => variant(),
set(next?: string) {
setVariant(next)
},
},
},
}
}

View File

@@ -0,0 +1,24 @@
const accepted = new Set<string>()
function key(sessionID: string, directory?: string) {
return `${directory ?? ""}:${sessionID}`
}
export function usePermission() {
return {
autoResponds() {
return false
},
isAutoAccepting(sessionID: string, directory?: string) {
return accepted.has(key(sessionID, directory))
},
toggleAutoAccept(sessionID: string, directory?: string) {
const next = key(sessionID, directory)
if (accepted.has(next)) {
accepted.delete(next)
return
}
accepted.add(next)
},
}
}

View File

@@ -0,0 +1,16 @@
import type { Platform } from "../../../../../app/src/context/platform"
const value: Platform = {
platform: "web",
openLink() {},
restart: async () => {},
back() {},
forward() {},
notify: async () => {},
fetch: globalThis.fetch.bind(globalThis),
parseMarkdown: async (markdown: string) => markdown,
}
export function usePlatform() {
return value
}

View File

@@ -0,0 +1,117 @@
import { createSignal } from "solid-js"
interface PartBase {
content: string
start: number
end: number
}
export interface TextPart extends PartBase {
type: "text"
}
export interface FileAttachmentPart extends PartBase {
type: "file"
path: string
}
export interface AgentPart extends PartBase {
type: "agent"
name: string
}
export interface ImageAttachmentPart {
type: "image"
id: string
filename: string
mime: string
dataUrl: string
}
export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart
export type Prompt = ContentPart[]
type ContextItem = {
key: string
type: "file"
path: string
selection?: { startLine: number; startChar: number; endLine: number; endChar: number }
comment?: string
commentID?: string
commentOrigin?: "review" | "file"
preview?: string
}
export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
function clonePart(part: ContentPart): ContentPart {
if (part.type === "image") return { ...part }
if (part.type === "agent") return { ...part }
if (part.type === "file") return { ...part }
return { ...part }
}
function clonePrompt(prompt: Prompt) {
return prompt.map(clonePart)
}
export function isPromptEqual(a: Prompt, b: Prompt) {
if (a.length !== b.length) return false
return a.every((part, i) => JSON.stringify(part) === JSON.stringify(b[i]))
}
let index = 0
const [prompt, setPrompt] = createSignal<Prompt>(clonePrompt(DEFAULT_PROMPT))
const [cursor, setCursor] = createSignal<number>(0)
const [items, setItems] = createSignal<ContextItem[]>([])
const withKey = (item: Omit<ContextItem, "key"> & { key?: string }): ContextItem => ({
...item,
key: item.key ?? `ctx:${++index}`,
})
export function usePrompt() {
return {
ready: () => true,
current: prompt,
cursor,
dirty: () => !isPromptEqual(prompt(), DEFAULT_PROMPT),
set(next: Prompt, cursorPosition?: number) {
setPrompt(clonePrompt(next))
if (cursorPosition !== undefined) setCursor(cursorPosition)
},
reset() {
setPrompt(clonePrompt(DEFAULT_PROMPT))
setCursor(0)
setItems((current) => current.filter((item) => !!item.comment?.trim()))
},
context: {
items,
add(item: Omit<ContextItem, "key"> & { key?: string }) {
const next = withKey(item)
if (items().some((current) => current.key === next.key)) return
setItems((current) => [...current, next])
},
remove(key: string) {
setItems((current) => current.filter((item) => item.key !== key))
},
removeComment(path: string, commentID: string) {
setItems((current) =>
current.filter((item) => !(item.type === "file" && item.path === path && item.commentID === commentID)),
)
},
updateComment(path: string, commentID: string, next: Partial<ContextItem>) {
setItems((current) =>
current.map((item) => {
if (item.type !== "file" || item.path !== path || item.commentID !== commentID) return item
return withKey({ ...item, ...next })
}),
)
},
replaceComments(next: Array<Omit<ContextItem, "key"> & { key?: string }>) {
const nonComment = items().filter((item) => !item.comment?.trim())
setItems([...nonComment, ...next.map(withKey)])
},
},
}
}

View File

@@ -0,0 +1,25 @@
const make = (directory: string) => ({
session: {
create: async () => ({ data: { id: "story-session" } }),
prompt: async () => ({ data: undefined }),
shell: async () => ({ data: undefined }),
command: async () => ({ data: undefined }),
abort: async () => ({ data: undefined }),
},
worktree: {
create: async () => ({ data: { directory: `${directory}/worktree-1` } }),
},
})
const root = "/tmp/story"
export function useSDK() {
return {
directory: root,
url: "http://localhost:4096",
client: make(root),
createClient(input: { directory: string }) {
return make(input.directory)
},
}
}

View File

@@ -0,0 +1,32 @@
import { createStore } from "solid-js/store"
const [data, setData] = createStore({
session: [] as Array<{ id: string; parentID?: string }>,
permission: {} as Record<string, Array<{ id: string; sessionID: string; permission: string; patterns: string[] }>>,
question: {} as Record<string, Array<{ id: string; questions: unknown[] }>>,
session_diff: {} as Record<string, Array<{ file: string }>>,
message: {
"story-session": [] as Array<{ id: string; role: string }>,
} as Record<string, Array<{ id: string; role: string }>>,
session_status: {} as Record<string, { type: "idle" | "busy" }>,
agent: [{ name: "build", mode: "task", hidden: false }],
command: [{ name: "fix", description: "Run fix command", source: "project" }],
})
export function useSync() {
return {
data,
set(...input: unknown[]) {
;(setData as (...args: unknown[]) => void)(...input)
},
session: {
get(id: string) {
return { id }
},
optimistic: {
add() {},
remove() {},
},
},
}
}

View File

@@ -0,0 +1,23 @@
const model_id = "claude-3-7-sonnet"
const provider = {
id: "anthropic",
models: {
[model_id]: {
id: model_id,
name: "Claude 3.7 Sonnet",
cost: { input: 1, output: 1 },
variants: { fast: {}, thinking: {} },
},
},
}
export function useProviders() {
return {
all: () => [provider],
default: () => ({ anthropic: model_id }),
connected: () => [provider],
paid: () => [provider],
popular: () => [provider],
}
}

View File

@@ -0,0 +1,20 @@
import type { ParentProps } from "solid-js"
export function useParams() {
return {
dir: "c3Rvcnk=",
id: "story-session",
}
}
export function useNavigate() {
return () => undefined
}
export function MemoryRouter(props: ParentProps) {
return props.children
}
export function Route(props: ParentProps) {
return props.children
}

View File

@@ -1,4 +1,4 @@
import "@opencode-ai/ui/styles"
import "@opencode-ai/ui/styles/tailwind"
import { createEffect, onCleanup, onMount } from "solid-js"
import addonA11y from "@storybook/addon-a11y"
@@ -7,12 +7,8 @@ import { MetaProvider } from "@solidjs/meta"
import { addons } from "storybook/preview-api"
import { GLOBALS_UPDATED } from "storybook/internal/core-events"
import { createJSXDecorator, definePreview } from "storybook-solidjs-vite"
import { Code } from "@opencode-ai/ui/code"
import { CodeComponentProvider } from "@opencode-ai/ui/context/code"
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { DiffComponentProvider } from "@opencode-ai/ui/context/diff"
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
import { Diff } from "@opencode-ai/ui/diff"
import { ThemeProvider, useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
import { Font } from "@opencode-ai/ui/font"
@@ -58,20 +54,16 @@ const frame = createJSXDecorator((Story, context) => {
<Scheme value={scheme} />
<DialogProvider>
<MarkedProvider>
<DiffComponentProvider component={Diff}>
<CodeComponentProvider component={Code}>
<div
style={{
"min-height": "100vh",
padding: "24px",
"background-color": "var(--background-base)",
color: "var(--text-base)",
}}
>
<Story />
</div>
</CodeComponentProvider>
</DiffComponentProvider>
<div
style={{
"min-height": "100vh",
padding: "24px",
"background-color": "var(--background-base)",
color: "var(--text-base)",
}}
>
<Story />
</div>
</MarkedProvider>
</DialogProvider>
</ThemeProvider>

View File

@@ -8,19 +8,20 @@
"build": "storybook build"
},
"devDependencies": {
"@tailwindcss/vite": "catalog:",
"@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:"

View File

@@ -59,6 +59,9 @@
"marked-katex-extension": "5.1.6",
"marked-shiki": "catalog:",
"morphdom": "2.7.8",
"motion": "12.34.3",
"motion-dom": "12.34.3",
"motion-utils": "12.29.2",
"remeda": "catalog:",
"shiki": "catalog:",
"solid-js": "catalog:",

View File

@@ -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;
}
}

View File

@@ -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 = 600
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 (
<span data-slot="animated-number-digit">
<span
data-slot="animated-number-strip"
data-animating={animating() ? "true" : "false"}
onTransitionEnd={() => {
setAnimating(false)
setStep((value) => normalize(value) + 10)
}}
style={{
"--animated-number-offset": `${step()}`,
"--animated-number-duration": `var(--tool-motion-odometer-ms, ${DURATION}ms)`,
}}
>
<For each={TRACK}>{(value) => <span data-slot="animated-number-cell">{value}</span>}</For>
</span>
</span>
)
}
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 (
<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>
</span>
)
}

View File

@@ -64,7 +64,7 @@
[data-slot="basic-tool-tool-info-main"] {
display: flex;
align-items: center;
align-items: baseline;
gap: 8px;
min-width: 0;
overflow: hidden;

View File

@@ -1,4 +1,5 @@
import { createEffect, createSignal, For, Match, on, onCleanup, Show, Switch, type JSX } from "solid-js"
import { animate, type AnimationPlaybackControls } from "motion"
import { Collapsible } from "./collapsible"
import type { IconProps } from "./icon"
import { TextShimmer } from "./text-shimmer"
@@ -29,9 +30,12 @@ export interface BasicToolProps {
forceOpen?: boolean
defer?: boolean
locked?: boolean
animated?: boolean
onSubtitleClick?: () => void
}
const SPRING = { type: "spring" as const, visualDuration: 0.35, bounce: 0 }
export function BasicTool(props: BasicToolProps) {
const [open, setOpen] = createSignal(props.defaultOpen ?? false)
const [ready, setReady] = createSignal(open())
@@ -73,6 +77,38 @@ export function BasicTool(props: BasicToolProps) {
),
)
// Animated height for collapsible open/close
let contentRef: HTMLDivElement | undefined
let heightAnim: AnimationPlaybackControls | undefined
const initialOpen = open()
createEffect(
on(
open,
(isOpen) => {
if (!props.animated || !contentRef) return
heightAnim?.stop()
if (isOpen) {
contentRef.style.overflow = "hidden"
heightAnim = animate(contentRef, { height: "auto" }, SPRING)
heightAnim.finished.then(() => {
if (!contentRef || !open()) return
contentRef.style.overflow = "visible"
contentRef.style.height = "auto"
})
} else {
contentRef.style.overflow = "hidden"
heightAnim = animate(contentRef, { height: "0px" }, SPRING)
}
},
{ defer: true },
),
)
onCleanup(() => {
heightAnim?.stop()
})
const handleOpenChange = (value: boolean) => {
if (pending()) return
if (props.locked && !value) return
@@ -96,9 +132,7 @@ export function BasicTool(props: BasicToolProps) {
[trigger().titleClass ?? ""]: !!trigger().titleClass,
}}
>
<Show when={pending()} fallback={trigger().title}>
<TextShimmer text={trigger().title} />
</Show>
<TextShimmer text={trigger().title} active={pending()} />
</span>
<Show when={!pending()}>
<Show when={trigger().subtitle}>
@@ -147,7 +181,20 @@ export function BasicTool(props: BasicToolProps) {
</Show>
</div>
</Collapsible.Trigger>
<Show when={props.children && !props.hideDetails}>
<Show when={props.animated && props.children && !props.hideDetails}>
<div
ref={contentRef}
data-slot="collapsible-content"
data-animated
style={{
height: initialOpen ? "auto" : "0px",
overflow: initialOpen ? "visible" : "hidden",
}}
>
{props.children}
</div>
</Show>
<Show when={!props.animated && props.children && !props.hideDetails}>
<Collapsible.Content>
<Show when={!props.defer || ready()}>{props.children}</Show>
</Collapsible.Content>

View File

@@ -28,6 +28,10 @@
flex-shrink: 0;
border-radius: var(--radius-sm);
border: 1px solid var(--border-weak-base);
transition:
border-color 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)),
background-color 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)),
box-shadow 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
/* background-color: var(--surface-weak); */
}
@@ -39,6 +43,10 @@
height: 100%;
color: var(--icon-base);
opacity: 0;
transform: scale(0.9);
transition:
opacity 180ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)),
transform 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
}
/* [data-slot="checkbox-checkbox-content"] { */
@@ -100,6 +108,7 @@
&[data-checked] [data-slot="checkbox-checkbox-indicator"],
&[data-indeterminate] [data-slot="checkbox-checkbox-indicator"] {
opacity: 1;
transform: scale(1);
}
&[data-disabled] {

View File

@@ -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 },
],
},
}

View File

@@ -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 (
<WorkerPoolProvider pools={getWorkerPools()}>
<Show when={data()} fallback={<div>Loading pre-rendered diff...</div>}>
{(preloaded) => (
<div style={{ "max-width": "960px" }}>
<mod.Diff before={diff.before} after={diff.after} diffStyle="unified" preloadedDiff={preloaded()} />
</div>
)}
</Show>
</WorkerPoolProvider>
)
},
}
export const Split = {
render: () => {
const [data] = createResource(loadSplit)
return (
<WorkerPoolProvider pools={getWorkerPools()}>
<Show when={data()} fallback={<div>Loading pre-rendered diff...</div>}>
{(preloaded) => (
<div style={{ "max-width": "960px" }}>
<mod.Diff before={diff.before} after={diff.after} diffStyle="split" preloadedDiff={preloaded()} />
</div>
)}
</Show>
</WorkerPoolProvider>
)
},
}

View File

@@ -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 },
],
},
}

View File

@@ -608,29 +608,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"] {

View File

@@ -5,6 +5,7 @@ import {
createSignal,
For,
Match,
onMount,
Show,
Switch,
onCleanup,
@@ -47,6 +48,42 @@ 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"
import { animate } from "motion"
function ShellSubmessage(props: { text: string; animate?: boolean }) {
let widthRef: HTMLSpanElement | undefined
let valueRef: HTMLSpanElement | undefined
onMount(() => {
if (!props.animate) return
requestAnimationFrame(() => {
if (widthRef) {
animate(widthRef, { width: "auto" }, { type: "spring", visualDuration: 0.25, bounce: 0 })
}
if (valueRef) {
animate(valueRef, { opacity: 1, filter: "blur(0px)" }, { duration: 0.32, ease: [0.16, 1, 0.3, 1] })
}
})
})
return (
<span data-component="shell-submessage">
<span ref={widthRef} data-slot="shell-submessage-width" style={{ width: props.animate ? "0px" : undefined }}>
<span data-slot="basic-tool-tool-subtitle">
<span
ref={valueRef}
data-slot="shell-submessage-value"
style={props.animate ? { opacity: 0, filter: "blur(2px)" } : undefined}
>
{props.text}
</span>
</span>
</span>
</span>
)
}
interface Diagnostic {
range: {
@@ -272,6 +309,102 @@ function list<T>(value: T[] | undefined | null, fallback: T[]) {
return fallback
}
function same<T>(a: readonly T[] | undefined, b: readonly T[] | undefined) {
if (a === b) return true
if (!a || !b) return false
if (a.length !== b.length) return false
return a.every((x, i) => x === b[i])
}
type PartRef = {
messageID: string
partID: string
}
type PartGroup =
| {
key: string
type: "part"
ref: PartRef
}
| {
key: string
type: "context"
refs: PartRef[]
}
function sameRef(a: PartRef, b: PartRef) {
return a.messageID === b.messageID && a.partID === b.partID
}
function sameGroup(a: PartGroup, b: PartGroup) {
if (a === b) return true
if (a.key !== b.key) return false
if (a.type !== b.type) return false
if (a.type === "part") {
if (b.type !== "part") return false
return sameRef(a.ref, b.ref)
}
if (b.type !== "context") return false
if (a.refs.length !== b.refs.length) return false
return a.refs.every((ref, i) => sameRef(ref, b.refs[i]!))
}
function sameGroups(a: readonly PartGroup[] | undefined, b: readonly PartGroup[] | undefined) {
if (a === b) return true
if (!a || !b) return false
if (a.length !== b.length) return false
return a.every((item, i) => sameGroup(item, b[i]!))
}
function groupParts(parts: { messageID: string; part: PartType }[]) {
const result: PartGroup[] = []
let start = -1
const flush = (end: number) => {
if (start < 0) return
const first = parts[start]
const last = parts[end]
if (!first || !last) {
start = -1
return
}
result.push({
key: `context:${first.part.id}`,
type: "context",
refs: parts.slice(start, end + 1).map((item) => ({
messageID: item.messageID,
partID: item.part.id,
})),
})
start = -1
}
parts.forEach((item, index) => {
if (isContextGroupTool(item.part)) {
if (start < 0) start = index
return
}
flush(index - 1)
result.push({
key: `part:${item.messageID}:${item.part.id}`,
type: "part",
ref: {
messageID: item.messageID,
partID: item.part.id,
},
})
})
flush(parts.length - 1)
return result
}
function partByID(parts: readonly PartType[], partID: string) {
return parts.find((part) => part.id === partID)
}
function renderable(part: PartType, showReasoningSummaries = true) {
if (part.type === "tool") {
if (HIDDEN_TOOLS.has(part.tool)) return false
@@ -304,98 +437,68 @@ export function AssistantParts(props: {
}) {
const data = useData()
const emptyParts: PartType[] = []
const emptyTools: ToolPart[] = []
const grouped = createMemo(() => {
const keys: string[] = []
const items: Record<
string,
{ type: "part"; part: PartType; message: AssistantMessage } | { type: "context"; parts: ToolPart[] }
> = {}
const push = (
key: string,
item: { type: "part"; part: PartType; message: AssistantMessage } | { type: "context"; parts: ToolPart[] },
) => {
keys.push(key)
items[key] = item
}
const grouped = createMemo(
() =>
groupParts(
props.messages.flatMap((message) =>
list(data.store.part?.[message.id], emptyParts)
.filter((part) => renderable(part, props.showReasoningSummaries ?? true))
.map((part) => ({
messageID: message.id,
part,
})),
),
),
[] as PartGroup[],
{ equals: sameGroups },
)
const parts = props.messages.flatMap((message) =>
list(data.store.part?.[message.id], emptyParts)
.filter((part) => renderable(part, props.showReasoningSummaries ?? true))
.map((part) => ({ message, part })),
)
let start = -1
const flush = (end: number) => {
if (start < 0) return
const first = parts[start]
const last = parts[end]
if (!first || !last) {
start = -1
return
}
push(`context:${first.part.id}`, {
type: "context",
parts: parts
.slice(start, end + 1)
.map((x) => x.part)
.filter((part): part is ToolPart => isContextGroupTool(part)),
})
start = -1
}
parts.forEach((item, index) => {
if (isContextGroupTool(item.part)) {
if (start < 0) start = index
return
}
flush(index - 1)
push(`part:${item.message.id}:${item.part.id}`, { type: "part", part: item.part, message: item.message })
})
flush(parts.length - 1)
return { keys, items }
})
const last = createMemo(() => grouped().keys.at(-1))
const last = createMemo(() => grouped().at(-1)?.key)
return (
<For each={grouped().keys}>
{(key) => {
const item = createMemo(() => grouped().items[key])
const ctx = createMemo(() => {
const value = item()
if (!value) return
if (value.type !== "context") return
return value
})
const part = createMemo(() => {
const value = item()
if (!value) return
if (value.type !== "part") return
return value
})
const tail = createMemo(() => last() === key)
<For each={grouped()}>
{(entry) => {
if (entry.type === "context") {
const parts = createMemo(
() =>
entry.refs
.map((ref) => partByID(list(data.store.part?.[ref.messageID], emptyParts), ref.partID))
.filter((part): part is ToolPart => !!part && isContextGroupTool(part)),
emptyTools,
{ equals: same },
)
const busy = createMemo(() => props.working && last() === entry.key)
return (
<Show when={parts().length > 0}>
<ContextToolGroup parts={parts()} busy={busy()} />
</Show>
)
}
const message = createMemo(() => props.messages.find((item) => item.id === entry.ref.messageID))
const part = createMemo(() =>
partByID(list(data.store.part?.[entry.ref.messageID], emptyParts), entry.ref.partID),
)
return (
<>
<Show when={ctx()}>
{(entry) => <ContextToolGroup parts={entry().parts} busy={props.working && tail()} />}
</Show>
<Show when={part()}>
{(entry) => (
<Part
part={entry().part}
message={entry().message}
showAssistantCopyPartID={props.showAssistantCopyPartID}
turnDurationMs={props.turnDurationMs}
defaultOpen={partDefaultOpen(entry().part, props.shellToolDefaultOpen, props.editToolDefaultOpen)}
/>
)}
</Show>
</>
<Show when={message()}>
{(message) => (
<Show when={part()}>
{(part) => (
<Part
part={part()}
message={message()}
showAssistantCopyPartID={props.showAssistantCopyPartID}
turnDurationMs={props.turnDurationMs}
defaultOpen={partDefaultOpen(part(), props.shellToolDefaultOpen, props.editToolDefaultOpen)}
/>
)}
</Show>
)}
</Show>
)
}}
</For>
@@ -469,23 +572,11 @@ function contextToolTrigger(part: ToolPart, i18n: ReturnType<typeof useI18n>) {
}
}
function contextToolSummary(parts: ToolPart[], i18n: ReturnType<typeof useI18n>) {
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) {
@@ -525,78 +616,49 @@ export function AssistantMessageDisplay(props: {
showAssistantCopyPartID?: string | null
showReasoningSummaries?: boolean
}) {
const grouped = createMemo(() => {
const keys: string[] = []
const items: Record<string, { type: "part"; part: PartType } | { type: "context"; parts: ToolPart[] }> = {}
const push = (key: string, item: { type: "part"; part: PartType } | { type: "context"; parts: ToolPart[] }) => {
keys.push(key)
items[key] = item
}
const parts = props.parts
let start = -1
const flush = (end: number) => {
if (start < 0) return
const first = parts[start]
const last = parts[end]
if (!first || !last) {
start = -1
return
}
push(`context:${first.id}`, {
type: "context",
parts: parts.slice(start, end + 1).filter((part): part is ToolPart => isContextGroupTool(part)),
})
start = -1
}
parts.forEach((part, index) => {
if (!renderable(part, props.showReasoningSummaries ?? true)) return
if (isContextGroupTool(part)) {
if (start < 0) start = index
return
}
flush(index - 1)
push(`part:${part.id}`, { type: "part", part })
})
flush(parts.length - 1)
return { keys, items }
})
const emptyTools: ToolPart[] = []
const grouped = createMemo(
() =>
groupParts(
props.parts
.filter((part) => renderable(part, props.showReasoningSummaries ?? true))
.map((part) => ({
messageID: props.message.id,
part,
})),
),
[] as PartGroup[],
{ equals: sameGroups },
)
return (
<For each={grouped().keys}>
{(key) => {
const item = createMemo(() => grouped().items[key])
const ctx = createMemo(() => {
const value = item()
if (!value) return
if (value.type !== "context") return
return value
})
const part = createMemo(() => {
const value = item()
if (!value) return
if (value.type !== "part") return
return value
})
return (
<>
<Show when={ctx()}>{(entry) => <ContextToolGroup parts={entry().parts} />}</Show>
<Show when={part()}>
{(entry) => (
<Part
part={entry().part}
message={props.message}
showAssistantCopyPartID={props.showAssistantCopyPartID}
/>
)}
<For each={grouped()}>
{(entry) => {
if (entry.type === "context") {
const parts = createMemo(
() =>
entry.refs
.map((ref) => partByID(props.parts, ref.partID))
.filter((part): part is ToolPart => !!part && isContextGroupTool(part)),
emptyTools,
{ equals: same },
)
return (
<Show when={parts().length > 0}>
<ContextToolGroup parts={parts()} />
</Show>
</>
)
}
const part = createMemo(() => partByID(props.parts, entry.ref.partID))
return (
<Show when={part()}>
{(part) => (
<Part part={part()} message={props.message} showAssistantCopyPartID={props.showAssistantCopyPartID} />
)}
</Show>
)
}}
</For>
@@ -610,33 +672,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 (
<Collapsible open={open()} onOpenChange={setOpen} variant="ghost">
<Collapsible.Trigger>
<div data-component="context-tool-group-trigger">
<Show
when={pending()}
fallback={
<span data-slot="context-tool-group-title">
<span data-slot="context-tool-group-label">{i18n.t("ui.sessionTurn.status.gatheredContext")}</span>
<Show when={details().length}>
<span data-slot="context-tool-group-summary">{details()}</span>
</Show>
</span>
}
<span
data-slot="context-tool-group-title"
class="min-w-0 flex items-center gap-2 text-14-medium text-text-strong"
>
<span data-slot="context-tool-group-title">
<span data-slot="context-tool-group-label">
<TextShimmer text={i18n.t("ui.sessionTurn.status.gatheringContext")} />
</span>
<Show when={details().length}>
<span data-slot="context-tool-group-summary">{details()}</span>
</Show>
<span data-slot="context-tool-group-label" class="shrink-0">
<ToolStatusTitle
active={pending()}
activeText={i18n.t("ui.sessionTurn.status.gatheringContext")}
doneText={i18n.t("ui.sessionTurn.status.gatheredContext")}
split={false}
/>
</span>
</Show>
<span
data-slot="context-tool-group-summary"
class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap font-normal text-text-base"
>
<AnimatedCountList
items={[
{
key: "read",
count: summary().read,
one: i18n.t("ui.messagePart.context.read.one"),
other: i18n.t("ui.messagePart.context.read.other"),
},
{
key: "search",
count: summary().search,
one: i18n.t("ui.messagePart.context.search.one"),
other: i18n.t("ui.messagePart.context.search.other"),
},
{
key: "list",
count: summary().list,
one: i18n.t("ui.messagePart.context.list.one"),
other: i18n.t("ui.messagePart.context.list.other"),
},
]}
fallback=""
/>
</span>
</span>
<Collapsible.Arrow />
</div>
</Collapsible.Trigger>
@@ -654,9 +736,7 @@ function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) {
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
<span data-slot="basic-tool-tool-title">
<Show when={running} fallback={trigger.title}>
<TextShimmer text={trigger.title} />
</Show>
<TextShimmer text={trigger.title} active={running} />
</span>
<Show when={!running && trigger.subtitle}>
<span data-slot="basic-tool-tool-subtitle">{trigger.subtitle}</span>
@@ -1319,9 +1399,7 @@ ToolRegistry.register({
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
<span data-slot="basic-tool-tool-title">
<Show when={pending()} fallback={i18n.t("ui.tool.webfetch")}>
<TextShimmer text={i18n.t("ui.tool.webfetch")} />
</Show>
<TextShimmer text={i18n.t("ui.tool.webfetch")} active={pending()} />
</span>
<Show when={!pending() && url()}>
<a
@@ -1436,6 +1514,8 @@ ToolRegistry.register({
name: "bash",
render(props) {
const i18n = useI18n()
const pending = () => props.status === "pending" || props.status === "running"
const sawPending = pending()
const text = createMemo(() => {
const cmd = props.input.command ?? props.metadata.command ?? ""
const out = stripAnsi(props.output || props.metadata.output || "")
@@ -1455,10 +1535,18 @@ ToolRegistry.register({
<BasicTool
{...props}
icon="console"
trigger={{
title: i18n.t("ui.tool.shell"),
subtitle: props.input.description,
}}
trigger={
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
<span data-slot="basic-tool-tool-title">
<TextShimmer text={i18n.t("ui.tool.shell")} active={pending()} />
</span>
<Show when={!pending() && props.input.description}>
<ShellSubmessage text={props.input.description} animate={sawPending} />
</Show>
</div>
</div>
}
>
<div data-component="bash-output">
<div data-slot="bash-copy">
@@ -1508,9 +1596,7 @@ ToolRegistry.register({
<div data-slot="message-part-title-area">
<div data-slot="message-part-title">
<span data-slot="message-part-title-text">
<Show when={pending()} fallback={i18n.t("ui.messagePart.title.edit")}>
<TextShimmer text={i18n.t("ui.messagePart.title.edit")} />
</Show>
<TextShimmer text={i18n.t("ui.messagePart.title.edit")} active={pending()} />
</span>
<Show when={!pending()}>
<span data-slot="message-part-title-filename">{filename()}</span>
@@ -1580,9 +1666,7 @@ ToolRegistry.register({
<div data-slot="message-part-title-area">
<div data-slot="message-part-title">
<span data-slot="message-part-title-text">
<Show when={pending()} fallback={i18n.t("ui.messagePart.title.write")}>
<TextShimmer text={i18n.t("ui.messagePart.title.write")} />
</Show>
<TextShimmer text={i18n.t("ui.messagePart.title.write")} active={pending()} />
</span>
<Show when={!pending()}>
<span data-slot="message-part-title-filename">{filename()}</span>
@@ -1774,9 +1858,7 @@ ToolRegistry.register({
<div data-slot="message-part-title-area">
<div data-slot="message-part-title">
<span data-slot="message-part-title-text">
<Show when={pending()} fallback={i18n.t("ui.tool.patch")}>
<TextShimmer text={i18n.t("ui.tool.patch")} />
</Show>
<TextShimmer text={i18n.t("ui.tool.patch")} active={pending()} />
</span>
<Show when={!pending()}>
<span data-slot="message-part-title-filename">{getFilename(file().relativePath)}</span>

View File

@@ -0,0 +1,45 @@
import { attachSpring, motionValue } from "motion"
import type { SpringOptions } from "motion"
import { createEffect, createSignal, onCleanup } from "solid-js"
type Opt = Partial<Pick<SpringOptions, "visualDuration" | "bounce" | "stiffness" | "damping" | "mass" | "velocity">>
const eq = (a: Opt | undefined, b: Opt | undefined) =>
a?.visualDuration === b?.visualDuration &&
a?.bounce === b?.bounce &&
a?.stiffness === b?.stiffness &&
a?.damping === b?.damping &&
a?.mass === b?.mass &&
a?.velocity === b?.velocity
export function useSpring(target: () => number, options?: Opt | (() => Opt)) {
const read = () => (typeof options === "function" ? options() : options)
const [value, setValue] = createSignal(target())
const source = motionValue(value())
const spring = motionValue(value())
let config = read()
let stop = attachSpring(spring, source, config)
let off = spring.on("change", (next: number) => setValue(next))
createEffect(() => {
source.set(target())
})
createEffect(() => {
if (!options) return
const next = read()
if (eq(config, next)) return
config = next
stop()
stop = attachSpring(spring, source, next)
setValue(spring.get())
})
onCleanup(() => {
off()
stop()
spring.destroy()
source.destroy()
})
return value
}

View File

@@ -48,9 +48,9 @@
transition:
opacity 200ms ease-out,
box-shadow 100ms ease-in-out,
width 200ms ease-out,
height 200ms ease-out,
transform 200ms ease-out;
width 200ms cubic-bezier(0.22, 1.2, 0.36, 1),
height 200ms cubic-bezier(0.22, 1.2, 0.36, 1),
transform 300ms cubic-bezier(0.22, 1.2, 0.36, 1);
will-change: transform;
z-index: 0;
}

View File

@@ -60,16 +60,13 @@
width: 16px;
height: 16px;
}
}
[data-slot="session-turn-thinking-heading"] {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-weaker);
font-weight: var(--font-weight-regular);
}
[data-component="text-reveal"].session-turn-thinking-heading {
flex: 1 1 auto;
min-width: 0;
color: var(--text-weaker);
font-weight: var(--font-weight-regular);
}
.error-card {

View File

@@ -1,4 +1,5 @@
import { AssistantMessage, type FileDiff, Message as MessageType, Part as PartType } from "@opencode-ai/sdk/v2/client"
import type { SessionStatus } from "@opencode-ai/sdk/v2"
import { useData } from "../context"
import { useFileComponent } from "../context/file"
@@ -15,6 +16,7 @@ import { DiffChanges } from "./diff-changes"
import { Icon } from "./icon"
import { TextShimmer } from "./text-shimmer"
import { SessionRetry } from "./session-retry"
import { TextReveal } from "./text-reveal"
import { createAutoScroll } from "../hooks"
import { useI18n } from "../context/i18n"
@@ -142,6 +144,9 @@ export function SessionTurn(
showReasoningSummaries?: boolean
shellToolDefaultOpen?: boolean
editToolDefaultOpen?: boolean
active?: boolean
queued?: boolean
status?: SessionStatus
onUserInteracted?: () => void
classes?: {
root?: string
@@ -187,6 +192,7 @@ export function SessionTurn(
})
const pending = createMemo(() => {
if (typeof props.active === "boolean" && typeof props.queued === "boolean") return
const messages = allMessages() ?? emptyMessages
return messages.findLast(
(item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number",
@@ -204,6 +210,7 @@ export function SessionTurn(
})
const active = createMemo(() => {
if (typeof props.active === "boolean") return props.active
const msg = message()
const parent = pendingUser()
if (!msg || !parent) return false
@@ -211,6 +218,7 @@ export function SessionTurn(
})
const queued = createMemo(() => {
if (typeof props.queued === "boolean") return props.queued
const id = message()?.id
if (!id) return false
if (!pendingUser()) return false
@@ -305,7 +313,11 @@ export function SessionTurn(
return unwrap(String(msg))
})
const status = createMemo(() => data.store.session_status[props.sessionID] ?? idle)
const status = createMemo(() => {
if (props.status !== undefined) return props.status
if (typeof props.active === "boolean" && !props.active) return idle
return data.store.session_status[props.sessionID] ?? idle
})
const working = createMemo(() => status().type !== "idle" && active())
const showReasoningSummaries = createMemo(() => props.showReasoningSummaries ?? true)
@@ -410,8 +422,13 @@ export function SessionTurn(
<Show when={showThinking()}>
<div data-slot="session-turn-thinking">
<TextShimmer text={i18n.t("ui.sessionTurn.status.thinking")} />
<Show when={!showReasoningSummaries() && reasoningHeading()}>
{(text) => <span data-slot="session-turn-thinking-heading">{text()}</span>}
<Show when={!showReasoningSummaries()}>
<TextReveal
text={reasoningHeading()}
class="session-turn-thinking-heading"
travel={25}
duration={700}
/>
</Show>
</div>
</Show>

View File

@@ -0,0 +1,329 @@
// @ts-nocheck
import { createEffect, createSignal, onCleanup } from "solid-js"
import { BasicTool } from "./basic-tool"
import { animate } from "motion"
export default {
title: "UI/Shell Submessage Motion",
id: "components-shell-submessage-motion",
tags: ["autodocs"],
parameters: {
docs: {
description: {
component: `### Overview
Interactive playground for animating the Shell tool subtitle ("submessage") in the timeline trigger row.
### Production component path
- Trigger layout: \`packages/ui/src/components/basic-tool.tsx\`
- Bash tool subtitle source: \`packages/ui/src/components/message-part.tsx\` (tool: \`bash\`, \`trigger.subtitle\`)
### What this playground tunes
- Width reveal (spring-driven pixel width via \`useSpring\`)
- Opacity fade
- Blur settle`,
},
},
},
}
const btn = (accent?: boolean) =>
({
padding: "6px 14px",
"border-radius": "6px",
border: "1px solid var(--color-divider, #333)",
background: accent ? "var(--color-accent, #58f)" : "var(--color-fill-element, #222)",
color: "var(--color-text, #eee)",
cursor: "pointer",
"font-size": "13px",
}) as const
const sliderLabel = {
"font-size": "11px",
"font-family": "monospace",
color: "var(--color-text-weak, #666)",
"min-width": "84px",
"flex-shrink": "0",
"text-align": "right",
}
const sliderValue = {
"font-family": "monospace",
"font-size": "11px",
color: "var(--color-text-weak, #aaa)",
"min-width": "76px",
}
const shellCss = `
[data-component="shell-submessage-scene"] [data-component="tool-trigger"] [data-slot="basic-tool-tool-info-main"] {
align-items: baseline;
}
[data-component="shell-submessage"] {
min-width: 0;
max-width: 100%;
display: inline-flex;
align-items: baseline;
vertical-align: baseline;
}
[data-component="shell-submessage"] [data-slot="shell-submessage-width"] {
min-width: 0;
max-width: 100%;
display: inline-flex;
align-items: baseline;
overflow: hidden;
}
[data-component="shell-submessage"] [data-slot="shell-submessage-value"] {
display: inline-block;
vertical-align: baseline;
min-width: 0;
line-height: inherit;
white-space: nowrap;
opacity: 0;
filter: blur(var(--shell-sub-blur, 2px));
transition-property: opacity, filter;
transition-duration: var(--shell-sub-fade-ms, 320ms);
transition-timing-function: var(--shell-sub-fade-ease, cubic-bezier(0.22, 1, 0.36, 1));
}
[data-component="shell-submessage"][data-visible] [data-slot="shell-submessage-value"] {
opacity: 1;
filter: blur(0px);
}
`
const ease = {
smooth: "cubic-bezier(0.16, 1, 0.3, 1)",
snappy: "cubic-bezier(0.22, 1, 0.36, 1)",
standard: "cubic-bezier(0.2, 0.8, 0.2, 1)",
linear: "linear",
}
function SpringSubmessage(props: { text: string; visible: boolean; visualDuration: number; bounce: number }) {
let ref: HTMLSpanElement | undefined
let widthRef: HTMLSpanElement | undefined
createEffect(() => {
if (!widthRef) return
if (props.visible) {
requestAnimationFrame(() => {
ref?.setAttribute("data-visible", "")
animate(
widthRef!,
{ width: "auto" },
{ type: "spring", visualDuration: props.visualDuration, bounce: props.bounce },
)
})
} else {
ref?.removeAttribute("data-visible")
animate(
widthRef,
{ width: "0px" },
{ type: "spring", visualDuration: props.visualDuration, bounce: props.bounce },
)
}
})
return (
<span ref={ref} data-component="shell-submessage">
<span ref={widthRef} data-slot="shell-submessage-width" style={{ width: "0px" }}>
<span data-slot="basic-tool-tool-subtitle">
<span data-slot="shell-submessage-value">{props.text || "\u00A0"}</span>
</span>
</span>
</span>
)
}
export const Playground = {
render: () => {
const [text, setText] = createSignal("Prints five topic blocks between timed commands")
const [show, setShow] = createSignal(true)
const [visualDuration, setVisualDuration] = createSignal(0.35)
const [bounce, setBounce] = createSignal(0)
const [fadeMs, setFadeMs] = createSignal(320)
const [blur, setBlur] = createSignal(2)
const [fadeEase, setFadeEase] = createSignal<keyof typeof ease>("snappy")
const [auto, setAuto] = createSignal(false)
let replayTimer
let autoTimer
const replay = () => {
setShow(false)
if (replayTimer) clearTimeout(replayTimer)
replayTimer = setTimeout(() => {
setShow(true)
}, 50)
}
const stopAuto = () => {
if (autoTimer) clearInterval(autoTimer)
autoTimer = undefined
setAuto(false)
}
const toggleAuto = () => {
if (auto()) {
stopAuto()
return
}
setAuto(true)
autoTimer = setInterval(replay, 2200)
}
onCleanup(() => {
if (replayTimer) clearTimeout(replayTimer)
if (autoTimer) clearInterval(autoTimer)
})
return (
<div
data-component="shell-submessage-scene"
style={{
display: "grid",
gap: "20px",
padding: "20px",
"max-width": "860px",
"--shell-sub-fade-ms": `${fadeMs()}ms`,
"--shell-sub-blur": `${blur()}px`,
"--shell-sub-fade-ease": ease[fadeEase()],
}}
>
<style>{shellCss}</style>
<BasicTool
icon="console"
defaultOpen
trigger={
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
<span data-slot="basic-tool-tool-title">Shell</span>
<SpringSubmessage text={text()} visible={show()} visualDuration={visualDuration()} bounce={bounce()} />
</div>
</div>
}
>
<div
style={{
"border-radius": "8px",
border: "1px solid var(--color-divider, #333)",
background: "var(--color-fill-secondary, #161616)",
padding: "14px 16px",
"font-family": "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
"font-size": "18px",
color: "var(--color-text, #eee)",
"white-space": "pre-wrap",
}}
>
{"$ cat <<'TOPIC1'"}
</div>
</BasicTool>
<div style={{ display: "flex", gap: "8px", "flex-wrap": "wrap" }}>
<button onClick={replay} style={btn()}>
Replay entry
</button>
<button onClick={() => setShow((v) => !v)} style={btn(show())}>
{show() ? "Hide subtitle" : "Show subtitle"}
</button>
<button onClick={toggleAuto} style={btn(auto())}>
{auto() ? "Stop auto replay" : "Auto replay"}
</button>
</div>
<div
style={{
display: "grid",
gap: "10px",
"border-top": "1px solid var(--color-divider, #333)",
"padding-top": "14px",
}}
>
<div style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={sliderLabel}>subtitle</span>
<input
value={text()}
onInput={(e) => setText(e.currentTarget.value)}
style={{
width: "420px",
"max-width": "100%",
padding: "6px 8px",
"border-radius": "6px",
border: "1px solid var(--color-divider, #333)",
background: "var(--color-fill-element, #222)",
color: "var(--color-text, #eee)",
}}
/>
</div>
<div style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={sliderLabel}>visualDuration</span>
<input
type="range"
min={0.05}
max={1.5}
step={0.01}
value={visualDuration()}
onInput={(e) => setVisualDuration(Number(e.currentTarget.value))}
/>
<span style={sliderValue}>{visualDuration().toFixed(2)}s</span>
</div>
<div style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={sliderLabel}>bounce</span>
<input
type="range"
min={0}
max={0.5}
step={0.01}
value={bounce()}
onInput={(e) => setBounce(Number(e.currentTarget.value))}
/>
<span style={sliderValue}>{bounce().toFixed(2)}</span>
</div>
<div style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={sliderLabel}>fade ease</span>
<button
onClick={() =>
setFadeEase((v) =>
v === "snappy" ? "smooth" : v === "smooth" ? "standard" : v === "standard" ? "linear" : "snappy",
)
}
style={btn()}
>
{fadeEase()}
</button>
</div>
<div style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={sliderLabel}>fade</span>
<input
type="range"
min={0}
max={1400}
step={10}
value={fadeMs()}
onInput={(e) => setFadeMs(Number(e.currentTarget.value))}
/>
<span style={sliderValue}>{fadeMs()}ms</span>
</div>
<div style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={sliderLabel}>blur</span>
<input
type="range"
min={0}
max={14}
step={0.5}
value={blur()}
onInput={(e) => setBlur(Number(e.currentTarget.value))}
/>
<span style={sliderValue}>{blur()}px</span>
</div>
</div>
</div>
)
},
}

View File

@@ -0,0 +1,23 @@
[data-component="shell-submessage"] {
min-width: 0;
max-width: 100%;
display: inline-flex;
align-items: baseline;
vertical-align: baseline;
}
[data-component="shell-submessage"] [data-slot="shell-submessage-width"] {
min-width: 0;
max-width: 100%;
display: inline-flex;
align-items: baseline;
overflow: hidden;
}
[data-component="shell-submessage"] [data-slot="shell-submessage-value"] {
display: inline-block;
vertical-align: baseline;
min-width: 0;
line-height: inherit;
white-space: nowrap;
}

View File

@@ -0,0 +1,144 @@
/*
* TextReveal — mask-position wipe animation
*
* Instead of sliding text through a fixed mask (odometer style),
* the mask itself sweeps across each span to reveal/hide text.
*
* Direction: top-to-bottom. New text drops in from above, old text exits downward.
*
* Entering: gradient reveals top-to-bottom (top of text appears first).
* gradient(to bottom, white 33%, transparent 33%+edge)
* pos 0 100% = transparent covers element = hidden
* pos 0 0% = white covers element = visible
*
* Leaving: gradient hides top-to-bottom (top of text disappears first).
* gradient(to top, white 33%, transparent 33%+edge)
* pos 0 100% = white covers element = visible
* pos 0 0% = transparent covers element = hidden
*
* Both transition from 0 100% (swap) → 0 0% (settled).
*/
[data-component="text-reveal"] {
--_edge: var(--text-reveal-edge, 17%);
--_dur: var(--text-reveal-duration, 450ms);
--_spring: var(--text-reveal-spring, cubic-bezier(0.34, 1.08, 0.64, 1));
--_spring-soft: var(--text-reveal-spring-soft, cubic-bezier(0.34, 1, 0.64, 1));
--_travel: var(--text-reveal-travel, 0px);
display: inline-flex;
align-items: center;
min-width: 0;
overflow: visible;
[data-slot="text-reveal-track"] {
display: grid;
min-height: 20px;
line-height: 20px;
justify-items: start;
align-items: center;
overflow: visible;
transition: width var(--_dur) var(--_spring-soft);
}
[data-slot="text-reveal-entering"],
[data-slot="text-reveal-leaving"] {
grid-area: 1 / 1;
line-height: 20px;
white-space: nowrap;
justify-self: start;
text-align: start;
mask-size: 100% 300%;
-webkit-mask-size: 100% 300%;
mask-repeat: no-repeat;
-webkit-mask-repeat: no-repeat;
transition-duration: var(--_dur);
transition-timing-function: var(--_spring);
}
/* ── entering: reveal top-to-bottom ──
* Gradient(to top): white at bottom, transparent at top of mask.
* Settled pos 0 100% = white covers element = visible
* Swap pos 0 0% = transparent covers = hidden
* Slides from above: translateY(-travel) → translateY(0)
*/
[data-slot="text-reveal-entering"] {
mask-image: linear-gradient(to top, white 33%, transparent calc(33% + var(--_edge)));
-webkit-mask-image: linear-gradient(to top, white 33%, transparent calc(33% + var(--_edge)));
mask-position: 0 100%;
-webkit-mask-position: 0 100%;
transition-property: mask-position, -webkit-mask-position, transform;
transform: translateY(0);
}
/* ── leaving: hide top-to-bottom + slide downward ──
* Gradient(to bottom): white at top, transparent at bottom of mask.
* Swap pos 0 0% = white covers element = visible
* Settled pos 0 100% = transparent covers = hidden
* Slides down: translateY(0) → translateY(travel)
*/
[data-slot="text-reveal-leaving"] {
mask-image: linear-gradient(to bottom, white 33%, transparent calc(33% + var(--_edge)));
-webkit-mask-image: linear-gradient(to bottom, white 33%, transparent calc(33% + var(--_edge)));
mask-position: 0 100%;
-webkit-mask-position: 0 100%;
transition-property: mask-position, -webkit-mask-position, transform;
transform: translateY(var(--_travel));
}
/* ── swapping: instant reset ──
* Snap entering to hidden (above), leaving to visible (center).
*/
&[data-swapping="true"] [data-slot="text-reveal-entering"] {
mask-position: 0 0%;
-webkit-mask-position: 0 0%;
transform: translateY(calc(var(--_travel) * -1));
transition-duration: 0ms !important;
}
&[data-swapping="true"] [data-slot="text-reveal-leaving"] {
mask-position: 0 0%;
-webkit-mask-position: 0 0%;
transform: translateY(0);
transition-duration: 0ms !important;
}
/* ── not ready: kill all transitions ── */
&[data-ready="false"] [data-slot="text-reveal-track"] {
transition-duration: 0ms !important;
}
&[data-ready="false"] [data-slot="text-reveal-entering"],
&[data-ready="false"] [data-slot="text-reveal-leaving"] {
transition-duration: 0ms !important;
}
&[data-truncate="true"] {
width: 100%;
}
&[data-truncate="true"] [data-slot="text-reveal-track"] {
width: 100%;
min-width: 0;
overflow: hidden;
}
&[data-truncate="true"] [data-slot="text-reveal-entering"],
&[data-truncate="true"] [data-slot="text-reveal-leaving"] {
min-width: 0;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
}
@media (prefers-reduced-motion: reduce) {
[data-component="text-reveal"] [data-slot="text-reveal-track"] {
transition-duration: 0ms !important;
}
[data-component="text-reveal"] [data-slot="text-reveal-entering"],
[data-component="text-reveal"] [data-slot="text-reveal-leaving"] {
transition-duration: 0ms !important;
}
}

View File

@@ -0,0 +1,248 @@
// @ts-nocheck
import { createSignal, onCleanup } from "solid-js"
import { TextReveal } from "./text-reveal"
export default {
title: "UI/TextReveal",
id: "components-text-reveal",
tags: ["autodocs"],
parameters: {
docs: {
description: {
component: `### Overview
Playground for the TextReveal text transition component.
**Hybrid** — mask wipe + vertical slide: gradient sweeps AND text moves downward.
**Wipe only** — pure mask wipe: gradient sweeps top-to-bottom, text stays in place.`,
},
},
},
}
const TEXTS = [
"Refactor ToolStatusTitle DOM measurement",
"Remove inline measure nodes",
"Run typechecks and report changes",
"Verify reduced-motion behavior",
"Review diff for animation edge cases",
"Check keyboard semantics",
undefined,
"Planning key generation details",
"Analyzing error handling",
"Considering edge cases",
]
const btn = (accent?: boolean) =>
({
padding: "5px 12px",
"border-radius": "6px",
border: accent ? "1px solid var(--color-accent, #58f)" : "1px solid var(--color-divider, #333)",
background: accent ? "var(--color-accent, #58f)" : "var(--color-fill-element, #222)",
color: "var(--color-text, #eee)",
cursor: "pointer",
"font-size": "12px",
}) as const
const sliderLabel = {
width: "90px",
"font-size": "12px",
color: "var(--color-text-secondary, #a3a3a3)",
"flex-shrink": "0",
} as const
const cardStyle = {
padding: "20px 24px",
"border-radius": "10px",
border: "1px solid var(--color-divider, #333)",
background: "var(--color-fill-element, #1a1a1a)",
display: "grid",
gap: "12px",
} as const
const cardLabel = {
"font-size": "11px",
"font-family": "monospace",
color: "var(--color-text-weak, #666)",
} as const
const previewRow = {
display: "flex",
"align-items": "center",
gap: "8px",
"font-size": "14px",
"font-weight": "500",
"line-height": "20px",
color: "var(--text-weak, #aaa)",
"min-height": "20px",
overflow: "visible",
} as const
const headingSlot = {
"min-width": "0",
overflow: "visible",
color: "var(--text-weaker, #888)",
"font-weight": "400",
} as const
export const Playground = {
render: () => {
const [index, setIndex] = createSignal(0)
const [cycling, setCycling] = createSignal(false)
const [growOnly, setGrowOnly] = createSignal(true)
const [duration, setDuration] = createSignal(600)
const [bounce, setBounce] = createSignal(1.0)
const [bounceSoft, setBounceSoft] = createSignal(1.0)
const [hybridTravel, setHybridTravel] = createSignal(25)
const [hybridEdge, setHybridEdge] = createSignal(17)
const [edge, setEdge] = createSignal(17)
const [revealTravel, setRevealTravel] = createSignal(0)
let timer: number | undefined
const text = () => TEXTS[index()]
const next = () => setIndex((i) => (i + 1) % TEXTS.length)
const prev = () => setIndex((i) => (i - 1 + TEXTS.length) % TEXTS.length)
const toggleCycle = () => {
if (cycling()) {
if (timer) clearTimeout(timer)
timer = undefined
setCycling(false)
return
}
setCycling(true)
const tick = () => {
next()
timer = window.setTimeout(tick, 700 + Math.floor(Math.random() * 600))
}
timer = window.setTimeout(tick, 700 + Math.floor(Math.random() * 600))
}
onCleanup(() => {
if (timer) clearTimeout(timer)
})
const spring = () => `cubic-bezier(0.34, ${bounce()}, 0.64, 1)`
const springSoft = () => `cubic-bezier(0.34, ${bounceSoft()}, 0.64, 1)`
return (
<div style={{ display: "grid", gap: "24px", padding: "20px", "max-width": "700px" }}>
<div style={{ display: "grid", gap: "16px" }}>
<div style={cardStyle}>
<span style={cardLabel}>text-reveal (mask wipe + slide)</span>
<div style={previewRow}>
<span>Thinking</span>
<span style={headingSlot}>
<TextReveal
class="text-14-regular"
text={text()}
duration={duration()}
edge={hybridEdge()}
travel={hybridTravel()}
spring={spring()}
springSoft={springSoft()}
growOnly={growOnly()}
/>
</span>
</div>
</div>
<div style={cardStyle}>
<span style={cardLabel}>text-reveal (mask wipe only)</span>
<div style={previewRow}>
<span>Thinking</span>
<span style={headingSlot}>
<TextReveal
class="text-14-regular"
text={text()}
duration={duration()}
edge={edge()}
travel={revealTravel()}
spring={spring()}
springSoft={springSoft()}
growOnly={growOnly()}
/>
</span>
</div>
</div>
</div>
<div style={{ display: "flex", gap: "6px", "flex-wrap": "wrap" }}>
{TEXTS.map((t, i) => (
<button onClick={() => setIndex(i)} style={btn(index() === i)}>
{t ?? "(none)"}
</button>
))}
</div>
<div style={{ display: "flex", gap: "8px", "flex-wrap": "wrap" }}>
<button onClick={prev} style={btn()}>Prev</button>
<button onClick={next} style={btn()}>Next</button>
<button onClick={toggleCycle} style={btn(cycling())}>
{cycling() ? "Stop cycle" : "Auto cycle"}
</button>
<button onClick={() => setGrowOnly((v) => !v)} style={btn(growOnly())}>
{growOnly() ? "growOnly: on" : "growOnly: off"}
</button>
</div>
<div style={{ display: "grid", gap: "8px", "max-width": "480px" }}>
<div style={{ "font-size": "11px", color: "var(--color-text-weak, #666)" }}>Hybrid (wipe + slide)</div>
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={sliderLabel}>edge</span>
<input type="range" min="1" max="40" step="1" value={hybridEdge()} onInput={(e) => setHybridEdge(e.currentTarget.valueAsNumber)} style={{ flex: 1 }} />
<span style={{ width: "60px", "text-align": "right", "font-size": "12px" }}>{hybridEdge()}%</span>
</label>
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={sliderLabel}>travel</span>
<input type="range" min="0" max="40" step="1" value={hybridTravel()} onInput={(e) => setHybridTravel(e.currentTarget.valueAsNumber)} style={{ flex: 1 }} />
<span style={{ width: "60px", "text-align": "right", "font-size": "12px" }}>{hybridTravel()}px</span>
</label>
<div style={{ "font-size": "11px", color: "var(--color-text-weak, #666)", "margin-top": "8px" }}>Shared</div>
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={sliderLabel}>duration</span>
<input type="range" min="100" max="1400" step="10" value={duration()} onInput={(e) => setDuration(e.currentTarget.valueAsNumber)} style={{ flex: 1 }} />
<span style={{ width: "60px", "text-align": "right", "font-size": "12px" }}>{duration()}ms</span>
</label>
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={sliderLabel}>bounce</span>
<input type="range" min="1" max="2" step="0.01" value={bounce()} onInput={(e) => setBounce(e.currentTarget.valueAsNumber)} style={{ flex: 1 }} />
<span style={{ width: "60px", "text-align": "right", "font-size": "12px" }}>{bounce().toFixed(2)}</span>
</label>
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={sliderLabel}>bounce soft</span>
<input type="range" min="1" max="1.5" step="0.01" value={bounceSoft()} onInput={(e) => setBounceSoft(e.currentTarget.valueAsNumber)} style={{ flex: 1 }} />
<span style={{ width: "60px", "text-align": "right", "font-size": "12px" }}>{bounceSoft().toFixed(2)}</span>
</label>
<div style={{ "font-size": "11px", color: "var(--color-text-weak, #666)", "margin-top": "8px" }}>Wipe only</div>
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={sliderLabel}>edge</span>
<input type="range" min="1" max="40" step="1" value={edge()} onInput={(e) => setEdge(e.currentTarget.valueAsNumber)} style={{ flex: 1 }} />
<span style={{ width: "60px", "text-align": "right", "font-size": "12px" }}>{edge()}%</span>
</label>
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={sliderLabel}>travel</span>
<input type="range" min="0" max="16" step="1" value={revealTravel()} onInput={(e) => setRevealTravel(e.currentTarget.valueAsNumber)} style={{ flex: 1 }} />
<span style={{ width: "60px", "text-align": "right", "font-size": "12px" }}>{revealTravel()}px</span>
</label>
</div>
<div style={{ "font-size": "11px", color: "var(--color-text-weak, #888)", "font-family": "monospace" }}>
text: {text() ?? "(none)"} · growOnly: {growOnly() ? "on" : "off"}
</div>
</div>
)
},
}

View File

@@ -0,0 +1,130 @@
import { createEffect, createSignal, on, onCleanup, onMount } from "solid-js"
const px = (value: number | string | undefined, fallback: number) => {
if (typeof value === "number") return `${value}px`
if (typeof value === "string") return value
return `${fallback}px`
}
const ms = (value: number | string | undefined, fallback: number) => {
if (typeof value === "number") return `${value}ms`
if (typeof value === "string") return value
return `${fallback}ms`
}
const pct = (value: number | undefined, fallback: number) => {
const v = value ?? fallback
return `${v}%`
}
export function TextReveal(props: {
text?: string
class?: string
duration?: number | string
/** Gradient edge softness as a percentage of the mask (0 = hard wipe, 17 = soft). */
edge?: number
/** Optional small vertical travel for entering text (px). Default 0. */
travel?: number | string
spring?: string
springSoft?: string
growOnly?: boolean
truncate?: boolean
}) {
const [cur, setCur] = createSignal(props.text)
const [old, setOld] = createSignal<string | undefined>()
const [width, setWidth] = createSignal("auto")
const [ready, setReady] = createSignal(false)
const [swapping, setSwapping] = createSignal(false)
let inRef: HTMLSpanElement | undefined
let outRef: HTMLSpanElement | undefined
let rootRef: HTMLSpanElement | undefined
let frame: number | undefined
const win = () => inRef?.scrollWidth ?? 0
const wout = () => outRef?.scrollWidth ?? 0
const widen = (next: number) => {
if (next <= 0) return
if (props.growOnly ?? true) {
const prev = Number.parseFloat(width())
if (Number.isFinite(prev) && next <= prev) return
}
setWidth(`${next}px`)
}
createEffect(
on(
() => props.text,
(next, prev) => {
if (next === prev) return
setSwapping(true)
setOld(prev)
setCur(next)
if (typeof requestAnimationFrame !== "function") {
widen(Math.max(win(), wout()))
rootRef?.offsetHeight
setSwapping(false)
return
}
if (frame !== undefined && typeof cancelAnimationFrame === "function") cancelAnimationFrame(frame)
frame = requestAnimationFrame(() => {
widen(Math.max(win(), wout()))
rootRef?.offsetHeight
setSwapping(false)
frame = undefined
})
},
),
)
onMount(() => {
widen(win())
const fonts = typeof document !== "undefined" ? document.fonts : undefined
if (typeof requestAnimationFrame !== "function") {
setReady(true)
return
}
if (!fonts) {
requestAnimationFrame(() => setReady(true))
return
}
fonts.ready.finally(() => {
widen(win())
requestAnimationFrame(() => setReady(true))
})
})
onCleanup(() => {
if (frame === undefined || typeof cancelAnimationFrame !== "function") return
cancelAnimationFrame(frame)
})
return (
<span
ref={rootRef}
data-component="text-reveal"
data-ready={ready() ? "true" : "false"}
data-swapping={swapping() ? "true" : "false"}
data-truncate={props.truncate ? "true" : "false"}
class={props.class}
aria-label={props.text ?? ""}
style={{
"--text-reveal-duration": ms(props.duration, 450),
"--text-reveal-edge": pct(props.edge, 17),
"--text-reveal-travel": px(props.travel, 0),
"--text-reveal-spring": props.spring ?? "cubic-bezier(0.34, 1.08, 0.64, 1)",
"--text-reveal-spring-soft": props.springSoft ?? "cubic-bezier(0.34, 1, 0.64, 1)",
}}
>
<span data-slot="text-reveal-track" style={{ width: props.truncate ? "100%" : width() }}>
<span data-slot="text-reveal-entering" ref={inRef}>
{cur() ?? "\u00A0"}
</span>
<span data-slot="text-reveal-leaving" ref={outRef}>
{old() ?? "\u00A0"}
</span>
</span>
</span>
)
}

View File

@@ -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;
color: inherit;
font: inherit;
letter-spacing: inherit;
line-height: inherit;
}
[data-component="text-shimmer"][data-active="true"] [data-slot="text-shimmer-char"] {
animation-name: text-shimmer-char;
[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-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%,
@keyframes text-shimmer-sweep {
0% {
background-position:
100% 0,
0 0;
}
100% {
color: var(--text-weaker);
background-position:
0% 0,
0 0;
}
}
@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;
}
30% {
color: var(--text-weak);
}
55% {
color: var(--text-base);
}
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;
}
}

View File

@@ -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 (
<div style={{ display: "grid", gap: "12px", "justify-items": "start" }}>
<mod.TextShimmer {...args} />
<button
onClick={reset}
style={{
padding: "4px 10px",
"font-size": "12px",
"border-radius": "6px",
border: "1px solid var(--color-divider, #333)",
background: "var(--color-fill-element, #222)",
color: "var(--color-text, #eee)",
cursor: "pointer",
}}
>
Reset controls
</button>
</div>
)
},
}
export const Inactive = {
args: {
@@ -49,11 +90,3 @@ export const Inactive = {
active: false,
},
}
export const CustomTiming = {
args: {
text: "Custom timing",
stepMs: 80,
durationMs: 1800,
},
}

View File

@@ -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 = <T extends ValidComponent = "span">(props: {
@@ -6,31 +6,56 @@ export const TextShimmer = <T extends ValidComponent = "span">(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<typeof setTimeout> | 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 (
<Dynamic
component={props.as || "span"}
component={props.as ?? "span"}
data-component="text-shimmer"
data-active={active()}
data-active={active() ? "true" : "false"}
class={props.class}
aria-label={props.text}
style={{
"--text-shimmer-step": `${props.stepMs ?? 45}ms`,
"--text-shimmer-duration": `${props.durationMs ?? 1200}ms`,
"--text-shimmer-swap": `${swap}ms`,
"--text-shimmer-index": `${offset()}`,
}}
>
<For each={chars()}>
{(char, index) => (
<span data-slot="text-shimmer-char" aria-hidden="true" style={{ "--text-shimmer-index": `${index()}` }}>
{char}
</span>
)}
</For>
<span data-slot="text-shimmer-char">
<span data-slot="text-shimmer-char-base" aria-hidden="true">
{props.text}
</span>
<span data-slot="text-shimmer-char-shimmer" data-run={run() ? "true" : "false"} aria-hidden="true">
{props.text}
</span>
</span>
</Dynamic>
)
}

View File

@@ -0,0 +1,27 @@
/*
* TextStrikethrough — spring-animated strikethrough line
*
* Draws a line-through from left to right using clip-path on a
* transparent-text overlay that carries the text-decoration.
* Grid stacking (grid-area: 1/1) layers the overlay on the base text.
*
* Key trick: -webkit-text-fill-color hides the glyph paint while
* keeping `color` (and therefore `currentColor` / text-decoration-color)
* set to the real inherited text color.
*/
[data-component="text-strikethrough"] {
display: grid;
}
[data-slot="text-strikethrough-line"] {
-webkit-text-fill-color: transparent;
text-decoration-line: line-through;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
[data-slot="text-strikethrough-line"] {
clip-path: none !important;
}
}

View File

@@ -0,0 +1,279 @@
// @ts-nocheck
import { createEffect, createSignal, onCleanup, onMount } from "solid-js"
import { useSpring } from "./motion-spring"
import { TextStrikethrough } from "./text-strikethrough"
const TEXT_SHORT = "Remove inline measure nodes"
const TEXT_MED = "Remove inline measure nodes and keep width morph behavior intact"
const TEXT_LONG =
"Refactor ToolStatusTitle DOM measurement to offscreen global measurer (unconstrained by timeline layout)"
const btn = (active?: boolean) =>
({
padding: "8px 18px",
"border-radius": "6px",
border: "1px solid var(--color-divider, #444)",
background: active ? "var(--color-accent, #58f)" : "var(--color-fill-element, #222)",
color: "var(--color-text, #eee)",
cursor: "pointer",
"font-size": "14px",
"font-weight": "500",
}) as const
const heading = {
"font-size": "11px",
"font-weight": "600",
"text-transform": "uppercase" as const,
"letter-spacing": "0.05em",
color: "var(--text-weak, #888)",
"margin-bottom": "4px",
}
const card = {
padding: "16px 20px",
"border-radius": "10px",
border: "1px solid var(--border-weak-base, #333)",
background: "var(--surface-base, #1a1a1a)",
}
/* ─── Variant A: scaleX pseudo-line at 50% ─── */
function VariantA(props: { active: boolean; text: string }) {
const progress = useSpring(
() => (props.active ? 1 : 0),
() => ({ visualDuration: 0.35, bounce: 0 }),
)
return (
<span
style={{
position: "relative",
display: "block",
color: props.active ? "var(--text-weak, #888)" : "var(--text-strong, #eee)",
transition: "color 220ms ease",
}}
>
{props.text}
<span
style={{
position: "absolute",
left: "0",
right: "0",
top: "50%",
height: "1.5px",
background: "currentColor",
"transform-origin": "left center",
transform: `scaleX(${progress()})`,
"pointer-events": "none",
}}
/>
</span>
)
}
/* ─── Variant D: background-image line ─── */
function VariantD(props: { active: boolean; text: string }) {
const progress = useSpring(
() => (props.active ? 1 : 0),
() => ({ visualDuration: 0.35, bounce: 0 }),
)
return (
<span
style={{
display: "block",
color: props.active ? "var(--text-weak, #888)" : "var(--text-strong, #eee)",
transition: "color 220ms ease",
"background-image": "linear-gradient(currentColor, currentColor)",
"background-repeat": "no-repeat",
"background-size": `${progress() * 100}% 1.5px`,
"background-position": "left center",
}}
>
{props.text}
</span>
)
}
/* ─── Variant E: grid stacking + clip-path (container %) ─── */
function VariantE(props: { active: boolean; text: string }) {
const progress = useSpring(
() => (props.active ? 1 : 0),
() => ({ visualDuration: 0.35, bounce: 0 }),
)
return (
<span
style={{
display: "grid",
color: props.active ? "var(--text-weak, #888)" : "var(--text-strong, #eee)",
transition: "color 220ms ease",
}}
>
<span style={{ "grid-area": "1 / 1" }}>{props.text}</span>
<span
aria-hidden="true"
style={{
"grid-area": "1 / 1",
"text-decoration": "line-through",
"pointer-events": "none",
"clip-path": `inset(0 ${(1 - progress()) * 100}% 0 0)`,
}}
>
{props.text}
</span>
</span>
)
}
/* ─── Variant F: grid stacking + clip-path mapped to text width ─── */
function VariantF(props: { active: boolean; text: string }) {
const progress = useSpring(
() => (props.active ? 1 : 0),
() => ({ visualDuration: 0.35, bounce: 0 }),
)
let baseRef: HTMLSpanElement | undefined
let containerRef: HTMLSpanElement | undefined
const [textWidth, setTextWidth] = createSignal(0)
const [containerWidth, setContainerWidth] = createSignal(0)
const measure = () => {
if (baseRef) setTextWidth(baseRef.scrollWidth)
if (containerRef) setContainerWidth(containerRef.offsetWidth)
}
onMount(measure)
createEffect(() => {
const el = containerRef
if (!el) return
const observer = new ResizeObserver(measure)
observer.observe(el)
onCleanup(() => observer.disconnect())
})
const clipRight = () => {
const cw = containerWidth()
const tw = textWidth()
if (cw <= 0 || tw <= 0) return `${(1 - progress()) * 100}%`
const revealed = progress() * tw
const remaining = Math.max(0, cw - revealed)
return `${remaining}px`
}
return (
<span
ref={containerRef}
style={{
display: "grid",
color: props.active ? "var(--text-weak, #888)" : "var(--text-strong, #eee)",
transition: "color 220ms ease",
}}
>
<span ref={baseRef} style={{ "grid-area": "1 / 1" }}>
{props.text}
</span>
<span
aria-hidden="true"
style={{
"grid-area": "1 / 1",
"text-decoration": "line-through",
"pointer-events": "none",
"clip-path": `inset(0 ${clipRight()} 0 0)`,
}}
>
{props.text}
</span>
</span>
)
}
export default {
title: "UI/Text Strikethrough",
id: "components-text-strikethrough",
tags: ["autodocs"],
parameters: {
docs: {
description: {
component: `### Animated Strikethrough Variants
- **A** — scaleX line at 50% (single line only)
- **D** — background-image line (single line only)
- **E** — grid stacking + clip-path (container %)
- **F** — grid stacking + clip-path mapped to text width (the real component)`,
},
},
},
}
export const Playground = {
render: () => {
const [active, setActive] = createSignal(false)
const toggle = () => setActive((v) => !v)
return (
<div style={{ display: "grid", gap: "24px", padding: "24px", "max-width": "700px" }}>
<button onClick={toggle} style={btn(active())}>
{active() ? "Undo strikethrough" : "Strike through all"}
</button>
<div style={card}>
<div style={heading}>F grid stacking + clip mapped to text width (THE COMPONENT)</div>
<TextStrikethrough
active={active()}
text={TEXT_SHORT}
style={{
color: active() ? "var(--text-weak, #888)" : "var(--text-strong, #eee)",
transition: "color 220ms ease",
}}
/>
<div style={{ "margin-top": "12px" }} />
<TextStrikethrough
active={active()}
text={TEXT_MED}
style={{
color: active() ? "var(--text-weak, #888)" : "var(--text-strong, #eee)",
transition: "color 220ms ease",
}}
/>
<div style={{ "margin-top": "12px" }} />
<TextStrikethrough
active={active()}
text={TEXT_LONG}
style={{
color: active() ? "var(--text-weak, #888)" : "var(--text-strong, #eee)",
transition: "color 220ms ease",
}}
/>
</div>
<div style={card}>
<div style={heading}>F (inline) same but just inline variants</div>
<VariantF active={active()} text={TEXT_SHORT} />
<div style={{ "margin-top": "12px" }} />
<VariantF active={active()} text={TEXT_MED} />
<div style={{ "margin-top": "12px" }} />
<VariantF active={active()} text={TEXT_LONG} />
</div>
<div style={card}>
<div style={heading}>E grid stacking + clip-path (container %)</div>
<VariantE active={active()} text={TEXT_SHORT} />
<div style={{ "margin-top": "12px" }} />
<VariantE active={active()} text={TEXT_MED} />
<div style={{ "margin-top": "12px" }} />
<VariantE active={active()} text={TEXT_LONG} />
</div>
<div style={card}>
<div style={heading}>A scaleX line at 50%</div>
<VariantA active={active()} text={TEXT_SHORT} />
<div style={{ "margin-top": "12px" }} />
<VariantA active={active()} text={TEXT_LONG} />
</div>
<div style={card}>
<div style={heading}>D background-image line</div>
<VariantD active={active()} text={TEXT_SHORT} />
<div style={{ "margin-top": "12px" }} />
<VariantD active={active()} text={TEXT_LONG} />
</div>
</div>
)
},
}

View File

@@ -0,0 +1,85 @@
import type { JSX } from "solid-js"
import { createEffect, createSignal, onCleanup, onMount } from "solid-js"
import { useSpring } from "./motion-spring"
export function TextStrikethrough(props: {
/** Whether the strikethrough is active (line drawn across). */
active: boolean
/** The text to display. Rendered twice internally (base + decoration overlay). */
text: string
/** Spring visual duration in seconds. Default 0.35. */
visualDuration?: number
class?: string
style?: JSX.CSSProperties
}) {
const progress = useSpring(
() => (props.active ? 1 : 0),
() => ({ visualDuration: props.visualDuration ?? 0.35, bounce: 0 }),
)
let baseRef: HTMLSpanElement | undefined
let containerRef: HTMLSpanElement | undefined
const [textWidth, setTextWidth] = createSignal(0)
const [containerWidth, setContainerWidth] = createSignal(0)
const measure = () => {
if (baseRef) setTextWidth(baseRef.scrollWidth)
if (containerRef) setContainerWidth(containerRef.offsetWidth)
}
onMount(measure)
createEffect(() => {
const el = containerRef
if (!el) return
const observer = new ResizeObserver(measure)
observer.observe(el)
onCleanup(() => observer.disconnect())
})
// Revealed pixels from left = progress * textWidth
const revealedPx = () => {
const tw = textWidth()
return tw > 0 ? progress() * tw : 0
}
// Overlay clip: hide everything to the right of revealed area
const overlayClip = () => {
const cw = containerWidth()
const tw = textWidth()
if (cw <= 0 || tw <= 0) return `inset(0 ${(1 - progress()) * 100}% 0 0)`
const remaining = Math.max(0, cw - revealedPx())
return `inset(0 ${remaining}px 0 0)`
}
// Base clip: hide everything to the left of revealed area (complementary)
const baseClip = () => {
const px = revealedPx()
if (px <= 0.5) return "none"
return `inset(0 0 0 ${px}px)`
}
return (
<span
data-component="text-strikethrough"
class={props.class}
style={{ display: "grid", ...props.style }}
ref={containerRef}
>
<span ref={baseRef} style={{ "grid-area": "1 / 1", "clip-path": baseClip() }}>
{props.text}
</span>
<span
aria-hidden="true"
style={{
"grid-area": "1 / 1",
"text-decoration": "line-through",
"pointer-events": "none",
"clip-path": overlayClip(),
}}
>
{props.text}
</span>
</span>
)
}

View File

@@ -0,0 +1,837 @@
// @ts-nocheck
import { createSignal, createEffect, on, onMount, onCleanup } from "solid-js"
import { TextShimmer } from "./text-shimmer"
import { TextReveal } from "./text-reveal"
export default {
title: "UI/ThinkingHeading",
id: "components-thinking-heading",
tags: ["autodocs"],
parameters: {
docs: {
description: {
component: `### Overview
Playground for animating the secondary heading beside "Thinking".
Uses TextReveal for the production heading animation with tunable
duration, travel, bounce, and fade controls.`,
},
},
},
}
const HEADINGS = [
"Planning key generation details",
"Analyzing error handling",
undefined,
"Reviewing authentication flow",
"Considering edge cases",
"Evaluating performance",
"Structuring the response",
"Checking type safety",
"Designing the API surface",
"Mapping dependencies",
"Outlining test strategy",
]
// ---------------------------------------------------------------------------
// CSS
//
// Custom properties driven by sliders:
// --h-duration transition duration (e.g. "600ms")
// --h-duration-raw unitless number for calc (e.g. "600")
// --h-blur blur radius (e.g. "4px")
// --h-travel vertical travel distance (e.g. "18px")
// --h-spring full cubic-bezier for movement (set from bounce slider)
// --h-spring-soft softer version for width transitions
// --h-mask-size fade depth at top/bottom of odometer mask
// --h-mask-pad base padding-block on odometer track
// --h-mask-height extra vertical mask area per side
// --h-mask-bg background color for fade overlays
// ---------------------------------------------------------------------------
const STYLES = `
/* ── shared base ────────────────────────────────────────────────── */
[data-variant] {
display: inline-flex;
align-items: center;
}
[data-variant] [data-slot="track"] {
display: grid;
overflow: visible;
min-height: 20px;
justify-items: start;
align-items: center;
transition: width var(--h-duration, 600ms) var(--h-spring-soft, cubic-bezier(0.34, 1.1, 0.64, 1));
}
[data-variant] [data-slot="entering"],
[data-variant] [data-slot="leaving"] {
grid-area: 1 / 1;
line-height: 20px;
white-space: nowrap;
justify-self: start;
}
/* kill transitions before fonts are ready */
[data-variant][data-ready="false"] [data-slot="track"],
[data-variant][data-ready="false"] [data-slot="entering"],
[data-variant][data-ready="false"] [data-slot="leaving"] {
transition-duration: 0ms !important;
}
/* ── 1. spring-up ───────────────────────────────────────────────── *
* New text rises from below, old text exits upward. */
[data-variant="spring-up"] [data-slot="entering"],
[data-variant="spring-up"] [data-slot="leaving"] {
transition-property: transform, opacity, filter;
transition-duration:
var(--h-duration, 600ms),
calc(var(--h-duration-raw, 600) * 0.6 * 1ms),
calc(var(--h-duration-raw, 600) * 0.5 * 1ms);
transition-timing-function: var(--h-spring), ease-out, ease-out;
}
[data-variant="spring-up"] [data-slot="entering"] {
transform: translateY(0);
opacity: 1;
filter: blur(0);
}
[data-variant="spring-up"] [data-slot="leaving"] {
transform: translateY(calc(var(--h-travel, 18px) * -1));
opacity: 0;
filter: blur(var(--h-blur, 0px));
}
[data-variant="spring-up"][data-swapping="true"] [data-slot="entering"] {
transform: translateY(var(--h-travel, 18px));
opacity: 0;
filter: blur(var(--h-blur, 0px));
transition-duration: 0ms !important;
}
[data-variant="spring-up"][data-swapping="true"] [data-slot="leaving"] {
transform: translateY(0);
opacity: 1;
filter: blur(0);
transition-duration: 0ms !important;
}
/* ── 2. spring-down ─────────────────────────────────────────────── *
* New text drops from above, old text exits downward. */
[data-variant="spring-down"] [data-slot="entering"],
[data-variant="spring-down"] [data-slot="leaving"] {
transition-property: transform, opacity, filter;
transition-duration:
var(--h-duration, 600ms),
calc(var(--h-duration-raw, 600) * 0.6 * 1ms),
calc(var(--h-duration-raw, 600) * 0.5 * 1ms);
transition-timing-function: var(--h-spring), ease-out, ease-out;
}
[data-variant="spring-down"] [data-slot="entering"] {
transform: translateY(0);
opacity: 1;
filter: blur(0);
}
[data-variant="spring-down"] [data-slot="leaving"] {
transform: translateY(var(--h-travel, 18px));
opacity: 0;
filter: blur(var(--h-blur, 0px));
}
[data-variant="spring-down"][data-swapping="true"] [data-slot="entering"] {
transform: translateY(calc(var(--h-travel, 18px) * -1));
opacity: 0;
filter: blur(var(--h-blur, 0px));
transition-duration: 0ms !important;
}
[data-variant="spring-down"][data-swapping="true"] [data-slot="leaving"] {
transform: translateY(0);
opacity: 1;
filter: blur(0);
transition-duration: 0ms !important;
}
/* ── 3. spring-pop ──────────────────────────────────────────────── *
* Scale + slight vertical shift + blur. Playful, bouncy. */
[data-variant="spring-pop"] [data-slot="entering"],
[data-variant="spring-pop"] [data-slot="leaving"] {
transition-property: transform, opacity, filter;
transition-duration:
var(--h-duration, 600ms),
calc(var(--h-duration-raw, 600) * 0.55 * 1ms),
calc(var(--h-duration-raw, 600) * 0.55 * 1ms);
transition-timing-function: var(--h-spring), ease-out, ease-out;
transform-origin: left center;
}
[data-variant="spring-pop"] [data-slot="entering"] {
transform: translateY(0) scale(1);
opacity: 1;
filter: blur(0);
}
[data-variant="spring-pop"] [data-slot="leaving"] {
transform: translateY(calc(var(--h-travel, 18px) * -0.35)) scale(0.92);
opacity: 0;
filter: blur(var(--h-blur, 3px));
}
[data-variant="spring-pop"][data-swapping="true"] [data-slot="entering"] {
transform: translateY(calc(var(--h-travel, 18px) * 0.35)) scale(0.92);
opacity: 0;
filter: blur(var(--h-blur, 3px));
transition-duration: 0ms !important;
}
[data-variant="spring-pop"][data-swapping="true"] [data-slot="leaving"] {
transform: translateY(0) scale(1);
opacity: 1;
filter: blur(0);
transition-duration: 0ms !important;
}
/* ── 4. spring-blur ─────────────────────────────────────────────── *
* Pure crossfade with heavy blur. No vertical movement. *
* Width still animates with spring. */
[data-variant="spring-blur"] [data-slot="entering"],
[data-variant="spring-blur"] [data-slot="leaving"] {
transition-property: opacity, filter;
transition-duration:
calc(var(--h-duration-raw, 600) * 0.75 * 1ms),
var(--h-duration, 600ms);
transition-timing-function: ease-out, var(--h-spring-soft);
}
[data-variant="spring-blur"] [data-slot="entering"] {
opacity: 1;
filter: blur(0);
}
[data-variant="spring-blur"] [data-slot="leaving"] {
opacity: 0;
filter: blur(calc(var(--h-blur, 4px) * 2));
}
[data-variant="spring-blur"][data-swapping="true"] [data-slot="entering"] {
opacity: 0;
filter: blur(calc(var(--h-blur, 4px) * 2));
transition-duration: 0ms !important;
}
[data-variant="spring-blur"][data-swapping="true"] [data-slot="leaving"] {
opacity: 1;
filter: blur(0);
transition-duration: 0ms !important;
}
/* ── 5. odometer ──────────────────────────────────────────────── *
* Both texts scroll vertically through a clipped track. *
* *
* overflow:hidden clips at the padding-box edge. *
* mask-image fades to transparent at that same edge. *
* Result: content is invisible at the clip boundary → no hard *
* edge ever visible. Padding + mask height extend the clip area *
* so text has room to travel through the gradient fade zone. *
* *
* Uses transparent→white which works in both alpha & luminance *
* mask modes (transparent=hidden, white=visible in both). */
[data-variant="odometer"] [data-slot="track"] {
--h-mask-stop: min(var(--h-mask-size, 20px), calc(50% - 0.5px));
--h-odo-shift: calc(
100% + var(--h-travel, 18px) + var(--h-mask-height, 0px) + max(calc(var(--h-mask-pad, 28px) - 28px), 0px)
);
position: relative;
align-items: stretch;
overflow: hidden;
padding-block: calc(var(--h-mask-pad, 28px) + var(--h-mask-height, 0px));
margin-block: calc((var(--h-mask-pad, 28px) + var(--h-mask-height, 0px)) * -1);
-webkit-mask-image: linear-gradient(
to bottom,
transparent 0px,
white var(--h-mask-stop),
white calc(100% - var(--h-mask-stop)),
transparent 100%
);
mask-image: linear-gradient(
to bottom,
transparent 0px,
white var(--h-mask-stop),
white calc(100% - var(--h-mask-stop)),
transparent 100%
);
transition: width var(--h-duration, 600ms) var(--h-spring-soft, cubic-bezier(0.34, 1.1, 0.64, 1));
}
/* on swap, jump width instantly to the max of both texts */
[data-variant="odometer"][data-swapping="true"] [data-slot="track"] {
transition-duration: 0ms !important;
}
[data-variant="odometer"] [data-slot="entering"],
[data-variant="odometer"] [data-slot="leaving"] {
transition-property: transform;
transition-duration: var(--h-duration, 600ms);
transition-timing-function: var(--h-spring);
opacity: 1;
}
/* settled: entering in view, leaving pushed below */
[data-variant="odometer"] [data-slot="entering"] {
transform: translateY(0);
}
[data-variant="odometer"] [data-slot="leaving"] {
transform: translateY(var(--h-odo-shift));
}
/* swapping: snap entering above, leaving in-place */
[data-variant="odometer"][data-swapping="true"] [data-slot="entering"] {
transform: translateY(calc(var(--h-odo-shift) * -1));
transition-duration: 0ms !important;
}
[data-variant="odometer"][data-swapping="true"] [data-slot="leaving"] {
transform: translateY(0);
transition-duration: 0ms !important;
}
/* ── odometer + blur ──────────────────────────────────────────── *
* Optional: adds opacity + blur transitions on top of the *
* positional odometer movement. */
[data-variant="odometer"][data-odo-blur="true"] [data-slot="entering"],
[data-variant="odometer"][data-odo-blur="true"] [data-slot="leaving"] {
transition-property: transform, opacity, filter;
transition-duration:
var(--h-duration, 600ms),
calc(var(--h-duration-raw, 600) * 0.6 * 1ms),
calc(var(--h-duration-raw, 600) * 0.5 * 1ms);
}
[data-variant="odometer"][data-odo-blur="true"] [data-slot="entering"] {
opacity: 1;
filter: blur(0);
}
[data-variant="odometer"][data-odo-blur="true"] [data-slot="leaving"] {
opacity: 0;
filter: blur(var(--h-blur, 4px));
}
[data-variant="odometer"][data-odo-blur="true"][data-swapping="true"] [data-slot="entering"] {
opacity: 0;
filter: blur(var(--h-blur, 4px));
}
[data-variant="odometer"][data-odo-blur="true"][data-swapping="true"] [data-slot="leaving"] {
opacity: 1;
filter: blur(0);
}
/* ── debug: show fade zones ───────────────────────────────────── */
[data-variant="odometer"][data-debug="true"] [data-slot="track"] {
outline: 1px dashed rgba(255, 0, 0, 0.6);
}
[data-variant="odometer"][data-debug="true"] [data-slot="track"]::before,
[data-variant="odometer"][data-debug="true"] [data-slot="track"]::after {
content: "";
position: absolute;
left: 0;
right: 0;
height: var(--h-mask-stop);
pointer-events: none;
}
[data-variant="odometer"][data-debug="true"] [data-slot="track"]::before {
top: 0;
background: linear-gradient(to bottom, rgba(255, 0, 0, 0.3), transparent);
}
[data-variant="odometer"][data-debug="true"] [data-slot="track"]::after {
bottom: 0;
background: linear-gradient(to top, rgba(255, 0, 0, 0.3), transparent);
}
/* ── slider styling ─────────────────────────────────────────────── */
input[type="range"].heading-slider {
-webkit-appearance: none;
appearance: none;
width: 140px;
height: 4px;
border-radius: 2px;
background: var(--color-divider, #444);
outline: none;
}
input[type="range"].heading-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--color-accent, #58f);
cursor: pointer;
border: none;
}
`
// ---------------------------------------------------------------------------
// Animated heading component
//
// Width is measured via scrollWidth (NOT Range.getBoundingClientRect) because
// getBoundingClientRect includes CSS transforms — so scale(0.92) during the
// swap phase would measure 92% of the real width and permanently clip text.
// scrollWidth returns the layout/intrinsic width, unaffected by transforms.
// ---------------------------------------------------------------------------
function AnimatedHeading(props) {
const [current, setCurrent] = createSignal(props.text)
const [leaving, setLeaving] = createSignal(undefined)
const [width, setWidth] = createSignal("auto")
const [ready, setReady] = createSignal(false)
const [swapping, setSwapping] = createSignal(false)
let enterRef
let leaveRef
let containerRef
let frame
const measureEnter = () => enterRef?.scrollWidth ?? 0
const measureLeave = () => leaveRef?.scrollWidth ?? 0
const widen = (px) => {
if (px <= 0) return
const w = Number.parseFloat(width())
if (Number.isFinite(w) && px <= w) return
setWidth(`${px}px`)
}
const measure = () => {
if (!current()) {
setWidth("0px")
return
}
const px = measureEnter()
if (px > 0) setWidth(`${px}px`)
}
createEffect(
on(
() => props.text,
(next, prev) => {
if (next === prev) return
setSwapping(true)
setLeaving(prev)
setCurrent(next)
if (frame) cancelAnimationFrame(frame)
frame = requestAnimationFrame(() => {
// For odometer keep width as a grow-only max so heading never shrinks.
if (props.variant === "odometer") {
const enterW = measureEnter()
const leaveW = measureLeave()
widen(Math.max(enterW, leaveW))
containerRef?.offsetHeight // reflow with max width + swap positions
setSwapping(false)
} else {
containerRef?.offsetHeight
setSwapping(false)
measure()
}
frame = undefined
})
},
),
)
onMount(() => {
measure()
document.fonts?.ready.finally(() => {
measure()
requestAnimationFrame(() => setReady(true))
})
})
onCleanup(() => {
if (frame) cancelAnimationFrame(frame)
})
return (
<span
ref={containerRef}
data-variant={props.variant}
data-ready={ready()}
data-swapping={swapping()}
data-debug={props.debug ? "true" : undefined}
data-odo-blur={props.odoBlur ? "true" : undefined}
>
<span data-slot="track" style={{ width: width() }}>
<span data-slot="entering" ref={enterRef}>
{current() ?? "\u00A0"}
</span>
<span data-slot="leaving" ref={leaveRef}>
{leaving() ?? "\u00A0"}
</span>
</span>
</span>
)
}
// ---------------------------------------------------------------------------
// Button / layout styles
// ---------------------------------------------------------------------------
const btn = (accent) => ({
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",
})
const smallBtn = (active) => ({
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",
})
const sliderLabel = {
"font-size": "11px",
"font-family": "monospace",
color: "var(--color-text-weak, #666)",
"min-width": "70px",
"flex-shrink": "0",
"text-align": "right",
}
const sliderValue = {
"font-family": "monospace",
"font-size": "11px",
color: "var(--color-text-weak, #aaa)",
"min-width": "60px",
}
const cardLabel = {
"font-size": "11px",
"font-family": "monospace",
color: "var(--color-text-weak, #666)",
}
const thinkingRow = {
display: "flex",
"align-items": "center",
gap: "8px",
"min-width": "0",
"font-size": "14px",
"font-weight": "500",
"line-height": "20px",
"min-height": "20px",
color: "var(--text-weak, #aaa)",
}
const headingSlot = {
"min-width": "0",
overflow: "visible",
"white-space": "nowrap",
color: "var(--text-weaker, #888)",
"font-weight": "400",
}
const cardStyle = {
padding: "16px 20px",
"border-radius": "10px",
border: "1px solid var(--color-divider, #333)",
background: "var(--h-mask-bg, #1a1a1a)",
display: "grid",
gap: "8px",
}
// ---------------------------------------------------------------------------
// Variants
// ---------------------------------------------------------------------------
const VARIANTS: { key: string; label: string }[] = []
// ---------------------------------------------------------------------------
// Story
// ---------------------------------------------------------------------------
export const Playground = {
render: () => {
const [heading, setHeading] = createSignal(HEADINGS[0])
const [headingIndex, setHeadingIndex] = createSignal(0)
const [active, setActive] = createSignal(true)
const [cycling, setCycling] = createSignal(false)
let cycleTimer
// tunable params
const [duration, setDuration] = createSignal(550)
const [blur, setBlur] = createSignal(2)
const [travel, setTravel] = createSignal(4)
const [bounce, setBounce] = createSignal(1.35)
const [maskSize, setMaskSize] = createSignal(12)
const [maskPad, setMaskPad] = createSignal(9)
const [maskHeight, setMaskHeight] = createSignal(0)
const [debug, setDebug] = createSignal(false)
const [odoBlur, setOdoBlur] = createSignal(false)
const nextHeading = () => {
setHeadingIndex((i) => {
const next = (i + 1) % HEADINGS.length
setHeading(HEADINGS[next])
return next
})
}
const prevHeading = () => {
setHeadingIndex((i) => {
const prev = (i - 1 + HEADINGS.length) % HEADINGS.length
setHeading(HEADINGS[prev])
return prev
})
}
const toggleCycling = () => {
if (cycling()) {
clearTimeout(cycleTimer)
cycleTimer = undefined
setCycling(false)
return
}
setCycling(true)
const tick = () => {
if (!cycling()) return
nextHeading()
cycleTimer = setTimeout(tick, 850 + Math.floor(Math.random() * 550))
}
cycleTimer = setTimeout(tick, 850 + Math.floor(Math.random() * 550))
}
const clearHeading = () => {
setHeading(undefined)
if (cycling()) {
clearTimeout(cycleTimer)
cycleTimer = undefined
setCycling(false)
}
}
onCleanup(() => {
if (cycleTimer) clearTimeout(cycleTimer)
})
const vars = () => ({
"--h-duration": `${duration()}ms`,
"--h-duration-raw": `${duration()}`,
"--h-blur": `${blur()}px`,
"--h-travel": `${travel()}px`,
"--h-spring": `cubic-bezier(0.34, ${bounce()}, 0.64, 1)`,
"--h-spring-soft": `cubic-bezier(0.34, ${Math.max(bounce() * 0.7, 1)}, 0.64, 1)`,
"--h-mask-size": `${maskSize()}px`,
"--h-mask-pad": `${maskPad()}px`,
"--h-mask-height": `${maskHeight()}px`,
"--h-mask-bg": "#1a1a1a",
})
return (
<div style={{ display: "grid", gap: "24px", padding: "20px", "max-width": "820px", ...vars() }}>
<style>{STYLES}</style>
{/* ── Variant cards ─────────────────────────────────── */}
<div style={{ display: "grid", "grid-template-columns": "1fr", gap: "16px" }}>
<div style={cardStyle}>
<span style={cardLabel}>TextReveal (production)</span>
<span style={thinkingRow}>
<TextShimmer text="Thinking" active={active()} />
<span style={headingSlot}>
<TextReveal
text={heading()}
duration={duration()}
travel={25}
edge={17}
spring={`cubic-bezier(0.34, ${bounce()}, 0.64, 1)`}
springSoft={`cubic-bezier(0.34, ${Math.max(bounce() * 0.7, 1)}, 0.64, 1)`}
growOnly
/>
</span>
</span>
</div>
{VARIANTS.map((v) => (
<div style={cardStyle}>
<span style={cardLabel}>{v.label}</span>
<span style={thinkingRow}>
<TextShimmer text="Thinking" active={active()} />
<span style={headingSlot}>
<AnimatedHeading
text={heading()}
variant={v.key}
debug={v.key === "odometer" && debug()}
odoBlur={v.key === "odometer" && odoBlur()}
/>
</span>
</span>
</div>
))}
</div>
{/* ── Sliders ──────────────────────────────────────── */}
<div
style={{
"border-top": "1px solid var(--color-divider, #333)",
"padding-top": "16px",
display: "grid",
gap: "10px",
}}
>
<div style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={sliderLabel}>duration</span>
<input
type="range"
class="heading-slider"
min={200}
max={1400}
step={50}
value={duration()}
onInput={(e) => setDuration(Number(e.currentTarget.value))}
/>
<span style={sliderValue}>{duration()}ms</span>
</div>
<div style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={sliderLabel}>blur</span>
<input
type="range"
class="heading-slider"
min={0}
max={16}
step={0.5}
value={blur()}
onInput={(e) => setBlur(Number(e.currentTarget.value))}
/>
<span style={sliderValue}>{blur()}px</span>
</div>
<div style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={sliderLabel}>travel</span>
<input
type="range"
class="heading-slider"
min={4}
max={120}
step={1}
value={travel()}
onInput={(e) => setTravel(Number(e.currentTarget.value))}
/>
<span style={sliderValue}>{travel()}px</span>
</div>
<div style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={sliderLabel}>bounce</span>
<input
type="range"
class="heading-slider"
min={1}
max={2.2}
step={0.05}
value={bounce()}
onInput={(e) => setBounce(Number(e.currentTarget.value))}
/>
<span style={sliderValue}>
{bounce().toFixed(2)} {bounce() <= 1.05 ? "(none)" : bounce() >= 1.9 ? "(heavy)" : ""}
</span>
</div>
<div style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={sliderLabel}>mask</span>
<input
type="range"
class="heading-slider"
min={0}
max={50}
step={1}
value={maskSize()}
onInput={(e) => setMaskSize(Number(e.currentTarget.value))}
/>
<span style={sliderValue}>
{maskSize()}px {maskSize() === 0 ? "(hard)" : ""}
</span>
</div>
<div style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={sliderLabel}>mask pad</span>
<input
type="range"
class="heading-slider"
min={0}
max={60}
step={1}
value={maskPad()}
onInput={(e) => setMaskPad(Number(e.currentTarget.value))}
/>
<span style={sliderValue}>{maskPad()}px</span>
</div>
<div style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={sliderLabel}>mask height</span>
<input
type="range"
class="heading-slider"
min={0}
max={80}
step={1}
value={maskHeight()}
onInput={(e) => setMaskHeight(Number(e.currentTarget.value))}
/>
<span style={sliderValue}>{maskHeight()}px</span>
</div>
</div>
{/* ── Controls ─────────────────────────────────────── */}
<div style={{ display: "grid", gap: "12px" }}>
<div style={{ display: "flex", gap: "8px", "flex-wrap": "wrap" }}>
<button onClick={toggleCycling} style={btn(cycling())}>
{cycling() ? "Stop sim" : "Simulate jitter"}
</button>
<button onClick={prevHeading} style={btn()}>
Prev
</button>
<button onClick={nextHeading} style={btn()}>
Next
</button>
<button onClick={clearHeading} style={btn()}>
Clear
</button>
<button onClick={() => setActive((v) => !v)} style={smallBtn(active())}>
{active() ? "Shimmer: on" : "Shimmer: off"}
</button>
<button onClick={() => setDebug((v) => !v)} style={smallBtn(debug())}>
{debug() ? "Debug mask: on" : "Debug mask"}
</button>
<button onClick={() => setOdoBlur((v) => !v)} style={smallBtn(odoBlur())}>
{odoBlur() ? "Odo blur: on" : "Odo blur"}
</button>
</div>
<div style={{ display: "flex", gap: "6px", "flex-wrap": "wrap" }}>
{HEADINGS.map((h, i) => (
<button
onClick={() => {
setHeadingIndex(i)
setHeading(h)
}}
style={smallBtn(headingIndex() === i)}
>
{h ?? "(no submessage)"}
</button>
))}
</div>
<div
style={{
"font-size": "11px",
color: "var(--color-text-weak, #888)",
"font-family": "monospace",
}}
>
heading: {heading() ?? "(none)"} · sim: {cycling() ? "on" : "off"} · bounce: {bounce().toFixed(2)} ·
odo-blur: {odoBlur() ? "on" : "off"}
</div>
</div>
</div>
)
},
}

View File

@@ -0,0 +1,584 @@
// @ts-nocheck
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import type { Todo } from "@opencode-ai/sdk/v2"
import { useGlobalSync } from "@/context/global-sync"
import { SessionComposerRegion, createSessionComposerState } from "@/pages/session/composer"
export default {
title: "UI/Todo Panel Motion",
id: "components-todo-panel-motion",
tags: ["autodocs"],
parameters: {
docs: {
description: {
component: `### Overview
This playground renders the real session composer region from app code.
### Source path
- \`packages/app/src/pages/session/composer/session-composer-region.tsx\`
### Includes
- \`SessionTodoDock\` (real)
- \`PromptInput\` (real)
No visual reimplementation layer is used for the dock/input stack.`,
},
},
},
}
const pool = [
"Refactor ToolStatusTitle DOM measurement to offscreen global measurer (unconstrained by timeline layout)",
"Remove inline measure nodes/CSS hooks and keep width morph behavior intact",
"Run typechecks/tests and report what changed",
"Verify reduced-motion behavior in timeline",
"Review diff for animation edge cases",
"Document rollout notes in PR description",
"Check keyboard and screen reader semantics",
"Add storybook controls for iteration speed",
]
const btn = (accent?: boolean) =>
({
padding: "6px 14px",
"border-radius": "6px",
border: "1px solid var(--color-divider, #333)",
background: accent ? "var(--color-accent, #58f)" : "var(--color-fill-element, #222)",
color: "var(--color-text, #eee)",
cursor: "pointer",
"font-size": "13px",
}) as const
const css = `
[data-component="todo-stage"] {
display: grid;
gap: 20px;
padding: 20px;
}
[data-component="todo-preview"] {
height: 560px;
min-height: 0;
}
[data-component="todo-session-root"] {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
background: var(--background-base);
border: 1px solid var(--border-weak-base);
border-radius: 12px;
}
[data-component="todo-session-frame"] {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
}
[data-component="todo-session-panel"] {
position: relative;
flex: 1 1 auto;
min-height: 0;
height: 100%;
display: flex;
flex-direction: column;
background: var(--background-stronger);
}
[data-slot="todo-preview-content"] {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
[data-slot="todo-preview-scroll"] {
height: 100%;
overflow: auto;
min-height: 0;
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
[data-slot="todo-preview-spacer"] {
flex: 1 1 auto;
min-height: 0;
}
[data-slot="todo-preview-msg"] {
border-radius: 8px;
border: 1px solid var(--border-weak-base);
background: var(--surface-base);
color: var(--text-weak);
padding: 8px 10px;
font-size: 13px;
line-height: 1.35;
}
[data-slot="todo-preview-msg"][data-strong="true"] {
color: var(--text-strong);
}
`
export const Playground = {
render: () => {
const global = useGlobalSync()
const [open, setOpen] = createSignal(true)
const [step, setStep] = createSignal(1)
const [dockOpenDuration, setDockOpenDuration] = createSignal(0.3)
const [dockOpenBounce, setDockOpenBounce] = createSignal(0)
const [dockCloseDuration, setDockCloseDuration] = createSignal(0.3)
const [dockCloseBounce, setDockCloseBounce] = createSignal(0)
const [drawerExpandDuration, setDrawerExpandDuration] = createSignal(0.3)
const [drawerExpandBounce, setDrawerExpandBounce] = createSignal(0)
const [drawerCollapseDuration, setDrawerCollapseDuration] = createSignal(0.3)
const [drawerCollapseBounce, setDrawerCollapseBounce] = createSignal(0)
const [subtitleDuration, setSubtitleDuration] = createSignal(600)
const [subtitleAuto, setSubtitleAuto] = createSignal(true)
const [subtitleTravel, setSubtitleTravel] = createSignal(25)
const [subtitleEdge, setSubtitleEdge] = createSignal(17)
const [countDuration, setCountDuration] = createSignal(600)
const [countMask, setCountMask] = createSignal(18)
const [countMaskHeight, setCountMaskHeight] = createSignal(0)
const [countWidthDuration, setCountWidthDuration] = createSignal(560)
const state = createSessionComposerState({ closeMs: () => Math.round(dockCloseDuration() * 1000) })
let frame
let composerRef
let scrollRef
const todos = createMemo<Todo[]>(() => {
const done = Math.max(0, Math.min(3, step()))
return pool.slice(0, 3).map((content, i) => ({
id: `todo-${i + 1}`,
content,
status: i < done ? "completed" : i === done && done < 3 ? "in_progress" : "pending",
}))
})
createEffect(() => {
global.todo.set("story-session", todos())
})
const clear = () => {
if (frame) cancelAnimationFrame(frame)
frame = undefined
}
const pin = () => {
if (!scrollRef) return
scrollRef.scrollTop = scrollRef.scrollHeight
}
const collapsed = () =>
!!composerRef?.querySelector('[data-action="session-todo-toggle-button"][data-collapsed="true"]')
const setCollapsed = (value: boolean) => {
const button = composerRef?.querySelector('[data-action="session-todo-toggle-button"]')
if (!(button instanceof HTMLButtonElement)) return
if (collapsed() === value) return
button.click()
}
const openDock = () => {
clear()
setOpen(true)
frame = requestAnimationFrame(() => {
pin()
frame = undefined
})
}
const closeDock = () => {
clear()
setOpen(false)
}
const dockOpen = () => open()
const toggleDock = () => {
if (dockOpen()) {
closeDock()
return
}
openDock()
}
const toggleDrawer = () => {
if (!dockOpen()) {
openDock()
frame = requestAnimationFrame(() => {
pin()
setCollapsed(true)
frame = undefined
})
return
}
setCollapsed(!collapsed())
}
const cycle = () => {
setStep((value) => (value + 1) % 4)
}
onCleanup(clear)
return (
<div data-component="todo-stage">
<style>{css}</style>
<div data-component="todo-preview">
<div data-component="todo-session-root">
<div data-component="todo-session-frame">
<div data-component="todo-session-panel">
<div data-slot="todo-preview-content">
<div data-slot="todo-preview-scroll" class="scroll-view__viewport" ref={scrollRef}>
<div data-slot="todo-preview-spacer" />
<div data-slot="todo-preview-msg" data-strong="true">
Thinking Checking type safety
</div>
<div data-slot="todo-preview-msg">Shell Prints five topic blocks between timed commands</div>
</div>
</div>
<div ref={composerRef}>
<SessionComposerRegion
state={state}
centered={false}
inputRef={() => {}}
newSessionWorktree=""
onNewSessionWorktreeReset={() => {}}
onSubmit={() => {}}
onResponseSubmit={pin}
setPromptDockRef={() => {}}
dockOpenVisualDuration={dockOpenDuration()}
dockOpenBounce={dockOpenBounce()}
dockCloseVisualDuration={dockCloseDuration()}
dockCloseBounce={dockCloseBounce()}
drawerExpandVisualDuration={drawerExpandDuration()}
drawerExpandBounce={drawerExpandBounce()}
drawerCollapseVisualDuration={drawerCollapseDuration()}
drawerCollapseBounce={drawerCollapseBounce()}
subtitleDuration={subtitleDuration()}
subtitleTravel={subtitleAuto() ? undefined : subtitleTravel()}
subtitleEdge={subtitleAuto() ? undefined : subtitleEdge()}
countDuration={countDuration()}
countMask={countMask()}
countMaskHeight={countMaskHeight()}
countWidthDuration={countWidthDuration()}
/>
</div>
</div>
</div>
</div>
</div>
<div style={{ display: "flex", gap: "8px", "flex-wrap": "wrap" }}>
<button onClick={toggleDock} style={btn(dockOpen())}>
{dockOpen() ? "Animate close" : "Animate open"}
</button>
<button onClick={toggleDrawer} style={btn(dockOpen() && collapsed())}>
{dockOpen() && collapsed() ? "Expand todo dock" : "Collapse todo dock"}
</button>
<button onClick={cycle} style={btn(step() > 0)}>
Cycle progress ({step()}/3 done)
</button>
{[0, 1, 2, 3].map((value) => (
<button onClick={() => setStep(value)} style={btn(step() === value)}>
{value} done
</button>
))}
</div>
<div style={{ display: "grid", gap: "10px", "max-width": "560px" }}>
<div style={{ "font-size": "12px", color: "var(--color-text-secondary, #a3a3a3)" }}>Dock open</div>
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
duration
</span>
<input
type="range"
min="0.1"
max="1"
step="0.01"
value={dockOpenDuration()}
onInput={(event) => setDockOpenDuration(event.currentTarget.valueAsNumber)}
style={{ flex: 1 }}
/>
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
{Math.round(dockOpenDuration() * 1000)}ms
</span>
</label>
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
bounce
</span>
<input
type="range"
min="0"
max="1"
step="0.01"
value={dockOpenBounce()}
onInput={(event) => setDockOpenBounce(event.currentTarget.valueAsNumber)}
style={{ flex: 1 }}
/>
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
{dockOpenBounce().toFixed(2)}
</span>
</label>
<div style={{ "font-size": "12px", color: "var(--color-text-secondary, #a3a3a3)", "margin-top": "4px" }}>
Dock close
</div>
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
duration
</span>
<input
type="range"
min="0.1"
max="1"
step="0.01"
value={dockCloseDuration()}
onInput={(event) => setDockCloseDuration(event.currentTarget.valueAsNumber)}
style={{ flex: 1 }}
/>
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
{Math.round(dockCloseDuration() * 1000)}ms
</span>
</label>
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
bounce
</span>
<input
type="range"
min="0"
max="1"
step="0.01"
value={dockCloseBounce()}
onInput={(event) => setDockCloseBounce(event.currentTarget.valueAsNumber)}
style={{ flex: 1 }}
/>
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
{dockCloseBounce().toFixed(2)}
</span>
</label>
<div style={{ "font-size": "12px", color: "var(--color-text-secondary, #a3a3a3)", "margin-top": "4px" }}>
Drawer expand
</div>
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
duration
</span>
<input
type="range"
min="0.1"
max="1"
step="0.01"
value={drawerExpandDuration()}
onInput={(event) => setDrawerExpandDuration(event.currentTarget.valueAsNumber)}
style={{ flex: 1 }}
/>
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
{Math.round(drawerExpandDuration() * 1000)}ms
</span>
</label>
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
bounce
</span>
<input
type="range"
min="0"
max="1"
step="0.01"
value={drawerExpandBounce()}
onInput={(event) => setDrawerExpandBounce(event.currentTarget.valueAsNumber)}
style={{ flex: 1 }}
/>
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
{drawerExpandBounce().toFixed(2)}
</span>
</label>
<div style={{ "font-size": "12px", color: "var(--color-text-secondary, #a3a3a3)", "margin-top": "4px" }}>
Drawer collapse
</div>
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
duration
</span>
<input
type="range"
min="0.1"
max="1"
step="0.01"
value={drawerCollapseDuration()}
onInput={(event) => setDrawerCollapseDuration(event.currentTarget.valueAsNumber)}
style={{ flex: 1 }}
/>
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
{Math.round(drawerCollapseDuration() * 1000)}ms
</span>
</label>
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
bounce
</span>
<input
type="range"
min="0"
max="1"
step="0.01"
value={drawerCollapseBounce()}
onInput={(event) => setDrawerCollapseBounce(event.currentTarget.valueAsNumber)}
style={{ flex: 1 }}
/>
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
{drawerCollapseBounce().toFixed(2)}
</span>
</label>
<div style={{ "font-size": "12px", color: "var(--color-text-secondary, #a3a3a3)", "margin-top": "4px" }}>
Subtitle odometer
</div>
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
duration
</span>
<input
type="range"
min="120"
max="1400"
step="10"
value={subtitleDuration()}
onInput={(event) => setSubtitleDuration(event.currentTarget.valueAsNumber)}
style={{ flex: 1 }}
/>
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
{Math.round(subtitleDuration())}ms
</span>
</label>
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
auto fit
</span>
<input
type="checkbox"
checked={subtitleAuto()}
onInput={(event) => setSubtitleAuto(event.currentTarget.checked)}
/>
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
{subtitleAuto() ? "on" : "off"}
</span>
</label>
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
travel
</span>
<input
type="range"
min="0"
max="40"
step="1"
value={subtitleTravel()}
onInput={(event) => setSubtitleTravel(event.currentTarget.valueAsNumber)}
style={{ flex: 1 }}
/>
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>{subtitleTravel()}px</span>
</label>
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
edge
</span>
<input
type="range"
min="1"
max="40"
step="1"
value={subtitleEdge()}
onInput={(event) => setSubtitleEdge(event.currentTarget.valueAsNumber)}
style={{ flex: 1 }}
/>
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>{subtitleEdge()}%</span>
</label>
<div style={{ "font-size": "12px", color: "var(--color-text-secondary, #a3a3a3)", "margin-top": "4px" }}>
Count odometer
</div>
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
duration
</span>
<input
type="range"
min="120"
max="1400"
step="10"
value={countDuration()}
onInput={(event) => setCountDuration(event.currentTarget.valueAsNumber)}
style={{ flex: 1 }}
/>
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
{Math.round(countDuration())}ms
</span>
</label>
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
mask
</span>
<input
type="range"
min="4"
max="40"
step="1"
value={countMask()}
onInput={(event) => setCountMask(event.currentTarget.valueAsNumber)}
style={{ flex: 1 }}
/>
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>{countMask()}%</span>
</label>
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
mask height
</span>
<input
type="range"
min="0"
max="14"
step="1"
value={countMaskHeight()}
onInput={(event) => setCountMaskHeight(event.currentTarget.valueAsNumber)}
style={{ flex: 1 }}
/>
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>{countMaskHeight()}px</span>
</label>
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
width spring
</span>
<input
type="range"
min="0"
max="1200"
step="10"
value={countWidthDuration()}
onInput={(event) => setCountWidthDuration(event.currentTarget.valueAsNumber)}
style={{ flex: 1 }}
/>
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
{Math.round(countWidthDuration())}ms
</span>
</label>
</div>
</div>
)
},
}

View File

@@ -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;
}
}

View File

@@ -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 (
<span data-component="tool-count-label" class={props.class}>
<span data-slot="tool-count-label-before">{before()}</span>
<AnimatedNumber value={props.count} />
<span data-slot="tool-count-label-word">
<span data-slot="tool-count-label-stem">{stem()}</span>
<span data-slot="tool-count-label-suffix" data-active={showTail() ? "true" : "false"}>
<span data-slot="tool-count-label-suffix-inner">{tail()}</span>
</span>
</span>
</span>
)
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,230 @@
// @ts-nocheck
import { createSignal, onCleanup } 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 TEXT = {
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" },
} as const
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 [reducedMotion, setReducedMotion] = createSignal(false)
let timeouts: ReturnType<typeof setTimeout>[] = []
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: TEXT.read.one, other: TEXT.read.other },
{ key: "search", count: searches(), one: TEXT.search.one, other: TEXT.search.other },
{ key: "list", count: lists(), one: TEXT.list.one, other: TEXT.list.other },
]
return (
<div style={{ display: "grid", gap: "24px", padding: "20px", "max-width": "520px" }}>
{reducedMotion() && (
<style>
{`[data-reduced-motion="true"] *,
[data-reduced-motion="true"] *::before,
[data-reduced-motion="true"] *::after {
transition-duration: 0ms !important;
}`}
</style>
)}
{/* Matches context-tool-group-trigger layout from message-part.tsx */}
<span
data-reduced-motion={reducedMotion()}
style={{
display: "flex",
"align-items": "center",
gap: "8px",
"font-size": "14px",
"font-weight": "500",
color: "var(--text-strong, #eee)",
"min-width": "0",
}}
>
<span style={{ "flex-shrink": "0" }}>
<ToolStatusTitle active={active()} activeText={TEXT.active} doneText={TEXT.done} split={false} />
</span>
<span
style={{
"min-width": "0",
overflow: "hidden",
"text-overflow": "ellipsis",
"white-space": "nowrap",
"font-weight": "400",
color: "var(--text-base, #ccc)",
}}
>
<AnimatedCountList items={items()} fallback="" />
</span>
</span>
<div style={{ display: "flex", gap: "8px", "flex-wrap": "wrap" }}>
<button onClick={() => (active() ? stopSim() : startSim())} style={btn(active())}>
{active() ? "Stop" : "Simulate"}
</button>
<button onClick={reset} style={btn()}>
Reset
</button>
<button onClick={() => setReducedMotion((v) => !v)} style={smallBtn(reducedMotion())}>
{reducedMotion() ? "Motion: reduced" : "Motion: normal"}
</button>
</div>
<div style={{ display: "flex", gap: "8px", "flex-wrap": "wrap" }}>
<button onClick={() => setReads((n) => n + 1)} style={smallBtn()}>
+ read
</button>
<button onClick={() => setSearches((n) => n + 1)} style={smallBtn()}>
+ search
</button>
<button onClick={() => setLists((n) => n + 1)} style={smallBtn()}>
+ list
</button>
</div>
<div
style={{
"font-size": "11px",
color: "var(--color-text-weak, #888)",
"font-family": "monospace",
}}
>
motion: {reducedMotion() ? "reduced" : "normal"} · active: {active() ? "true" : "false"} · reads: {reads()} ·
searches: {searches()} · lists: {lists()}
</div>
</div>
)
},
}
export const Empty = {
render: () => (
<span style={{ display: "flex", "align-items": "center", gap: "8px", "font-size": "14px", "font-weight": "500" }}>
<ToolStatusTitle active activeText="Exploring" doneText="Explored" split={false} />
<AnimatedCountList
items={[
{ key: "read", count: 0, one: "{{count}} read", other: "{{count}} reads" },
{ key: "search", count: 0, one: "{{count}} search", other: "{{count}} searches" },
]}
fallback=""
/>
</span>
),
}
export const Done = {
render: () => (
<span style={{ display: "flex", "align-items": "center", gap: "8px", "font-size": "14px", "font-weight": "500" }}>
<ToolStatusTitle active={false} activeText="Exploring" doneText="Explored" split={false} />
<span style={{ "font-weight": "400", color: "var(--text-base, #ccc)" }}>
<AnimatedCountList
items={[
{ key: "read", count: 5, one: "{{count}} read", other: "{{count}} reads" },
{ key: "search", count: 3, one: "{{count}} search", other: "{{count}} searches" },
{ key: "list", count: 1, one: "{{count}} list", other: "{{count}} lists" },
]}
fallback=""
/>
</span>
</span>
),
}

View File

@@ -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 (
<span data-component="tool-count-summary" class={props.class}>
<span data-slot="tool-count-summary-empty" data-active={showEmpty() ? "true" : "false"}>
<span data-slot="tool-count-summary-empty-inner">{fallback()}</span>
</span>
<Index each={props.items}>
{(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 (
<>
<span data-slot="tool-count-summary-prefix" data-active={active() && hasPrev() ? "true" : "false"}>
,
</span>
<span data-slot="tool-count-summary-item" data-active={active() ? "true" : "false"}>
<span data-slot="tool-count-summary-item-inner">
<AnimatedCountLabel
one={item().one}
other={item().other}
count={Math.max(0, Math.round(item().count))}
/>
</span>
</span>
</>
)
}}
</Index>
</span>
)
}

View File

@@ -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;
}
}

View File

@@ -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 (
<span
data-component="tool-status-title"
data-active={props.active ? "true" : "false"}
data-ready={ready() ? "true" : "false"}
data-mode={suffix() ? "suffix" : "swap"}
class={props.class}
aria-label={props.active ? props.activeText : props.doneText}
>
<Show
when={suffix()}
fallback={
<span data-slot="tool-status-swap" style={{ width: width() }}>
<span data-slot="tool-status-active" ref={activeRef}>
<TextShimmer text={activeTail()} active={props.active} offset={0} />
</span>
<span data-slot="tool-status-done" ref={doneRef}>
<TextShimmer text={doneTail()} active={false} offset={0} />
</span>
</span>
}
>
<span data-slot="tool-status-suffix">
<span data-slot="tool-status-prefix">
<TextShimmer text={split().prefix} active={props.active} offset={0} />
</span>
<span data-slot="tool-status-tail" style={{ width: width() }}>
<span data-slot="tool-status-active" ref={activeRef}>
<TextShimmer text={activeTail()} active={props.active} offset={prefixLen()} />
</span>
<span data-slot="tool-status-done" ref={doneRef}>
<TextShimmer text={doneTail()} active={false} offset={prefixLen()} />
</span>
</span>
</span>
</Show>
</span>
)
}

View File

@@ -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);
@@ -45,10 +46,16 @@
@import "../components/scroll-view.css" layer(components);
@import "../components/session-review.css" layer(components);
@import "../components/session-turn.css" layer(components);
@import "../components/shell-submessage.css" layer(components);
@import "../components/sticky-accordion-header.css" layer(components);
@import "../components/tabs.css" layer(components);
@import "../components/tag.css" layer(components);
@import "../components/text-reveal.css" layer(components);
@import "../components/text-strikethrough.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);