feat(app): add dialog stories and Components Lab previews (#60264)

This commit is contained in:
usrnk1 2026-11-04 20:51:44 +01:00 • committed by GitHub
parent 2bb49886d7
commit 5aeb55dfc8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 2158 additions and 318 deletions

View file

@ -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<string, DirectoryEntry[]>
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<T>(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<string, DirectoryEntry[]>(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<Uint8Array>({
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<string, Map<string, string>>()
const bucket = (name = "default") => {
const existing = namespaces.get(name)
if (existing) return existing
const created = new Map<string, string>()
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<DialogStoryRuntimeOptions>) {
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 (
<PlatformProvider value={platform}>
<QueryClientProvider client={client}>
<PluginRoot>
<ServersProvider canonicalLocalServer={ServerConnection.key(STORY_SERVER)} servers={servers()}>
<SettingsProvider>
<TabsProvider>
<GlobalProvider>
<ProjectIconsProvider>
<CommandProvider>
<ServerProvider conn={servers()[0] ?? STORY_SERVER}>
<ModelsProvider directory={STORY_DIRECTORY}>
<LocationProvider directory={STORY_DIRECTORY}>
<LocalProvider>{props.children}</LocalProvider>
</LocationProvider>
</ModelsProvider>
</ServerProvider>
</CommandProvider>
</ProjectIconsProvider>
</GlobalProvider>
</TabsProvider>
</SettingsProvider>
</ServersProvider>
</PluginRoot>
</QueryClientProvider>
</PlatformProvider>
)
}

View file

@ -58,12 +58,14 @@ const themeIcon = `<svg viewBox="0 0 16 16"><circle cx="8" cy="8" r="5.5" fill="
const gridIcon = `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2.5" y="2.5" width="4.5" height="4.5" rx="1"/><rect x="9" y="2.5" width="4.5" height="4.5" rx="1"/><rect x="2.5" y="9" width="4.5" height="4.5" rx="1"/><rect x="9" y="9" width="4.5" height="4.5" rx="1"/></svg>`
// 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<StoryModule>([
"../**/*.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<string>(
"../../../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={
<Grid
entries={entries().filter((entry) => 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, () => <Tile entry={entry} />)
.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, () => <Tile entry={entry} />)
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 }) {
<div
ref={setCanvas}
data-lab-story={props.entry.file}
class="relative flex size-full cursor-pointer items-center justify-center overflow-hidden bg-v2-background-bg-base p-4 [contain:layout]"
class="relative flex size-full cursor-pointer overflow-hidden bg-v2-background-bg-base [contain:layout]"
classList={{
"items-center justify-center p-4": !props.entry.fullscreen,
"flex-col": props.entry.fullscreen,
}}
>
{/* The provider waits for the card, so a story that opens a dialog at once opens it inside. */}
<Show when={canvas()}>
{(container) => (
<div class="pointer-events-none contents">
<DialogProvider container={container()}>
<ErrorBoundary
fallback={(error) => <pre class="font-mono text-[11px] text-v2-state-fg-danger">{String(error)}</pre>}
>
{render
? untrack(() => render(stateArgs(props.entry, 0)))
: component && <Dynamic component={component} {...stateArgs(props.entry, 0)} />}
</ErrorBoundary>
</DialogProvider>
<Plugin file={props.entry.file}>
<DialogProvider container={container()}>
<ErrorBoundary
fallback={(error) => <pre class="font-mono text-[11px] text-v2-state-fg-danger">{String(error)}</pre>}
>
{render
? untrack(() => render(stateArgs(props.entry, 0)))
: component && <Dynamic component={component} {...stateArgs(props.entry, 0)} />}
</ErrorBoundary>
</DialogProvider>
</Plugin>
</div>
)}
</Show>
@ -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) {

View file

@ -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(() => (
<Dialog fit>
<DialogHeader hideClose>
<DialogTitleGroup
title={language.t("session.delete.title")}
description={language.t("session.delete.confirm", {
name: sessionTitle(props.sessionTitle) ?? language.t("command.session.new"),
})}
/>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" autofocus onClick={() => dialog.close()}>
{language.t("common.cancel")}
</Button>
<Button
variant="danger"
onClick={() => {
setStore("status", "Session deleted")
dialog.close()
}}
>
{language.t("session.delete.button")}
</Button>
</DialogFooter>
</Dialog>
))
onMount(open)
return (
<div class="flex flex-col items-start gap-3">
<Button variant="neutral" onClick={open}>
Open delete session dialog
</Button>
<p role="status">{store.status}</p>
</div>
)
}
export default {
title: "App/Dialogs/Delete Session",
id: "app-dialog-delete-session",
}
export const Default = {
render: () => <DeleteSessionStory sessionTitle="Refactor the dialog primitives" />,
}
export const Untitled = {
render: () => <DeleteSessionStory />,
}

View file

@ -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 (
<Button variant="neutral" onClick={open}>
Open session location dialog
</Button>
)
}
export default {
title: "App/Dialogs/New Session Location",
id: "app-dialog-new-session-location",
}
export const Default = {
render: () => (
<DialogStoryRuntime>
<LocationDialogStory />
</DialogStoryRuntime>
),
}
export const MultipleServers = {
render: () => (
<DialogStoryRuntime>
<LocationDialogStory multipleServers />
</DialogStoryRuntime>
),
}

View file

@ -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(() => <DialogConnectProvider />)
const open = () => void dialog.show(() => <DialogConnectProvider directory={STORY_DIRECTORY} />)
onMount(open)
return (
<Button variant="neutral" onClick={open}>
Open connect provider dialog
Open connect integration dialog
</Button>
)
}
// 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(() => <DialogConnectProvider controller={controller} />)
const open = () => {
controller.select(props.provider)
void dialog.show(() => <DialogConnectProvider directory={STORY_DIRECTORY} controller={controller} />)
}
onMount(open)
@ -34,11 +37,11 @@ function ProviderConnectionDialogStory(props) {
)
}
function renderConnection(provider) {
function renderConnection(provider: string) {
return () => (
<QueryClientProvider client={new QueryClient()}>
<DialogStoryRuntime>
<ProviderConnectionDialogStory provider={provider} />
</QueryClientProvider>
</DialogStoryRuntime>
)
}
@ -49,20 +52,20 @@ export default {
export const Picker = {
render: () => (
<QueryClientProvider client={new QueryClient()}>
<DialogStoryRuntime>
<ConnectProviderDialogStory />
</QueryClientProvider>
</DialogStoryRuntime>
),
}
export const ChooseMethod = {
render: renderConnection("opencode"),
}
export const ApiKey = {
render: renderConnection("openrouter"),
}
export const OpenCodeZen = {
render: renderConnection("opencode"),
}
export const LoginMethods = {
render: renderConnection("openai"),
}

View file

@ -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(() => <DialogManageModels />)
onMount(open)
return (
<Button variant="neutral" onClick={open}>
Open manage models dialog
</Button>
)
}
export default {
title: "App/Dialogs/Manage Models",
id: "app-dialog-manage-models",
}
export const Default = {
render: () => (
<DialogStoryRuntime>
<ManageModelsStory />
</DialogStoryRuntime>
),
}

View file

@ -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<ReturnType<ModelSelection["current"]>>
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(() => <DialogSelectModel model={selection} />)
onMount(open)
return (
<div class="flex min-h-[280px] items-end justify-center">
<Button variant="ghost-muted" onClick={open}>
{find(store.current)?.name}
</Button>
</div>
)
}
export default {
title: "App/Dialogs/Model Selector",
id: "app-dialog-model-selector",
}
export const Dialog = {
render: () => (
<DialogStoryRuntime>
<SelectorStory />
</DialogStoryRuntime>
),
}
export const Favorites = {
render: () => (
<DialogStoryRuntime>
<SelectorStory favorite />
</DialogStoryRuntime>
),
}

