wip(desktop): progress

This commit is contained in:
Adam 2025-12-14 19:33:40 -06:00
parent 7ade6d386d
commit 4246cdb069
No known key found for this signature in database
GPG key ID: 9CB48779AF150E75
20 changed files with 726 additions and 479 deletions

View file

@ -117,7 +117,7 @@ export const DialogConnect: Component<{ provider: string }> = (props) => {
title: `${provider().name} connected`,
description: `${provider().name} models are now available to use.`,
})
dialog.replace(() => <DialogModel connectedProvider={props.provider} />)
dialog.replace(() => <DialogModel provider={props.provider} />)
}, 500)
}

View file

@ -0,0 +1,52 @@
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>
)
}

View file

@ -0,0 +1,65 @@
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"
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>
)
}

View file

@ -0,0 +1,133 @@
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>
)
}

View file

@ -1,208 +1,95 @@
import { Component, createMemo, Match, onCleanup, onMount, Show, Switch } from "solid-js"
import { Component, createMemo, Show } from "solid-js"
import { useLocal } from "@/context/local"
import { useDialog } from "@/context/dialog"
import { popularProviders, useProviders } from "@/hooks/use-providers"
import { SelectDialog } from "@opencode-ai/ui/select-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, ListRef } from "@opencode-ai/ui/list"
import { iife } from "@opencode-ai/util/iife"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { IconName } from "@opencode-ai/ui/icons/provider"
import { List } from "@opencode-ai/ui/list"
import { DialogSelectProvider } from "./dialog-select-provider"
import { DialogConnect } from "./dialog-connect"
import { DialogManageModels } from "./dialog-manage-models"
export const DialogModel: Component<{ connectedProvider?: string }> = (props) => {
export const DialogModel: Component<{ provider?: string }> = (props) => {
const local = useLocal()
const dialog = useDialog()
const providers = useProviders()
let closeButton!: HTMLButtonElement
const models = createMemo(() =>
local.model
.list()
.filter((m) => m.visible)
.filter((m) => (props.provider ? m.provider.id === props.provider : true)),
)
return (
<Switch>
<Match when={providers.paid().length > 0}>
{iife(() => {
const models = createMemo(() =>
local.model
.list()
.filter((m) => m.visible)
.filter((m) => (props.connectedProvider ? m.provider.id === props.connectedProvider : true)),
)
return (
<SelectDialog
defaultOpen
onOpenChange={(open) => {
if (!open) {
dialog.clear()
}
}}
title="Select model"
placeholder="Search models"
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,
})
}
actions={
<Button
class="h-7 -my-1 text-14-medium"
icon="plus-small"
tabIndex={-1}
onClick={() => dialog.replace(() => <DialogSelectProvider />)}
>
Connect provider
</Button>
}
>
{(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>
)}
</SelectDialog>
)
})}
</Match>
<Match when={true}>
{iife(() => {
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)
<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,
})
})
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>
)
})}
</Match>
</Switch>
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>
)
}

View file

@ -1,7 +1,8 @@
import { Component, Show } from "solid-js"
import { useDialog } from "@/context/dialog"
import { popularProviders, useProviders } from "@/hooks/use-providers"
import { SelectDialog } from "@opencode-ai/ui/select-dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
import { Tag } from "@opencode-ai/ui/tag"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { IconName } from "@opencode-ai/ui/icons/provider"
@ -12,56 +13,66 @@ export const DialogSelectProvider: Component = () => {
const providers = useProviders()
return (
<SelectDialog
<Dialog
modal
defaultOpen
title="Connect provider"
placeholder="Search providers"
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} />)
}}
onOpenChange={(open) => {
if (!open) {
dialog.clear()
}
}}
>
{(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>
)}
</SelectDialog>
<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>
)
}

View file

