diff --git a/packages/app/src/components-lab/dialog-story-runtime.tsx b/packages/app/src/components-lab/dialog-story-runtime.tsx new file mode 100644 index 000000000000..fb1b596567b1 --- /dev/null +++ b/packages/app/src/components-lab/dialog-story-runtime.tsx @@ -0,0 +1,626 @@ +import type { IntegrationInfo } from "@opencode/client/promise" +import { QueryClient, QueryClientProvider } from "@tanstack/solid-query" +import type { ParentProps } from "solid-js" +import { LocalProvider } from "@/providers/models/selection" +import { ModelsProvider } from "@/providers/models/models" +import { PlatformProvider, type Platform } from "@/runtime/platform/platform" +import { PluginRoot } from "@/runtime/plugin/root" +import { ServerProvider } from "@/runtime/server/current" +import { GlobalProvider } from "@/runtime/server/runtime" +import { ServerConnection, ServersProvider } from "@/runtime/server/registry" +import { SettingsProvider } from "@/settings/model" +import { CommandProvider } from "@/shell/commands/command" +import { ProjectIconsProvider } from "@/shell/layout/project-icons" +import { TabsProvider } from "@/shell/tabs/tabs" +import { LocationProvider } from "@/workspaces/location" + +const STORY_SERVER_URL = "http://localhost:0" + +const STORY_UNAUTHORIZED_URL = "http://locked.opencode.local:4096" + +const STORY_HOME = "/Users/alex" + +export const STORY_DIRECTORY = "/Users/alex/work/opencode" + +export const STORY_SERVER: ServerConnection.Http = { + type: "http", + displayName: "This computer", + http: { url: STORY_SERVER_URL }, +} + +export const STORY_LOCKED_SERVER: ServerConnection.Http = { + type: "http", + displayName: "Staging server", + http: { url: STORY_UNAUTHORIZED_URL }, +} + +type DirectoryEntry = { + name: string + type: "file" | "directory" +} + +const directoryTree = { + "/": [{ name: "Users", type: "directory" }], + "/Users": [{ name: "alex", type: "directory" }], + [STORY_HOME]: [ + { name: "work", type: "directory" }, + { name: "notes", type: "directory" }, + ], + "/Users/alex/work": [ + { name: "opencode", type: "directory" }, + { name: "docs", type: "directory" }, + { name: "website", type: "directory" }, + ], + [STORY_DIRECTORY]: [ + { name: ".git", type: "directory" }, + { name: "apps", type: "directory" }, + { name: "docs", type: "directory" }, + { name: "packages", type: "directory" }, + { name: "package.json", type: "file" }, + { name: "README.md", type: "file" }, + ], + [`${STORY_DIRECTORY}/apps`]: [ + { name: "desktop", type: "directory" }, + { name: "web", type: "directory" }, + ], + [`${STORY_DIRECTORY}/docs`]: [ + { name: "architecture.md", type: "file" }, + { name: "dialogs.md", type: "file" }, + ], + [`${STORY_DIRECTORY}/packages`]: [ + { name: "app", type: "directory" }, + { name: "core", type: "directory" }, + { name: "ui", type: "directory" }, + ], + "/Users/alex/work/docs": [ + { name: ".git", type: "directory" }, + { name: "guides", type: "directory" }, + ], + "/Users/alex/work/website": [ + { name: ".git", type: "directory" }, + { name: "src", type: "directory" }, + ], +} satisfies Record + +export const STORY_PROJECTS = [ + { + id: "prj_opencode", + worktree: STORY_DIRECTORY, + canonical: STORY_DIRECTORY, + name: "opencode", + vcs: "git" as const, + sandboxes: ["/Users/alex/work/opencode-dialogs"], + time: { created: Date.UTC(2026, 8, 1), updated: Date.UTC(2026, 9, 28), active: Date.UTC(2026, 9, 28) }, + }, + { + id: "prj_docs", + worktree: "/Users/alex/work/docs", + canonical: "/Users/alex/work/docs", + name: "docs", + vcs: "git" as const, + sandboxes: [], + time: { created: Date.UTC(2026, 8, 5), updated: Date.UTC(2026, 9, 20), active: Date.UTC(2026, 9, 20) }, + }, + { + id: "prj_website", + worktree: "/Users/alex/work/website", + canonical: "/Users/alex/work/website", + name: "website", + vcs: "git" as const, + sandboxes: [], + time: { created: Date.UTC(2026, 8, 10), updated: Date.UTC(2026, 9, 15), active: Date.UTC(2026, 9, 15) }, + }, +] + +const defaultProviders = [ + { id: "opencode-go", name: "OpenCode Go", package: "opencode-go", activation: "enabled" }, + { id: "opencode", name: "OpenCode Zen", package: "opencode", activation: "enabled" }, + { id: "anthropic", name: "Anthropic", package: "anthropic", activation: "enabled" }, +] + +const releasedRecent = Date.UTC(2026, 9, 1) + +const releasedOlder = Date.UTC(2025, 1, 1) + +function modelRecord(input: { + id: string + providerID: string + name: string + inputCost: number + outputCost: number + released?: number + variants?: string[] +}) { + return { + id: input.id, + modelID: input.id, + providerID: input.providerID, + name: input.name, + capabilities: { tools: true, reasoning: true, input: ["text", "image"], output: ["text"] }, + variants: (input.variants ?? []).map((id) => ({ id, settings: {} })), + time: { released: input.released ?? releasedRecent }, + cost: [{ input: input.inputCost, output: input.outputCost, cache: { read: 0, write: 0 } }], + status: "active", + enabled: true, + limit: { context: 200_000, output: 64_000 }, + } +} + +const defaultModels = [ + modelRecord({ + id: "deepseek-v4-pro", + providerID: "opencode-go", + name: "DeepSeek V4 Pro", + inputCost: 1, + outputCost: 3, + released: releasedOlder, + }), + modelRecord({ + id: "glm-5", + providerID: "opencode-go", + name: "GLM-5", + inputCost: 1, + outputCost: 2, + released: releasedOlder, + }), + modelRecord({ + id: "kimi-k3", + providerID: "opencode-go", + name: "Kimi K3", + inputCost: 1, + outputCost: 3, + variants: ["fast", "thinking"], + }), + modelRecord({ + id: "big-pickle", + providerID: "opencode", + name: "Big Pickle", + inputCost: 0, + outputCost: 0, + }), + modelRecord({ + id: "claude-opus-5-5", + providerID: "opencode", + name: "Claude Opus 5.5", + inputCost: 5, + outputCost: 25, + variants: ["high", "max"], + }), + modelRecord({ + id: "gpt-5-6", + providerID: "opencode", + name: "GPT-5.6", + inputCost: 2.5, + outputCost: 10, + released: releasedOlder, + }), + modelRecord({ + id: "claude-sonnet-5", + providerID: "anthropic", + name: "Claude Sonnet 5", + inputCost: 3, + outputCost: 15, + variants: ["thinking"], + }), +] + +const integrations = [ + { + id: "anthropic", + name: "Anthropic", + methods: [{ type: "key", label: "API key" }], + connections: [ + { + type: "credential", + id: "cred_anthropic_work", + method: "key", + label: "Work account", + }, + ], + }, + { + id: "opencode", + name: "OpenCode Console", + methods: [ + { type: "oauth", id: "console", label: "OpenCode Console account" }, + { type: "key", label: "API key (service account)" }, + ], + connections: [], + }, + { + id: "opencode-go", + name: "OpenCode Go", + methods: [{ type: "oauth", id: "go", label: "Sign in with OpenCode" }], + connections: [], + }, + { + id: "openai", + name: "OpenAI", + methods: [ + { type: "oauth", id: "chatgpt", label: "ChatGPT Plus / Pro" }, + { type: "key", label: "API key" }, + ], + connections: [], + }, + { + id: "github-copilot", + name: "GitHub Copilot", + methods: [{ type: "oauth", id: "copilot", label: "Sign in with GitHub" }], + connections: [], + }, + { + id: "google", + name: "Google", + methods: [{ type: "key", label: "API key" }], + connections: [], + }, + { + id: "openrouter", + name: "OpenRouter", + methods: [{ type: "key", label: "API key" }], + connections: [], + }, +] satisfies IntegrationInfo[] + +function jsonResponse(body: T, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }) +} + +function errorResponse(tag: string, message: string, status: number) { + // SAFETY: the server's HTTP wire body for a tagged error, decoded at the OpenCode client boundary. + // oxlint-disable-next-line anti-slop-effect/no-manual-tagged-construction -- see SAFETY above + return jsonResponse({ _tag: tag, message }, status) +} + +function noContentResponse() { + return new Response(null, { status: 204 }) +} + +function locationEnvelope(directory: string) { + const project = + STORY_PROJECTS.find( + (item) => + directory === item.worktree || directory.startsWith(`${item.worktree}/`) || item.sandboxes.includes(directory), + ) ?? STORY_PROJECTS[0] + + return { + directory, + project: { + id: project.id, + directory: project.worktree, + canonical: project.canonical, + }, + } +} + +const directoryEntries = new Map(Object.entries(directoryTree)) + +function listDirectoryEntries(root: string, path: string) { + const clean = path === "/" ? "/" : path.replace(/\/+$/, "") + + const items = directoryEntries.get(clean) + + if (!items) return undefined + const base = root === "/" ? "" : root.replace(/\/+$/, "") + + return items.map((entry) => { + const absolute = clean === "/" ? `/${entry.name}` : `${clean}/${entry.name}` + + const relative = base && absolute.startsWith(`${base}/`) ? absolute.slice(base.length + 1) : absolute + + return { + name: entry.name, + path: relative, + absolute, + type: entry.type, + ignored: false, + } + }) +} + +function findMatchingPaths(root: string, query: string, type: string | null) { + const text = query.trim().toLowerCase() + + const base = root === "/" ? "" : root.replace(/\/+$/, "") + + return [...directoryEntries.entries()].flatMap(([parent, entries]) => + entries.flatMap((entry) => { + if (entry.name === ".git") return [] + + if (type && entry.type !== type) return [] + const absolute = parent === "/" ? `/${entry.name}` : `${parent}/${entry.name}` + + if (base && !absolute.startsWith(`${base}/`)) return [] + const relative = base ? absolute.slice(base.length + 1) : absolute.slice(1) + + if (text && !relative.toLowerCase().includes(text) && !entry.name.toLowerCase().includes(text)) return [] + + return [ + { + name: entry.name, + path: relative, + absolute, + type: entry.type, + ignored: false, + }, + ] + }), + ) +} + +type DialogStoryRuntimeOptions = { + servers?: ServerConnection.Any[] +} + +function createStoryFetch(): typeof fetch { + const encoder = new TextEncoder() + + const handler = async (input: RequestInfo | URL, init?: RequestInit) => { + const raw = input instanceof Request ? input.url : input + + const url = new URL(raw, STORY_SERVER_URL) + + if (url.origin === new URL(STORY_UNAUTHORIZED_URL).origin) { + return errorResponse("UnauthorizedError", "Authentication required", 401) + } + + const directory = + url.searchParams.get("location[directory]") ?? url.searchParams.get("directory") ?? STORY_DIRECTORY + + const location = locationEnvelope(directory) + + const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase() + + const pathname = url.pathname + + if (pathname === "/api/event") { + const signal = init?.signal ?? (input instanceof Request ? input.signal : undefined) + + const stream = new ReadableStream({ + start(controller) { + controller.enqueue( + encoder.encode(`data: ${JSON.stringify({ id: "evt_story_1", type: "server.connected", data: {} })}\n\n`), + ) + + signal?.addEventListener("abort", () => controller.error(signal.reason), { once: true }) + }, + }) + + return new Response(stream, { + status: 200, + headers: { "cache-control": "no-cache", "content-type": "text/event-stream" }, + }) + } + + if (pathname === "/api/info") { + return jsonResponse({ + version: "2.0.0", + pid: 1, + hostname: "MacBook-Pro", + urls: [STORY_SERVER_URL], + paths: { tmp: "/tmp/opencode" }, + }) + } + + if (pathname === "/api/location") return jsonResponse(location) + + if (pathname === "/api/project") return jsonResponse(STORY_PROJECTS) + + if (pathname === "/api/worktree") { + const projectID = url.searchParams.get("projectID") + + const project = STORY_PROJECTS.find((item) => item.id === projectID) ?? STORY_PROJECTS[0] + + return jsonResponse([ + { directory: project.worktree }, + ...project.sandboxes.map((sandbox) => ({ directory: sandbox, strategy: "git" })), + ]) + } + + if (pathname === "/api/worktree/refresh") return noContentResponse() + + if (pathname === "/api/fs/list") { + const path = url.searchParams.get("path") ?? directory + + const items = listDirectoryEntries(directory, path) + + if (!items) return errorResponse("NotFoundError", `Directory not found: ${path}`, 404) + + return jsonResponse({ location, data: items }) + } + + if (pathname === "/api/fs/find") { + const query = url.searchParams.get("query") ?? "" + + const type = url.searchParams.get("type") + + return jsonResponse({ location, data: findMatchingPaths(directory, query, type) }) + } + + if (pathname === "/api/provider") return jsonResponse({ location, data: defaultProviders }) + + if (pathname === "/api/model") return jsonResponse({ location, data: defaultModels }) + + if (pathname === "/api/model/default") { + return jsonResponse({ location, data: defaultModels.find((item) => item.id === "claude-opus-5-5") ?? null }) + } + + if (pathname === "/api/integration") return jsonResponse({ location, data: integrations }) + + if (pathname.startsWith("/api/integration/")) { + const rest = pathname.slice("/api/integration/".length).split("/") + + const integrationID = decodeURIComponent(rest[0] ?? "") + + if (rest.length === 1 && method === "GET") { + const found = integrations.find((item) => item.id === integrationID) ?? { + id: integrationID, + name: integrationID, + methods: [{ type: "key" as const, label: "API key" }], + connections: [], + } + + return jsonResponse({ location, data: found }) + } + + if (rest[1] === "connect" && rest[2] === "key" && method === "POST") return noContentResponse() + + if (rest[1] === "connect" && rest[2] === "oauth" && rest.length === 3 && method === "POST") { + const now = Date.now() + + return jsonResponse({ + location, + data: { + attemptID: "con_story_1", + url: "https://opencode.ai/auth/authorize", + instructions: "", + mode: "auto", + time: { created: now, expires: now + 600_000 }, + }, + }) + } + + if (rest[1] === "connect" && rest[2] === "oauth" && rest[3] && method === "GET") { + const now = Date.now() + + return jsonResponse({ + location, + data: { status: "pending", time: { created: now, expires: now + 600_000 } }, + }) + } + + return noContentResponse() + } + + if (pathname.startsWith("/api/credential/")) return noContentResponse() + + if (pathname === "/api/experimental/usage/limits") { + return jsonResponse({ location, data: null }) + } + + if (pathname === "/api/agent") { + return jsonResponse({ + location, + data: [ + { + id: "build", + name: "Build", + mode: "primary", + hidden: false, + request: { settings: {}, headers: {}, body: {} }, + permissions: [], + }, + ], + }) + } + + if (pathname === "/api/vcs") { + return jsonResponse({ location, data: { branch: { current: "main", default: "main" } } }) + } + + if (pathname === "/api/vcs/branch") return jsonResponse({ location, data: ["main"] }) + + if (pathname === "/api/session") { + return jsonResponse({ data: [], cursor: {} }) + } + + if (pathname === "/api/session/active") return jsonResponse({ data: {} }) + + if (pathname === "/api/config" || pathname === "/api/config/shell") return jsonResponse([]) + + if (pathname === "/api/mcp/resource") return jsonResponse({ location, data: { resources: [], templates: [] } }) + + if (pathname.startsWith("/api/experimental/media/")) return new Response(null, { status: 404 }) + + return jsonResponse({ location, data: [] }) + } + + return Object.assign(handler, { preconnect: globalThis.fetch.preconnect }) +} + +function createMemoryStorage() { + const namespaces = new Map>() + + const bucket = (name = "default") => { + const existing = namespaces.get(name) + + if (existing) return existing + const created = new Map() + namespaces.set(name, created) + + return created + } + + return (name?: string) => { + const store = bucket(name) + + return { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => { + store.set(key, value) + }, + removeItem: (key: string) => { + store.delete(key) + }, + } + } +} + +// Standalone story-server fixtures only: web persistence does not honor the storage override. +// Callers use *.standalone.stories.tsx so the in-app lab never mounts these providers. +export function DialogStoryRuntime(props: ParentProps) { + const servers = () => props.servers ?? [STORY_SERVER] + + const storage = createMemoryStorage() + + const platform: Platform = { + platform: "web", + openExternal() {}, + restart: async () => {}, + notify: async () => {}, + storage, + fetch: createStoryFetch(), + } + + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + refetchOnReconnect: false, + refetchOnWindowFocus: false, + }, + mutations: { retry: false }, + }, + }) + + return ( + + + + + + + + + + + + + {props.children} + + + + + + + + + + + + + ) +} diff --git a/packages/app/src/components-lab/lab.tsx b/packages/app/src/components-lab/lab.tsx index c299537e199b..a7df208ee43a 100644 --- a/packages/app/src/components-lab/lab.tsx +++ b/packages/app/src/components-lab/lab.tsx @@ -58,12 +58,14 @@ const themeIcon = `` +// Standalone stories mount fixture runtimes that must not access the live app's storage. // Vite needs the patterns as literals in each call. const modules = import.meta.glob([ "../**/*.stories.tsx", "../../../ui/src/**/*.stories.tsx", "../../../session-ui/src/**/*.stories.tsx", "../../../gui-plugins/src/**/*.stories.tsx", + "!../**/*.standalone.stories.tsx", ]) // Each file's text, for the order its states were written in. @@ -73,6 +75,7 @@ const sources = import.meta.glob( "../../../ui/src/**/*.stories.tsx", "../../../session-ui/src/**/*.stories.tsx", "../../../gui-plugins/src/**/*.stories.tsx", + "!../**/*.standalone.stories.tsx", ], { query: "?raw", import: "default" }, ) @@ -230,7 +233,7 @@ export default function ComponentsLab() { when={!grid()} fallback={ entry.title.startsWith("UI/"))} + entries={entries().filter((entry) => entry.title.startsWith("UI/") || isDialogEntry(entry))} scheme={untrack(scheme)} onOpen={open} /> @@ -613,21 +616,25 @@ function Grid(props: { entries: LabEntry[]; scheme: "dark" | "light"; onOpen: (f const board = canvas(element, { theme: props.scheme, label: language.t("lab.grid"), - // Neighbours share the library's folder: actions, forms, overlays and so on. + // Neighbours share the library's folder: actions, forms, overlays, then app dialogs. items: props.entries .toSorted((a, b) => folderOf(a).localeCompare(folderOf(b))) - .map((entry) => ({ - id: entry.file, - title: entry.title.split("/").at(-1), - w: 320, - h: 240, - render: (card: HTMLElement) => - createRoot((dispose) => { - insert(card, () => ) + .map((entry) => { + const size = tileSize(entry) - return dispose - }, owner ?? undefined), - })), + return { + id: entry.file, + title: entry.title.split("/").at(-1), + w: size.w, + h: size.h, + render: (card: HTMLElement) => + createRoot((dispose) => { + insert(card, () => ) + + return dispose + }, owner ?? undefined), + } + }), }) // The canvas captures the pointer to pan, so a card never sees its click: a press that ends where it began opens @@ -667,21 +674,27 @@ function Tile(props: { entry: LabEntry }) {
{/* The provider waits for the card, so a story that opens a dialog at once opens it inside. */} {(container) => (
- -
{String(error)}
} - > - {render - ? untrack(() => render(stateArgs(props.entry, 0))) - : component && } -
-
+ + +
{String(error)}
} + > + {render + ? untrack(() => render(stateArgs(props.entry, 0))) + : component && } +
+
+
)}
@@ -689,8 +702,26 @@ function Tile(props: { entry: LabEntry }) { ) } +function isDialogEntry(entry: LabEntry) { + return ( + entry.title.startsWith("App/Dialogs/") || + entry.file.endsWith("/beta-dialog.stories.tsx") || + entry.file.endsWith("/overlays/dialog/dialog.stories.tsx") + ) +} + +function tileSize(entry: LabEntry) { + if (entry.fullscreen) return { w: 1120, h: 720 } + + if (isDialogEntry(entry)) return { w: 720, h: 820 } + + return { w: 320, h: 240 } +} + function folderOf(entry: LabEntry) { - return entry.file.match(/ui\/src\/([^/]+)\//)?.[1] ?? "" + if (isDialogEntry(entry)) return `1:dialogs:${entry.title}` + + return `0:${entry.file.match(/ui\/src\/([^/]+)\//)?.[1] ?? ""}` } function typing(target: EventTarget | null) { diff --git a/packages/app/src/home/sessions/delete-dialog.stories.tsx b/packages/app/src/home/sessions/delete-dialog.stories.tsx new file mode 100644 index 000000000000..ae2718e85fb9 --- /dev/null +++ b/packages/app/src/home/sessions/delete-dialog.stories.tsx @@ -0,0 +1,67 @@ +import { Button } from "@opencode/ui/button" +import { useDialog } from "@opencode/ui/context/dialog" +import { Dialog, DialogFooter, DialogHeader, DialogTitleGroup } from "@opencode/ui/dialog" +import { onMount } from "solid-js" +import { createStore } from "solid-js/store" +import { useLanguage } from "@/runtime/i18n/language" +import { sessionTitle } from "@/session/title" + +function DeleteSessionStory(props: { sessionTitle?: string }) { + const dialog = useDialog() + + const language = useLanguage() + + const [store, setStore] = createStore({ status: "Session active" }) + + const open = () => + void dialog.show(() => ( + + + + + + + + + + )) + + onMount(open) + + return ( +
+ +

{store.status}

+
+ ) +} + +export default { + title: "App/Dialogs/Delete Session", + id: "app-dialog-delete-session", +} + +export const Default = { + render: () => , +} + +export const Untitled = { + render: () => , +} diff --git a/packages/app/src/new-session/location/dialog.standalone.stories.tsx b/packages/app/src/new-session/location/dialog.standalone.stories.tsx new file mode 100644 index 000000000000..7632c14df64c --- /dev/null +++ b/packages/app/src/new-session/location/dialog.standalone.stories.tsx @@ -0,0 +1,122 @@ +import { Button } from "@opencode/ui/button" +import { onMount } from "solid-js" +import { createStore } from "solid-js/store" +import { + DialogStoryRuntime, + STORY_DIRECTORY, + STORY_PROJECTS, + STORY_SERVER, +} from "@/components-lab/dialog-story-runtime" +import { createPromptProjectController, type PromptProject, type PromptServer } from "@/new-session/project/selector" +import { createNewSessionWorkspaceController } from "@/new-session/workspace/controller" +import { ServerConnection } from "@/runtime/server/registry" +import { useNewSessionLocationDialog } from "./dialog" + +const currentServer: PromptServer = { + key: ServerConnection.key(STORY_SERVER), + name: "MacBook-Pro", + hint: "This computer", + kind: "current", +} + +const sshServer: PromptServer = { + key: "ssh:devbox", + name: "devbox", + kind: "plugin", + label: "SSH", +} + +function LocationDialogStory(props: { multipleServers?: boolean }) { + const [store, setStore] = createStore<{ + directory: string + worktree?: string + branch?: string + }>({ + directory: STORY_DIRECTORY, + }) + + const available = (): PromptProject[] => [ + ...STORY_PROJECTS.map((project, index) => ({ + id: project.id, + name: project.name, + worktree: project.worktree, + vcs: "git", + sandboxes: project.sandboxes, + worktrees: project.sandboxes.map((directory) => ({ directory, strategy: "git" })), + used: index === 0, + server: currentServer, + time: { active: project.time.active }, + })), + ...(props.multipleServers + ? [ + { + id: "prj_remote_api", + name: "cloud-api", + worktree: "/home/dev/cloud-api", + vcs: "git", + used: true, + server: sshServer, + time: { active: Date.now() - 3_600_000 }, + }, + ] + : []), + ] + + const project = createPromptProjectController({ + controls: () => ({ + available: available(), + servers: props.multipleServers ? [currentServer, sshServer] : [currentServer], + directory: store.directory, + projectID: STORY_PROJECTS[0].id, + server: currentServer.key, + select: (directory, _server, workspace) => { + setStore({ directory, worktree: workspace }) + }, + add: () => {}, + }), + }) + + const workspace = createNewSessionWorkspaceController({ + selectedWorktree: () => store.worktree, + selectedBranch: () => store.branch, + setSelectedWorktree: (worktree) => setStore("worktree", worktree), + setSelectedBranch: (branch) => setStore("branch", branch), + }) + + const openLocation = useNewSessionLocationDialog({ + project, + workspace, + onDone: () => {}, + }) + + const open = () => openLocation() + + onMount(open) + + return ( + + ) +} + +export default { + title: "App/Dialogs/New Session Location", + id: "app-dialog-new-session-location", +} + +export const Default = { + render: () => ( + + + + ), +} + +export const MultipleServers = { + render: () => ( + + + + ), +} diff --git a/packages/app/src/providers/connect/dialog.stories.tsx b/packages/app/src/providers/connect/dialog.standalone.stories.tsx similarity index 61% rename from packages/app/src/providers/connect/dialog.stories.tsx rename to packages/app/src/providers/connect/dialog.standalone.stories.tsx index fecb69ceb1bc..d6467de964b8 100644 --- a/packages/app/src/providers/connect/dialog.stories.tsx +++ b/packages/app/src/providers/connect/dialog.standalone.stories.tsx @@ -1,29 +1,32 @@ -// @ts-nocheck import { Button } from "@opencode/ui/button" import { useDialog } from "@opencode/ui/context/dialog" -import { QueryClient, QueryClientProvider } from "@tanstack/solid-query" import { onMount } from "solid-js" +import { DialogStoryRuntime, STORY_DIRECTORY } from "@/components-lab/dialog-story-runtime" import { DialogConnectProvider, useProviderConnectController } from "./dialog" function ConnectProviderDialogStory() { const dialog = useDialog() - const open = () => dialog.show(() => ) + + const open = () => void dialog.show(() => ) onMount(open) return ( ) } -// The server describes the provider's sign-in methods. -function ProviderConnectionDialogStory(props) { +function ProviderConnectionDialogStory(props: { provider: string }) { const dialog = useDialog() + const controller = useProviderConnectController() - controller.select(props.provider) - const open = () => dialog.show(() => ) + + const open = () => { + controller.select(props.provider) + void dialog.show(() => ) + } onMount(open) @@ -34,11 +37,11 @@ function ProviderConnectionDialogStory(props) { ) } -function renderConnection(provider) { +function renderConnection(provider: string) { return () => ( - + - + ) } @@ -49,20 +52,20 @@ export default { export const Picker = { render: () => ( - + - + ), } +export const ChooseMethod = { + render: renderConnection("opencode"), +} + export const ApiKey = { render: renderConnection("openrouter"), } -export const OpenCodeZen = { - render: renderConnection("opencode"), -} - export const LoginMethods = { render: renderConnection("openai"), } diff --git a/packages/app/src/providers/models/manage.standalone.stories.tsx b/packages/app/src/providers/models/manage.standalone.stories.tsx new file mode 100644 index 000000000000..b0f1f5f59297 --- /dev/null +++ b/packages/app/src/providers/models/manage.standalone.stories.tsx @@ -0,0 +1,32 @@ +import { Button } from "@opencode/ui/button" +import { useDialog } from "@opencode/ui/context/dialog" +import { onMount } from "solid-js" +import { DialogStoryRuntime } from "@/components-lab/dialog-story-runtime" +import { DialogManageModels } from "./manage" + +function ManageModelsStory() { + const dialog = useDialog() + + const open = () => void dialog.show(() => ) + + onMount(open) + + return ( + + ) +} + +export default { + title: "App/Dialogs/Manage Models", + id: "app-dialog-manage-models", +} + +export const Default = { + render: () => ( + + + + ), +} diff --git a/packages/app/src/providers/models/select-dialog.standalone.stories.tsx b/packages/app/src/providers/models/select-dialog.standalone.stories.tsx new file mode 100644 index 000000000000..652f1cd7154e --- /dev/null +++ b/packages/app/src/providers/models/select-dialog.standalone.stories.tsx @@ -0,0 +1,180 @@ +import { Button } from "@opencode/ui/button" +import { useDialog } from "@opencode/ui/context/dialog" +import { onMount } from "solid-js" +import { createStore } from "solid-js/store" +import { DialogStoryRuntime } from "@/components-lab/dialog-story-runtime" +import type { ModelSelection } from "./selection" +import { DialogSelectModel } from "./select-dialog" + +type StoryModel = NonNullable> + +function makeModel(input: { + id: string + name: string + providerID: string + providerName: string + family: string + inputCost: number + outputCost: number + latest?: boolean + variants?: StoryModel["variants"] +}): StoryModel { + return { + id: input.id, + providerID: input.providerID, + api: { + id: input.id, + url: `https://api.${input.providerID}.example/v1`, + npm: `@opencode/ai/providers/${input.providerID}`, + }, + name: input.name, + family: input.family, + capabilities: { + temperature: true, + reasoning: true, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: true, + }, + cost: { input: input.inputCost, output: input.outputCost, cache: { read: 0, write: 0 } }, + limit: { context: 200_000, output: 64_000 }, + status: "active", + options: {}, + headers: {}, + release_date: "2026-09-01", + variants: input.variants ?? {}, + provider: { + id: input.providerID, + name: input.providerName, + source: "custom", + env: [], + options: {}, + models: {}, + }, + latest: input.latest ?? false, + } +} + +const models: StoryModel[] = [ + makeModel({ + id: "kimi-k3", + name: "Kimi K3", + providerID: "opencode-go", + providerName: "OpenCode Go", + family: "kimi", + inputCost: 1, + outputCost: 3, + variants: { fast: {}, thinking: {} }, + }), + makeModel({ + id: "big-pickle", + name: "Big Pickle", + providerID: "opencode", + providerName: "OpenCode Zen", + family: "pickle", + inputCost: 0, + outputCost: 0, + }), + makeModel({ + id: "claude-opus-5-5", + name: "Claude Opus 5.5", + providerID: "opencode", + providerName: "OpenCode Zen", + family: "claude-opus", + inputCost: 5, + outputCost: 25, + variants: { high: {}, max: {} }, + }), + makeModel({ + id: "claude-sonnet-5", + name: "Claude Sonnet 5", + providerID: "anthropic", + providerName: "Anthropic", + family: "claude-sonnet", + inputCost: 3, + outputCost: 15, + variants: { thinking: {} }, + }), +] + +const keyOf = (model: StoryModel) => `${model.provider.id}:${model.id}` + +function SelectorStory(props: { favorite?: boolean }) { + const dialog = useDialog() + + const [store, setStore] = createStore<{ current: string; favorite: string[]; variant?: string }>({ + current: "opencode:claude-opus-5-5", + favorite: props.favorite ? ["anthropic:claude-sonnet-5"] : [], + }) + + const find = (key: string) => models.find((model) => keyOf(model) === key) + + const selection = { + ready: Object.assign(() => true, { promise: undefined }), + key: () => { + const item = find(store.current) + + return item ? { providerID: item.provider.id, modelID: item.id } : undefined + }, + current: () => find(store.current), + recent: () => (props.favorite ? [find(store.current)].filter((model) => !!model) : []), + favorite: { + list: () => store.favorite.flatMap((key) => find(key) ?? []), + toggle: (key: { providerID: string; modelID: string }) => { + const id = `${key.providerID}:${key.modelID}` + + setStore("favorite", (ids) => (ids.includes(id) ? ids.filter((entry) => entry !== id) : [id, ...ids])) + }, + }, + list: () => models, + cycle() {}, + set: (key: { providerID: string; modelID: string } | undefined) => { + if (key) setStore("current", `${key.providerID}:${key.modelID}`) + }, + visible: () => true, + setVisibility() {}, + variant: { + configured: () => undefined, + selected: () => store.variant, + current: () => store.variant, + list: () => Object.keys(find(store.current)?.variants ?? {}), + set: (variant: string | undefined) => setStore("variant", variant), + cycle() {}, + }, + } satisfies ModelSelection + + const open = () => void dialog.show(() => ) + + onMount(open) + + return ( +
+ +
+ ) +} + +export default { + title: "App/Dialogs/Model Selector", + id: "app-dialog-model-selector", +} + +export const Dialog = { + render: () => ( + + + + ), +} + +export const Favorites = { + render: () => ( + + + + ), +} diff --git a/packages/app/src/providers/models/select-dialog.stories.tsx b/packages/app/src/providers/models/select-dialog.stories.tsx deleted file mode 100644 index 65085a552c10..000000000000 --- a/packages/app/src/providers/models/select-dialog.stories.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import { Button } from "@opencode/ui/button" -import { useDialog } from "@opencode/ui/context/dialog" -import { onMount } from "solid-js" -import { createStore } from "solid-js/store" -import type { ModelSelection } from "./selection" -import { DialogSelectModel } from "./select-dialog" - -const chatgpt = { - id: "gpt-5.6-sol", - providerID: "openai", - api: { id: "gpt-5.6-sol", url: "https://api.openai.com/v1", npm: "@opencode/ai/providers/openai" }, - name: "GPT-5.6 Sol", - family: "gpt", - capabilities: { - temperature: true, - reasoning: true, - attachment: true, - toolcall: true, - input: { text: true, audio: false, image: true, video: false, pdf: false }, - output: { text: true, audio: false, image: false, video: false, pdf: false }, - interleaved: true, - }, - cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, - limit: { context: 400_000, output: 64_000 }, - status: "active", - options: {}, - headers: {}, - release_date: "2026-09-01", - variants: {}, - provider: { - id: "openai", - name: "OpenAI", - source: "custom", - env: [], - options: {}, - models: {}, - }, - latest: true, -} satisfies NonNullable> - -const models = [ - chatgpt, - { - ...chatgpt, - id: "gpt-5.6-terra", - api: { ...chatgpt.api, id: "gpt-5.6-terra" }, - name: "GPT-5.6 Terra", - latest: false, - }, -] - -function SelectorStory(props: { favorite?: boolean }) { - const dialog = useDialog() - - const [store, setStore] = createStore<{ current: string; favorite: string[]; variant?: string }>({ - current: models[0].id, - favorite: props.favorite ? [models[1].id] : [], - }) - - const find = (id: string) => models.find((model) => model.id === id) - - const selection = { - ready: Object.assign(() => true, { promise: undefined }), - key: () => ({ providerID: "openai", modelID: store.current }), - current: () => find(store.current), - recent: () => [find(store.current)].filter((model) => !!model), - favorite: { - list: () => store.favorite.flatMap((id) => find(id) ?? []), - toggle: (key: { modelID: string }) => - setStore("favorite", (ids) => - ids.includes(key.modelID) ? ids.filter((id) => id !== key.modelID) : [key.modelID, ...ids], - ), - }, - list: () => models, - cycle() {}, - set: (key: { modelID: string } | undefined) => key && setStore("current", key.modelID), - visible: () => true, - setVisibility() {}, - variant: { - configured: () => undefined, - selected: () => store.variant, - current: () => store.variant, - list: () => [], - set: (variant: string | undefined) => setStore("variant", variant), - cycle() {}, - }, - } satisfies ModelSelection - - const open = () => dialog.show(() => ) - - onMount(open) - - return ( -
- -
- ) -} - -export default { - title: "App/Dialogs/Model Selector", - id: "app-dialog-model-selector", -} - -export const Dialog = { render: () => } - -export const Favorites = { render: () => } diff --git a/packages/app/src/providers/models/unpaid.stories.tsx b/packages/app/src/providers/models/unpaid.standalone.stories.tsx similarity index 87% rename from packages/app/src/providers/models/unpaid.stories.tsx rename to packages/app/src/providers/models/unpaid.standalone.stories.tsx index fe51f9689685..e37304c26db0 100644 --- a/packages/app/src/providers/models/unpaid.stories.tsx +++ b/packages/app/src/providers/models/unpaid.standalone.stories.tsx @@ -3,6 +3,7 @@ import { Button } from "@opencode/ui/button" import { useDialog } from "@opencode/ui/context/dialog" import { createSignal, onMount } from "solid-js" import { DialogSelectModelUnpaid } from "./unpaid" +import { DialogStoryRuntime } from "@/components-lab/dialog-story-runtime" const names = [ "MiMo V2.5 Free", @@ -57,5 +58,9 @@ export default { } export const WithoutProviders = { - render: (args) => , + render: (args) => ( + + + + ), } diff --git a/packages/app/src/servers/connect/dialog.standalone.stories.tsx b/packages/app/src/servers/connect/dialog.standalone.stories.tsx new file mode 100644 index 000000000000..5a77ec7a6c3a --- /dev/null +++ b/packages/app/src/servers/connect/dialog.standalone.stories.tsx @@ -0,0 +1,55 @@ +import { Button } from "@opencode/ui/button" +import { useDialog } from "@opencode/ui/context/dialog" +import { onMount } from "solid-js" +import { DialogStoryRuntime, STORY_LOCKED_SERVER, STORY_SERVER } from "@/components-lab/dialog-story-runtime" +import type { ServerConnection } from "@/runtime/server/registry" +import { DialogServer } from "./dialog" + +const editableServer: ServerConnection.Http = { + type: "http", + displayName: "Build cluster", + http: { url: STORY_SERVER.http.url }, +} + +function ServerDialogStory(props: { mode: "add" | "edit"; server?: ServerConnection.Http }) { + const dialog = useDialog() + + const open = () => void dialog.show(() => ) + + onMount(open) + + return ( + + ) +} + +export default { + title: "App/Dialogs/Add Server", + id: "app-dialog-add-server", +} + +export const PairingLink = { + render: () => ( + + + + ), +} + +export const Password = { + render: () => ( + + + + ), +} + +export const SignedOut = { + render: () => ( + + + + ), +} diff --git a/packages/app/src/settings/shell.standalone.stories.tsx b/packages/app/src/settings/shell.standalone.stories.tsx new file mode 100644 index 000000000000..dfff34049533 --- /dev/null +++ b/packages/app/src/settings/shell.standalone.stories.tsx @@ -0,0 +1,47 @@ +import { createMemoryHistory, MemoryRouter, Route } from "@solidjs/router" +import { DialogStoryRuntime } from "@/components-lab/dialog-story-runtime" +import { SettingsScreen } from "@/settings/shell" +import { SettingsSurfaceProvider, type SettingsRootTab } from "@/settings/surface" +import { LayoutProvider } from "@/shell/state/layout" + +function SettingsStory(props: { tab: SettingsRootTab }) { + const history = createMemoryHistory() + history.set({ value: `/settings?tab=${props.tab}`, replace: true, scroll: false }) + + return ( + +
+ ( + + {rootProps.children} + + )} + > + + +
+
+ ) +} + +export default { + title: "App/Dialogs/Settings", + id: "app-dialog-settings", + parameters: { + layout: "fullscreen", + }, +} + +export const Preferences = { + render: () => , +} + +export const Providers = { + render: () => , +} + +export const Models = { + render: () => , +} diff --git a/packages/app/src/shell/commands/dialog.standalone.stories.tsx b/packages/app/src/shell/commands/dialog.standalone.stories.tsx new file mode 100644 index 000000000000..138e54a54754 --- /dev/null +++ b/packages/app/src/shell/commands/dialog.standalone.stories.tsx @@ -0,0 +1,226 @@ +import { Button } from "@opencode/ui/button" +import { useDialog } from "@opencode/ui/context/dialog" +import { onMount } from "solid-js" +import { + DialogStoryRuntime, + STORY_DIRECTORY, + STORY_PROJECTS, + STORY_SERVER, +} from "@/components-lab/dialog-story-runtime" +import { useLanguage } from "@/runtime/i18n/language" +import { ServerConnection } from "@/runtime/server/registry" +import { useTabs } from "@/shell/tabs/tabs" +import { CommandPaletteView, matchesCommandPaletteEntry } from "./dialog" +import { createCommandPaletteFileEntry, type CommandPaletteEntry } from "./palette" + +function CommandPaletteStory(props: { query?: string }) { + const dialog = useDialog() + + const language = useLanguage() + + const tabs = useTabs() + + const serverKey = ServerConnection.key(STORY_SERVER) + + const project = STORY_PROJECTS[0] + + const preferredCommands = (): CommandPaletteEntry[] => { + const category = language.t("palette.group.commands") + + return [ + { id: "command:session.new", type: "command", title: language.t("command.session.new"), category }, + { + id: "command:session.previous", + type: "command", + title: language.t("command.session.previous"), + keybind: "alt+arrowup", + category, + }, + { + id: "command:session.next", + type: "command", + title: language.t("command.session.next"), + keybind: "alt+arrowdown", + category, + }, + { + id: "command:terminal.toggle", + type: "command", + title: "Toggle terminal", + keybind: "ctrl+`", + category, + }, + { + id: "command:review.toggle", + type: "command", + title: language.t("command.review.toggle"), + keybind: "ctrl+shift+r", + category, + }, + ] + } + + const allCommands = (): CommandPaletteEntry[] => { + const category = language.t("palette.group.commands") + + return [ + ...preferredCommands(), + { + id: "command:model.choose", + type: "command", + title: language.t("command.model.choose"), + description: language.t("command.model.choose.description"), + keybind: "ctrl+'", + category, + }, + { + id: "command:model.variant.cycle", + type: "command", + title: language.t("command.model.variant.cycle"), + description: language.t("command.model.variant.cycle.description"), + keybind: "ctrl+shift+d", + category, + }, + { + id: "command:provider.connect", + type: "command", + title: language.t("command.provider.connect"), + description: language.t("dialog.provider.description"), + keywords: "model provider integration", + category, + }, + ] + } + + const sessions = (): CommandPaletteEntry[] => { + const category = language.t("palette.group.recentSessions") + + return [ + { + id: "session:ses_story_1", + type: "session", + title: "Refactor the dialog primitives", + description: project.name, + category, + server: serverKey, + directory: STORY_DIRECTORY, + sessionID: "ses_story_1", + project, + updated: Date.now() - 18 * 60_000, + }, + { + id: "session:ses_story_2", + type: "session", + title: "Fix scrollbar in menus", + description: project.name, + category, + server: serverKey, + directory: STORY_DIRECTORY, + sessionID: "ses_story_2", + project, + updated: Date.now() - 2 * 3_600_000, + }, + ] + } + + const settings = (): CommandPaletteEntry[] => { + const category = language.t("palette.group.settings") + + return [ + { + id: "setting:models", + type: "setting", + title: language.t("settings.models.title"), + keywords: "model selector default", + category, + }, + { + id: "setting:providers", + type: "setting", + title: language.t("settings.providers.title"), + description: language.t("settings.providers.description"), + keywords: "model provider api key", + category, + }, + { + id: "setting:mcps", + type: "setting", + title: "MCPs", + description: language.t("settings.tab.extensions"), + keywords: "model context protocol", + category, + }, + ] + } + + const files = () => [ + createCommandPaletteFileEntry( + "packages/app/src/providers/models/select-dialog.tsx", + language.t("palette.group.files"), + ), + createCommandPaletteFileEntry("packages/ui/src/overlays/dialog/dialog.tsx", language.t("palette.group.files")), + ] + + const items = (query: string) => { + if (!query) return [...preferredCommands(), ...sessions()] + + return [ + ...allCommands().filter((entry) => matchesCommandPaletteEntry(entry, query)), + ...sessions().filter((entry) => matchesCommandPaletteEntry(entry, query)), + ...settings().filter((entry) => matchesCommandPaletteEntry(entry, query)), + ] + } + + const open = () => { + tabs.addSessionTab({ server: serverKey, sessionId: "ses_story_1" }) + void dialog.show(() => ( + (query ? files().filter((entry) => matchesCommandPaletteEntry(entry, query)) : [])]} + highlight={() => {}} + select={() => dialog.close()} + close={() => dialog.close()} + /> + )) + + if (!props.query) return + const initial = props.query + queueMicrotask(() => { + const input = document.querySelector(".command-palette input[role='combobox']") + + if (!input) return + input.value = initial + input.dispatchEvent(new InputEvent("input", { bubbles: true })) + }) + } + + onMount(open) + + return ( + + ) +} + +export default { + title: "App/Dialogs/Command Palette", + id: "app-dialog-command-palette", +} + +export const Default = { + render: () => ( + + + + ), +} + +export const Searching = { + render: () => ( + + + + ), +} diff --git a/packages/app/src/usage/dialog.stories.tsx b/packages/app/src/usage/dialog.standalone.stories.tsx similarity index 71% rename from packages/app/src/usage/dialog.stories.tsx rename to packages/app/src/usage/dialog.standalone.stories.tsx index f09f75ad02f4..ece8f56ebc88 100644 --- a/packages/app/src/usage/dialog.stories.tsx +++ b/packages/app/src/usage/dialog.standalone.stories.tsx @@ -2,13 +2,16 @@ import type { UsageLimitsBudget, UsageLimitsInfo } from "@opencode/client/promis import { Button } from "@opencode/ui/button" import { useDialog } from "@opencode/ui/context/dialog" import { onMount } from "solid-js" -import { UsageDialogContent } from "./dialog" +import { DialogStoryRuntime, STORY_DIRECTORY } from "@/components-lab/dialog-story-runtime" +import { DialogUsage, UsageDialogContent } from "./dialog" import type { CurrentModel } from "./limits" const resetsAt = Date.UTC(2026, 10, 1) + const hour = 3_600_000 const opus = { providerID: "opencode", modelID: "claude-opus-5", name: "Claude Opus 5" } + const sonnet = { providerID: "opencode", modelID: "claude-sonnet-5", name: "Claude Sonnet 5" } const budget = (input: Partial & Pick): UsageLimitsBudget => ({ @@ -32,21 +35,20 @@ const pool = (spent: number) => budget({ id: "pool", scope: "combined", shared: const info = (budgets: UsageLimitsBudget[], go = false): UsageLimitsInfo => ({ workspace: { id: "wrk_1", name: "Acme", usageURL: "https://opencode.ai/wrk_1/activity/overview" }, - ...(go + go: go ? { - go: { - rolling: { percent: 42, limited: false, resetsAt: Date.now() + 3 * hour }, - weekly: { percent: 18, limited: false, resetsAt: Date.now() + 100 * hour }, - monthly: { percent: 7, limited: false, resetsAt: Date.now() + 500 * hour }, - }, + rolling: { percent: 42, limited: false, resetsAt: Date.now() + 3 * hour }, + weekly: { percent: 18, limited: false, resetsAt: Date.now() + 100 * hour }, + monthly: { percent: 7, limited: false, resetsAt: Date.now() + 500 * hour }, } - : {}), + : undefined, budgets, }) function UsageStory(props: { info: UsageLimitsInfo; model?: CurrentModel; credits?: boolean }) { const dialog = useDialog() - const open = () => dialog.show(() => {}} />) + + const open = () => void dialog.show(() => {}} />) onMount(open) @@ -59,6 +61,22 @@ function UsageStory(props: { info: UsageLimitsInfo; model?: CurrentModel; credit ) } +function LiveUsageStory() { + const dialog = useDialog() + + const open = () => void dialog.show(() => ) + + onMount(open) + + return ( +
+ +
+ ) +} + export default { title: "App/Dialogs/Usage", id: "app-dialog-usage", @@ -66,6 +84,14 @@ export default { export const Default = { render: () => } +export const SignedOut = { + render: () => ( + + + + ), +} + export const PerModel = { render: () => ( + void dialog.show(() => ( + { + const text = Array.isArray(result) ? result.join(", ") : (result ?? "Cancelled") + setStore("selected", text) + }} + /> + )) + + onMount(open) + + return ( +
+ +

{store.selected}

+
+ ) +} + +export default { + title: "App/Dialogs/Directory Picker", + id: "app-dialog-directory-picker", +} + +export const OpenProject = { + render: () => ( + + + + ), +} + +export const SelectFile = { + render: () => ( + + + + ), +} diff --git a/packages/gui-plugins/src/pairing/dialog.stories.tsx b/packages/gui-plugins/src/pairing/dialog.stories.tsx new file mode 100644 index 000000000000..e763b94adb8f --- /dev/null +++ b/packages/gui-plugins/src/pairing/dialog.stories.tsx @@ -0,0 +1,41 @@ +import { onMount } from "solid-js" +import { PairingHost, pairingRemote } from "../../../stories/mocks/gui-plugins/pairing" +import PairingPage from "./page" + +function PairingDialogStory(props: { mode: "ready" | "pending" | "failed" }) { + const mock = pairingRemote({ enabled: props.mode === "ready" }) + + const client = { + ...mock.client, + setRemote: (...args: Parameters) => { + if (props.mode === "pending") return new Promise(() => {}) + + if (props.mode === "failed") return Promise.reject(new Error("Unable to enable remote access")) + + return mock.client.setRemote(...args) + }, + } + + let root!: HTMLDivElement + + onMount(() => root.getElementsByTagName("button")[0].click()) + + return ( + +
+ mock.server} pairing={() => ({ status: "active", value: client, generation: 1 })} /> +
+
+ ) +} + +export default { + title: "App/Dialogs/Pair Device", + id: "app-dialog-pair-device", +} + +export const Ready = { render: () => } + +export const SettingUp = { render: () => } + +export const Failed = { render: () => } diff --git a/packages/gui-plugins/src/ssh/dialog.stories.tsx b/packages/gui-plugins/src/ssh/dialog.stories.tsx index 5834231f237e..27a48d83db47 100644 --- a/packages/gui-plugins/src/ssh/dialog.stories.tsx +++ b/packages/gui-plugins/src/ssh/dialog.stories.tsx @@ -302,18 +302,6 @@ function Open(props: { initial?: string }) { export default { title: "App/Dialogs/SSH", id: "app-dialog-ssh" } -export const AuthenticationRequired = { render: () => } - -export const SettingsReconnect = { render: () => } - -export const IncompatibleHost = { render: () => } - -export const IncompatibleSession = { render: () => } - -export const InactiveSession = { render: () => } - -export const KeyReconnect = { render: () => } - export const Host = { render: () => } export const Connecting = { render: () => } @@ -325,3 +313,15 @@ export const SlowPassword = { render: () => } export const Failure = { render: () => } + +export const IncompatibleHost = { render: () => } + +export const AuthenticationRequired = { render: () => } + +export const SettingsReconnect = { render: () => } + +export const IncompatibleSession = { render: () => } + +export const InactiveSession = { render: () => } + +export const KeyReconnect = { render: () => } diff --git a/packages/stories/mocks/app/context/command.ts b/packages/stories/mocks/app/context/command.ts index 154f26298ec8..83020fb80cae 100644 --- a/packages/stories/mocks/app/context/command.ts +++ b/packages/stories/mocks/app/context/command.ts @@ -1,3 +1,25 @@ +import type { ParentProps } from "solid-js" +import { + formatKeybindParts, + matchKeybind, + parseKeybind, + type CommandOption, +} from "../../../../app/src/shell/commands/command" + +export { + COMMAND_SEARCH_ID, + DEFAULT_PALETTE_KEYBIND, + commandPaletteOptions, + formatKeybind, + formatKeybindParts, + keyFromKeyboardEvent, + matchKeybind, + parseKeybind, + type CommandOption, + type CommandSection, + type CommandSource, +} from "../../../../app/src/shell/commands/command" + const keybinds: Record = { "file.attach": "mod+u", "prompt.mode.shell": "mod+shift+x", @@ -7,62 +29,31 @@ const keybinds: Record = { "model.choose": "mod+m", "model.variant.cycle": "mod+shift+m", "session.background": "ctrl+b", + "project.open": "mod+o", } -export const DEFAULT_PALETTE_KEYBIND = "mod+k,mod+shift+p" - -export function commandPaletteOptions() { - return [] -} - -export function parseKeybind(config: string) { - if (!config || config === "none") return [] - return config.split(",").map((combo) => { - const parts = combo.trim().toLowerCase().split("+") - return { - key: - parts.find( - (part) => !["ctrl", "control", "meta", "cmd", "command", "mod", "alt", "option", "shift"].includes(part), - ) ?? "", - ctrl: parts.includes("ctrl") || parts.includes("control") || parts.includes("mod"), - meta: parts.includes("meta") || parts.includes("cmd") || parts.includes("command"), - shift: parts.includes("shift"), - alt: parts.includes("alt") || parts.includes("option"), - } - }) -} - -export function matchKeybind(keybinds: ReturnType, event: KeyboardEvent) { - return keybinds.some( - (keybind) => - keybind.key === event.key.toLowerCase() && - keybind.ctrl === event.ctrlKey && - keybind.meta === event.metaKey && - keybind.shift === event.shiftKey && - keybind.alt === event.altKey, - ) -} - -export function formatKeybind(config: string) { - return config === "none" ? "" : config -} - -export function formatKeybindParts(config: string) { - return !config || config === "none" ? [] : config.split(",")[0].split("+") +export function CommandProvider(props: ParentProps) { + return props.children } export function useCommand() { + const options: CommandOption[] = [] + return { - options: [], + options, + catalog: options, register() { return () => undefined }, trigger() {}, + matches(id: string, event: KeyboardEvent) { + return matchKeybind(parseKeybind(keybinds[id] ?? "none"), event) + }, keybind(id: string) { return keybinds[id] }, keybindParts(id: string) { - return keybinds[id]?.split("+") ?? [] + return formatKeybindParts(keybinds[id] ?? "none") }, } } diff --git a/packages/stories/mocks/app/context/layout.ts b/packages/stories/mocks/app/context/layout.ts index cd082bb0d865..c163783a27b9 100644 --- a/packages/stories/mocks/app/context/layout.ts +++ b/packages/stories/mocks/app/context/layout.ts @@ -1,11 +1,43 @@ -import { createSignal } from "solid-js" +import { useLocation } from "../../solid-router" +import { createSignal, type ParentProps } from "solid-js" +import type { ServerConnection } from "@/runtime/server/registry" import type { Project } from "@/runtime/server/types" -export type LocalProject = Partial & { worktree: string; expanded: boolean } +export type LocalProject = Partial & { worktree: string; expanded?: boolean } + +export type LayoutRoute = + | { type: "home" } + | { type: "settings" } + | { type: "connect" } + | { type: "components" } + | { type: "draft"; draftID: string } + | { type: "session"; sessionId: string; server: ServerConnection.Key } + +export function currentRoute(pathname: string, search: string): LayoutRoute { + const parts = pathname.split("/").filter(Boolean) + + if (parts.length === 0) return { type: "home" } + + if (parts[0] === "settings") return { type: "settings" } + + if (parts[0] === "connect") return { type: "connect" } + + if (parts[0] === "components") return { type: "components" } + + if (parts[0] === "new-session") { + const draftID = new URLSearchParams(search).get("draftId") + + return draftID ? { type: "draft", draftID } : { type: "home" } + } + + return { type: "home" } +} export function getProjectAvatarVariant(key?: string) { if (key === "mint") return "cyan" as const + if (key === "lime") return "green" as const + if ( key === "orange" || key === "yellow" || @@ -18,15 +50,20 @@ export function getProjectAvatarVariant(key?: string) { key === "gray" ) return key + return "gray" as const } const [all, setAll] = createSignal([]) + const [active, setActive] = createSignal(undefined) + const [sideOpen, setSideOpen] = createSignal(false) export function useCurrentRoute() { - return () => ({ type: "home" as const }) + const location = useLocation() + + return () => currentRoute(location.pathname, location.search) } const tabs = { @@ -39,6 +76,7 @@ const tabs = { if (!all().includes(tab)) { tabs.open(tab) } + setActive(tab) }, } @@ -52,8 +90,18 @@ const view = { }, } +export function LayoutProvider(props: ParentProps) { + return props.children +} + export function useLayout() { + const route = useCurrentRoute() + return { + route, + home: { + selection: () => ({ server: undefined as string | undefined, directory: undefined as string | undefined }), + }, tabs: () => tabs, view: () => view, fileTree: { diff --git a/packages/stories/mocks/app/context/local.ts b/packages/stories/mocks/app/context/local.ts index d1f02e5bb321..b7fea8434afa 100644 --- a/packages/stories/mocks/app/context/local.ts +++ b/packages/stories/mocks/app/context/local.ts @@ -1,41 +1,155 @@ -import { createSignal } from "solid-js" +import { createComponent, createContext, createSignal, useContext, type ParentProps } from "solid-js" +import { createStore } from "solid-js/store" +import { useModels } from "@/providers/models/models" +import { useWorkspaceLocation } from "@/workspaces/location" +import type { ModelKey, ModelSelection } from "../../../../app/src/providers/models/selection" -const model = { +export type { ModelKey, ModelSelection } from "../../../../app/src/providers/models/selection" + +const defaultModel = { id: "claude-3-7-sonnet", + providerID: "anthropic", + api: { id: "claude-3-7-sonnet", url: "https://api.anthropic.com", npm: "@ai-sdk/anthropic" }, name: "Claude 3.7 Sonnet", - provider: { id: "anthropic" }, + family: "claude-sonnet", + capabilities: { + temperature: true, + reasoning: true, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: true }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: true, + }, + cost: { input: 3, output: 15, cache: { read: 0, write: 0 } }, + limit: { context: 200_000, output: 64_000 }, + status: "active" as const, + options: {}, + headers: {}, + release_date: "2026-09-01", variants: { fast: {}, thinking: {} }, + provider: { + id: "anthropic", + name: "Anthropic", + source: "custom" as const, + env: [], + options: {}, + models: {}, + }, + latest: true, } const agents = [{ name: "build" }, { name: "review" }, { name: "plan" }] const [agent, setAgent] = createSignal(agents[0].name) + const [variant, setVariant] = createSignal(undefined) -export function useLocal() { +const fallbackModel: ModelSelection = { + ready: Object.assign(() => true, { promise: undefined }), + key: () => ({ providerID: defaultModel.provider.id, modelID: defaultModel.id }), + current: () => defaultModel, + recent: () => [defaultModel], + favorite: { list: () => [], toggle() {} }, + list: () => [defaultModel], + cycle() {}, + set() {}, + visible: () => true, + setVisibility() {}, + variant: { + configured: () => undefined, + selected: variant, + current: variant, + list: () => Object.keys(defaultModel.variants), + set: setVariant, + cycle() {}, + }, +} + +function makeDefaultLocal() { return { slug: () => "c3Rvcnk=", agent: { list: () => agents, + visible: () => true, 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) }, + move() {}, }, + model: fallbackModel, + } +} + +type LocalValue = ReturnType + +const LocalContext = createContext() + +export function LocalProvider(props: ParentProps) { + const models = useModels() + + const location = useWorkspaceLocation() + + const [selected, setSelected] = createStore<{ key?: ModelKey; variant?: string }>({}) + + const current = () => { + if (selected.key) return models.find(selected.key) + + return models.list()[0] + } + + const value: LocalValue = { + ...makeDefaultLocal(), + slug: () => btoa(location().directory), model: { - current: () => model, + ready: models.ready, + key: () => { + const item = current() + + return item ? { providerID: item.provider.id, modelID: item.id } : undefined + }, + current: () => current(), + recent: () => models.recent.list().flatMap((item) => models.find(item) ?? []), + favorite: { + list: () => models.favorite.list().flatMap((item) => models.find(item) ?? []), + toggle: models.favorite.toggle, + }, + list: () => models.list(), + cycle() {}, + set(next?: ModelKey) { + setSelected("key", next) + }, + visible: models.visible, + setVisibility: models.setVisibility, variant: { - list: () => Object.keys(model.variants), - current: () => variant(), + configured: () => undefined, + selected: () => selected.variant, + current: () => selected.variant, + list: () => Object.keys(current()?.variants ?? {}), set(next?: string) { - setVariant(next) + setSelected("variant", next) }, + cycle() {}, }, }, } + + return createComponent(LocalContext.Provider, { + value, + get children() { + return props.children + }, + }) +} + +export function useLocal() { + return useContext(LocalContext) ?? makeDefaultLocal() } diff --git a/packages/stories/mocks/app/context/location.ts b/packages/stories/mocks/app/context/location.ts index f01ecaac9f08..0f55f35d86ea 100644 --- a/packages/stories/mocks/app/context/location.ts +++ b/packages/stories/mocks/app/context/location.ts @@ -1,18 +1,44 @@ -const location = { - directory: "/tmp/story", - ref: { directory: "/tmp/story" }, - current: undefined, - error: undefined, +import { createComponent, createContext, useContext, type ParentProps } from "solid-js" + +type WorkspaceLocation = { + directory: string + ref: { directory: string } + current: undefined + error: undefined event: { - on: () => () => undefined, - listen: () => () => undefined, - }, + on: () => () => undefined + listen: () => () => undefined + } } +function makeLocation(directory: string): WorkspaceLocation { + return { + directory, + ref: { directory }, + current: undefined, + error: undefined, + event: { + on: () => () => undefined, + listen: () => () => undefined, + }, + } +} + +const defaultLocation = makeLocation("/tmp/story") + +const LocationContext = createContext<() => WorkspaceLocation>() + export function useWorkspaceLocation() { - return () => location + return useContext(LocationContext) ?? (() => defaultLocation) } -export function LocationProvider(props: { children?: unknown }) { - return props.children +export function LocationProvider(props: ParentProps<{ directory?: string }>) { + const accessor = () => (props.directory ? makeLocation(props.directory) : defaultLocation) + + return createComponent(LocationContext.Provider, { + value: accessor, + get children() { + return props.children + }, + }) } diff --git a/packages/stories/mocks/app/context/server-current.ts b/packages/stories/mocks/app/context/server-current.ts deleted file mode 100644 index fdeada146eef..000000000000 --- a/packages/stories/mocks/app/context/server-current.ts +++ /dev/null @@ -1,34 +0,0 @@ -// Stories render without a connected server: no location has synced integrations or usage limits yet. -const data = { - session: { - form: { - reply: async () => {}, - cancel: async () => {}, - }, - }, - location: { - default: () => ({ directory: "/tmp/story" }), - syncInfo: async () => {}, - integration: { - list: () => undefined, - sync: async () => {}, - }, - usage: { - limits: () => undefined, - sync: async () => {}, - invalidate: () => {}, - }, - }, -} - -export function useServer() { - return { ctx: { data } } -} - -export function useData() { - return data -} - -export function ServerProvider(props: { children?: unknown }) { - return props.children -} diff --git a/packages/stories/mocks/app/context/server-current.tsx b/packages/stories/mocks/app/context/server-current.tsx new file mode 100644 index 000000000000..1b319964e1ab --- /dev/null +++ b/packages/stories/mocks/app/context/server-current.tsx @@ -0,0 +1,103 @@ +import { createContext, Show, useContext, type ParentProps } from "solid-js" +import { ServerConnection } from "@/runtime/server/registry" +import { useGlobal, type ServerCtx } from "@/runtime/server/runtime" + +// Stories render without a connected server by default: no location has synced integrations or usage limits yet. +const fallbackData = { + session: { + get: () => undefined, + list: () => [], + form: { + reply: async () => {}, + cancel: async () => {}, + }, + }, + location: { + default: () => ({ directory: "/tmp/story" }), + syncInfo: async () => {}, + agent: { + list: () => undefined, + sync: async () => {}, + invalidate: () => {}, + }, + provider: { + list: () => undefined, + sync: async () => {}, + invalidate: () => {}, + }, + model: { + list: () => undefined, + sync: async () => {}, + invalidate: () => {}, + }, + integration: { + list: () => undefined, + sync: async () => {}, + invalidate: () => {}, + }, + mcp: { + server: { + list: () => undefined, + sync: async () => {}, + }, + }, + usage: { + limits: () => undefined, + sync: async () => {}, + invalidate: () => {}, + }, + }, +} + +type ActiveServerValue = { + conn: ServerConnection.Any + key: ServerConnection.Key + isLocal: boolean + ctx: ServerCtx + readonly health: ReturnType["servers"]["health"][ServerConnection.Key] +} + +const ServerContext = createContext() + +export function useServer() { + const active = useContext(ServerContext) + + if (active) return active + + return { ctx: { data: fallbackData } } +} + +export function useData() { + return useServer().ctx.data +} + +export function ServerProvider(props: ParentProps<{ conn?: ServerConnection.Any }>) { + if (!props.conn) return props.children + const conn = props.conn + + const global = useGlobal() + + const key = ServerConnection.key(conn) + + const ctx = () => global.serverCtx(key) + + return ( + + {(resolved) => ( + + {props.children} + + )} + + ) +} diff --git a/packages/stories/mocks/app/context/server-sdk.ts b/packages/stories/mocks/app/context/server-sdk.ts index aa4a279aa8b0..817bd4a8e41e 100644 --- a/packages/stories/mocks/app/context/server-sdk.ts +++ b/packages/stories/mocks/app/context/server-sdk.ts @@ -1,3 +1,6 @@ +import { useServer } from "@/runtime/server/current" +import { ServerScope } from "@/runtime/server/scope" + const providers = [ "opencode-go", "opencode", @@ -30,6 +33,7 @@ const client = { }), callback: async (input: { method?: number }) => { if (input.method === 0) return new Promise(() => {}) + return { data: undefined } }, }, @@ -51,12 +55,31 @@ const api = { }, } +const noopStream = { + on: () => () => undefined, + listen: () => () => undefined, +} + export function useServerSDK() { + const server = useServer() + + if ("sdk" in server.ctx) return server.ctx.sdk + return { + server: { type: "http" as const, http: { url: "http://storybook.local" } }, scope: ServerScope.local, url: "http://storybook.local", + http: { url: "http://storybook.local" }, api, client, + connection: { + status: () => "disconnected" as const, + attempt: () => 0, + error: () => undefined, + }, + event: { + ...noopStream, + location: () => noopStream, + }, } } -import { ServerScope } from "@/runtime/server/scope" diff --git a/packages/stories/mocks/app/hooks/use-providers.ts b/packages/stories/mocks/app/hooks/use-providers.ts index 99f734834048..b11e9190d317 100644 --- a/packages/stories/mocks/app/hooks/use-providers.ts +++ b/packages/stories/mocks/app/hooks/use-providers.ts @@ -1,5 +1,12 @@ +import type { Accessor } from "solid-js" +import { useServer } from "@/runtime/server/current" +import { useProviders } from "../../../../app/src/providers/catalog/providers" + +export { popularProviders, syncWhileBooting } from "../../../../app/src/providers/catalog/providers" + +export { useStoryProviders as useProviders } + const model_id = "claude-3-7-sonnet" -export { popularProviders } from "@/providers/catalog/order" const provider = { id: "anthropic", @@ -35,11 +42,17 @@ const catalog = [ { id: "alibaba-coding-plan", name: "Alibaba Coding Plan", models: {} }, ] -export function useProviders() { +function useStoryProviders(directory: Accessor = () => undefined) { + const server = useServer() + + if ("sdk" in server.ctx) return useProviders(directory) + return { + ready: () => true, all: () => new Map(catalog.map((item) => [item.id, item])), default: () => ({ anthropic: model_id }), connected: () => [provider], + anyConnection: () => true, paid: () => [provider], popular: () => popular, } diff --git a/packages/stories/mocks/solid-router.tsx b/packages/stories/mocks/solid-router.tsx index d7d4d61b24ff..f0a23570e700 100644 --- a/packages/stories/mocks/solid-router.tsx +++ b/packages/stories/mocks/solid-router.tsx @@ -1,4 +1,57 @@ -import type { ParentProps } from "solid-js" +import { createContext, createMemo, onCleanup, useContext, type Component, type JSX, type ParentProps } from "solid-js" +import { createStore } from "solid-js/store" + +type LocationState = { + pathname: string + search: string + hash: string + state?: unknown +} + +export type MemoryHistory = { + get: () => string + set: (change: { value: string; replace?: boolean; scroll?: boolean; state?: unknown }) => void + listen: (listener: (value: string) => void) => () => void + state: () => unknown +} + +export function createMemoryHistory(): MemoryHistory { + let current = "/" + + let currentState: unknown + + const listeners = new Set<(value: string) => void>() + + return { + get: () => current, + state: () => currentState, + set(change) { + current = change.value + currentState = change.state + listeners.forEach((listener) => listener(current)) + }, + listen(listener) { + listeners.add(listener) + + return () => { + listeners.delete(listener) + } + }, + } +} + +type RouterContextValue = { + location: LocationState + navigate: (to: string, options?: { replace?: boolean; state?: unknown }) => void +} + +const RouterContext = createContext() + +const defaultLocation: LocationState = { + pathname: "/story/session/story-session", + search: "", + hash: "", +} export function useParams() { return { @@ -8,29 +61,131 @@ export function useParams() { } export function useNavigate() { - return () => undefined + const ctx = useContext(RouterContext) + + return (to: string | number, options?: { replace?: boolean; state?: unknown }) => { + if (typeof to === "string") ctx?.navigate(to, options) + } } export function useSearchParams>() { - return [{} as Partial, () => undefined] as const + const ctx = useContext(RouterContext) + + if (!ctx) return [{} as Partial, () => undefined] as const + + const params = createMemo(() => Object.fromEntries(new URLSearchParams(ctx.location.search)) as Partial) + + const setParams = (next: Record) => { + const query = new URLSearchParams(ctx.location.search) + + for (const [key, value] of Object.entries(next)) { + if (value === undefined) query.delete(key) + else query.set(key, value) + } + + const search = query.toString() + ctx.navigate(`${ctx.location.pathname}${search ? `?${search}` : ""}`, { replace: true }) + } + + return [params(), setParams] as const } export function useIsRouting() { return () => false } -export function useLocation() { +export function useLocation() { + const ctx = useContext(RouterContext) + + if (!ctx) return defaultLocation as LocationState & { state?: S; query: Record } + return { - pathname: "/story/session/story-session", - search: "", - hash: "", + get pathname() { + return ctx.location.pathname + }, + get search() { + return ctx.location.search + }, + get hash() { + return ctx.location.hash + }, + get state() { + return ctx.location.state as S | undefined + }, + get query() { + return Object.fromEntries(new URLSearchParams(ctx.location.search)) + }, } } -export function MemoryRouter(props: ParentProps) { - return props.children +function parseUrl(input: string): Pick { + const url = new URL(input, "http://story.local") + + return { + pathname: url.pathname, + search: url.search, + hash: url.hash, + } } -export function Route(props: ParentProps) { +export function MemoryRouter( + props: ParentProps<{ + history?: MemoryHistory + root?: (props: ParentProps) => JSX.Element + }>, +) { + const initial = props.history?.get() ?? "/story/session/story-session" + + const [location, setLocation] = createStore({ + ...parseUrl(initial), + state: props.history?.state(), + }) + + if (props.history) { + const stop = props.history.listen((next) => { + setLocation({ + ...parseUrl(next), + state: props.history?.state(), + }) + }) + onCleanup(stop) + } + + const value: RouterContextValue = { + location, + navigate: (to, options) => { + if (props.history) { + props.history.set({ value: to, replace: options?.replace, state: options?.state }) + + return + } + + setLocation({ + ...parseUrl(to), + state: options?.state, + }) + }, + } + + return ( + + {props.root + ? props.root({ + get children() { + return props.children + }, + }) + : props.children} + + ) +} + +export function Route(props: ParentProps<{ path?: string; component?: Component }>) { + if (props.component) { + const Comp = props.component + + return + } + return props.children } diff --git a/packages/stories/vite.config.ts b/packages/stories/vite.config.ts index 8fa7f9d4ff33..16529da36032 100644 --- a/packages/stories/vite.config.ts +++ b/packages/stories/vite.config.ts @@ -58,7 +58,7 @@ export default defineConfig({ { find: /^@\/runtime\/platform\/platform$/, replacement: path.resolve(mocks, "app/context/platform.ts") }, { find: /^@\/runtime\/server\/sync$/, replacement: path.resolve(mocks, "app/context/server-sync.ts") }, { find: /^@\/runtime\/server\/client$/, replacement: path.resolve(mocks, "app/context/server-sdk.ts") }, - { find: /^@\/runtime\/server\/current$/, replacement: path.resolve(mocks, "app/context/server-current.ts") }, + { find: /^@\/runtime\/server\/current$/, replacement: path.resolve(mocks, "app/context/server-current.tsx") }, { find: /^@\/providers\/catalog\/providers$/, replacement: path.resolve(mocks, "app/hooks/use-providers.ts") }, { find: /^@\/providers\/models\/unpaid$/, diff --git a/packages/ui/src/overlays/dialog/dialog.stories.tsx b/packages/ui/src/overlays/dialog/dialog.stories.tsx index 416c4b09d702..d49191251fed 100644 --- a/packages/ui/src/overlays/dialog/dialog.stories.tsx +++ b/packages/ui/src/overlays/dialog/dialog.stories.tsx @@ -2,7 +2,7 @@ import { Overlay, Portal, Root, Trigger } from "@kobalte/core/dialog" import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle, DialogTitleGroup } from "./dialog" import { Button } from "@opencode/ui/button" import { useDialog } from "@opencode/ui/context/dialog" -import { onMount } from "solid-js" +import { onMount, type JSX } from "solid-js" const docs = `### Overview Dialog content wrapper built on Kobalte's dialog primitive with v2 styling. @@ -45,16 +45,10 @@ function dialogHeader(title: string, description: string) { } // Opens through the dialog stack, so a preview that holds its dialogs holds this one too. -function OpenAtOnce() { +function OpenStoryDialog(props: { render: () => JSX.Element }) { const dialog = useDialog() - const open = () => - dialog.show(() => ( - - {dialogHeader("Dialog", "Description")} - Dialog body content. - - )) + const open = () => void dialog.show(props.render) onMount(open) @@ -66,7 +60,16 @@ function OpenAtOnce() { } export const Basic = { - render: () => , + render: () => ( + ( + + {dialogHeader("Dialog", "Description")} + Dialog body content. + + )} + /> + ), } export const Sizes = { @@ -116,20 +119,16 @@ export const Sizes = { export const TitleOnly = { render: () => ( - - - Open dialog - - - + ( Open project Dialog body content. - - + )} + /> ), } @@ -157,12 +156,8 @@ export const HeaderControls = { export const WithFooter = { render: () => ( - - - Open dialog - - - + ( {dialogHeader("Save changes", "Your changes will be lost if you don't save them.")} @@ -170,19 +165,15 @@ export const WithFooter = { - - + )} + /> ), } export const WithFooterThreeButtons = { render: () => ( - - - Open dialog - - - + ( {dialogHeader("Unsaved changes", "You have unsaved changes. What would you like to do?")} @@ -193,8 +184,8 @@ export const WithFooterThreeButtons = { - - + )} + /> ), }