View file

@ -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<ReturnType<ModelSelection["current"]>>
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(() => <DialogSelectModel model={selection} />)
onMount(open)
return (
<div class="flex min-h-[280px] items-end justify-center">
<Button variant="ghost-muted" onClick={open}>
{find(store.current)?.name}
</Button>
</div>
)
}
export default {
title: "App/Dialogs/Model Selector",
id: "app-dialog-model-selector",
}
export const Dialog = { render: () => <SelectorStory /> }
export const Favorites = { render: () => <SelectorStory favorite /> }

View file

@ -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) => <SelectModelWithoutProviders count={args.count} />,
render: (args) => (
<DialogStoryRuntime>
<SelectModelWithoutProviders count={args.count} />
</DialogStoryRuntime>
),
}

View file

@ -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(() => <DialogServer mode={props.mode} server={props.server} />)
onMount(open)
return (
<Button variant="neutral" onClick={open}>
{props.mode === "add" ? "Open add server dialog" : "Open edit server dialog"}
</Button>
)
}
export default {
title: "App/Dialogs/Add Server",
id: "app-dialog-add-server",
}
export const PairingLink = {
render: () => (
<DialogStoryRuntime>
<ServerDialogStory mode="add" />
</DialogStoryRuntime>
),
}
export const Password = {
render: () => (
<DialogStoryRuntime servers={[editableServer]}>
<ServerDialogStory mode="edit" server={editableServer} />
</DialogStoryRuntime>
),
}
export const SignedOut = {
render: () => (
<DialogStoryRuntime servers={[STORY_SERVER, STORY_LOCKED_SERVER]}>
<ServerDialogStory mode="edit" server={STORY_LOCKED_SERVER} />
</DialogStoryRuntime>
),
}