@ -17,6 +17,8 @@ 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 { useProviders } from "@/hooks/use-providers"
interface PromptInputProps {
class?: string
@ -58,6 +60,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const local = useLocal()
const session = useSession()
const dialog = useDialog()
const providers = useProviders()
let editorRef!: HTMLDivElement
const [store, setStore] = createStore<{
@ -610,7 +613,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
class="capitalize"
variant="ghost"
/>
<Button as="div" variant="ghost" onClick={() => dialog.push(() => <DialogModel />)}>
<Button
as="div"
variant="ghost"
onClick={() => dialog.push(() => (providers.paid().length > 0 ? <DialogModel /> : <DialogModelUnpaid />))}
>
{local.model.current()?.name ?? "Select model"}
<span class="ml-0.5 text-text-weak text-12-regular">{local.model.current()?.provider.name}</span>
<Icon name="chevron-down" size="small" />

View file

@ -239,7 +239,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
function updateVisibility(model: ModelKey, visibility: "show" | "hide") {
const index = store.user.findIndex((x) => x.modelID === model.modelID && x.providerID === model.providerID)
if (index >= 0) {
setStore("user", index, { visibility: visibility })
setStore("user", index, { visibility })
} else {
setStore("user", (prev) => [...prev, { ...model, visibility }])
}
}
@ -264,6 +266,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
hide(model: ModelKey) {
updateVisibility(model, "hide")
},
setVisibility(model: ModelKey, visible: boolean) {
updateVisibility(model, visible ? "show" : "hide")
},
}
})()

View file

