Skip to content

Commit 5aeb55d

Browse files
authored
feat(app): add dialog stories and Components Lab previews (#60264)
1 parent 2bb4988 commit 5aeb55d

27 files changed

Lines changed: 2159 additions & 319 deletions

‎packages/app/src/components-lab/dialog-story-runtime.tsx‎

Lines changed: 626 additions & 0 deletions
Large diffs are not rendered by default.

‎packages/app/src/components-lab/lab.tsx‎

Lines changed: 56 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -58,12 +58,14 @@ const themeIcon = `<svg viewBox="0 0 16 16"><circle cx="8" cy="8" r="5.5" fill="
5858

5959
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>`
6060

61+
// Standalone stories mount fixture runtimes that must not access the live app's storage.
6162
// Vite needs the patterns as literals in each call.
6263
const modules = import.meta.glob<StoryModule>([
6364
"../**/*.stories.tsx",
6465
"../../../ui/src/**/*.stories.tsx",
6566
"../../../session-ui/src/**/*.stories.tsx",
6667
"../../../gui-plugins/src/**/*.stories.tsx",
68+
"!../**/*.standalone.stories.tsx",
6769
])
6870

6971
// Each file's text, for the order its states were written in.
@@ -73,6 +75,7 @@ const sources = import.meta.glob<string>(
7375
"../../../ui/src/**/*.stories.tsx",
7476
"../../../session-ui/src/**/*.stories.tsx",
7577
"../../../gui-plugins/src/**/*.stories.tsx",
78+
"!../**/*.standalone.stories.tsx",
7679
],
7780
{ query: "?raw", import: "default" },
7881
)
@@ -230,7 +233,7 @@ export default function ComponentsLab() {
230233
when={!grid()}
231234
fallback={
232235
<Grid
233-
entries={entries().filter((entry) => entry.title.startsWith("UI/"))}
236+
entries={entries().filter((entry) => entry.title.startsWith("UI/") || isDialogEntry(entry))}
234237
scheme={untrack(scheme)}
235238
onOpen={open}
236239
/>
@@ -613,21 +616,25 @@ function Grid(props: { entries: LabEntry[]; scheme: "dark" | "light"; onOpen: (f
613616
const board = canvas(element, {
614617
theme: props.scheme,
615618
label: language.t("lab.grid"),
616-
// Neighbours share the library's folder: actions, forms, overlays and so on.
619+
// Neighbours share the library's folder: actions, forms, overlays, then app dialogs.
617620
items: props.entries
618621
.toSorted((a, b) => folderOf(a).localeCompare(folderOf(b)))
619-
.map((entry) => ({
620-
id: entry.file,
621-
title: entry.title.split("/").at(-1),
622-
w: 320,
623-
h: 240,
624-
render: (card: HTMLElement) =>
625-
createRoot((dispose) => {
626-
insert(card, () => <Tile entry={entry} />)
627-
628-
return dispose
629-
}, owner ?? undefined),
630-
})),
622+
.map((entry) => {
623+
const size = tileSize(entry)
624+
625+
return {
626+
id: entry.file,
627+
title: entry.title.split("/").at(-1),
628+
w: size.w,
629+
h: size.h,
630+
render: (card: HTMLElement) =>
631+
createRoot((dispose) => {
632+
insert(card, () => <Tile entry={entry} />)
633+
634+
return dispose
635+
}, owner ?? undefined),
636+
}
637+
}),
631638
})
632639

633640
// The canvas captures the pointer to pan, so a card never sees its click: a press that ends where it began opens
@@ -667,30 +674,54 @@ function Tile(props: { entry: LabEntry }) {
667674
<div
668675
ref={setCanvas}
669676
data-lab-story={props.entry.file}
670-
class="relative flex size-full cursor-pointer items-center justify-center overflow-hidden bg-v2-background-bg-base p-4 [contain:layout]"
677+
class="relative flex size-full cursor-pointer overflow-hidden bg-v2-background-bg-base [contain:layout]"
678+
classList={{
679+
"items-center justify-center p-4": !props.entry.fullscreen,
680+
"flex-col": props.entry.fullscreen,
681+
}}
671682
>
672683
{/* The provider waits for the card, so a story that opens a dialog at once opens it inside. */}
673684
<Show when={canvas()}>
674685
{(container) => (
675686
<div class="pointer-events-none contents">
676-
<DialogProvider container={container()}>
677-
<ErrorBoundary
678-
fallback={(error) => <pre class="font-mono text-[11px] text-v2-state-fg-danger">{String(error)}</pre>}
679-
>
680-
{render
681-
? untrack(() => render(stateArgs(props.entry, 0)))
682-
: component && <Dynamic component={component} {...stateArgs(props.entry, 0)} />}
683-
</ErrorBoundary>
684-
</DialogProvider>
687+
<Plugin file={props.entry.file}>
688+
<DialogProvider container={container()}>
689+
<ErrorBoundary
690+
fallback={(error) => <pre class="font-mono text-[11px] text-v2-state-fg-danger">{String(error)}</pre>}
691+
>
692+
{render
693+
? untrack(() => render(stateArgs(props.entry, 0)))
694+
: component && <Dynamic component={component} {...stateArgs(props.entry, 0)} />}
695+
</ErrorBoundary>
696+
</DialogProvider>
697+
</Plugin>
685698
</div>
686699
)}
687700
</Show>
688701
</div>
689702
)
690703
}
691704

705+
function isDialogEntry(entry: LabEntry) {
706+
return (
707+
entry.title.startsWith("App/Dialogs/") ||
708+
entry.file.endsWith("/beta-dialog.stories.tsx") ||
709+
entry.file.endsWith("/overlays/dialog/dialog.stories.tsx")
710+
)
711+
}
712+
713+
function tileSize(entry: LabEntry) {
714+
if (entry.fullscreen) return { w: 1120, h: 720 }
715+
716+
if (isDialogEntry(entry)) return { w: 720, h: 820 }
717+
718+
return { w: 320, h: 240 }
719+
}
720+
692721
function folderOf(entry: LabEntry) {
693-
return entry.file.match(/ui\/src\/([^/]+)\//)?.[1] ?? ""
722+
if (isDialogEntry(entry)) return `1:dialogs:${entry.title}`
723+
724+
return `0:${entry.file.match(/ui\/src\/([^/]+)\//)?.[1] ?? ""}`
694725
}
695726

696727
function typing(target: EventTarget | null) {
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { Button } from "@opencode/ui/button"
2+
import { useDialog } from "@opencode/ui/context/dialog"
3+
import { Dialog, DialogFooter, DialogHeader, DialogTitleGroup } from "@opencode/ui/dialog"
4+
import { onMount } from "solid-js"
5+
import { createStore } from "solid-js/store"
6+
import { useLanguage } from "@/runtime/i18n/language"
7+
import { sessionTitle } from "@/session/title"
8+
9+
function DeleteSessionStory(props: { sessionTitle?: string }) {
10+
const dialog = useDialog()
11+
12+
const language = useLanguage()
13+
14+
const [store, setStore] = createStore({ status: "Session active" })
15+
16+
const open = () =>
17+
void dialog.show(() => (
18+
<Dialog fit>
19+
<DialogHeader hideClose>
20+
<DialogTitleGroup
21+
title={language.t("session.delete.title")}
22+
description={language.t("session.delete.confirm", {
23+
name: sessionTitle(props.sessionTitle) ?? language.t("command.session.new"),
24+
})}
25+
/>
26+
</DialogHeader>
27+
<DialogFooter>
28+
<Button variant="ghost" autofocus onClick={() => dialog.close()}>
29+
{language.t("common.cancel")}
30+
</Button>
31+
<Button
32+
variant="danger"
33+
onClick={() => {
34+
setStore("status", "Session deleted")
35+
dialog.close()
36+
}}
37+
>
38+
{language.t("session.delete.button")}
39+
</Button>
40+
</DialogFooter>
41+
</Dialog>
42+
))
43+
44+
onMount(open)
45+
46+
return (
47+
<div class="flex flex-col items-start gap-3">
48+
<Button variant="neutral" onClick={open}>
49+
Open delete session dialog
50+
</Button>
51+
<p role="status">{store.status}</p>
52+
</div>
53+
)
54+
}
55+
56+
export default {
57+
title: "App/Dialogs/Delete Session",
58+
id: "app-dialog-delete-session",
59+
}
60+
61+
export const Default = {
62+
render: () => <DeleteSessionStory sessionTitle="Refactor the dialog primitives" />,
63+
}
64+
65+
export const Untitled = {
66+
render: () => <DeleteSessionStory />,
67+
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { Button } from "@opencode/ui/button"
2+
import { onMount } from "solid-js"
3+
import { createStore } from "solid-js/store"
4+
import {
5+
DialogStoryRuntime,
6+
STORY_DIRECTORY,
7+
STORY_PROJECTS,
8+
STORY_SERVER,
9+
} from "@/components-lab/dialog-story-runtime"
10+
import { createPromptProjectController, type PromptProject, type PromptServer } from "@/new-session/project/selector"
11+
import { createNewSessionWorkspaceController } from "@/new-session/workspace/controller"
12+
import { ServerConnection } from "@/runtime/server/registry"
13+
import { useNewSessionLocationDialog } from "./dialog"
14+
15+
const currentServer: PromptServer = {
16+
key: ServerConnection.key(STORY_SERVER),
17+
name: "MacBook-Pro",
18+
hint: "This computer",
19+
kind: "current",
20+
}
21+
22+
const sshServer: PromptServer = {
23+
key: "ssh:devbox",
24+
name: "devbox",
25+
kind: "plugin",
26+
label: "SSH",
27+
}
28+
29+
function LocationDialogStory(props: { multipleServers?: boolean }) {
30+
const [store, setStore] = createStore<{
31+
directory: string
32+
worktree?: string
33+
branch?: string
34+
}>({
35+
directory: STORY_DIRECTORY,
36+
})
37+
38+
const available = (): PromptProject[] => [
39+
...STORY_PROJECTS.map((project, index) => ({
40+
id: project.id,
41+
name: project.name,
42+
worktree: project.worktree,
43+
vcs: "git",
44+
sandboxes: project.sandboxes,
45+
worktrees: project.sandboxes.map((directory) => ({ directory, strategy: "git" })),
46+
used: index === 0,
47+
server: currentServer,
48+
time: { active: project.time.active },
49+
})),
50+
...(props.multipleServers
51+
? [
52+
{
53+
id: "prj_remote_api",
54+
name: "cloud-api",
55+
worktree: "/home/dev/cloud-api",
56+
vcs: "git",
57+
used: true,
58+
server: sshServer,
59+
time: { active: Date.now() - 3_600_000 },
60+
},
61+
]
62+
: []),
63+
]
64+
65+
const project = createPromptProjectController({
66+
controls: () => ({
67+
available: available(),
68+
servers: props.multipleServers ? [currentServer, sshServer] : [currentServer],
69+
directory: store.directory,
70+
projectID: STORY_PROJECTS[0].id,
71+
server: currentServer.key,
72+
select: (directory, _server, workspace) => {
73+
setStore({ directory, worktree: workspace })
74+
},
75+
add: () => {},
76+
}),
77+
})
78+
79+
const workspace = createNewSessionWorkspaceController({
80+
selectedWorktree: () => store.worktree,
81+
selectedBranch: () => store.branch,
82+
setSelectedWorktree: (worktree) => setStore("worktree", worktree),
83+
setSelectedBranch: (branch) => setStore("branch", branch),
84+
})
85+
86+
const openLocation = useNewSessionLocationDialog({
87+
project,
88+
workspace,
89+
onDone: () => {},
90+
})
91+
92+
const open = () => openLocation()
93+
94+
onMount(open)
95+
96+
return (
97+
<Button variant="neutral" onClick={open}>
98+
Open session location dialog
99+
</Button>
100+
)
101+
}
102+
103+
export default {
104+
title: "App/Dialogs/New Session Location",
105+
id: "app-dialog-new-session-location",
106+
}
107+
108+
export const Default = {
109+
render: () => (
110+
<DialogStoryRuntime>
111+
<LocationDialogStory />
112+
</DialogStoryRuntime>
113+
),
114+
}
115+
116+
export const MultipleServers = {
117+
render: () => (
118+
<DialogStoryRuntime>
119+
<LocationDialogStory multipleServers />
120+
</DialogStoryRuntime>
121+
),
122+
}

0 commit comments

Comments
 (0)