View file

@ -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 (
<DialogStoryRuntime>
<div class="h-full min-h-[640px] w-full overflow-hidden bg-v2-background-bg-base">
<MemoryRouter
history={history}
root={(rootProps) => (
<LayoutProvider>
<SettingsSurfaceProvider>{rootProps.children}</SettingsSurfaceProvider>
</LayoutProvider>
)}
>
<Route path="*all" component={SettingsScreen} />
</MemoryRouter>
</div>
</DialogStoryRuntime>
)
}
export default {
title: "App/Dialogs/Settings",
id: "app-dialog-settings",
parameters: {
layout: "fullscreen",
},
}
export const Preferences = {
render: () => <SettingsStory tab="general" />,
}
export const Providers = {
render: () => <SettingsStory tab="providers" />,
}
export const Models = {
render: () => <SettingsStory tab="models" />,
}

View file

@ -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(() => (
<CommandPaletteView
placeholder={language.t("palette.search.placeholder")}
items={items}
sources={[async (query) => (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<HTMLInputElement>(".command-palette input[role='combobox']")
if (!input) return
input.value = initial
input.dispatchEvent(new InputEvent("input", { bubbles: true }))
})
}
onMount(open)
return (
<Button variant="neutral" onClick={open}>
Open command palette
</Button>
)
}
export default {
title: "App/Dialogs/Command Palette",
id: "app-dialog-command-palette",
}
export const Default = {
render: () => (
<DialogStoryRuntime>
<CommandPaletteStory />
</DialogStoryRuntime>
),
}
export const Searching = {
render: () => (
<DialogStoryRuntime>
<CommandPaletteStory query="model" />
</DialogStoryRuntime>
),
}

View file

@ -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<UsageLimitsBudget> & Pick<UsageLimitsBudget, "id">): 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(() => <UsageDialogContent {...props} onSwitchModel={() => {}} />)
const open = () => void dialog.show(() => <UsageDialogContent {...props} onSwitchModel={() => {}} />)
onMount(open)
@ -59,6 +61,22 @@ function UsageStory(props: { info: UsageLimitsInfo; model?: CurrentModel; credit
)
}
function LiveUsageStory() {
const dialog = useDialog()
const open = () => void dialog.show(() => <DialogUsage location={{ directory: STORY_DIRECTORY }} />)
onMount(open)
return (
<div class="flex min-h-[320px] items-end justify-center">
<Button variant="ghost-muted" onClick={open}>
Usage
</Button>
</div>
)
}
export default {
title: "App/Dialogs/Usage",
id: "app-dialog-usage",
@ -66,6 +84,14 @@ export default {
export const Default = { render: () => <UsageStory info={info([anthropic(123.14), pool(394.57)], true)} /> }
export const SignedOut = {
render: () => (
<DialogStoryRuntime>
<LiveUsageStory />
</DialogStoryRuntime>
),
}
export const PerModel = {
render: () => (
<UsageStory

View file

@ -0,0 +1,58 @@
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, STORY_DIRECTORY, STORY_SERVER } from "@/components-lab/dialog-story-runtime"
import { DirectoryPickerDialog } from "./dialog"
function DirectoryPickerStory(props: { mode?: "directory" | "file" }) {
const dialog = useDialog()
const [store, setStore] = createStore({ selected: "No path selected" })
const open = () =>
void dialog.show(() => (
<DirectoryPickerDialog
server={STORY_SERVER}
location={{ directory: STORY_DIRECTORY }}
start={STORY_DIRECTORY}
mode={props.mode}
onSelect={(result) => {
const text = Array.isArray(result) ? result.join(", ") : (result ?? "Cancelled")
setStore("selected", text)
}}
/>
))
onMount(open)
return (
<div class="flex flex-col items-start gap-3">
<Button variant="neutral" onClick={open}>
Open directory picker
</Button>
<p role="status">{store.selected}</p>
</div>
)
}
export default {
title: "App/Dialogs/Directory Picker",
id: "app-dialog-directory-picker",
}
export const OpenProject = {
render: () => (
<DialogStoryRuntime>
<DirectoryPickerStory />
</DialogStoryRuntime>
),
}
export const SelectFile = {
render: () => (
<DialogStoryRuntime>
<DirectoryPickerStory mode="file" />
</DialogStoryRuntime>
),
}

View file

@ -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<typeof mock.client.setRemote>) => {
if (props.mode === "pending") return new Promise<never>(() => {})
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 (
<PairingHost>
<div ref={root}>
<PairingPage server={() => mock.server} pairing={() => ({ status: "active", value: client, generation: 1 })} />
</div>
</PairingHost>
)
}
export default {
title: "App/Dialogs/Pair Device",
id: "app-dialog-pair-device",
}
export const Ready = { render: () => <PairingDialogStory mode="ready" /> }
export const SettingUp = { render: () => <PairingDialogStory mode="pending" /> }
export const Failed = { render: () => <PairingDialogStory mode="failed" /> }

View file

@ -302,18 +302,6 @@ function Open(props: { initial?: string }) {
export default { title: "App/Dialogs/SSH", id: "app-dialog-ssh" }
export const AuthenticationRequired = { render: () => <Fixture initial="required" /> }
export const SettingsReconnect = { render: () => <Fixture initial="required" settings connectionDelay={200} /> }
export const IncompatibleHost = { render: () => <Fixture incompatible /> }
export const IncompatibleSession = { render: () => <Fixture initial="required" session incompatible /> }
export const InactiveSession = { render: () => <Fixture initial="required" session connectionDelay={3000} /> }
export const KeyReconnect = { render: () => <Fixture initial="required" session keyOnly connectionDelay={3000} /> }
export const Host = { render: () => <Fixture /> }
export const Connecting = { render: () => <Fixture initial="connecting" /> }
@ -325,3 +313,15 @@ export const SlowPassword = { render: () => <Fixture initial="password" response
export const Confirmation = { render: () => <Fixture initial="confirmation" /> }
export const Failure = { render: () => <Fixture initial="failure" /> }
export const IncompatibleHost = { render: () => <Fixture incompatible /> }
export const AuthenticationRequired = { render: () => <Fixture initial="required" /> }
export const SettingsReconnect = { render: () => <Fixture initial="required" settings connectionDelay={200} /> }
export const IncompatibleSession = { render: () => <Fixture initial="required" session incompatible /> }
export const InactiveSession = { render: () => <Fixture initial="required" session connectionDelay={3000} /> }
export const KeyReconnect = { render: () => <Fixture initial="required" session keyOnly connectionDelay={3000} /> }

View file

@ -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<string, string> = {
"file.attach": "mod+u",
"prompt.mode.shell": "mod+shift+x",
@ -7,62 +29,31 @@ const keybinds: Record<string, string> = {
"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<typeof parseKeybind>, 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")
},
}
}

View file

@ -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<Project> & { worktree: string; expanded: boolean }
export type LocalProject = Partial<Project> & { 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<string[]>([])
const [active, setActive] = createSignal<string | undefined>(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: {

View file

@ -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<string | undefined>(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<typeof makeDefaultLocal>
const LocalContext = createContext<LocalValue>()
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()
}

View file

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

View file

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

View file

@ -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<typeof useGlobal>["servers"]["health"][ServerConnection.Key]
}
const ServerContext = createContext<ActiveServerValue>()
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 (
<Show when={ctx()} keyed>
{(resolved) => (
<ServerContext.Provider
value={{
conn,
key,
isLocal: ServerConnection.local(conn),
ctx: resolved,
get health() {
return global.servers.health[key]
},
}}
>
{props.children}
</ServerContext.Provider>
)}
</Show>
)
}

View file

@ -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<never>(() => {})
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"

View file

@ -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<string | undefined> = () => 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,
}

View file

@ -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<RouterContextValue>()
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<T extends Record<string, string>>() {
return [{} as Partial<T>, () => undefined] as const
const ctx = useContext(RouterContext)
if (!ctx) return [{} as Partial<T>, () => undefined] as const
const params = createMemo(() => Object.fromEntries(new URLSearchParams(ctx.location.search)) as Partial<T>)
const setParams = (next: Record<string, string | undefined>) => {
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<S = unknown>() {
const ctx = useContext(RouterContext)
if (!ctx) return defaultLocation as LocationState & { state?: S; query: Record<string, string> }
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<LocationState, "pathname" | "search" | "hash"> {
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<LocationState>({
...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 (
<RouterContext.Provider value={value}>
{props.root
? props.root({
get children() {
return props.children
},
})
: props.children}
</RouterContext.Provider>
)
}
export function Route(props: ParentProps<{ path?: string; component?: Component }>) {
if (props.component) {
const Comp = props.component
return <Comp />
}
return props.children
}

View file

@ -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$/,

View file

@ -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(() => (
<Dialog>
{dialogHeader("Dialog", "Description")}
<DialogBody>Dialog body content.</DialogBody>
</Dialog>
))
const open = () => void dialog.show(props.render)
onMount(open)
@ -66,7 +60,16 @@ function OpenAtOnce() {
}
export const Basic = {
render: () => <OpenAtOnce />,
render: () => (
<OpenStoryDialog
render={() => (
<Dialog>
{dialogHeader("Dialog", "Description")}
<DialogBody>Dialog body content.</DialogBody>
</Dialog>
)}
/>
),
}
export const Sizes = {
@ -116,20 +119,16 @@ export const Sizes = {
export const TitleOnly = {
render: () => (
<Root defaultOpen>
<Trigger as={Button} variant="neutral">
Open dialog
</Trigger>
<Portal>
<Overlay />
<OpenStoryDialog
render={() => (
<Dialog>
<DialogHeader>
<DialogTitle>Open project</DialogTitle>
</DialogHeader>
<DialogBody>Dialog body content.</DialogBody>
</Dialog>
</Portal>
</Root>
)}
/>
),
}
@ -157,12 +156,8 @@ export const HeaderControls = {
export const WithFooter = {
render: () => (
<Root defaultOpen>
<Trigger as={Button} variant="neutral">
Open dialog
</Trigger>
<Portal>
<Overlay />
<OpenStoryDialog
render={() => (
<Dialog fit>
{dialogHeader("Save changes", "Your changes will be lost if you don't save them.")}
<DialogFooter>
@ -170,19 +165,15 @@ export const WithFooter = {
<Button variant="contrast">Save</Button>
</DialogFooter>
</Dialog>
</Portal>
</Root>
)}
/>
),
}
export const WithFooterThreeButtons = {
render: () => (
<Root defaultOpen>
<Trigger as={Button} variant="neutral">
Open dialog
</Trigger>
<Portal>
<Overlay />
<OpenStoryDialog
render={() => (
<Dialog fit>
{dialogHeader("Unsaved changes", "You have unsaved changes. What would you like to do?")}
<DialogFooter>
@ -193,8 +184,8 @@ export const WithFooterThreeButtons = {
<Button variant="contrast">Save</Button>
</DialogFooter>
</Dialog>
</Portal>
</Root>
)}
/>
),
}