mirror of
https://github.com/sst/opencode.git
synced 2025-12-23 10:11:41 +00:00
wip(desktop): progress
This commit is contained in:
parent
7ade6d386d
commit
4246cdb069
20 changed files with 726 additions and 479 deletions
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
52
packages/desktop/src/components/dialog-file-select.tsx
Normal file
52
packages/desktop/src/components/dialog-file-select.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
65
packages/desktop/src/components/dialog-manage-models.tsx
Normal file
65
packages/desktop/src/components/dialog-manage-models.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
133
packages/desktop/src/components/dialog-model-unpaid.tsx
Normal file
133
packages/desktop/src/components/dialog-model-unpaid.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
},
|
||||
}
|
||||
})()
|
||||
|
||||
|
|
|
|||
|
|
@ -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()}>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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">"{filter()}"</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">"{filter()}"</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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"] {} */
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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"] {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
131
packages/ui/src/components/switch.css
Normal file
131
packages/ui/src/components/switch.css
Normal 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;
|
||||
}
|
||||
}
|
||||
30
packages/ui/src/components/switch.tsx
Normal file
30
packages/ui/src/components/switch.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue