mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 09:43:38 +00:00
feat(app): add dialog stories and Components Lab previews (#60264)
This commit is contained in:
parent
2bb49886d7
commit
5aeb55dfc8
27 changed files with 2158 additions and 318 deletions
626
packages/app/src/components-lab/dialog-story-runtime.tsx
Normal file
626
packages/app/src/components-lab/dialog-story-runtime.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
67
packages/app/src/home/sessions/delete-dialog.stories.tsx
Normal file
67
packages/app/src/home/sessions/delete-dialog.stories.tsx
Normal 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 />,
|
||||
}
|
||||
|
|
@ -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>
|
||||
),
|
||||
}
|
||||
|
|
@ -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"),
|
||||
}
|
||||
|
|
@ -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>
|
||||
),
|
||||
}
|
||||
|
|
@ -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>
|
||||
),
|
||||
}
|
||||
|
|
@ -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 /> }
|
||||
|
|
@ -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>
|
||||
),
|
||||
}
|
||||
|
|
@ -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>
|
||||
),
|
||||
}
|
||||
47
packages/app/src/settings/shell.standalone.stories.tsx
Normal file
47
packages/app/src/settings/shell.standalone.stories.tsx
Normal 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" />,
|
||||
}
|
||||
226
packages/app/src/shell/commands/dialog.standalone.stories.tsx
Normal file
226
packages/app/src/shell/commands/dialog.standalone.stories.tsx
Normal 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>
|
||||
),
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
),
|
||||
}
|
||||
41
packages/gui-plugins/src/pairing/dialog.stories.tsx
Normal file
41
packages/gui-plugins/src/pairing/dialog.stories.tsx
Normal 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" /> }
|
||||
|
|
@ -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} /> }
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
103
packages/stories/mocks/app/context/server-current.tsx
Normal file
103
packages/stories/mocks/app/context/server-current.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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$/,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue