diff options
| author | Kit Langton <[email protected]> | 2026-03-02 17:24:32 -0500 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-03-02 16:24:32 -0600 |
| commit | 9d7852b5c39b64b96fe1bc14a80a344a0667f0b7 (patch) | |
| tree | bcbe5c2bac7e5dc203c5e1bf242a83ba5b32aaac | |
| parent | 78069369e2253c9788c09b7a71478d140c9741f2 (diff) | |
| download | opencode-9d7852b5c39b64b96fe1bc14a80a344a0667f0b7.tar.gz opencode-9d7852b5c39b64b96fe1bc14a80a344a0667f0b7.zip | |
Animation Smorgasbord (#15637)
Co-authored-by: Adam <[email protected]>
62 files changed, 5224 insertions, 703 deletions
@@ -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/[email protected]", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="], - "@storybook/addon-a11y": ["@storybook/[email protected]", "", { "dependencies": { "@storybook/global": "^5.0.0", "axe-core": "^4.2.0" }, "peerDependencies": { "storybook": "^10.2.10" } }, "sha512-1S9pDXgvbHhBStGarCvfJ3/rfcaiAcQHRhuM3Nk4WGSIYtC1LCSRuzYdDYU0aNRpdCbCrUA7kUCbqvIE3tH+3Q=="], + "@storybook/addon-a11y": ["@storybook/[email protected]", "", { "dependencies": { "@storybook/global": "^5.0.0", "axe-core": "^4.2.0" }, "peerDependencies": { "storybook": "^10.2.13" } }, "sha512-zuR1n1xgWoieEnr6E5xdTR40BI61IBQahgmsRpTvqRffL3mxAs5aFoORDmA5pZWI2LE9URdMkY85h218ijuLiw=="], - "@storybook/addon-docs": ["@storybook/[email protected]", "", { "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/[email protected]", "", { "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/[email protected]", "", { "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/[email protected]", "", { "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/[email protected]", "", { "peerDependencies": { "storybook": "^10.2.10" } }, "sha512-DkzZQTXHp99SpHMIQ5plbbHcs4EWVzWhLXlW+icA8sBlKo5Bwj540YcOApKbqB0m/OzWprsznwN7Kv4vfvHu4w=="], + "@storybook/addon-onboarding": ["@storybook/[email protected]", "", { "peerDependencies": { "storybook": "^10.2.13" } }, "sha512-kw2GgIY67UR8YXKfuVS0k+mfWL1joNQHeSe5DlDL4+7qbgp9zfV6cRJ199BMdfRAQNMzQoxHgRUcAMAqs3Rkpw=="], - "@storybook/addon-vitest": ["@storybook/[email protected]", "", { "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/[email protected]", "", { "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/[email protected]", "", { "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/[email protected]", "", { "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/[email protected]", "", { "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/[email protected]", "", {}, "sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ=="], "@storybook/icons": ["@storybook/[email protected]", "", { "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/[email protected]", "", { "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/[email protected]", "", { "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/[email protected]", "", {}, "sha512-UJ05U2062XDgydbUcETH1AoRQLNhigQ2KmDn1BG8sC3xfzu6JKg95Qt6YozdzFpxl1Npii/02m2LEWFt1RYjVA=="], @@ -3325,6 +3329,12 @@ "morphdom": ["[email protected]", "", {}, "sha512-D/fR4xgGUyVRbdMGU6Nejea1RFzYxYtyurG4Fbv2Fi/daKlWKuXGLOdXtl+3eIwL110cI2hz1ZojGICjjFLgTg=="], + "motion": ["[email protected]", "", { "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": ["[email protected]", "", { "dependencies": { "motion-utils": "^12.29.2" } }, "sha512-sYgFe+pR9aIM7o4fhs2aXtOI+oqlUd33N9Yoxcgo1Fv7M20sRkHtCmzE/VRNIcq7uNJ+qio+Xubt1FXH3pQ+eQ=="], + + "motion-utils": ["[email protected]", "", {}, "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A=="], + "mrmime": ["[email protected]", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], "ms": ["[email protected]", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -3897,7 +3907,7 @@ "stoppable": ["[email protected]", "", {}, "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw=="], - "storybook": ["[email protected]", "", { "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": ["[email protected]", "", { "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": ["[email protected]", "", { "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": ["[email protected]", "", { "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/[email protected]", "", { "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": ["[email protected]", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/[email protected]", "", { "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": ["[email protected]", "", {}, "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug=="], + "motion/framer-motion": ["[email protected]", "", { "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": ["[email protected]", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], "nitro/h3": ["[email protected]", "", { "dependencies": { "rou3": "^0.7.9", "srvx": "^0.9.1" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"] }, "sha512-qkohAzCab0nLzXNm78tBjZDvtKMTmtygS8BJLT3VPczAQofdqlFXDPkXdLMJN4r05+xqneG8snZJ0HgkERCZTg=="], diff --git a/package.json b/package.json index bd9dbac41..dc78d14e8 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts", "dev:desktop": "bun --cwd packages/desktop tauri dev", "dev:web": "bun --cwd packages/app dev", + "dev:storybook": "bun --cwd packages/storybook storybook", "typecheck": "bun turbo typecheck", "prepare": "husky", "random": "echo 'Random script'", diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index ba5a33a47..89169af0d 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -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 diff --git a/packages/app/src/pages/session/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx index cfd78ece8..9f17d3f4b 100644 --- a/packages/app/src/pages/session/composer/session-composer-region.tsx +++ b/packages/app/src/pages/session/composer/session-composer-region.tsx @@ -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 diff --git a/packages/app/src/pages/session/composer/session-composer-state.ts b/packages/app/src/pages/session/composer/session-composer-state.ts index 201846177..03e96463c 100644 --- a/packages/app/src/pages/session/composer/session-composer-state.ts +++ b/packages/app/src/pages/session/composer/session-composer-state.ts @@ -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( diff --git a/packages/app/src/pages/session/composer/session-todo-dock.tsx b/packages/app/src/pages/session/composer/session-todo-dock.tsx index 279982760..ab8755aec 100644 --- a/packages/app/src/pages/session/composer/session-todo-dock.tsx +++ b/packages/app/src/pages/session/composer/session-todo-dock.tsx @@ -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> - </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() + <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)`, }} - onClick={(event) => { - event.stopPropagation() - toggle() + > + <AnimatedNumber value={done()} /> + <span class="mx-1">of</span> + <AnimatedNumber value={total()} /> + <span> {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%", }} - aria-label={store.collapsed ? props.expandLabel : props.collapseLabel} - /> + > + <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> </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" diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 7c711989e..0aa07bf74 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -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()} diff --git a/packages/storybook/.storybook/main.ts b/packages/storybook/.storybook/main.ts index 6c850858a..a6423530f 100644 --- a/packages/storybook/.storybook/main.ts +++ b/packages/storybook/.storybook/main.ts @@ -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], }, }, }) diff --git a/packages/storybook/.storybook/mocks/app/components/dialog-select-model-unpaid.tsx b/packages/storybook/.storybook/mocks/app/components/dialog-select-model-unpaid.tsx new file mode 100644 index 000000000..8496c59a7 --- /dev/null +++ b/packages/storybook/.storybook/mocks/app/components/dialog-select-model-unpaid.tsx @@ -0,0 +1,3 @@ +export function DialogSelectModelUnpaid() { + return <div data-component="dialog-select-model-unpaid">Select model</div> +} diff --git a/packages/storybook/.storybook/mocks/app/components/dialog-select-model.tsx b/packages/storybook/.storybook/mocks/app/components/dialog-select-model.tsx new file mode 100644 index 000000000..584924f02 --- /dev/null +++ b/packages/storybook/.storybook/mocks/app/components/dialog-select-model.tsx @@ -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> +} diff --git a/packages/storybook/.storybook/mocks/app/context/command.ts b/packages/storybook/.storybook/mocks/app/context/command.ts new file mode 100644 index 000000000..1aa0423d3 --- /dev/null +++ b/packages/storybook/.storybook/mocks/app/context/command.ts @@ -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] + }, + } +} diff --git a/packages/storybook/.storybook/mocks/app/context/comments.ts b/packages/storybook/.storybook/mocks/app/context/comments.ts new file mode 100644 index 000000000..6c01d203b --- /dev/null +++ b/packages/storybook/.storybook/mocks/app/context/comments.ts @@ -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, + } +} diff --git a/packages/storybook/.storybook/mocks/app/context/file.ts b/packages/storybook/.storybook/mocks/app/context/file.ts new file mode 100644 index 000000000..db2158a5c --- /dev/null +++ b/packages/storybook/.storybook/mocks/app/context/file.ts @@ -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)) + }, + } +} diff --git a/packages/storybook/.storybook/mocks/app/context/global-sync.ts b/packages/storybook/.storybook/mocks/app/context/global-sync.ts new file mode 100644 index 000000000..2eb134d37 --- /dev/null +++ b/packages/storybook/.storybook/mocks/app/context/global-sync.ts @@ -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) + }, + }, + } +} diff --git a/packages/storybook/.storybook/mocks/app/context/language.ts b/packages/storybook/.storybook/mocks/app/context/language.ts new file mode 100644 index 000000000..874465542 --- /dev/null +++ b/packages/storybook/.storybook/mocks/app/context/language.ts @@ -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) + }, + } +} diff --git a/packages/storybook/.storybook/mocks/app/context/layout.ts b/packages/storybook/.storybook/mocks/app/context/layout.ts new file mode 100644 index 000000000..0c5d6e97c --- /dev/null +++ b/packages/storybook/.storybook/mocks/app/context/layout.ts @@ -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() {}, + }, + } +} diff --git a/packages/storybook/.storybook/mocks/app/context/local.ts b/packages/storybook/.storybook/mocks/app/context/local.ts new file mode 100644 index 000000000..d1f02e5bb --- /dev/null +++ b/packages/storybook/.storybook/mocks/app/context/local.ts @@ -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) + }, + }, + }, + } +} diff --git a/packages/storybook/.storybook/mocks/app/context/permission.ts b/packages/storybook/.storybook/mocks/app/context/permission.ts new file mode 100644 index 000000000..b6fb37d96 --- /dev/null +++ b/packages/storybook/.storybook/mocks/app/context/permission.ts @@ -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) + }, + } +} diff --git a/packages/storybook/.storybook/mocks/app/context/platform.ts b/packages/storybook/.storybook/mocks/app/context/platform.ts new file mode 100644 index 000000000..74233cd1e --- /dev/null +++ b/packages/storybook/.storybook/mocks/app/context/platform.ts @@ -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 +} diff --git a/packages/storybook/.storybook/mocks/app/context/prompt.ts b/packages/storybook/.storybook/mocks/app/context/prompt.ts new file mode 100644 index 000000000..e5e0e5d33 --- /dev/null +++ b/packages/storybook/.storybook/mocks/app/context/prompt.ts @@ -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)]) + }, + }, + } +} diff --git a/packages/storybook/.storybook/mocks/app/context/sdk.ts b/packages/storybook/.storybook/mocks/app/context/sdk.ts new file mode 100644 index 000000000..c37d68249 --- /dev/null +++ b/packages/storybook/.storybook/mocks/app/context/sdk.ts @@ -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) + }, + } +} diff --git a/packages/storybook/.storybook/mocks/app/context/sync.ts b/packages/storybook/.storybook/mocks/app/context/sync.ts new file mode 100644 index 000000000..bfc49dc83 --- /dev/null +++ b/packages/storybook/.storybook/mocks/app/context/sync.ts @@ -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() {}, + }, + }, + } +} diff --git a/packages/storybook/.storybook/mocks/app/hooks/use-providers.ts b/packages/storybook/.storybook/mocks/app/hooks/use-providers.ts new file mode 100644 index 000000000..04bd2b485 --- /dev/null +++ b/packages/storybook/.storybook/mocks/app/hooks/use-providers.ts @@ -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], + } +} diff --git a/packages/storybook/.storybook/mocks/solid-router.tsx b/packages/storybook/.storybook/mocks/solid-router.tsx new file mode 100644 index 000000000..872c17ffe --- /dev/null +++ b/packages/storybook/.storybook/mocks/solid-router.tsx @@ -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 +} diff --git a/packages/storybook/.storybook/preview.tsx b/packages/storybook/.storybook/preview.tsx index cb5ee4329..4e28e4303 100644 --- a/packages/storybook/.storybook/preview.tsx +++ b/packages/storybook/.storybook/preview.tsx @@ -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> diff --git a/packages/storybook/package.json b/packages/storybook/package.json index 2ab92bd5f..b1dae1c95 100644 --- a/packages/storybook/package.json +++ b/packages/storybook/package.json @@ -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:" diff --git a/packages/ui/package.json b/packages/ui/package.json index b2f9bb401..c3b2cf086 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -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:", diff --git a/packages/ui/src/components/animated-number.css b/packages/ui/src/components/animated-number.css new file mode 100644 index 000000000..022b347e9 --- /dev/null +++ b/packages/ui/src/components/animated-number.css @@ -0,0 +1,75 @@ +[data-component="animated-number"] { + display: inline-flex; + align-items: baseline; + vertical-align: baseline; + line-height: inherit; + font-variant-numeric: tabular-nums; + + [data-slot="animated-number-value"] { + display: inline-flex; + flex-direction: row-reverse; + align-items: baseline; + justify-content: flex-end; + line-height: inherit; + width: var(--animated-number-width, 1ch); + overflow: hidden; + transition: width var(--tool-motion-spring-ms, 560ms) var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)); + } + + [data-slot="animated-number-digit"] { + display: inline-block; + width: 1ch; + height: 1em; + line-height: 1em; + overflow: hidden; + vertical-align: baseline; + -webkit-mask-image: linear-gradient( + to bottom, + transparent 0%, + #000 var(--tool-motion-mask, 18%), + #000 calc(100% - var(--tool-motion-mask, 18%)), + transparent 100% + ); + mask-image: linear-gradient( + to bottom, + transparent 0%, + #000 var(--tool-motion-mask, 18%), + #000 calc(100% - var(--tool-motion-mask, 18%)), + transparent 100% + ); + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + } + + [data-slot="animated-number-strip"] { + display: inline-flex; + flex-direction: column; + transform: translateY(calc(var(--animated-number-offset, 10) * -1em)); + transition-property: transform; + transition-duration: var(--animated-number-duration, 560ms); + transition-timing-function: var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)); + } + + [data-slot="animated-number-strip"][data-animating="false"] { + transition-duration: 0ms; + } + + [data-slot="animated-number-cell"] { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1ch; + height: 1em; + line-height: 1em; + } +} + +@media (prefers-reduced-motion: reduce) { + [data-component="animated-number"] [data-slot="animated-number-value"] { + transition-duration: 0ms; + } + + [data-component="animated-number"] [data-slot="animated-number-strip"] { + transition-duration: 0ms; + } +} diff --git a/packages/ui/src/components/animated-number.tsx b/packages/ui/src/components/animated-number.tsx new file mode 100644 index 000000000..b5fceba25 --- /dev/null +++ b/packages/ui/src/components/animated-number.tsx @@ -0,0 +1,100 @@ +import { For, Index, createEffect, createMemo, createSignal, on } from "solid-js" + +const TRACK = Array.from({ length: 30 }, (_, index) => index % 10) +const DURATION = 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> + ) +} diff --git a/packages/ui/src/components/basic-tool.css b/packages/ui/src/components/basic-tool.css index 1240ad7b9..02be54d73 100644 --- a/packages/ui/src/components/basic-tool.css +++ b/packages/ui/src/components/basic-tool.css @@ -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; diff --git a/packages/ui/src/components/basic-tool.tsx b/packages/ui/src/components/basic-tool.tsx index 53bdc9ce1..fff6e92f1 100644 --- a/packages/ui/src/components/basic-tool.tsx +++ b/packages/ui/src/components/basic-tool.tsx @@ -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> diff --git a/packages/ui/src/components/checkbox.css b/packages/ui/src/components/checkbox.css index 82994bb88..304179965 100644 --- a/packages/ui/src/components/checkbox.css +++ b/packages/ui/src/components/checkbox.css @@ -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] { diff --git a/packages/ui/src/components/code.stories.tsx b/packages/ui/src/components/code.stories.tsx deleted file mode 100644 index 992fa6302..000000000 --- a/packages/ui/src/components/code.stories.tsx +++ /dev/null @@ -1,70 +0,0 @@ -// @ts-nocheck -import * as mod from "./code" -import { create } from "../storybook/scaffold" -import { code } from "../storybook/fixtures" - -const docs = `### Overview -Syntax-highlighted code viewer with selection support and large-file virtualization. - -Use alongside \`LineComment\` and \`Diff\` in review workflows. - -### API -- Required: \`file\` with file name + contents. -- Optional: \`language\`, \`annotations\`, \`selectedLines\`, \`commentedLines\`. -- Optional callbacks: \`onRendered\`, \`onLineSelectionEnd\`. - -### Variants and states -- Supports large-file virtualization automatically. - -### Behavior -- Re-renders when \`file\` or rendering options change. -- Optional line selection integrates with selection callbacks. - -### Accessibility -- TODO: confirm keyboard find and selection behavior. - -### Theming/tokens -- Uses \`data-component="code"\` and Pierre CSS variables from \`styleVariables\`. - -` - -const story = create({ - title: "UI/Code", - mod, - args: { - file: code, - language: "ts", - }, -}) - -export default { - title: "UI/Code", - id: "components-code", - component: story.meta.component, - tags: ["autodocs"], - parameters: { - docs: { - description: { - component: docs, - }, - }, - }, -} - -export const Basic = story.Basic - -export const SelectedLines = { - args: { - enableLineSelection: true, - selectedLines: { start: 2, end: 4 }, - }, -} - -export const CommentedLines = { - args: { - commentedLines: [ - { start: 1, end: 1 }, - { start: 5, end: 6 }, - ], - }, -} diff --git a/packages/ui/src/components/diff-ssr.stories.tsx b/packages/ui/src/components/diff-ssr.stories.tsx deleted file mode 100644 index d1adce280..000000000 --- a/packages/ui/src/components/diff-ssr.stories.tsx +++ /dev/null @@ -1,97 +0,0 @@ -// @ts-nocheck -import { preloadMultiFileDiff } from "@pierre/diffs/ssr" -import { createResource, Show } from "solid-js" -import * as mod from "./diff-ssr" -import { createDefaultOptions } from "../pierre" -import { WorkerPoolProvider } from "../context/worker-pool" -import { getWorkerPools } from "../pierre/worker" -import { diff } from "../storybook/fixtures" - -const docs = `### Overview -Server-rendered diff hydration component for preloaded Pierre diff output. - -Use alongside server routes that preload diffs. -Pair with \`DiffChanges\` for summaries. - -### API -- Required: \`before\`, \`after\`, and \`preloadedDiff\` from \`preloadMultiFileDiff\`. -- Optional: \`diffStyle\`, \`annotations\`, \`selectedLines\`, \`commentedLines\`. - -### Variants and states -- Unified/split styles (preloaded must match the style used during preload). - -### Behavior -- Hydrates pre-rendered diff HTML into a live diff instance. -- Requires a worker pool provider for syntax highlighting. - -### Accessibility -- TODO: confirm keyboard behavior from the Pierre diff engine. - -### Theming/tokens -- Uses \`data-component="diff"\` with Pierre CSS variables and theme tokens. - -` - -const load = async () => { - return preloadMultiFileDiff({ - oldFile: diff.before, - newFile: diff.after, - options: createDefaultOptions("unified"), - }) -} - -const loadSplit = async () => { - return preloadMultiFileDiff({ - oldFile: diff.before, - newFile: diff.after, - options: createDefaultOptions("split"), - }) -} - -export default { - title: "UI/DiffSSR", - id: "components-diff-ssr", - component: mod.Diff, - tags: ["autodocs"], - parameters: { - docs: { - description: { - component: docs, - }, - }, - }, -} - -export const Basic = { - render: () => { - const [data] = createResource(load) - return ( - <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> - ) - }, -} diff --git a/packages/ui/src/components/diff.stories.tsx b/packages/ui/src/components/diff.stories.tsx deleted file mode 100644 index 03bf4a0e0..000000000 --- a/packages/ui/src/components/diff.stories.tsx +++ /dev/null @@ -1,96 +0,0 @@ -// @ts-nocheck -import * as mod from "./diff" -import { create } from "../storybook/scaffold" -import { diff } from "../storybook/fixtures" - -const docs = `### Overview -Render a code diff with OpenCode styling using the Pierre diff engine. - -Pair with \`DiffChanges\` for summary counts. -Use \`LineComment\` or external UI for annotation workflows. - -### API -- Required: \`before\` and \`after\` file contents (name + contents). -- Optional: \`diffStyle\` ("unified" | "split"), \`annotations\`, \`selectedLines\`, \`commentedLines\`. -- Optional interaction: \`enableLineSelection\`, \`onLineSelectionEnd\`. -- Passes through Pierre FileDiff options (see component source). - -### Variants and states -- Unified and split diff styles. -- Optional line selection + commented line highlighting. - -### Behavior -- Re-renders when \`before\`/\`after\` or diff options change. -- Line selection uses mouse drag/selection when enabled. - -### Accessibility -- TODO: confirm keyboard behavior from the Pierre diff engine. -- Provide surrounding labels or headings when used as a standalone view. - -### Theming/tokens -- Uses \`data-component="diff"\` and Pierre CSS variables from \`styleVariables\`. -- Colors derive from theme tokens (diff add/delete, background, text). - -` - -const story = create({ - title: "UI/Diff", - mod, - args: { - before: diff.before, - after: diff.after, - diffStyle: "unified", - }, -}) - -export default { - title: "UI/Diff", - id: "components-diff", - component: story.meta.component, - tags: ["autodocs"], - parameters: { - docs: { - description: { - component: docs, - }, - }, - }, - argTypes: { - diffStyle: { - control: "select", - options: ["unified", "split"], - }, - enableLineSelection: { - control: "boolean", - }, - }, -} - -export const Unified = story.Basic - -export const Split = { - args: { - diffStyle: "split", - }, -} - -export const Selectable = { - args: { - enableLineSelection: true, - }, -} - -export const SelectedLines = { - args: { - selectedLines: { start: 2, end: 4 }, - }, -} - -export const CommentedLines = { - args: { - commentedLines: [ - { start: 1, end: 1 }, - { start: 4, end: 4 }, - ], - }, -} diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index c23a16ee1..3eee45c75 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -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"] { diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 39a2b4c23..b59dd47b8 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -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 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 }) - }) + 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 }, + ) - flush(parts.length - 1) + const last = createMemo(() => grouped().at(-1)?.key) - return { keys, items } - }) + return ( + <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 last = createMemo(() => grouped().keys.at(-1)) + 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 ( - <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) 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 }) - }) + 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 }, + ) - flush(parts.length - 1) + return ( + <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> + ) + } - return { keys, items } - }) + const part = createMemo(() => partByID(props.parts, entry.ref.partID)) - 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} - /> - )} - </Show> - </> + <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> diff --git a/packages/ui/src/components/motion-spring.tsx b/packages/ui/src/components/motion-spring.tsx new file mode 100644 index 000000000..a5104a1a3 --- /dev/null +++ b/packages/ui/src/components/motion-spring.tsx @@ -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 +} diff --git a/packages/ui/src/components/radio-group.css b/packages/ui/src/components/radio-group.css index 4faaa33f4..e9cc71184 100644 --- a/packages/ui/src/components/radio-group.css +++ b/packages/ui/src/components/radio-group.css @@ -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; } diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css index 4af87b361..60e4e17f5 100644 --- a/packages/ui/src/components/session-turn.css +++ b/packages/ui/src/components/session-turn.css @@ -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 { diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index c441bcf61..2bf2c3318 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -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> diff --git a/packages/ui/src/components/shell-submessage-motion.stories.tsx b/packages/ui/src/components/shell-submessage-motion.stories.tsx new file mode 100644 index 000000000..1f53b6e4d --- /dev/null +++ b/packages/ui/src/components/shell-submessage-motion.stories.tsx @@ -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> + ) + }, +} diff --git a/packages/ui/src/components/shell-submessage.css b/packages/ui/src/components/shell-submessage.css new file mode 100644 index 000000000..f72ba3fc7 --- /dev/null +++ b/packages/ui/src/components/shell-submessage.css @@ -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; +} diff --git a/packages/ui/src/components/text-reveal.css b/packages/ui/src/components/text-reveal.css new file mode 100644 index 000000000..a9036f8da --- /dev/null +++ b/packages/ui/src/components/text-reveal.css @@ -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; + } +} diff --git a/packages/ui/src/components/text-reveal.stories.tsx b/packages/ui/src/components/text-reveal.stories.tsx new file mode 100644 index 000000000..f3da13f5d --- /dev/null +++ b/packages/ui/src/components/text-reveal.stories.tsx @@ -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> + ) + }, +} diff --git a/packages/ui/src/components/text-reveal.tsx b/packages/ui/src/components/text-reveal.tsx new file mode 100644 index 000000000..f01704365 --- /dev/null +++ b/packages/ui/src/components/text-reveal.tsx @@ -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> + ) +} diff --git a/packages/ui/src/components/text-shimmer.css b/packages/ui/src/components/text-shimmer.css index 929a2d851..f042dd2d8 100644 --- a/packages/ui/src/components/text-shimmer.css +++ b/packages/ui/src/components/text-shimmer.css @@ -1,43 +1,119 @@ [data-component="text-shimmer"] { --text-shimmer-step: 45ms; --text-shimmer-duration: 1200ms; + --text-shimmer-swap: 220ms; + --text-shimmer-index: 0; + --text-shimmer-angle: 90deg; + --text-shimmer-spread: 5.2ch; + --text-shimmer-size: 360%; + --text-shimmer-base-color: var(--text-weak); + --text-shimmer-peak-color: var(--text-strong); + --text-shimmer-sweep: linear-gradient( + var(--text-shimmer-angle), + transparent calc(50% - var(--text-shimmer-spread)), + var(--text-shimmer-peak-color) 50%, + transparent calc(50% + var(--text-shimmer-spread)) + ); + --text-shimmer-base: linear-gradient(var(--text-shimmer-base-color), var(--text-shimmer-base-color)); + + display: inline-flex; + align-items: baseline; + font: inherit; + letter-spacing: inherit; + line-height: inherit; } [data-component="text-shimmer"] [data-slot="text-shimmer-char"] { + display: inline-grid; white-space: pre; + font: inherit; + letter-spacing: inherit; + line-height: inherit; +} + +[data-component="text-shimmer"] [data-slot="text-shimmer-char-base"], +[data-component="text-shimmer"] [data-slot="text-shimmer-char-shimmer"] { + grid-area: 1 / 1; + white-space: pre; + transition: opacity var(--text-shimmer-swap) ease-out; + font: inherit; + letter-spacing: inherit; + line-height: inherit; +} + +[data-component="text-shimmer"] [data-slot="text-shimmer-char-base"] { color: inherit; + opacity: 1; +} + +[data-component="text-shimmer"] [data-slot="text-shimmer-char-shimmer"] { + color: var(--text-weaker); + opacity: 0; } -[data-component="text-shimmer"][data-active="true"] [data-slot="text-shimmer-char"] { - animation-name: text-shimmer-char; +[data-component="text-shimmer"][data-active="true"] [data-slot="text-shimmer-char-shimmer"] { + opacity: 1; +} + +[data-component="text-shimmer"] [data-slot="text-shimmer-char-shimmer"][data-run="true"] { + animation-name: text-shimmer-sweep; animation-duration: var(--text-shimmer-duration); animation-iteration-count: infinite; - animation-timing-function: ease-in-out; - animation-delay: calc(var(--text-shimmer-step) * var(--text-shimmer-index)); + animation-timing-function: linear; + animation-fill-mode: both; + animation-delay: calc(var(--text-shimmer-step) * var(--text-shimmer-index) * -1); + will-change: background-position; } -@keyframes text-shimmer-char { - 0%, - 100% { - color: var(--text-weaker); +@keyframes text-shimmer-sweep { + 0% { + background-position: + 100% 0, + 0 0; } - 30% { - color: var(--text-weak); + 100% { + background-position: + 0% 0, + 0 0; } +} - 55% { - color: var(--text-base); +@supports ((-webkit-background-clip: text) or (background-clip: text)) { + [data-component="text-shimmer"] [data-slot="text-shimmer-char-shimmer"] { + color: transparent; + -webkit-text-fill-color: transparent; + background-image: var(--text-shimmer-sweep), var(--text-shimmer-base); + background-size: + var(--text-shimmer-size) 100%, + 100% 100%; + background-position: + 100% 0, + 0 0; + background-repeat: no-repeat; + -webkit-background-clip: text; + background-clip: text; } - 75% { - color: var(--text-strong); + [data-component="text-shimmer"][data-active="true"] [data-slot="text-shimmer-char-base"] { + opacity: 0; } } @media (prefers-reduced-motion: reduce) { - [data-component="text-shimmer"] [data-slot="text-shimmer-char"] { + [data-component="text-shimmer"] [data-slot="text-shimmer-char-base"], + [data-component="text-shimmer"] [data-slot="text-shimmer-char-shimmer"] { + transition-duration: 0ms; + } + + [data-component="text-shimmer"] [data-slot="text-shimmer-char-shimmer"] { animation: none !important; color: inherit; + -webkit-text-fill-color: currentColor; + background-image: none; + } + + [data-component="text-shimmer"] [data-slot="text-shimmer-char-base"] { + opacity: 1 !important; } } diff --git a/packages/ui/src/components/text-shimmer.stories.tsx b/packages/ui/src/components/text-shimmer.stories.tsx index 4b6de34c2..a88a7158b 100644 --- a/packages/ui/src/components/text-shimmer.stories.tsx +++ b/packages/ui/src/components/text-shimmer.stories.tsx @@ -1,5 +1,6 @@ // @ts-nocheck import * as mod from "./text-shimmer" +import { useArgs } from "storybook/preview-api" import { create } from "../storybook/scaffold" const docs = `### Overview @@ -9,13 +10,14 @@ Use for pending states inside buttons or list rows. ### API - Required: \`text\` string. -- Optional: \`as\`, \`active\`, \`stepMs\`, \`durationMs\`. +- Optional: \`as\`, \`active\`, \`offset\`, \`class\`. ### Variants and states - Active/inactive state via \`active\`. ### Behavior -- Characters animate with staggered delays. +- Uses a moving gradient sweep clipped to text. +- \`offset\` lets multiple shimmers run out-of-phase. ### Accessibility - Uses \`aria-label\` with the full text. @@ -25,13 +27,27 @@ Use for pending states inside buttons or list rows. ` -const story = create({ title: "UI/TextShimmer", mod, args: { text: "Loading..." } }) +const defaults = { + text: "Loading...", + active: true, + class: "text-14-medium text-text-strong", + offset: 0, +} as const + +const story = create({ title: "UI/TextShimmer", mod, args: defaults }) export default { title: "UI/TextShimmer", id: "components-text-shimmer", component: story.meta.component, tags: ["autodocs"], + args: defaults, + argTypes: { + text: { control: "text" }, + class: { control: "text" }, + active: { control: "boolean" }, + offset: { control: { type: "range", min: 0, max: 80, step: 1 } }, + }, parameters: { docs: { description: { @@ -41,7 +57,32 @@ export default { }, } -export const Basic = story.Basic +export const Basic = { + args: defaults, + render: (args) => { + const [, updateArgs] = useArgs() + const reset = () => updateArgs(defaults) + return ( + <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, - }, -} diff --git a/packages/ui/src/components/text-shimmer.tsx b/packages/ui/src/components/text-shimmer.tsx index 6ee4ef402..c4c20b8e7 100644 --- a/packages/ui/src/components/text-shimmer.tsx +++ b/packages/ui/src/components/text-shimmer.tsx @@ -1,4 +1,4 @@ -import { For, createMemo, type ValidComponent } from "solid-js" +import { createEffect, createMemo, createSignal, onCleanup, type ValidComponent } from "solid-js" import { Dynamic } from "solid-js/web" export const TextShimmer = <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> ) } diff --git a/packages/ui/src/components/text-strikethrough.css b/packages/ui/src/components/text-strikethrough.css new file mode 100644 index 000000000..1be805468 --- /dev/null +++ b/packages/ui/src/components/text-strikethrough.css @@ -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; + } +} diff --git a/packages/ui/src/components/text-strikethrough.stories.tsx b/packages/ui/src/components/text-strikethrough.stories.tsx new file mode 100644 index 000000000..b07e74553 --- /dev/null +++ b/packages/ui/src/components/text-strikethrough.stories.tsx @@ -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> + ) + }, +} diff --git a/packages/ui/src/components/text-strikethrough.tsx b/packages/ui/src/components/text-strikethrough.tsx new file mode 100644 index 000000000..211e7d44c --- /dev/null +++ b/packages/ui/src/components/text-strikethrough.tsx @@ -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> + ) +} diff --git a/packages/ui/src/components/thinking-heading.stories.tsx b/packages/ui/src/components/thinking-heading.stories.tsx new file mode 100644 index 000000000..90eb7ee31 --- /dev/null +++ b/packages/ui/src/components/thinking-heading.stories.tsx @@ -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> + ) + }, +} diff --git a/packages/ui/src/components/todo-panel-motion.stories.tsx b/packages/ui/src/components/todo-panel-motion.stories.tsx new file mode 100644 index 000000000..39d342157 --- /dev/null +++ b/packages/ui/src/components/todo-panel-motion.stories.tsx @@ -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> + ) + }, +} diff --git a/packages/ui/src/components/tool-count-label.css b/packages/ui/src/components/tool-count-label.css new file mode 100644 index 000000000..11a33ff5d --- /dev/null +++ b/packages/ui/src/components/tool-count-label.css @@ -0,0 +1,57 @@ +[data-component="tool-count-label"] { + display: inline-flex; + align-items: baseline; + white-space: nowrap; + gap: 0; + + [data-slot="tool-count-label-before"] { + display: inline-block; + white-space: pre; + line-height: inherit; + } + + [data-slot="tool-count-label-word"] { + display: inline-flex; + align-items: baseline; + white-space: pre; + line-height: inherit; + } + + [data-slot="tool-count-label-stem"] { + display: inline-block; + white-space: pre; + } + + [data-slot="tool-count-label-suffix"] { + display: inline-grid; + grid-template-columns: 0fr; + opacity: 0; + filter: blur(calc(var(--tool-motion-blur, 2px) * 0.42)); + overflow: hidden; + transform: translateX(-0.04em); + transition-property: grid-template-columns, opacity, filter, transform; + transition-duration: 250ms, 250ms, 250ms, 250ms; + transition-timing-function: + var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), ease-out, ease-out, + var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)); + } + + [data-slot="tool-count-label-suffix"][data-active="true"] { + grid-template-columns: 1fr; + opacity: 1; + filter: blur(0); + transform: translateX(0); + } + + [data-slot="tool-count-label-suffix-inner"] { + min-width: 0; + overflow: hidden; + white-space: pre; + } +} + +@media (prefers-reduced-motion: reduce) { + [data-component="tool-count-label"] [data-slot="tool-count-label-suffix"] { + transition-duration: 0ms; + } +} diff --git a/packages/ui/src/components/tool-count-label.tsx b/packages/ui/src/components/tool-count-label.tsx new file mode 100644 index 000000000..67e861cdc --- /dev/null +++ b/packages/ui/src/components/tool-count-label.tsx @@ -0,0 +1,58 @@ +import { createMemo } from "solid-js" +import { AnimatedNumber } from "./animated-number" + +function split(text: string) { + const match = /{{\s*count\s*}}/.exec(text) + if (!match) return { before: "", after: text } + if (match.index === undefined) return { before: "", after: text } + return { + before: text.slice(0, match.index), + after: text.slice(match.index + match[0].length), + } +} + +function common(one: string, other: string) { + const a = Array.from(one) + const b = Array.from(other) + let i = 0 + while (i < a.length && i < b.length && a[i] === b[i]) i++ + return { + stem: a.slice(0, i).join(""), + one: a.slice(i).join(""), + other: b.slice(i).join(""), + } +} + +export function AnimatedCountLabel(props: { count: number; one: string; other: string; class?: string }) { + const one = createMemo(() => split(props.one)) + const other = createMemo(() => split(props.other)) + const singular = createMemo(() => Math.round(props.count) === 1) + const active = createMemo(() => (singular() ? one() : other())) + const suffix = createMemo(() => common(one().after, other().after)) + const splitSuffix = createMemo( + () => + one().before === other().before && + (one().after.startsWith(other().after) || other().after.startsWith(one().after)), + ) + const before = createMemo(() => (splitSuffix() ? one().before : active().before)) + const stem = createMemo(() => (splitSuffix() ? suffix().stem : active().after)) + const tail = createMemo(() => { + if (!splitSuffix()) return "" + if (singular()) return suffix().one + return suffix().other + }) + const showTail = createMemo(() => splitSuffix() && tail().length > 0) + + return ( + <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> + ) +} diff --git a/packages/ui/src/components/tool-count-summary.css b/packages/ui/src/components/tool-count-summary.css new file mode 100644 index 000000000..da8455267 --- /dev/null +++ b/packages/ui/src/components/tool-count-summary.css @@ -0,0 +1,102 @@ +[data-component="tool-count-summary"] { + display: inline-flex; + align-items: baseline; + white-space: nowrap; + + [data-slot="tool-count-summary-empty"] { + display: inline-grid; + grid-template-columns: 1fr; + align-items: baseline; + opacity: 1; + filter: blur(0); + transform: translateY(0) scale(1); + overflow: hidden; + transform-origin: left center; + transition-property: grid-template-columns, opacity, filter, transform; + transition-duration: + var(--tool-motion-spring-ms, 480ms), var(--tool-motion-fade-ms, 240ms), var(--tool-motion-fade-ms, 280ms), + var(--tool-motion-spring-ms, 480ms); + transition-timing-function: + var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), ease-out, ease-out, + var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)); + } + + [data-slot="tool-count-summary-empty"][data-active="false"] { + grid-template-columns: 0fr; + opacity: 0; + filter: blur(calc(var(--tool-motion-blur, 2px) * 0.72)); + transform: translateY(0.05em) scale(0.985); + } + + [data-slot="tool-count-summary-item"] { + display: inline-grid; + grid-template-columns: 0fr; + align-items: baseline; + opacity: 0; + filter: blur(var(--tool-motion-blur, 2px)); + transform: translateY(0.06em) scale(0.985); + overflow: hidden; + transform-origin: left center; + transition-property: grid-template-columns, opacity, filter, transform; + transition-duration: + var(--tool-motion-spring-ms, 480ms), var(--tool-motion-fade-ms, 280ms), var(--tool-motion-fade-ms, 320ms), + var(--tool-motion-spring-ms, 480ms); + transition-timing-function: + var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), ease-out, ease-out, + var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)); + } + + [data-slot="tool-count-summary-item"][data-active="true"] { + grid-template-columns: 1fr; + opacity: 1; + filter: blur(0); + transform: translateY(0) scale(1); + } + + [data-slot="tool-count-summary-empty-inner"] { + min-width: 0; + overflow: hidden; + white-space: nowrap; + } + + [data-slot="tool-count-summary-item-inner"] { + display: inline-flex; + align-items: baseline; + min-width: 0; + overflow: hidden; + white-space: nowrap; + } + + [data-slot="tool-count-summary-prefix"] { + display: inline-flex; + align-items: baseline; + justify-content: flex-start; + max-width: 0; + margin-right: 0; + opacity: 0; + filter: blur(calc(var(--tool-motion-blur, 2px) * 0.55)); + overflow: hidden; + transform: translateX(-0.08em); + transition-property: opacity, filter, transform; + transition-duration: + calc(var(--tool-motion-fade-ms, 200ms) * 0.75), calc(var(--tool-motion-fade-ms, 220ms) * 0.75), + calc(var(--tool-motion-fade-ms, 220ms) * 0.6); + transition-timing-function: ease-out, ease-out, ease-out; + } + + [data-slot="tool-count-summary-prefix"][data-active="true"] { + max-width: 1ch; + margin-right: 0.45ch; + opacity: 1; + filter: blur(0); + transform: translateX(0); + } +} + +@media (prefers-reduced-motion: reduce) { + [data-component="tool-count-summary"] [data-slot="tool-count-summary-empty"], + [data-component="tool-count-summary"] [data-slot="tool-count-summary-item"], + [data-component="tool-count-summary"] [data-slot="tool-count-summary-prefix"] { + transition-duration: 0ms; + } +} diff --git a/packages/ui/src/components/tool-count-summary.stories.tsx b/packages/ui/src/components/tool-count-summary.stories.tsx new file mode 100644 index 000000000..4be3a02bb --- /dev/null +++ b/packages/ui/src/components/tool-count-summary.stories.tsx @@ -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> + ), +} diff --git a/packages/ui/src/components/tool-count-summary.tsx b/packages/ui/src/components/tool-count-summary.tsx new file mode 100644 index 000000000..a5cb5b40d --- /dev/null +++ b/packages/ui/src/components/tool-count-summary.tsx @@ -0,0 +1,52 @@ +import { Index, createMemo } from "solid-js" +import { AnimatedCountLabel } from "./tool-count-label" + +export type CountItem = { + key: string + count: number + one: string + other: string +} + +export function AnimatedCountList(props: { items: CountItem[]; fallback?: string; class?: string }) { + const visible = createMemo(() => props.items.filter((item) => item.count > 0)) + const fallback = createMemo(() => props.fallback ?? "") + const showEmpty = createMemo(() => visible().length === 0 && fallback().length > 0) + + return ( + <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> + ) +} diff --git a/packages/ui/src/components/tool-status-title.css b/packages/ui/src/components/tool-status-title.css new file mode 100644 index 000000000..d4415bd2d --- /dev/null +++ b/packages/ui/src/components/tool-status-title.css @@ -0,0 +1,89 @@ +[data-component="tool-status-title"] { + display: inline-flex; + align-items: baseline; + white-space: nowrap; + text-align: start; + + [data-slot="tool-status-suffix"] { + display: inline-flex; + align-items: baseline; + white-space: nowrap; + } + + [data-slot="tool-status-prefix"] { + white-space: nowrap; + flex-shrink: 0; + } + + [data-slot="tool-status-swap"], + [data-slot="tool-status-tail"] { + display: inline-grid; + overflow: hidden; + justify-items: start; + transition: width var(--tool-motion-spring-ms, 480ms) var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)); + } + + [data-slot="tool-status-active"], + [data-slot="tool-status-done"] { + grid-area: 1 / 1; + white-space: nowrap; + justify-self: start; + text-align: start; + transition-property: opacity, filter, transform; + transition-duration: + var(--tool-motion-fade-ms, 240ms), calc(var(--tool-motion-fade-ms, 240ms) * 0.8), + calc(var(--tool-motion-fade-ms, 240ms) * 0.8); + transition-timing-function: ease-out, ease-out, ease-out; + } + + &[data-ready="false"] { + [data-slot="tool-status-swap"], + [data-slot="tool-status-tail"] { + transition-duration: 0ms; + } + + [data-slot="tool-status-active"], + [data-slot="tool-status-done"] { + transition-duration: 0ms; + } + } + + [data-slot="tool-status-active"] { + opacity: 0; + filter: blur(calc(var(--tool-motion-blur, 2px) * 0.45)); + transform: translateY(0.03em); + } + + [data-slot="tool-status-done"] { + color: var(--text-strong); + opacity: 1; + filter: blur(0); + transform: translateY(0); + } + + &[data-active="true"] { + [data-slot="tool-status-active"] { + opacity: 1; + filter: blur(0); + transform: translateY(0); + } + + [data-slot="tool-status-done"] { + opacity: 0; + filter: blur(calc(var(--tool-motion-blur, 2px) * 0.45)); + transform: translateY(0.03em); + } + } +} + +@media (prefers-reduced-motion: reduce) { + [data-component="tool-status-title"] [data-slot="tool-status-swap"], + [data-component="tool-status-title"] [data-slot="tool-status-tail"] { + transition-duration: 0ms; + } + + [data-component="tool-status-title"] [data-slot="tool-status-active"], + [data-component="tool-status-title"] [data-slot="tool-status-done"] { + transition-duration: 0ms; + } +} diff --git a/packages/ui/src/components/tool-status-title.tsx b/packages/ui/src/components/tool-status-title.tsx new file mode 100644 index 000000000..4cf8f15ab --- /dev/null +++ b/packages/ui/src/components/tool-status-title.tsx @@ -0,0 +1,138 @@ +import { Show, createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js" +import { TextShimmer } from "./text-shimmer" + +function common(active: string, done: string) { + const a = Array.from(active) + const b = Array.from(done) + let i = 0 + while (i < a.length && i < b.length && a[i] === b[i]) i++ + return { + prefix: a.slice(0, i).join(""), + active: a.slice(i).join(""), + done: b.slice(i).join(""), + } +} + +function contentWidth(el: HTMLSpanElement | undefined) { + if (!el) return 0 + const range = document.createRange() + range.selectNodeContents(el) + return Math.ceil(range.getBoundingClientRect().width) +} + +export function ToolStatusTitle(props: { + active: boolean + activeText: string + doneText: string + class?: string + split?: boolean +}) { + const split = createMemo(() => common(props.activeText, props.doneText)) + const suffix = createMemo( + () => (props.split ?? true) && split().prefix.length >= 2 && split().active.length > 0 && split().done.length > 0, + ) + const prefixLen = createMemo(() => Array.from(split().prefix).length) + const activeTail = createMemo(() => (suffix() ? split().active : props.activeText)) + const doneTail = createMemo(() => (suffix() ? split().done : props.doneText)) + + const [width, setWidth] = createSignal("auto") + const [ready, setReady] = createSignal(false) + let activeRef: HTMLSpanElement | undefined + let doneRef: HTMLSpanElement | undefined + let frame: number | undefined + let readyFrame: number | undefined + + const measure = () => { + const target = props.active ? activeRef : doneRef + const px = contentWidth(target) + if (px > 0) setWidth(`${px}px`) + } + + const schedule = () => { + if (typeof requestAnimationFrame !== "function") { + measure() + return + } + if (frame !== undefined) cancelAnimationFrame(frame) + frame = requestAnimationFrame(() => { + frame = undefined + measure() + }) + } + + const finish = () => { + if (typeof requestAnimationFrame !== "function") { + setReady(true) + return + } + if (readyFrame !== undefined) cancelAnimationFrame(readyFrame) + readyFrame = requestAnimationFrame(() => { + readyFrame = undefined + setReady(true) + }) + } + + createEffect( + on( + [() => props.active, activeTail, doneTail, suffix], + () => schedule(), + ), + ) + + onMount(() => { + measure() + const fonts = typeof document !== "undefined" ? document.fonts : undefined + if (!fonts) { + finish() + return + } + fonts.ready.finally(() => { + measure() + finish() + }) + }) + + onCleanup(() => { + if (frame !== undefined) cancelAnimationFrame(frame) + if (readyFrame !== undefined) cancelAnimationFrame(readyFrame) + }) + + return ( + <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> + ) +} diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index f822371f7..cec42f5a0 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -7,6 +7,7 @@ @import "katex/dist/katex.min.css" layer(base); @import "../components/accordion.css" layer(components); +@import "../components/animated-number.css" layer(components); @import "../components/app-icon.css" layer(components); @import "../components/avatar.css" layer(components); @import "../components/basic-tool.css" layer(components); @@ -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); |