@ -15,7 +15,7 @@ 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 { SelectDialog } from "@opencode-ai/ui/select-dialog"
import { DialogFileSelect } from "@/components/dialog-file-select"
import {
DragDropProvider,
DragDropSensors,
@ -611,40 +611,10 @@ export default function Page() {
</Show>
</div>
<Show when={store.fileSelectOpen}>
<SelectDialog
defaultOpen
title="Select file"
placeholder="Search files"
emptyMessage="No files found"
items={local.file.searchFiles}
key={(x) => x}
<DialogFileSelect
onOpenChange={(open) => setStore("fileSelectOpen", open)}
onSelect={(x) => {
if (x) {
return session.layout.openTab("file://" + x)
}
return undefined
}}
>
{(i) => (
<div
classList={{
"w-full flex items-center justify-between rounded-md": true,
}}
>
<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 class="flex items-center gap-x-1 text-text-muted/40 shrink-0"></div>
</div>
)}
</SelectDialog>
onSelect={(path) => session.layout.openTab("file://" + path)}
/>
</Show>
</div>
<Show when={layout.terminal.opened()}>

View file

@ -59,9 +59,7 @@
[data-slot="dialog-header"] {
display: flex;
/* height: 40px; */
/* padding: 4px 4px 4px 8px; */
padding: 20px;
padding: 16px;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
@ -80,7 +78,28 @@
}
/* [data-slot="dialog-close-button"] {} */
}
/* [data-slot="dialog-description"] {} */
[data-slot="dialog-description"] {
display: flex;
padding: 16px;
padding-top: 0;
margin-top: -8px;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
align-self: stretch;
color: var(--text-base);
/* text-14-regular */
font-family: var(--font-family-sans);
font-size: 14px;
font-style: normal;
font-weight: var(--font-weight-regular);
line-height: var(--line-height-large); /* 142.857% */
letter-spacing: var(--letter-spacing-normal);
}
[data-slot="dialog-body"] {
width: 100%;
position: relative;

View file

@ -2,6 +2,43 @@
display: flex;
flex-direction: column;
gap: 20px;
overflow: hidden;
[data-slot="list-search"] {
display: flex;
height: 40px;
flex-shrink: 0;
padding: 4px 10px 4px 16px;
align-items: center;
gap: 12px;
align-self: stretch;
border-radius: var(--radius-md);
background: var(--surface-base);
[data-slot="list-search-container"] {
display: flex;
align-items: center;
gap: 16px;
flex: 1 0 0;
[data-slot="list-search-input"] {
width: 100%;
}
}
}
[data-slot="list-scroll"] {
display: flex;
flex-direction: column;
gap: 20px;
overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
}
[data-slot="list-empty-state"] {
display: flex;
@ -41,6 +78,7 @@
[data-slot="list-header"] {
display: flex;
z-index: 10;
height: 28px;
padding: 0 10px;
justify-content: space-between;

View file

@ -2,6 +2,13 @@ import { createEffect, Show, For, type JSX, createSignal } from "solid-js"
import { createStore } from "solid-js/store"
import { FilteredListProps, useFilteredList } from "@opencode-ai/ui/hooks"
import { Icon, IconProps } from "./icon"
import { IconButton } from "./icon-button"
import { TextField } from "./text-field"
export interface ListSearchProps {
placeholder?: string
autofocus?: boolean
}
export interface ListProps<T> extends FilteredListProps<T> {
class?: string
@ -10,6 +17,7 @@ export interface ListProps<T> extends FilteredListProps<T> {
onKeyEvent?: (event: KeyboardEvent, item: T | undefined) => void
activeIcon?: IconProps["name"]
filter?: string
search?: ListSearchProps | boolean
}
export interface ListRef {
@ -19,23 +27,22 @@ export interface ListRef {
export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void }) {
const [scrollRef, setScrollRef] = createSignal<HTMLDivElement | undefined>(undefined)
const [internalFilter, setInternalFilter] = createSignal("")
const [store, setStore] = createStore({
mouseActive: false,
})
const { filter, grouped, flat, reset, active, setActive, onKeyDown, onInput } = useFilteredList<T>({
items: props.items,
key: props.key,
filterKeys: props.filterKeys,
current: props.current,
groupBy: props.groupBy,
sortBy: props.sortBy,
sortGroupsBy: props.sortGroupsBy,
})
const { filter, grouped, flat, reset, active, setActive, onKeyDown, onInput } = useFilteredList<T>(props)
const searchProps = () => (typeof props.search === "object" ? props.search : {})
const hasSearch = () => !!props.search
createEffect(() => {
if (props.filter === undefined) return
onInput(props.filter)
if (props.filter !== undefined) {
onInput(props.filter)
} else if (hasSearch()) {
onInput(internalFilter())
}
})
createEffect(() => {
@ -92,52 +99,78 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
})
return (
<div ref={setScrollRef} data-component="list" classList={{ [props.class ?? ""]: !!props.class }}>
<Show
when={flat().length > 0}
fallback={
<div data-slot="list-empty-state">
<div data-slot="list-message">
{props.emptyMessage ?? "No results"} for <span data-slot="list-filter">&quot;{filter()}&quot;</span>
</div>
<div data-component="list" classList={{ [props.class ?? ""]: !!props.class }}>
<Show when={hasSearch()}>
<div data-slot="list-search">
<div data-slot="list-search-container">
<Icon name="magnifying-glass" />
<TextField
autofocus={searchProps().autofocus}
variant="ghost"
data-slot="list-search-input"
type="text"
value={internalFilter()}
onChange={setInternalFilter}
onKeyDown={handleKey}
placeholder={searchProps().placeholder}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
/>
</div>
}
>
<For each={grouped()}>
{(group) => (
<div data-slot="list-group">
<Show when={group.category}>
<div data-slot="list-header">{group.category}</div>
</Show>
<div data-slot="list-items">
<For each={group.items}>
{(item, i) => (
<button
data-slot="list-item"
data-key={props.key(item)}
data-active={props.key(item) === active()}
data-selected={item === props.current}
onClick={() => handleSelect(item, i())}
onMouseMove={() => {
setStore("mouseActive", true)
setActive(props.key(item))
}}
>
{props.children(item)}
<Show when={item === props.current}>
<Icon data-slot="list-item-selected-icon" name="check-small" />
</Show>
<Show when={props.activeIcon}>
{(icon) => <Icon data-slot="list-item-active-icon" name={icon()} />}
</Show>
</button>
)}
</For>
<Show when={internalFilter()}>
<IconButton icon="circle-x" variant="ghost" onClick={() => setInternalFilter("")} />
</Show>
</div>
</Show>
<div ref={setScrollRef} data-slot="list-scroll">
<Show
when={flat().length > 0}
fallback={
<div data-slot="list-empty-state">
<div data-slot="list-message">
{props.emptyMessage ?? "No results"} for <span data-slot="list-filter">&quot;{filter()}&quot;</span>
</div>
</div>
)}
</For>
</Show>
}
>
<For each={grouped()}>
{(group) => (
<div data-slot="list-group">
<Show when={group.category}>
<div data-slot="list-header">{group.category}</div>
</Show>
<div data-slot="list-items">
<For each={group.items}>
{(item, i) => (
<button
data-slot="list-item"
data-key={props.key(item)}
data-active={props.key(item) === active()}
data-selected={item === props.current}
onClick={() => handleSelect(item, i())}
onMouseMove={() => {
setStore("mouseActive", true)
setActive(props.key(item))
}}
>
{props.children(item)}
<Show when={item === props.current}>
<Icon data-slot="list-item-selected-icon" name="check-small" />
</Show>
<Show when={props.activeIcon}>
{(icon) => <Icon data-slot="list-item-active-icon" name={icon()} />}
</Show>
</button>
)}
</For>
</div>
</div>
)}
</For>
</Show>
</div>
</div>
)
}

View file

@ -1,44 +0,0 @@
[data-slot="select-dialog-content"] {
width: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
gap: 20px;
padding: 0 10px;
[data-slot="dialog-body"] {
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
}
}
[data-component="select-dialog-input"] {
display: flex;
height: 40px;
flex-shrink: 0;
padding: 4px 10px 4px 16px;
align-items: center;
gap: 12px;
align-self: stretch;
border-radius: var(--radius-md);
background: var(--surface-base);
[data-slot="select-dialog-input-container"] {
display: flex;
align-items: center;
gap: 16px;
flex: 1 0 0;
/* [data-slot="select-dialog-icon"] {} */
[data-slot="select-dialog-input"] {
width: 100%;
}
}
/* [data-slot="select-dialog-clear-button"] {} */
}

View file

@ -1,93 +0,0 @@
import { Show, type JSX, splitProps, createSignal } from "solid-js"
import { Dialog, DialogProps } from "./dialog"
import { Icon } from "./icon"
import { IconButton } from "./icon-button"
import { List, ListRef, ListProps } from "./list"
import { TextField } from "./text-field"
interface SelectDialogProps<T>
extends Omit<ListProps<T>, "filter">,
Pick<DialogProps, "trigger" | "onOpenChange" | "defaultOpen"> {
title: string
placeholder?: string
actions?: JSX.Element
}
export function SelectDialog<T>(props: SelectDialogProps<T>) {
const [dialog, others] = splitProps(props, ["trigger", "onOpenChange", "defaultOpen"])
let closeButton!: HTMLButtonElement
let inputRef: HTMLInputElement | undefined
const [filter, setFilter] = createSignal("")
let listRef: ListRef | undefined
const handleSelect = (item: T | undefined, index: number) => {
others.onSelect?.(item, index)
closeButton.click()
}
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") return
listRef?.onKeyDown(e)
}
const handleOpenChange = (open: boolean) => {
if (!open) setFilter("")
props.onOpenChange?.(open)
}
return (
<Dialog modal {...dialog} onOpenChange={handleOpenChange}>
<Dialog.Header>
<Dialog.Title>{others.title}</Dialog.Title>
<Show when={others.actions}>{others.actions}</Show>
<Dialog.CloseButton ref={closeButton} tabIndex={-1} style={{ display: others.actions ? "none" : undefined }} />
</Dialog.Header>
<div data-slot="select-dialog-content">
<div data-component="select-dialog-input">
<div data-slot="select-dialog-input-container">
<Icon name="magnifying-glass" />
<TextField
ref={inputRef}
autofocus
variant="ghost"
data-slot="select-dialog-input"
type="text"
value={filter()}
onChange={setFilter}
onKeyDown={handleKey}
placeholder={others.placeholder}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
/>
</div>
<Show when={filter()}>
<IconButton icon="circle-x" variant="ghost" onClick={() => setFilter("")} />
</Show>
</div>
<Dialog.Body>
<List
ref={(ref) => {
listRef = ref
}}
items={others.items}
key={others.key}
filterKeys={others.filterKeys}
current={others.current}
groupBy={others.groupBy}
sortBy={others.sortBy}
sortGroupsBy={others.sortGroupsBy}
emptyMessage={others.emptyMessage}
activeIcon={others.activeIcon}
filter={filter()}
onSelect={handleSelect}
onKeyEvent={others.onKeyEvent}
>
{others.children}
</List>
</Dialog.Body>
</div>
</Dialog>
)
}

View file

@ -37,7 +37,6 @@
top: 0;
background-color: var(--background-stronger);
z-index: 21;
/* padding-bottom: clamp(0px, calc(8px - var(--scroll-y) * 0.16), 8px); */
}
[data-slot="session-turn-response-trigger"] {
@ -297,7 +296,6 @@
[data-slot="session-turn-collapsible"] {
gap: 32px;
overflow: visible;
/* margin-top: clamp(8px, calc(24px - var(--scroll-y) * 0.32), 24px); */
}
[data-slot="session-turn-collapsible-trigger-content"] {

View file

@ -60,6 +60,8 @@ export function SessionTurn(
function handleScroll() {
if (!scrollRef) return
// prevents scroll loops
if (working() && scrollRef.scrollTop < 100) return
setState("scrollY", scrollRef.scrollTop)
if (state.autoScrolling) return
const { scrollTop, scrollHeight, clientHeight } = scrollRef
@ -79,7 +81,7 @@ export function SessionTurn(
if (!scrollRef || state.userScrolled || !working() || state.autoScrolling) return
setState("autoScrolling", true)
requestAnimationFrame(() => {
scrollRef?.scrollTo({ top: scrollRef.scrollHeight, behavior: "auto" })
scrollRef?.scrollTo({ top: scrollRef.scrollHeight, behavior: "instant" })
requestAnimationFrame(() => {
setState("autoScrolling", false)
})

View file

@ -0,0 +1,131 @@
[data-component="switch"] {
display: flex;
align-items: center;
gap: 8px;
cursor: default;
[data-slot="switch-input"] {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
[data-slot="switch-control"] {
display: inline-flex;
align-items: center;
width: 28px;
height: 16px;
flex-shrink: 0;
border-radius: 3px;
border: 1px solid var(--border-weak-base);
background: var(--surface-base);
transition:
background-color 150ms,
border-color 150ms;
}
[data-slot="switch-thumb"] {
width: 14px;
height: 14px;
box-sizing: content-box;
border-radius: 2px;
border: 1px solid var(--border-base);
background: var(--icon-invert-base);
/* shadows/shadow-xs */
box-shadow:
0 1px 2px -1px rgba(19, 16, 16, 0.04),
0 1px 2px 0 rgba(19, 16, 16, 0.06),
0 1px 3px 0 rgba(19, 16, 16, 0.08);
transform: translateX(-1px);
transition:
transform 150ms,
background-color 150ms;
}
[data-slot="switch-label"] {
user-select: none;
color: var(--text-base);
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
font-style: normal;
font-weight: var(--font-weight-regular);
line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal);
}
[data-slot="switch-description"] {
color: var(--text-base);
font-family: var(--font-family-sans);
font-size: 12px;
font-weight: var(--font-weight-regular);
line-height: var(--line-height-normal);
letter-spacing: var(--letter-spacing-normal);
}
[data-slot="switch-error"] {
color: var(--text-error);
font-family: var(--font-family-sans);
font-size: 12px;
font-weight: var(--font-weight-regular);
line-height: var(--line-height-normal);
letter-spacing: var(--letter-spacing-normal);
}
&:hover:not([data-disabled], [data-readonly]) [data-slot="switch-control"] {
border-color: var(--border-hover);
background-color: var(--surface-hover);
}
&:focus-within:not([data-readonly]) [data-slot="switch-control"] {
border-color: var(--border-focus);
box-shadow: 0 0 0 2px var(--surface-focus);
}
&[data-checked] [data-slot="switch-control"] {
box-sizing: border-box;
border-color: var(--icon-strong-base);
background-color: var(--icon-strong-base);
}
&[data-checked] [data-slot="switch-thumb"] {
border: none;
transform: translateX(12px);
background-color: var(--icon-invert-base);
}
&[data-checked]:hover:not([data-disabled], [data-readonly]) [data-slot="switch-control"] {
border-color: var(--border-hover);
background-color: var(--surface-hover);
}
&[data-disabled] {
cursor: not-allowed;
}
&[data-disabled] [data-slot="switch-control"] {
border-color: var(--border-disabled);
background-color: var(--surface-disabled);
}
&[data-disabled] [data-slot="switch-thumb"] {
background-color: var(--icon-disabled);
}
&[data-invalid] [data-slot="switch-control"] {
border-color: var(--border-error);
}
&[data-readonly] {
cursor: default;
pointer-events: none;
}
}

View file

@ -0,0 +1,30 @@
import { Switch as Kobalte } from "@kobalte/core/switch"
import { children, Show, splitProps } from "solid-js"
import type { ComponentProps, ParentProps } from "solid-js"
export interface SwitchProps extends ParentProps<ComponentProps<typeof Kobalte>> {
hideLabel?: boolean
description?: string
}
export function Switch(props: SwitchProps) {
const [local, others] = splitProps(props, ["children", "class", "hideLabel", "description"])
const resolved = children(() => local.children)
return (
<Kobalte {...others} data-component="switch">
<Kobalte.Input data-slot="switch-input" />
<Show when={resolved()}>
<Kobalte.Label data-slot="switch-label" classList={{ "sr-only": local.hideLabel }}>
{resolved()}
</Kobalte.Label>
</Show>
<Show when={local.description}>
<Kobalte.Description data-slot="switch-description">{local.description}</Kobalte.Description>
</Show>
<Kobalte.ErrorMessage data-slot="switch-error" />
<Kobalte.Control data-slot="switch-control">
<Kobalte.Thumb data-slot="switch-thumb" />
</Kobalte.Control>
</Kobalte>
)
}

View file

@ -5,7 +5,7 @@ import { createStore } from "solid-js/store"
import { createList } from "solid-list"
export interface FilteredListProps<T> {
items: (filter: string) => T[] | Promise<T[]>
items: T[] | ((filter: string) => T[] | Promise<T[]>)
key: (item: T) => string
filterKeys?: string[]
current?: T
@ -19,10 +19,13 @@ export function useFilteredList<T>(props: FilteredListProps<T>) {
const [store, setStore] = createStore<{ filter: string }>({ filter: "" })
const [grouped, { refetch }] = createResource(
() => store.filter,
async (filter) => {
() => ({
filter: store.filter,
items: typeof props.items === "function" ? undefined : props.items,
}),
async ({ filter, items }) => {
const needle = filter?.toLowerCase()
const all = (await props.items(needle)) || []
const all = (items ?? (await (props.items as (filter: string) => T[] | Promise<T[]>)(needle))) || []
const result = pipe(
all,
(x) => {

View file

@ -30,8 +30,8 @@
@import "../components/progress-circle.css" layer(components);
@import "../components/resize-handle.css" layer(components);
@import "../components/select.css" layer(components);
@import "../components/select-dialog.css" layer(components);
@import "../components/spinner.css" layer(components);
@import "../components/switch.css" layer(components);
@import "../components/session-review.css" layer(components);
@import "../components/session-turn.css" layer(components);
@import "../components/sticky-accordion-header.css" layer(components);