diff options
Diffstat (limited to 'packages')
18 files changed, 629 insertions, 764 deletions
diff --git a/packages/desktop/src/app.tsx b/packages/desktop/src/app.tsx index fd55b228e..a49dac9aa 100644 --- a/packages/desktop/src/app.tsx +++ b/packages/desktop/src/app.tsx @@ -11,7 +11,7 @@ import { LayoutProvider } from "@/context/layout" import { GlobalSDKProvider } from "@/context/global-sdk" import { SessionProvider } from "@/context/session" import { NotificationProvider } from "@/context/notification" -import { DialogProvider } from "@/context/dialog" +import { DialogProvider } from "@opencode-ai/ui/context/dialog" import Layout from "@/pages/layout" import Home from "@/pages/home" import DirectoryLayout from "@/pages/directory-layout" diff --git a/packages/desktop/src/components/dialog-connect.tsx b/packages/desktop/src/components/dialog-connect.tsx index 3a1e05f27..f61221f72 100644 --- a/packages/desktop/src/components/dialog-connect.tsx +++ b/packages/desktop/src/components/dialog-connect.tsx @@ -1,10 +1,10 @@ -import { Component, createMemo, Match, onCleanup, onMount, Switch } from "solid-js" +import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js" import { createStore, produce } from "solid-js/store" -import { useDialog } from "@/context/dialog" +import { useDialog } from "@opencode-ai/ui/context/dialog" import { useGlobalSync } from "@/context/global-sync" import { useGlobalSDK } from "@/context/global-sdk" import { usePlatform } from "@/context/platform" -import { ProviderAuthMethod, ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client" +import { ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client" import { Dialog } from "@opencode-ai/ui/dialog" import { List, ListRef } from "@opencode-ai/ui/list" import { Button } from "@opencode-ai/ui/button" @@ -18,14 +18,13 @@ import { IconName } from "@opencode-ai/ui/icons/provider" import { iife } from "@opencode-ai/util/iife" import { Link } from "@/components/link" import { DialogSelectProvider } from "./dialog-select-provider" -import { DialogModel } from "./dialog-model" +import { DialogSelectModel } from "./dialog-select-model" -export const DialogConnect: Component<{ provider: string }> = (props) => { +export function DialogConnect(props: { provider: string }) { const dialog = useDialog() const globalSync = useGlobalSync() const globalSDK = useGlobalSDK() const platform = usePlatform() - const provider = createMemo(() => globalSync.data.provider.all.find((x) => x.id === props.provider)!) const methods = createMemo( () => @@ -37,19 +36,19 @@ export const DialogConnect: Component<{ provider: string }> = (props) => { ], ) const [store, setStore] = createStore({ - method: undefined as undefined | ProviderAuthMethod, + methodIndex: undefined as undefined | number, authorization: undefined as undefined | ProviderAuthAuthorization, state: "pending" as undefined | "pending" | "complete" | "error", error: undefined as string | undefined, }) - const methodIndex = createMemo(() => methods().findIndex((x) => x.label === store.method?.label)) + const method = createMemo(() => (store.methodIndex !== undefined ? methods().at(store.methodIndex!) : undefined)) async function selectMethod(index: number) { const method = methods()[index] setStore( produce((draft) => { - draft.method = method + draft.methodIndex = index draft.authorization = undefined draft.state = undefined draft.error = undefined @@ -101,7 +100,6 @@ export const DialogConnect: Component<{ provider: string }> = (props) => { if (methods().length === 1) { selectMethod(0) } - document.addEventListener("keydown", handleKey) onCleanup(() => { document.removeEventListener("keydown", handleKey) @@ -117,8 +115,8 @@ export const DialogConnect: Component<{ provider: string }> = (props) => { title: `${provider().name} connected`, description: `${provider().name} models are now available to use.`, }) - dialog.replace(() => <DialogModel provider={props.provider} />) - }, 500) + dialog.replace(() => <DialogSelectModel provider={props.provider} />) + }, 1000) } function goBack() { @@ -128,275 +126,258 @@ export const DialogConnect: Component<{ provider: string }> = (props) => { } if (store.authorization) { setStore("authorization", undefined) - setStore("method", undefined) + setStore("methodIndex", undefined) return } - if (store.method) { - setStore("method", undefined) + if (store.methodIndex) { + setStore("methodIndex", undefined) return } dialog.replace(() => <DialogSelectProvider />) } return ( - <Dialog - modal - defaultOpen - onOpenChange={(open) => { - if (!open) { - dialog.clear() - } - }} - > - <Dialog.Header class="px-4.5"> - <Dialog.Title class="flex items-center"> - <IconButton tabIndex={-1} icon="arrow-left" variant="ghost" onClick={goBack} /> - </Dialog.Title> - <Dialog.CloseButton tabIndex={-1} /> - </Dialog.Header> - <Dialog.Body> - <div class="flex flex-col gap-6 px-2.5 pb-3"> - <div class="px-2.5 flex gap-4 items-center"> - <ProviderIcon id={props.provider as IconName} class="size-5 shrink-0 icon-strong-base" /> - <div class="text-16-medium text-text-strong"> - <Switch> - <Match when={props.provider === "anthropic" && store.method?.label?.toLowerCase().includes("max")}> - Login with Claude Pro/Max - </Match> - <Match when={true}>Connect {provider().name}</Match> - </Switch> - </div> - </div> - <div class="px-2.5 pb-10 flex flex-col gap-6"> + <Dialog title={<IconButton tabIndex={-1} icon="arrow-left" variant="ghost" onClick={goBack} />}> + <div class="flex flex-col gap-6 px-2.5 pb-3"> + <div class="px-2.5 flex gap-4 items-center"> + <ProviderIcon id={props.provider as IconName} class="size-5 shrink-0 icon-strong-base" /> + <div class="text-16-medium text-text-strong"> <Switch> - <Match when={store.method === undefined}> - <div class="text-14-regular text-text-base">Select login method for {provider().name}.</div> - <div class=""> - <List - ref={(ref) => (listRef = ref)} - items={methods} - key={(m) => m?.label} - onSelect={async (method, index) => { - if (!method) return - selectMethod(index) - }} - > - {(i) => ( - <div class="w-full flex items-center gap-x-4"> - <div class="w-4 h-2 rounded-[1px] bg-input-base shadow-xs-border-base flex items-center justify-center"> - <div class="w-2.5 h-0.5 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" /> - </div> - <span>{i.label}</span> + <Match when={props.provider === "anthropic" && method()?.label?.toLowerCase().includes("max")}> + Login with Claude Pro/Max + </Match> + <Match when={true}>Connect {provider().name}</Match> + </Switch> + </div> + </div> + <div class="px-2.5 pb-10 flex flex-col gap-6"> + <Switch> + <Match when={store.methodIndex === undefined}> + <div class="text-14-regular text-text-base">Select login method for {provider().name}.</div> + <div class=""> + <List + ref={(ref) => (listRef = ref)} + items={methods} + key={(m) => m?.label} + onSelect={async (method, index) => { + if (!method) return + selectMethod(index) + }} + > + {(i) => ( + <div class="w-full flex items-center gap-x-4"> + <div class="w-4 h-2 rounded-[1px] bg-input-base shadow-xs-border-base flex items-center justify-center"> + <div class="w-2.5 h-0.5 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" /> </div> - )} - </List> + <span>{i.label}</span> + </div> + )} + </List> + </div> + </Match> + <Match when={store.state === "pending"}> + <div class="text-14-regular text-text-base"> + <div class="flex items-center gap-x-4"> + <Spinner /> + <span>Authorization in progress...</span> </div> - </Match> - <Match when={store.state === "pending"}> - <div class="text-14-regular text-text-base"> - <div class="flex items-center gap-x-4"> - <Spinner /> - <span>Authorization in progress...</span> - </div> + </div> + </Match> + <Match when={store.state === "error"}> + <div class="text-14-regular text-text-base"> + <div class="flex items-center gap-x-4"> + <Icon name="circle-ban-sign" class="text-icon-critical-base" /> + <span>Authorization failed: {store.error}</span> </div> - </Match> - <Match when={store.state === "error"}> - <div class="text-14-regular text-text-base"> - <div class="flex items-center gap-x-4"> - <Icon name="circle-ban-sign" class="text-icon-critical-base" /> - <span>Authorization failed: {store.error}</span> - </div> - </div> - </Match> - <Match when={store.method?.type === "api"}> - {iife(() => { - const [formStore, setFormStore] = createStore({ - value: "", - error: undefined as string | undefined, - }) - - async function handleSubmit(e: SubmitEvent) { - e.preventDefault() + </div> + </Match> + <Match when={method()?.type === "api"}> + {iife(() => { + const [formStore, setFormStore] = createStore({ + value: "", + error: undefined as string | undefined, + }) - const form = e.currentTarget as HTMLFormElement - const formData = new FormData(form) - const apiKey = formData.get("apiKey") as string + async function handleSubmit(e: SubmitEvent) { + e.preventDefault() - if (!apiKey?.trim()) { - setFormStore("error", "API key is required") - return - } + const form = e.currentTarget as HTMLFormElement + const formData = new FormData(form) + const apiKey = formData.get("apiKey") as string - setFormStore("error", undefined) - await globalSDK.client.auth.set({ - providerID: props.provider, - auth: { - type: "api", - key: apiKey, - }, - }) - await complete() + if (!apiKey?.trim()) { + setFormStore("error", "API key is required") + return } - return ( - <div class="flex flex-col gap-6"> - <Switch> - <Match when={provider().id === "opencode"}> - <div class="flex flex-col gap-4"> - <div class="text-14-regular text-text-base"> - OpenCode Zen gives you access to a curated set of reliable optimized models for coding - agents. - </div> - <div class="text-14-regular text-text-base"> - With a single API key you'll get access to models such as Claude, GPT, Gemini, GLM and - more. - </div> - <div class="text-14-regular text-text-base"> - Visit{" "} - <Link href="https://opencode.ai/zen" tabIndex={-1}> - opencode.ai/zen - </Link>{" "} - to collect your API key. - </div> + setFormStore("error", undefined) + await globalSDK.client.auth.set({ + providerID: props.provider, + auth: { + type: "api", + key: apiKey, + }, + }) + await complete() + } + + return ( + <div class="flex flex-col gap-6"> + <Switch> + <Match when={provider().id === "opencode"}> + <div class="flex flex-col gap-4"> + <div class="text-14-regular text-text-base"> + OpenCode Zen gives you access to a curated set of reliable optimized models for coding + agents. </div> - </Match> - <Match when={true}> <div class="text-14-regular text-text-base"> - Enter your {provider().name} API key to connect your account and use {provider().name}{" "} - models in OpenCode. + With a single API key you'll get access to models such as Claude, GPT, Gemini, GLM and more. </div> - </Match> - </Switch> - <form onSubmit={handleSubmit} class="flex flex-col items-start gap-4"> - <TextField - autofocus - type="text" - label={`${provider().name} API key`} - placeholder="API key" - name="apiKey" - value={formStore.value} - onChange={setFormStore.bind(null, "value")} - validationState={formStore.error ? "invalid" : undefined} - error={formStore.error} - /> - <Button class="w-auto" type="submit" size="large" variant="primary"> - Submit - </Button> - </form> - </div> - ) - })} - </Match> - <Match when={store.method?.type === "oauth"}> - <Switch> - <Match when={store.authorization?.method === "code"}> - {iife(() => { - const [formStore, setFormStore] = createStore({ - value: "", - error: undefined as string | undefined, - }) + <div class="text-14-regular text-text-base"> + Visit{" "} + <Link href="https://opencode.ai/zen" tabIndex={-1}> + opencode.ai/zen + </Link>{" "} + to collect your API key. + </div> + </div> + </Match> + <Match when={true}> + <div class="text-14-regular text-text-base"> + Enter your {provider().name} API key to connect your account and use {provider().name} models + in OpenCode. + </div> + </Match> + </Switch> + <form onSubmit={handleSubmit} class="flex flex-col items-start gap-4"> + <TextField + autofocus + type="text" + label={`${provider().name} API key`} + placeholder="API key" + name="apiKey" + value={formStore.value} + onChange={setFormStore.bind(null, "value")} + validationState={formStore.error ? "invalid" : undefined} + error={formStore.error} + /> + <Button class="w-auto" type="submit" size="large" variant="primary"> + Submit + </Button> + </form> + </div> + ) + })} + </Match> + <Match when={method()?.type === "oauth"}> + <Switch> + <Match when={store.authorization?.method === "code"}> + {iife(() => { + const [formStore, setFormStore] = createStore({ + value: "", + error: undefined as string | undefined, + }) - onMount(() => { - if (store.authorization?.method === "code" && store.authorization?.url) { - platform.openLink(store.authorization.url) - } - }) + onMount(() => { + if (store.authorization?.method === "code" && store.authorization?.url) { + platform.openLink(store.authorization.url) + } + }) - async function handleSubmit(e: SubmitEvent) { - e.preventDefault() + async function handleSubmit(e: SubmitEvent) { + e.preventDefault() - const form = e.currentTarget as HTMLFormElement - const formData = new FormData(form) - const code = formData.get("code") as string + const form = e.currentTarget as HTMLFormElement + const formData = new FormData(form) + const code = formData.get("code") as string - if (!code?.trim()) { - setFormStore("error", "Authorization code is required") - return - } + if (!code?.trim()) { + setFormStore("error", "Authorization code is required") + return + } - setFormStore("error", undefined) - const { error } = await globalSDK.client.provider.oauth.callback({ - providerID: props.provider, - method: methodIndex(), - code, - }) - if (!error) { - await complete() - return - } - setFormStore("error", "Invalid authorization code") + setFormStore("error", undefined) + const { error } = await globalSDK.client.provider.oauth.callback({ + providerID: props.provider, + method: store.methodIndex, + code, + }) + if (!error) { + await complete() + return } + setFormStore("error", "Invalid authorization code") + } - return ( - <div class="flex flex-col gap-6"> - <div class="text-14-regular text-text-base"> - Visit <Link href={store.authorization!.url}>this link</Link> to collect your authorization - code to connect your account and use {provider().name} models in OpenCode. - </div> - <form onSubmit={handleSubmit} class="flex flex-col items-start gap-4"> - <TextField - autofocus - type="text" - label={`${store.method?.label} authorization code`} - placeholder="Authorization code" - name="code" - value={formStore.value} - onChange={setFormStore.bind(null, "value")} - validationState={formStore.error ? "invalid" : undefined} - error={formStore.error} - /> - <Button class="w-auto" type="submit" size="large" variant="primary"> - Submit - </Button> - </form> + return ( + <div class="flex flex-col gap-6"> + <div class="text-14-regular text-text-base"> + Visit <Link href={store.authorization!.url}>this link</Link> to collect your authorization + code to connect your account and use {provider().name} models in OpenCode. </div> - ) - })} - </Match> - <Match when={store.authorization?.method === "auto"}> - {iife(() => { - const code = createMemo(() => { - const instructions = store.authorization?.instructions - if (instructions?.includes(":")) { - return instructions?.split(":")[1]?.trim() - } - return instructions - }) + <form onSubmit={handleSubmit} class="flex flex-col items-start gap-4"> + <TextField + autofocus + type="text" + label={`${method()?.label} authorization code`} + placeholder="Authorization code" + name="code" + value={formStore.value} + onChange={setFormStore.bind(null, "value")} + validationState={formStore.error ? "invalid" : undefined} + error={formStore.error} + /> + <Button class="w-auto" type="submit" size="large" variant="primary"> + Submit + </Button> + </form> + </div> + ) + })} + </Match> + <Match when={store.authorization?.method === "auto"}> + {iife(() => { + const code = createMemo(() => { + const instructions = store.authorization?.instructions + if (instructions?.includes(":")) { + return instructions?.split(":")[1]?.trim() + } + return instructions + }) - onMount(async () => { - const result = await globalSDK.client.provider.oauth.callback({ - providerID: props.provider, - method: methodIndex(), - }) - if (result.error) { - // TODO: show error - dialog.clear() - return - } - await complete() + onMount(async () => { + const result = await globalSDK.client.provider.oauth.callback({ + providerID: props.provider, + method: store.methodIndex, }) + if (result.error) { + // TODO: show error + dialog.clear() + return + } + await complete() + }) - return ( - <div class="flex flex-col gap-6"> - <div class="text-14-regular text-text-base"> - Visit <Link href={store.authorization!.url}>this link</Link> and enter the code below to - connect your account and use {provider().name} models in OpenCode. - </div> - <TextField label="Confirmation code" class="font-mono" value={code()} readOnly copyable /> - <div class="text-14-regular text-text-base flex items-center gap-4"> - <Spinner /> - <span>Waiting for authorization...</span> - </div> + return ( + <div class="flex flex-col gap-6"> + <div class="text-14-regular text-text-base"> + Visit <Link href={store.authorization!.url}>this link</Link> and enter the code below to + connect your account and use {provider().name} models in OpenCode. </div> - ) - })} - </Match> - </Switch> - </Match> - </Switch> - </div> + <TextField label="Confirmation code" class="font-mono" value={code()} readOnly copyable /> + <div class="text-14-regular text-text-base flex items-center gap-4"> + <Spinner /> + <span>Waiting for authorization...</span> + </div> + </div> + ) + })} + </Match> + </Switch> + </Match> + </Switch> </div> - </Dialog.Body> + </div> </Dialog> ) } diff --git a/packages/desktop/src/components/dialog-file-select.tsx b/packages/desktop/src/components/dialog-file-select.tsx deleted file mode 100644 index 3afe06062..000000000 --- a/packages/desktop/src/components/dialog-file-select.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { Component } from "solid-js" -import { useLocal } from "@/context/local" -import { Dialog } from "@opencode-ai/ui/dialog" -import { List } from "@opencode-ai/ui/list" -import { FileIcon } from "@opencode-ai/ui/file-icon" -import { getDirectory, getFilename } from "@opencode-ai/util/path" - -export const DialogFileSelect: Component<{ - onOpenChange?: (open: boolean) => void - onSelect?: (path: string) => void -}> = (props) => { - const local = useLocal() - let closeButton!: HTMLButtonElement - - return ( - <Dialog modal defaultOpen onOpenChange={props.onOpenChange}> - <Dialog.Header> - <Dialog.Title>Select file</Dialog.Title> - <Dialog.CloseButton ref={closeButton} tabIndex={-1} /> - </Dialog.Header> - <Dialog.Body> - <List - class="px-2.5" - search={{ placeholder: "Search files", autofocus: true }} - emptyMessage="No files found" - items={local.file.searchFiles} - key={(x) => x} - onSelect={(x) => { - if (x) { - props.onSelect?.(x) - } - closeButton.click() - }} - > - {(i) => ( - <div class="w-full flex items-center justify-between rounded-md"> - <div class="flex items-center gap-x-2 grow min-w-0"> - <FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" /> - <div class="flex items-center text-14-regular"> - <span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0"> - {getDirectory(i)} - </span> - <span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span> - </div> - </div> - </div> - )} - </List> - </Dialog.Body> - </Dialog> - ) -} diff --git a/packages/desktop/src/components/dialog-manage-models.tsx b/packages/desktop/src/components/dialog-manage-models.tsx index 2904f9a5b..de1c3cb15 100644 --- a/packages/desktop/src/components/dialog-manage-models.tsx +++ b/packages/desktop/src/components/dialog-manage-models.tsx @@ -1,6 +1,5 @@ import { Component } from "solid-js" import { useLocal } from "@/context/local" -import { useDialog } from "@/context/dialog" import { popularProviders } from "@/hooks/use-providers" import { Dialog } from "@opencode-ai/ui/dialog" import { List } from "@opencode-ai/ui/list" @@ -8,58 +7,41 @@ import { Switch } from "@opencode-ai/ui/switch" export const DialogManageModels: Component = () => { const local = useLocal() - const dialog = useDialog() - return ( - <Dialog - modal - defaultOpen - onOpenChange={(open) => { - if (!open) { - dialog.clear() - } - }} - > - <Dialog.Header> - <Dialog.Title>Manage models</Dialog.Title> - <Dialog.CloseButton tabIndex={-1} /> - </Dialog.Header> - <Dialog.Description>Customize which models appear in the model selector.</Dialog.Description> - <Dialog.Body> - <List - class="px-2.5" - search={{ placeholder: "Search models", autofocus: true }} - emptyMessage="No model results" - key={(x) => `${x?.provider?.id}:${x?.id}`} - items={local.model.list()} - filterKeys={["provider.name", "name", "id"]} - sortBy={(a, b) => a.name.localeCompare(b.name)} - groupBy={(x) => x.provider.name} - sortGroupsBy={(a, b) => { - const aProvider = a.items[0].provider.id - const bProvider = b.items[0].provider.id - if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1 - if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1 - return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider) - }} - onSelect={(x) => { - if (!x) return - local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, !x.visible) - }} - > - {(i) => ( - <div class="w-full flex items-center justify-between gap-x-2.5"> - <span>{i.name}</span> - <Switch - checked={!!i.visible} - onChange={(checked) => { - local.model.setVisibility({ modelID: i.id, providerID: i.provider.id }, checked) - }} - /> - </div> - )} - </List> - </Dialog.Body> + <Dialog title="Manage models" description="Customize which models appear in the model selector."> + <List + class="px-2.5" + search={{ placeholder: "Search models", autofocus: true }} + emptyMessage="No model results" + key={(x) => `${x?.provider?.id}:${x?.id}`} + items={local.model.list()} + filterKeys={["provider.name", "name", "id"]} + sortBy={(a, b) => a.name.localeCompare(b.name)} + groupBy={(x) => x.provider.name} + sortGroupsBy={(a, b) => { + const aProvider = a.items[0].provider.id + const bProvider = b.items[0].provider.id + if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1 + if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1 + return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider) + }} + onSelect={(x) => { + if (!x) return + local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, !x.visible) + }} + > + {(i) => ( + <div class="w-full flex items-center justify-between gap-x-2.5"> + <span>{i.name}</span> + <Switch + checked={!!i.visible} + onChange={(checked) => { + local.model.setVisibility({ modelID: i.id, providerID: i.provider.id }, checked) + }} + /> + </div> + )} + </List> </Dialog> ) } diff --git a/packages/desktop/src/components/dialog-model-unpaid.tsx b/packages/desktop/src/components/dialog-model-unpaid.tsx deleted file mode 100644 index d218770d9..000000000 --- a/packages/desktop/src/components/dialog-model-unpaid.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import { Component, onCleanup, onMount, Show } from "solid-js" -import { useLocal } from "@/context/local" -import { useDialog } from "@/context/dialog" -import { popularProviders, useProviders } from "@/hooks/use-providers" -import { Button } from "@opencode-ai/ui/button" -import { Tag } from "@opencode-ai/ui/tag" -import { Dialog } from "@opencode-ai/ui/dialog" -import { List, ListRef } from "@opencode-ai/ui/list" -import { ProviderIcon } from "@opencode-ai/ui/provider-icon" -import { IconName } from "@opencode-ai/ui/icons/provider" -import { DialogSelectProvider } from "./dialog-select-provider" -import { DialogConnect } from "./dialog-connect" - -export const DialogModelUnpaid: Component = () => { - const local = useLocal() - const dialog = useDialog() - const providers = useProviders() - - let listRef: ListRef | undefined - const handleKey = (e: KeyboardEvent) => { - if (e.key === "Escape") return - listRef?.onKeyDown(e) - } - - onMount(() => { - document.addEventListener("keydown", handleKey) - onCleanup(() => { - document.removeEventListener("keydown", handleKey) - }) - }) - - return ( - <Dialog - modal - defaultOpen - onOpenChange={(open) => { - if (!open) { - dialog.clear() - } - }} - > - <Dialog.Header> - <Dialog.Title>Select model</Dialog.Title> - <Dialog.CloseButton tabIndex={-1} /> - </Dialog.Header> - <Dialog.Body> - <div class="flex flex-col gap-3 px-2.5"> - <div class="text-14-medium text-text-base px-2.5">Free models provided by OpenCode</div> - <List - ref={(ref) => (listRef = ref)} - items={local.model.list} - current={local.model.current()} - key={(x) => `${x.provider.id}:${x.id}`} - onSelect={(x) => { - local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { - recent: true, - }) - dialog.clear() - }} - > - {(i) => ( - <div class="w-full flex items-center gap-x-2.5"> - <span>{i.name}</span> - <Tag>Free</Tag> - <Show when={i.latest}> - <Tag>Latest</Tag> - </Show> - </div> - )} - </List> - <div /> - <div /> - </div> - <div class="px-1.5 pb-1.5"> - <div class="w-full rounded-sm border border-border-weak-base bg-surface-raised-base"> - <div class="w-full flex flex-col items-start gap-4 px-1.5 pt-4 pb-4"> - <div class="px-2 text-14-medium text-text-base">Add more models from popular providers</div> - <div class="w-full"> - <List - class="w-full" - key={(x) => x?.id} - items={providers.popular} - activeIcon="plus-small" - sortBy={(a, b) => { - if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) - return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id) - return a.name.localeCompare(b.name) - }} - onSelect={(x) => { - if (!x) return - dialog.replace(() => <DialogConnect provider={x.id} />) - }} - > - {(i) => ( - <div class="w-full flex items-center gap-x-4"> - <ProviderIcon - data-slot="list-item-extra-icon" - id={i.id as IconName} - // TODO: clean this up after we update icon in models.dev - classList={{ - "text-icon-weak-base": true, - "size-4 mx-0.5": i.id === "opencode", - "size-5": i.id !== "opencode", - }} - /> - <span>{i.name}</span> - <Show when={i.id === "opencode"}> - <Tag>Recommended</Tag> - </Show> - <Show when={i.id === "anthropic"}> - <div class="text-14-regular text-text-weak">Connect with Claude Pro/Max or API key</div> - </Show> - </div> - )} - </List> - <Button - variant="ghost" - class="w-full justify-start px-[11px] py-3.5 gap-4.5 text-14-medium" - icon="dot-grid" - onClick={() => { - dialog.replace(() => <DialogSelectProvider />) - }} - > - View all providers - </Button> - </div> - </div> - </div> - </div> - </Dialog.Body> - </Dialog> - ) -} diff --git a/packages/desktop/src/components/dialog-model.tsx b/packages/desktop/src/components/dialog-model.tsx deleted file mode 100644 index e8f9df055..000000000 --- a/packages/desktop/src/components/dialog-model.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { Component, createMemo, Show } from "solid-js" -import { useLocal } from "@/context/local" -import { useDialog } from "@/context/dialog" -import { popularProviders } from "@/hooks/use-providers" -import { Button } from "@opencode-ai/ui/button" -import { Tag } from "@opencode-ai/ui/tag" -import { Dialog } from "@opencode-ai/ui/dialog" -import { List } from "@opencode-ai/ui/list" -import { DialogSelectProvider } from "./dialog-select-provider" -import { DialogManageModels } from "./dialog-manage-models" - -export const DialogModel: Component<{ provider?: string }> = (props) => { - const local = useLocal() - const dialog = useDialog() - - let closeButton!: HTMLButtonElement - const models = createMemo(() => - local.model - .list() - .filter((m) => m.visible) - .filter((m) => (props.provider ? m.provider.id === props.provider : true)), - ) - - return ( - <Dialog - modal - defaultOpen - onOpenChange={(open) => { - if (!open) { - dialog.clear() - } - }} - > - <Dialog.Header> - <Dialog.Title>Select model</Dialog.Title> - <Button - class="h-7 -my-1 text-14-medium" - icon="plus-small" - tabIndex={-1} - onClick={() => dialog.replace(() => <DialogSelectProvider />)} - > - Connect provider - </Button> - <Dialog.CloseButton ref={closeButton} tabIndex={-1} style={{ display: "none" }} /> - </Dialog.Header> - <Dialog.Body> - <List - class="px-2.5" - search={{ placeholder: "Search models", autofocus: true }} - emptyMessage="No model results" - key={(x) => `${x.provider.id}:${x.id}`} - items={models} - current={local.model.current()} - filterKeys={["provider.name", "name", "id"]} - sortBy={(a, b) => a.name.localeCompare(b.name)} - groupBy={(x) => x.provider.name} - sortGroupsBy={(a, b) => { - if (a.category === "Recent" && b.category !== "Recent") return -1 - if (b.category === "Recent" && a.category !== "Recent") return 1 - const aProvider = a.items[0].provider.id - const bProvider = b.items[0].provider.id - if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1 - if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1 - return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider) - }} - onSelect={(x) => { - local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { - recent: true, - }) - closeButton.click() - }} - > - {(i) => ( - <div class="w-full flex items-center gap-x-2.5"> - <span>{i.name}</span> - <Show when={i.provider.id === "opencode" && (!i.cost || i.cost?.input === 0)}> - <Tag>Free</Tag> - </Show> - <Show when={i.latest}> - <Tag>Latest</Tag> - </Show> - </div> - )} - </List> - <Button - variant="ghost" - class="ml-2.5 mt-5 mb-6 text-text-base self-start" - onClick={() => dialog.replace(() => <DialogManageModels />)} - > - Manage models - </Button> - </Dialog.Body> - </Dialog> - ) -} diff --git a/packages/desktop/src/components/dialog-select-file.tsx b/packages/desktop/src/components/dialog-select-file.tsx new file mode 100644 index 000000000..0250963b0 --- /dev/null +++ b/packages/desktop/src/components/dialog-select-file.tsx @@ -0,0 +1,44 @@ +import { useLocal } from "@/context/local" +import { Dialog } from "@opencode-ai/ui/dialog" +import { List } from "@opencode-ai/ui/list" +import { FileIcon } from "@opencode-ai/ui/file-icon" +import { getDirectory, getFilename } from "@opencode-ai/util/path" +import { useSession } from "@/context/session" +import { useDialog } from "@opencode-ai/ui/context/dialog" + +export function DialogSelectFile() { + const session = useSession() + const local = useLocal() + const dialog = useDialog() + return ( + <Dialog title="Select file"> + <List + class="px-2.5" + search={{ placeholder: "Search files", autofocus: true }} + emptyMessage="No files found" + items={local.file.searchFiles} + key={(x) => x} + onSelect={(path) => { + if (path) { + session.layout.openTab("file://" + path) + } + dialog.clear() + }} + > + {(i) => ( + <div class="w-full flex items-center justify-between rounded-md"> + <div class="flex items-center gap-x-2 grow min-w-0"> + <FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" /> + <div class="flex items-center text-14-regular"> + <span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0"> + {getDirectory(i)} + </span> + <span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span> + </div> + </div> + </div> + )} + </List> + </Dialog> + ) +} diff --git a/packages/desktop/src/components/dialog-select-model-unpaid.tsx b/packages/desktop/src/components/dialog-select-model-unpaid.tsx new file mode 100644 index 000000000..1c9e9cc75 --- /dev/null +++ b/packages/desktop/src/components/dialog-select-model-unpaid.tsx @@ -0,0 +1,119 @@ +import { Component, onCleanup, onMount, Show } from "solid-js" +import { useLocal } from "@/context/local" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { popularProviders, useProviders } from "@/hooks/use-providers" +import { Button } from "@opencode-ai/ui/button" +import { Tag } from "@opencode-ai/ui/tag" +import { Dialog } from "@opencode-ai/ui/dialog" +import { List, ListRef } from "@opencode-ai/ui/list" +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { IconName } from "@opencode-ai/ui/icons/provider" +import { DialogSelectProvider } from "./dialog-select-provider" +import { DialogConnect } from "./dialog-connect" + +export const DialogSelectModelUnpaid: Component = () => { + const local = useLocal() + const dialog = useDialog() + const providers = useProviders() + + let listRef: ListRef | undefined + const handleKey = (e: KeyboardEvent) => { + if (e.key === "Escape") return + listRef?.onKeyDown(e) + } + + onMount(() => { + document.addEventListener("keydown", handleKey) + onCleanup(() => { + document.removeEventListener("keydown", handleKey) + }) + }) + + return ( + <Dialog title="Select model"> + <div class="flex flex-col gap-3 px-2.5"> + <div class="text-14-medium text-text-base px-2.5">Free models provided by OpenCode</div> + <List + ref={(ref) => (listRef = ref)} + items={local.model.list} + current={local.model.current()} + key={(x) => `${x.provider.id}:${x.id}`} + onSelect={(x) => { + local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { + recent: true, + }) + dialog.clear() + }} + > + {(i) => ( + <div class="w-full flex items-center gap-x-2.5"> + <span>{i.name}</span> + <Tag>Free</Tag> + <Show when={i.latest}> + <Tag>Latest</Tag> + </Show> + </div> + )} + </List> + <div /> + <div /> + </div> + <div class="px-1.5 pb-1.5"> + <div class="w-full rounded-sm border border-border-weak-base bg-surface-raised-base"> + <div class="w-full flex flex-col items-start gap-4 px-1.5 pt-4 pb-4"> + <div class="px-2 text-14-medium text-text-base">Add more models from popular providers</div> + <div class="w-full"> + <List + class="w-full" + key={(x) => x?.id} + items={providers.popular} + activeIcon="plus-small" + sortBy={(a, b) => { + if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) + return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id) + return a.name.localeCompare(b.name) + }} + onSelect={(x) => { + if (!x) return + dialog.replace(() => <DialogConnect provider={x.id} />) + }} + > + {(i) => ( + <div class="w-full flex items-center gap-x-4"> + <ProviderIcon + data-slot="list-item-extra-icon" + id={i.id as IconName} + // TODO: clean this up after we update icon in models.dev + classList={{ + "text-icon-weak-base": true, + "size-4 mx-0.5": i.id === "opencode", + "size-5": i.id !== "opencode", + }} + /> + <span>{i.name}</span> + <Show when={i.id === "opencode"}> + <Tag>Recommended</Tag> + </Show> + <Show when={i.id === "anthropic"}> + <div class="text-14-regular text-text-weak">Connect with Claude Pro/Max or API key</div> + </Show> + </div> + )} + </List> + <Button + variant="ghost" + class="w-full justify-start px-[11px] py-3.5 gap-4.5 text-14-medium" + icon="dot-grid" + onClick={() => { + dialog.replace(() => <DialogSelectProvider />) + }} + > + View all providers + </Button> + </div> + </div> + </div> + </div> + </Dialog> + ) +} diff --git a/packages/desktop/src/components/dialog-select-model.tsx b/packages/desktop/src/components/dialog-select-model.tsx new file mode 100644 index 000000000..805db47fe --- /dev/null +++ b/packages/desktop/src/components/dialog-select-model.tsx @@ -0,0 +1,85 @@ +import { Component, createMemo, Show } from "solid-js" +import { useLocal } from "@/context/local" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { popularProviders } from "@/hooks/use-providers" +import { Button } from "@opencode-ai/ui/button" +import { Tag } from "@opencode-ai/ui/tag" +import { Dialog } from "@opencode-ai/ui/dialog" +import { List } from "@opencode-ai/ui/list" +import { DialogSelectProvider } from "./dialog-select-provider" +import { DialogManageModels } from "./dialog-manage-models" + +export const DialogSelectModel: Component<{ provider?: string }> = (props) => { + const local = useLocal() + const dialog = useDialog() + + let closeButton!: HTMLButtonElement + const models = createMemo(() => + local.model + .list() + .filter((m) => m.visible) + .filter((m) => (props.provider ? m.provider.id === props.provider : true)), + ) + + return ( + <Dialog + title="Select model" + action={ + <Button + class="h-7 -my-1 text-14-medium" + icon="plus-small" + tabIndex={-1} + onClick={() => dialog.replace(() => <DialogSelectProvider />)} + > + Connect provider + </Button> + } + > + <List + class="px-2.5" + search={{ placeholder: "Search models", autofocus: true }} + emptyMessage="No model results" + key={(x) => `${x.provider.id}:${x.id}`} + items={models} + current={local.model.current()} + filterKeys={["provider.name", "name", "id"]} + sortBy={(a, b) => a.name.localeCompare(b.name)} + groupBy={(x) => x.provider.name} + sortGroupsBy={(a, b) => { + if (a.category === "Recent" && b.category !== "Recent") return -1 + if (b.category === "Recent" && a.category !== "Recent") return 1 + const aProvider = a.items[0].provider.id + const bProvider = b.items[0].provider.id + if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1 + if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1 + return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider) + }} + onSelect={(x) => { + local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { + recent: true, + }) + closeButton.click() + }} + > + {(i) => ( + <div class="w-full flex items-center gap-x-2.5"> + <span>{i.name}</span> + <Show when={i.provider.id === "opencode" && (!i.cost || i.cost?.input === 0)}> + <Tag>Free</Tag> + </Show> + <Show when={i.latest}> + <Tag>Latest</Tag> + </Show> + </div> + )} + </List> + <Button + variant="ghost" + class="ml-3 mt-5 mb-6 text-text-base self-start" + onClick={() => dialog.replace(() => <DialogManageModels />)} + > + Manage models + </Button> + </Dialog> + ) +} diff --git a/packages/desktop/src/components/dialog-select-provider.tsx b/packages/desktop/src/components/dialog-select-provider.tsx index 1c54184bd..292d5fccb 100644 --- a/packages/desktop/src/components/dialog-select-provider.tsx +++ b/packages/desktop/src/components/dialog-select-provider.tsx @@ -1,5 +1,5 @@ import { Component, Show } from "solid-js" -import { useDialog } from "@/context/dialog" +import { useDialog } from "@opencode-ai/ui/context/dialog" import { popularProviders, useProviders } from "@/hooks/use-providers" import { Dialog } from "@opencode-ai/ui/dialog" import { List } from "@opencode-ai/ui/list" @@ -13,66 +13,52 @@ export const DialogSelectProvider: Component = () => { const providers = useProviders() return ( - <Dialog - modal - defaultOpen - onOpenChange={(open) => { - if (!open) { - dialog.clear() - } - }} - > - <Dialog.Header> - <Dialog.Title>Connect provider</Dialog.Title> - <Dialog.CloseButton tabIndex={-1} /> - </Dialog.Header> - <Dialog.Body> - <List - class="px-2.5" - search={{ placeholder: "Search providers", autofocus: true }} - activeIcon="plus-small" - key={(x) => x?.id} - items={providers.all} - filterKeys={["id", "name"]} - groupBy={(x) => (popularProviders.includes(x.id) ? "Popular" : "Other")} - sortBy={(a, b) => { - if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) - return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id) - return a.name.localeCompare(b.name) - }} - sortGroupsBy={(a, b) => { - if (a.category === "Popular" && b.category !== "Popular") return -1 - if (b.category === "Popular" && a.category !== "Popular") return 1 - return 0 - }} - onSelect={(x) => { - if (!x) return - dialog.replace(() => <DialogConnect provider={x.id} />) - }} - > - {(i) => ( - <div class="px-1.25 w-full flex items-center gap-x-4"> - <ProviderIcon - data-slot="list-item-extra-icon" - id={i.id as IconName} - // TODO: clean this up after we update icon in models.dev - classList={{ - "text-icon-weak-base": true, - "size-4 mx-0.5": i.id === "opencode", - "size-5": i.id !== "opencode", - }} - /> - <span>{i.name}</span> - <Show when={i.id === "opencode"}> - <Tag>Recommended</Tag> - </Show> - <Show when={i.id === "anthropic"}> - <div class="text-14-regular text-text-weak">Connect with Claude Pro/Max or API key</div> - </Show> - </div> - )} - </List> - </Dialog.Body> + <Dialog title="Connect provider"> + <List + class="px-2.5" + search={{ placeholder: "Search providers", autofocus: true }} + activeIcon="plus-small" + key={(x) => x?.id} + items={providers.all} + filterKeys={["id", "name"]} + groupBy={(x) => (popularProviders.includes(x.id) ? "Popular" : "Other")} + sortBy={(a, b) => { + if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) + return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id) + return a.name.localeCompare(b.name) + }} + sortGroupsBy={(a, b) => { + if (a.category === "Popular" && b.category !== "Popular") return -1 + if (b.category === "Popular" && a.category !== "Popular") return 1 + return 0 + }} + onSelect={(x) => { + if (!x) return + dialog.replace(() => <DialogConnect provider={x.id} />) + }} + > + {(i) => ( + <div class="px-1.25 w-full flex items-center gap-x-4"> + <ProviderIcon + data-slot="list-item-extra-icon" + id={i.id as IconName} + // TODO: clean this up after we update icon in models.dev + classList={{ + "text-icon-weak-base": true, + "size-4 mx-0.5": i.id === "opencode", + "size-5": i.id !== "opencode", + }} + /> + <span>{i.name}</span> + <Show when={i.id === "opencode"}> + <Tag>Recommended</Tag> + </Show> + <Show when={i.id === "anthropic"}> + <div class="text-14-regular text-text-weak">Connect with Claude Pro/Max or API key</div> + </Show> + </div> + )} + </List> </Dialog> ) } diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index faecd9520..296fe8b2f 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -15,9 +15,9 @@ import { Tooltip } from "@opencode-ai/ui/tooltip" import { IconButton } from "@opencode-ai/ui/icon-button" import { Select } from "@opencode-ai/ui/select" import { getDirectory, getFilename } from "@opencode-ai/util/path" -import { useDialog } from "@/context/dialog" -import { DialogModel } from "@/components/dialog-model" -import { DialogModelUnpaid } from "@/components/dialog-model-unpaid" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { DialogSelectModel } from "@/components/dialog-select-model" +import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid" import { useProviders } from "@/hooks/use-providers" interface PromptInputProps { @@ -616,7 +616,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => { <Button as="div" variant="ghost" - onClick={() => dialog.push(() => (providers.paid().length > 0 ? <DialogModel /> : <DialogModelUnpaid />))} + onClick={() => + dialog.push(() => (providers.paid().length > 0 ? <DialogSelectModel /> : <DialogSelectModelUnpaid />)) + } > {local.model.current()?.name ?? "Select model"} <span class="ml-0.5 text-text-weak text-12-regular">{local.model.current()?.provider.name}</span> diff --git a/packages/desktop/src/pages/directory-layout.tsx b/packages/desktop/src/pages/directory-layout.tsx index 7b8d2ab9e..0dbb3f6d6 100644 --- a/packages/desktop/src/pages/directory-layout.tsx +++ b/packages/desktop/src/pages/directory-layout.tsx @@ -6,7 +6,7 @@ import { LocalProvider } from "@/context/local" import { base64Decode } from "@opencode-ai/util/encode" import { DataProvider } from "@opencode-ai/ui/context" import { iife } from "@opencode-ai/util/iife" -import { DialogRoot } from "@/context/dialog" +import { DialogRoot } from "@opencode-ai/ui/context/dialog" export default function Layout(props: ParentProps) { const params = useParams() diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx index c36cc234e..7af562d57 100644 --- a/packages/desktop/src/pages/layout.tsx +++ b/packages/desktop/src/pages/layout.tsx @@ -33,7 +33,7 @@ import { useGlobalSDK } from "@/context/global-sdk" import { useNotification } from "@/context/notification" import { Binary } from "@opencode-ai/util/binary" import { Header } from "@/components/header" -import { useDialog } from "@/context/dialog" +import { useDialog } from "@opencode-ai/ui/context/dialog" import { DialogSelectProvider } from "@/components/dialog-select-provider" export default function Layout(props: ParentProps) { diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx index c4adea000..a21135f76 100644 --- a/packages/desktop/src/pages/session.tsx +++ b/packages/desktop/src/pages/session.tsx @@ -15,7 +15,6 @@ import { Code } from "@opencode-ai/ui/code" import { SessionTurn } from "@opencode-ai/ui/session-turn" import { SessionMessageRail } from "@opencode-ai/ui/session-message-rail" import { SessionReview } from "@opencode-ai/ui/session-review" -import { DialogFileSelect } from "@/components/dialog-file-select" import { DragDropProvider, DragDropSensors, @@ -33,15 +32,17 @@ import { useLayout } from "@/context/layout" import { getDirectory, getFilename } from "@opencode-ai/util/path" import { Terminal } from "@/components/terminal" import { checksum } from "@opencode-ai/util/encode" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { DialogSelectFile } from "@/components/dialog-select-file" export default function Page() { const layout = useLayout() const local = useLocal() const sync = useSync() const session = useSession() + const dialog = useDialog() const [store, setStore] = createStore({ clickTimer: undefined as number | undefined, - fileSelectOpen: false, activeDraggable: undefined as string | undefined, activeTerminalDraggable: undefined as string | undefined, }) @@ -72,7 +73,7 @@ export default function Page() { } if (event.getModifierState(MOD) && event.key.toLowerCase() === "p") { event.preventDefault() - setStore("fileSelectOpen", true) + dialog.replace(() => <DialogSelectFile />) return } if (event.ctrlKey && event.key.toLowerCase() === "t") { @@ -388,7 +389,7 @@ export default function Page() { icon="plus-small" variant="ghost" iconSize="large" - onClick={() => setStore("fileSelectOpen", true)} + onClick={() => dialog.replace(() => <DialogSelectFile />)} /> </Tooltip> </div> @@ -610,12 +611,6 @@ export default function Page() { </ul> </Show> </div> - <Show when={store.fileSelectOpen}> - <DialogFileSelect - onOpenChange={(open) => setStore("fileSelectOpen", open)} - onSelect={(path) => session.layout.openTab("file://" + path)} - /> - </Show> </div> <Show when={layout.terminal.opened()}> <div diff --git a/packages/ui/src/components/dialog.css b/packages/ui/src/components/dialog.css index fa5e1171e..6fa71c64c 100644 --- a/packages/ui/src/components/dialog.css +++ b/packages/ui/src/components/dialog.css @@ -60,6 +60,7 @@ [data-slot="dialog-header"] { display: flex; padding: 16px; + padding-left: 20px; justify-content: space-between; align-items: center; flex-shrink: 0; @@ -82,6 +83,7 @@ [data-slot="dialog-description"] { display: flex; padding: 16px; + padding-left: 20px; padding-top: 0; margin-top: -8px; justify-content: space-between; diff --git a/packages/ui/src/components/dialog.tsx b/packages/ui/src/components/dialog.tsx index aebb77885..47d6af42e 100644 --- a/packages/ui/src/components/dialog.tsx +++ b/packages/ui/src/components/dialog.tsx @@ -1,96 +1,45 @@ -import { - Dialog as Kobalte, - DialogRootProps, - DialogTitleProps, - DialogCloseButtonProps, - DialogDescriptionProps, -} from "@kobalte/core/dialog" -import { ComponentProps, type JSX, onCleanup, onMount, Show, splitProps } from "solid-js" +import { Dialog as Kobalte } from "@kobalte/core/dialog" +import { ComponentProps, JSXElement, Match, ParentProps, Show, Switch } from "solid-js" import { IconButton } from "./icon-button" -export interface DialogProps extends DialogRootProps { - trigger?: JSX.Element +export interface DialogProps extends ParentProps { + title?: JSXElement + description?: JSXElement + action?: JSXElement class?: ComponentProps<"div">["class"] classList?: ComponentProps<"div">["classList"] } -function DialogRoot(props: DialogProps) { - let trigger!: HTMLElement - const [local, others] = splitProps(props, ["trigger", "class", "classList", "children"]) - - const resetTabIndex = () => { - trigger.tabIndex = 0 - } - - const handleTriggerFocus = (e: FocusEvent & { currentTarget: HTMLElement | null }) => { - const firstChild = e.currentTarget?.firstElementChild as HTMLElement - if (!firstChild) return - - firstChild.focus() - trigger.tabIndex = -1 - - firstChild.addEventListener("focusout", resetTabIndex) - onCleanup(() => { - firstChild.removeEventListener("focusout", resetTabIndex) - }) - } - - onMount(() => { - // @ts-ignore - document?.activeElement?.blur?.() - }) - +export function Dialog(props: DialogProps) { return ( - <Kobalte {...others}> - <Show when={props.trigger}> - <Kobalte.Trigger ref={trigger} data-component="dialog-trigger" onFocusIn={handleTriggerFocus}> - {props.trigger} - </Kobalte.Trigger> - </Show> - <Kobalte.Portal> - <Kobalte.Overlay data-component="dialog-overlay" /> - <div data-component="dialog"> - <div data-slot="dialog-container"> - <Kobalte.Content - data-slot="dialog-content" - classList={{ - ...(local.classList ?? {}), - [local.class ?? ""]: !!local.class, - }} - > - {local.children} - </Kobalte.Content> - </div> - </div> - </Kobalte.Portal> - </Kobalte> + <div data-component="dialog"> + <div data-slot="dialog-container"> + <Kobalte.Content + data-slot="dialog-content" + classList={{ + ...(props.classList ?? {}), + [props.class ?? ""]: !!props.class, + }} + > + <Show when={props.title || props.action}> + <div data-slot="dialog-header"> + <Show when={props.title}> + <Kobalte.Title data-slot="dialog-title">{props.title}</Kobalte.Title> + </Show> + <Switch> + <Match when={props.action}>{props.action}</Match> + <Match when={true}> + <Kobalte.CloseButton data-slot="dialog-close-button" as={IconButton} icon="close" variant="ghost" /> + </Match> + </Switch> + </div> + </Show> + <Show when={props.description}> + <Kobalte.Description data-slot="dialog-description">{props.description}</Kobalte.Description> + </Show> + <div data-slot="dialog-body">{props.children}</div> + </Kobalte.Content> + </div> + </div> ) } - -function DialogHeader(props: ComponentProps<"div">) { - return <div data-slot="dialog-header" {...props} /> -} - -function DialogBody(props: ComponentProps<"div">) { - return <div data-slot="dialog-body" {...props} /> -} - -function DialogTitle(props: DialogTitleProps & ComponentProps<"h2">) { - return <Kobalte.Title data-slot="dialog-title" {...props} /> -} - -function DialogDescription(props: DialogDescriptionProps & ComponentProps<"p">) { - return <Kobalte.Description data-slot="dialog-description" {...props} /> -} - -function DialogCloseButton(props: DialogCloseButtonProps & ComponentProps<"button">) { - return <Kobalte.CloseButton data-slot="dialog-close-button" as={IconButton} icon="close" variant="ghost" {...props} /> -} - -export const Dialog = Object.assign(DialogRoot, { - Header: DialogHeader, - Title: DialogTitle, - Description: DialogDescription, - CloseButton: DialogCloseButton, - Body: DialogBody, -}) diff --git a/packages/desktop/src/context/dialog.tsx b/packages/ui/src/context/dialog.tsx index cc49764fe..af5da06f9 100644 --- a/packages/desktop/src/context/dialog.tsx +++ b/packages/ui/src/context/dialog.tsx @@ -1,4 +1,4 @@ -import { createEffect, For, onCleanup, Show, type JSX } from "solid-js" +import { For, Show, type JSX } from "solid-js" import { createStore } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" @@ -14,23 +14,6 @@ export const { use: useDialog, provider: DialogProvider } = createSimpleContext( }[], }) - function handleKeyDown(e: KeyboardEvent) { - if (e.key === "Escape" && store.stack.length > 0) { - const current = store.stack.at(-1)! - current.onClose?.() - setStore("stack", store.stack.slice(0, -1)) - e.preventDefault() - e.stopPropagation() - } - } - - createEffect(() => { - document.addEventListener("keydown", handleKeyDown, true) - onCleanup(() => { - document.removeEventListener("keydown", handleKeyDown, true) - }) - }) - return { get stack() { return store.stack @@ -59,6 +42,8 @@ export const { use: useDialog, provider: DialogProvider } = createSimpleContext( }, }) +import { Dialog as Kobalte } from "@kobalte/core/dialog" + export function DialogRoot(props: { children?: JSX.Element }) { const dialog = useDialog() return ( @@ -69,7 +54,21 @@ export function DialogRoot(props: { children?: JSX.Element }) { <For each={dialog.stack}> {(item, index) => ( <Show when={index() === dialog.stack.length - 1}> - {typeof item.element === "function" ? item.element() : item.element} + <Kobalte + modal + defaultOpen + onOpenChange={(open) => { + if (!open) { + item.onClose?.() + dialog.pop() + } + }} + > + <Kobalte.Portal> + <Kobalte.Overlay data-component="dialog-overlay" /> + {typeof item.element === "function" ? item.element() : item.element} + </Kobalte.Portal> + </Kobalte> </Show> )} </For> diff --git a/packages/ui/src/context/index.ts b/packages/ui/src/context/index.ts index 3e0f5de74..499cb74d4 100644 --- a/packages/ui/src/context/index.ts +++ b/packages/ui/src/context/index.ts @@ -1,3 +1,4 @@ export * from "./helper" export * from "./data" export * from "./diff" +export * from "./dialog" |
