Animation Smorgasbord (#15637)

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,19 +8,20 @@
"build": "storybook build"
},
"devDependencies": {
"@tailwindcss/vite": "catalog:",
"@opencode-ai/ui": "workspace:*",
"@solidjs/meta": "catalog:",
"@storybook/addon-a11y": "^10.2.10",
"@storybook/addon-docs": "^10.2.10",
"@storybook/addon-links": "^10.2.10",
"@storybook/addon-onboarding": "^10.2.10",
"@storybook/addon-vitest": "^10.2.10",
"@storybook/addon-a11y": "^10.2.13",
"@storybook/addon-docs": "^10.2.13",
"@storybook/addon-links": "^10.2.13",
"@storybook/addon-onboarding": "^10.2.13",
"@storybook/addon-vitest": "^10.2.13",
"@tsconfig/node22": "catalog:",
"@types/node": "catalog:",
"@types/react": "18.0.25",
"react": "18.2.0",
"solid-js": "catalog:",
"storybook": "^10.2.10",
"storybook": "^10.2.13",
"storybook-solidjs-vite": "^10.0.9",
"typescript": "catalog:",
"vite": "catalog:"