summaryrefslogtreecommitdiffhomepage
path: root/packages/desktop/src
diff options
context:
space:
mode:
authorAdam <[email protected]>2025-10-22 17:31:44 -0500
committerAdam <[email protected]>2025-10-22 17:31:49 -0500
commit89b703c387aed3ee918d826b788b4be1729bdde9 (patch)
tree9a22d26bfc5e921789924e2344d04113660b67a3 /packages/desktop/src
parenteff12cb48453e45590a53a7705c5044a0da9e7f7 (diff)
downloadopencode-89b703c387aed3ee918d826b788b4be1729bdde9.tar.gz
opencode-89b703c387aed3ee918d826b788b4be1729bdde9.zip
wip: desktop work
Diffstat (limited to 'packages/desktop/src')
-rw-r--r--packages/desktop/src/components/code.tsx2
-rw-r--r--packages/desktop/src/components/editor-pane.tsx37
-rw-r--r--packages/desktop/src/components/file-tree.tsx4
-rw-r--r--packages/desktop/src/components/prompt-form-helpers.ts6
-rw-r--r--packages/desktop/src/components/prompt-form-hooks.ts2
-rw-r--r--packages/desktop/src/components/prompt-input.tsx428
-rw-r--r--packages/desktop/src/components/select-dialog.tsx226
-rw-r--r--packages/desktop/src/components/session-timeline.tsx47
-rw-r--r--packages/desktop/src/pages/index.tsx168
-rw-r--r--packages/desktop/src/ui/collapsible.tsx2
-rw-r--r--packages/desktop/src/ui/icon-button.tsx38
-rw-r--r--packages/desktop/src/ui/index.ts1
12 files changed, 406 insertions, 555 deletions
diff --git a/packages/desktop/src/components/code.tsx b/packages/desktop/src/components/code.tsx
index 40a40aa9a..b4dd216e9 100644
--- a/packages/desktop/src/components/code.tsx
+++ b/packages/desktop/src/components/code.tsx
@@ -394,7 +394,7 @@ export function Code(props: Props) {
[&_.diff-blank_.diff-oldln]:bg-background-element
[&_.diff-blank_.diff-newln]:bg-background-element
[&_.diff-collapsed]:block! [&_.diff-collapsed]:w-full [&_.diff-collapsed]:relative
- [&_.diff-collapsed]:cursor-pointer [&_.diff-collapsed]:select-none
+ [&_.diff-collapsed]:select-none
[&_.diff-collapsed]:bg-info/20 [&_.diff-collapsed]:hover:bg-info/40!
[&_.diff-collapsed]:text-info/80 [&_.diff-collapsed]:hover:text-info
[&_.diff-collapsed]:text-xs
diff --git a/packages/desktop/src/components/editor-pane.tsx b/packages/desktop/src/components/editor-pane.tsx
index 2741a6208..a97a0ef7f 100644
--- a/packages/desktop/src/components/editor-pane.tsx
+++ b/packages/desktop/src/components/editor-pane.tsx
@@ -1,7 +1,6 @@
import { For, Match, Show, Switch, createSignal, splitProps } from "solid-js"
-import { Tabs, Tooltip } from "@opencode-ai/ui"
-import { Icon } from "@opencode-ai/ui"
-import { FileIcon, IconButton } from "@/ui"
+import { IconButton, Tabs, Tooltip } from "@opencode-ai/ui"
+import { FileIcon } from "@/ui"
import {
DragDropProvider,
DragDropSensors,
@@ -92,20 +91,16 @@ export default function EditorPane(props: EditorPaneProps): JSX.Element {
<Show when={view !== "raw"}>
<div class="mr-1 flex items-center gap-1">
<Tooltip value="Previous change" placement="bottom">
- <IconButton size="xs" variant="ghost" onClick={() => navigateChange(-1)}>
- <Icon name="arrow-up" size={14} />
- </IconButton>
+ <IconButton icon="arrow-up" variant="ghost" onClick={() => navigateChange(-1)} />
</Tooltip>
<Tooltip value="Next change" placement="bottom">
- <IconButton size="xs" variant="ghost" onClick={() => navigateChange(1)}>
- <Icon name="arrow-down" size={14} />
- </IconButton>
+ <IconButton icon="arrow-down" variant="ghost" onClick={() => navigateChange(1)} />
</Tooltip>
</div>
</Show>
<Tooltip value="Raw" placement="bottom">
<IconButton
- size="xs"
+ icon="file-text"
variant="ghost"
classList={{
"text-text": view === "raw",
@@ -113,13 +108,11 @@ export default function EditorPane(props: EditorPaneProps): JSX.Element {
"bg-background-element": view === "raw",
}}
onClick={() => local.file.setView(activeFile.path, "raw")}
- >
- <Icon name="file-text" size={14} />
- </IconButton>
+ />
</Tooltip>
<Tooltip value="Unified diff" placement="bottom">
<IconButton
- size="xs"
+ icon="checklist"
variant="ghost"
classList={{
"text-text": view === "diff-unified",
@@ -127,13 +120,11 @@ export default function EditorPane(props: EditorPaneProps): JSX.Element {
"bg-background-element": view === "diff-unified",
}}
onClick={() => local.file.setView(activeFile.path, "diff-unified")}
- >
- <Icon name="checklist" size={14} />
- </IconButton>
+ />
</Tooltip>
<Tooltip value="Split diff" placement="bottom">
<IconButton
- size="xs"
+ icon="columns"
variant="ghost"
classList={{
"text-text": view === "diff-split",
@@ -141,9 +132,7 @@ export default function EditorPane(props: EditorPaneProps): JSX.Element {
"bg-background-element": view === "diff-split",
}}
onClick={() => local.file.setView(activeFile.path, "diff-split")}
- >
- <Icon name="columns" size={14} />
- </IconButton>
+ />
</Tooltip>
</div>
)
@@ -221,13 +210,11 @@ function SortableTab(props: {
<TabVisual file={props.file} />
</Tabs.Trigger>
<IconButton
+ icon="close"
class="absolute right-1 top-1.5 opacity-0 text-text-muted/60 peer-data-[selected]/tab:opacity-100 peer-data-[selected]/tab:text-text peer-data-[selected]/tab:hover:bg-border-subtle hover:opacity-100 peer-hover/tab:opacity-100"
- size="xs"
variant="ghost"
onClick={() => props.onTabClose(props.file)}
- >
- <Icon name="close" size={16} />
- </IconButton>
+ />
</div>
</Tooltip>
</div>
diff --git a/packages/desktop/src/components/file-tree.tsx b/packages/desktop/src/components/file-tree.tsx
index 348e25ad7..7e4b1abcc 100644
--- a/packages/desktop/src/components/file-tree.tsx
+++ b/packages/desktop/src/components/file-tree.tsx
@@ -19,7 +19,7 @@ export default function FileTree(props: {
<Dynamic
component={p.as ?? "div"}
classList={{
- "p-0.5 w-full flex items-center gap-x-2 hover:bg-background-element cursor-pointer": true,
+ "p-0.5 w-full flex items-center gap-x-2 hover:bg-background-element": true,
"bg-background-element": local.file.active()?.path === p.node.path,
[props.nodeClass ?? ""]: !!props.nodeClass,
}}
@@ -83,7 +83,7 @@ export default function FileTree(props: {
>
<Collapsible.Trigger>
<Node node={node}>
- <Collapsible.Arrow size={16} class="text-text-muted/60 ml-1" />
+ <Collapsible.Arrow class="text-text-muted/60 ml-1" />
<FileIcon
node={node}
expanded={local.file.node(node.path).expanded}
diff --git a/packages/desktop/src/components/prompt-form-helpers.ts b/packages/desktop/src/components/prompt-form-helpers.ts
index cc657c80e..298b831ee 100644
--- a/packages/desktop/src/components/prompt-form-helpers.ts
+++ b/packages/desktop/src/components/prompt-form-helpers.ts
@@ -7,7 +7,7 @@ export interface PromptTextPart {
}
export interface PromptAttachmentPart {
- kind: "attachment"
+ kind: "file"
token: string
display: string
path: string
@@ -106,7 +106,7 @@ export function parsePrompt(value: string, lookup: Map<string, AttachmentCandida
const start = rangeStart + localIndex
const end = start + match[0].length
segments.push({
- kind: "attachment",
+ kind: "file",
token,
display: candidate.display,
path: candidate.path,
@@ -152,7 +152,7 @@ export function composeDisplaySegments(
}
const { start, end, ...part } = segment
const placeholder = inputValue.slice(start, end)
- return { kind: "attachment", part: part as PromptAttachmentPart, source: placeholder }
+ return { kind: "file", part: part as PromptAttachmentPart, source: placeholder }
})
if (interim) {
diff --git a/packages/desktop/src/components/prompt-form-hooks.ts b/packages/desktop/src/components/prompt-form-hooks.ts
index 026b84359..c4d18fa70 100644
--- a/packages/desktop/src/components/prompt-form-hooks.ts
+++ b/packages/desktop/src/components/prompt-form-hooks.ts
@@ -309,7 +309,7 @@ export function useMentionController(options: MentionControllerOptions) {
const nextValue = `${leading}${mentionText}${trailingSpacer}${after}`
const origin = options.getActiveContext()?.path === path ? "active" : "context"
const part: PromptAttachmentPart = {
- kind: "attachment",
+ kind: "file",
token: alias,
display: createAttachmentDisplay(path, node?.selection),
path,
diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx
index 0f8fb5930..55a410516 100644
--- a/packages/desktop/src/components/prompt-input.tsx
+++ b/packages/desktop/src/components/prompt-input.tsx
@@ -1,63 +1,74 @@
-import { createEffect, on, Component, createMemo, Show } from "solid-js"
+import { useLocal } from "@/context"
+import { Button, Icon, IconButton, Select, SelectDialog, Tooltip } from "@opencode-ai/ui"
+import { useFilteredList } from "@opencode-ai/ui/hooks"
+import { createEffect, on, Component, createMemo, Show, Switch, Match, For } from "solid-js"
import { createStore } from "solid-js/store"
+import { FileIcon } from "@/ui"
+import { getDirectory, getFilename } from "@/utils"
+import { createFocusSignal } from "@solid-primitives/active-element"
+import { TextSelection } from "@/context/local"
+import { DateTime } from "luxon"
-interface TextPart {
- type: "text"
+interface PartBase {
content: string
}
-interface AttachmentPart {
- type: "attachment"
- fileId: string
- name: string
+interface TextPart extends PartBase {
+ type: "text"
}
-export type ContentPart = TextPart | AttachmentPart
-
-export interface AttachmentToAdd {
- id: string
- name: string
+interface FileAttachmentPart extends PartBase {
+ type: "file"
+ path: string
+ selection?: TextSelection
}
-type AddAttachmentCallback = (attachment: AttachmentToAdd) => void
-
-export interface PopoverState {
- isOpen: boolean
- searchQuery: string
- addAttachment: AddAttachmentCallback
-}
+export type ContentPart = TextPart | FileAttachmentPart
interface PromptInputProps {
onSubmit: (parts: ContentPart[]) => void
- onShowAttachments?: (state: PopoverState | null) => void
class?: string
+ ref?: (el: HTMLDivElement) => void
}
export const PromptInput: Component<PromptInputProps> = (props) => {
- let editorRef: HTMLDivElement | undefined
+ const local = useLocal()
+ let editorRef!: HTMLDivElement
const defaultParts = [{ type: "text", content: "" } as const]
const [store, setStore] = createStore<{
contentParts: ContentPart[]
- popover: {
- isOpen: boolean
- searchQuery: string
- }
+ popoverIsOpen: boolean
}>({
contentParts: defaultParts,
- popover: {
- isOpen: false,
- searchQuery: "",
- },
+ popoverIsOpen: false,
})
const isEmpty = createMemo(() => isEqual(store.contentParts, defaultParts))
+ const isFocused = createFocusSignal(() => editorRef)
+
+ createEffect(() => {
+ if (isFocused()) {
+ handleInput()
+ } else {
+ setStore("popoverIsOpen", false)
+ }
+ })
+
+ const { flat, active, onInput, onKeyDown } = useFilteredList<string>({
+ items: local.file.search,
+ key: (x) => x,
+ onSelect: (path) => {
+ if (!path) return
+ addPart({ type: "file", path, content: "@" + getFilename(path) })
+ setStore("popoverIsOpen", false)
+ },
+ })
createEffect(
on(
() => store.contentParts,
(currentParts) => {
- if (!editorRef) return
const domParts = parseFromDOM()
if (isEqual(currentParts, domParts)) return
@@ -70,14 +81,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
editorRef.innerHTML = ""
currentParts.forEach((part) => {
if (part.type === "text") {
- editorRef!.appendChild(document.createTextNode(part.content))
- } else if (part.type === "attachment") {
+ editorRef.appendChild(document.createTextNode(part.content))
+ } else if (part.type === "file") {
const pill = document.createElement("span")
- pill.textContent = `@${part.name}`
- pill.className = "attachment-pill"
- pill.setAttribute("data-file-id", part.fileId)
+ pill.textContent = part.content
+ pill.setAttribute("data-type", "file")
+ pill.setAttribute("data-path", part.path)
pill.setAttribute("contenteditable", "false")
- editorRef!.appendChild(pill)
+ pill.style.userSelect = "text"
+ pill.style.cursor = "default"
+ editorRef.appendChild(pill)
}
})
@@ -88,30 +101,23 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
),
)
- createEffect(() => {
- if (store.popover.isOpen) {
- props.onShowAttachments?.({
- isOpen: true,
- searchQuery: store.popover.searchQuery,
- addAttachment: addAttachment,
- })
- } else {
- props.onShowAttachments?.(null)
- }
- })
-
const parseFromDOM = (): ContentPart[] => {
- if (!editorRef) return []
const newParts: ContentPart[] = []
editorRef.childNodes.forEach((node) => {
if (node.nodeType === Node.TEXT_NODE) {
if (node.textContent) newParts.push({ type: "text", content: node.textContent })
- } else if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.fileId) {
- newParts.push({
- type: "attachment",
- fileId: (node as HTMLElement).dataset.fileId!,
- name: node.textContent!.substring(1),
- })
+ } else if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type) {
+ switch ((node as HTMLElement).dataset.type) {
+ case "file":
+ newParts.push({
+ type: "file",
+ path: (node as HTMLElement).dataset.path!,
+ content: node.textContent!,
+ })
+ break
+ default:
+ break
+ }
}
})
if (newParts.length === 0) newParts.push(...defaultParts)
@@ -120,96 +126,234 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const handleInput = () => {
const rawParts = parseFromDOM()
- const cursorPosition = getCursorPosition(editorRef!)
- const rawText = rawParts.map((p) => (p.type === "text" ? p.content : `@${p.name}`)).join("")
+ const cursorPosition = getCursorPosition(editorRef)
+ const rawText = rawParts.map((p) => p.content).join("")
const atMatch = rawText.substring(0, cursorPosition).match(/@(\S*)$/)
if (atMatch) {
- setStore("popover", { isOpen: true, searchQuery: atMatch[1] })
- } else if (store.popover.isOpen) {
- setStore("popover", "isOpen", false)
+ onInput(atMatch[1])
+ setStore("popoverIsOpen", true)
+ } else if (store.popoverIsOpen) {
+ setStore("popoverIsOpen", false)
}
setStore("contentParts", rawParts)
}
- const addAttachment: AddAttachmentCallback = (attachment) => {
- const rawText = store.contentParts.map((p) => (p.type === "text" ? p.content : `@${p.name}`)).join("")
- const cursorPosition = getCursorPosition(editorRef!)
-
+ const addPart = (part: ContentPart) => {
+ const cursorPosition = getCursorPosition(editorRef)
+ const rawText = store.contentParts.map((p) => p.content).join("")
const textBeforeCursor = rawText.substring(0, cursorPosition)
const atMatch = textBeforeCursor.match(/@(\S*)$/)
-
if (!atMatch) return
const startIndex = atMatch.index!
+ const endIndex = cursorPosition
+
+ const {
+ parts: nextParts,
+ cursorIndex,
+ cursorOffset,
+ inserted,
+ } = store.contentParts.reduce(
+ (acc, item) => {
+ if (acc.inserted) {
+ acc.parts.push(item)
+ acc.runningIndex += item.content.length
+ return acc
+ }
- // Create new structured content
- const newParts: ContentPart[] = []
- const textBeforeTrigger = rawText.substring(0, startIndex)
- if (textBeforeTrigger) newParts.push({ type: "text", content: textBeforeTrigger })
+ const nextIndex = acc.runningIndex + item.content.length
+ if (nextIndex <= startIndex) {
+ acc.parts.push(item)
+ acc.runningIndex = nextIndex
+ return acc
+ }
+
+ if (item.type !== "text") {
+ acc.parts.push(item)
+ acc.runningIndex = nextIndex
+ return acc
+ }
+
+ const headLength = Math.max(0, startIndex - acc.runningIndex)
+ const tailLength = Math.max(0, endIndex - acc.runningIndex)
+ const head = item.content.slice(0, headLength)
+ const tail = item.content.slice(tailLength)
- newParts.push({ type: "attachment", fileId: attachment.id, name: attachment.name })
+ if (head) acc.parts.push({ type: "text", content: head })
- // Add a space after the pill for better UX
- newParts.push({ type: "text", content: " " })
+ acc.parts.push(part)
+
+ const rest = /^\s/.test(tail) ? tail : ` ${tail}`
+ if (rest) {
+ acc.cursorIndex = acc.parts.length
+ acc.cursorOffset = Math.min(1, rest.length)
+ acc.parts.push({ type: "text", content: rest })
+ }
+
+ acc.inserted = true
+ acc.runningIndex = nextIndex
+ return acc
+ },
+ {
+ parts: [] as ContentPart[],
+ runningIndex: 0,
+ inserted: false,
+ cursorIndex: null as number | null,
+ cursorOffset: 0,
+ },
+ )
- const textAfterCursor = rawText.substring(cursorPosition)
- if (textAfterCursor) newParts.push({ type: "text", content: textAfterCursor })
+ if (!inserted || cursorIndex === null) return
- setStore("contentParts", newParts)
- setStore("popover", "isOpen", false)
+ setStore("contentParts", nextParts)
+ setStore("popoverIsOpen", false)
- // Set cursor position after the newly added pill + space
- // We need to wait for the DOM to update
queueMicrotask(() => {
- setCursorPosition(editorRef!, textBeforeTrigger.length + 1 + attachment.name.length + 1)
+ const node = editorRef.childNodes[cursorIndex]
+ if (node && node.nodeType === Node.TEXT_NODE) {
+ const range = document.createRange()
+ const selection = window.getSelection()
+ const length = node.textContent ? node.textContent.length : 0
+ const offset = cursorOffset > length ? length : cursorOffset
+ range.setStart(node, offset)
+ range.collapse(true)
+ selection?.removeAllRanges()
+ selection?.addRange(range)
+ }
})
}
const handleKeyDown = (event: KeyboardEvent) => {
- if (store.popover.isOpen && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) {
- // In a real implementation, you'd prevent default and delegate this to the popover
- console.log("Key press delegated to popover:", event.key)
+ if (store.popoverIsOpen && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) {
+ onKeyDown(event)
event.preventDefault()
return
}
-
if (event.key === "Enter" && !event.shiftKey) {
- event.preventDefault()
- if (store.contentParts.length > 0) {
- props.onSubmit([...store.contentParts])
- setStore("contentParts", defaultParts)
- }
+ handleSubmit(event)
+ }
+ }
+
+ const handleSubmit = (event: Event) => {
+ event.preventDefault()
+ if (store.contentParts.length > 0) {
+ props.onSubmit([...store.contentParts])
+ setStore("contentParts", defaultParts)
}
}
return (
- <div
- classList={{
- "size-full max-w-xl bg-surface-raised-stronger-non-alpha border border-border-strong-base": true,
- "rounded-2xl overflow-clip focus-within:shadow-xs-border-selected": true,
- [props.class ?? ""]: !!props.class,
- }}
- >
- <div class="p-3" />
- <div class="relative">
- <div
- ref={editorRef}
- contenteditable="true"
- onInput={handleInput}
- onKeyDown={handleKeyDown}
- classList={{
- "w-full p-3 text-sm focus:outline-none": true,
- }}
- />
- <Show when={isEmpty()}>
- <div class="absolute bottom-0 left-0 p-3 text-sm text-text-weak pointer-events-none">
- Plan and build anything
+ <div class="relative size-full max-w-[640px] _max-h-[320px] flex flex-col gap-3">
+ <Show when={store.popoverIsOpen}>
+ <div class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-[252px] min-h-10 overflow-y-auto flex flex-col p-2 pb-0 rounded-2xl border border-border-base bg-surface-raised-stronger-non-alpha shadow-md">
+ <For each={flat()}>
+ {(i) => (
+ <div
+ classList={{
+ "w-full flex items-center justify-between rounded-md": true,
+ "bg-surface-raised-base-hover": active() === i,
+ }}
+ >
+ <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>
+ )}
+ </For>
+ </div>
+ </Show>
+ <form
+ onSubmit={handleSubmit}
+ classList={{
+ "bg-surface-raised-stronger-non-alpha border border-border-strong-base": true,
+ "rounded-2xl overflow-clip focus-within:shadow-xs-border-selected": true,
+ [props.class ?? ""]: !!props.class,
+ }}
+ >
+ <div class="relative max-h-[240px] overflow-y-auto">
+ <div
+ ref={(el) => {
+ editorRef = el
+ props.ref?.(el)
+ }}
+ contenteditable="true"
+ onInput={handleInput}
+ onKeyDown={handleKeyDown}
+ classList={{
+ "w-full p-3 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
+ "[&>[data-type=file]]:text-icon-info-active": true,
+ }}
+ />
+ <Show when={isEmpty()}>
+ <div class="absolute top-0 left-0 p-3 text-14-regular text-text-weak pointer-events-none">
+ Plan and build anything
+ </div>
+ </Show>
+ </div>
+ <div class="p-3 flex items-center justify-between">
+ <div class="flex items-center justify-start gap-1">
+ <Select
+ options={local.agent.list().map((agent) => agent.name)}
+ current={local.agent.current().name}
+ onSelect={local.agent.set}
+ class="capitalize"
+ />
+ <SelectDialog
+ title="Select model"
+ placeholder="Search models"
+ emptyMessage="No model results"
+ key={(x) => `${x.provider.id}:${x.id}`}
+ items={local.model.list()}
+ current={local.model.current()}
+ filterKeys={["provider.name", "name", "id"]}
+ groupBy={(x) => x.provider.name}
+ sortGroupsBy={(a, b) => {
+ const order = ["opencode", "anthropic", "github-copilot", "openai", "google", "openrouter", "vercel"]
+ const aProvider = a.items[0].provider.id
+ const bProvider = b.items[0].provider.id
+ if (order.includes(aProvider) && !order.includes(bProvider)) return -1
+ if (!order.includes(aProvider) && order.includes(bProvider)) return 1
+ return order.indexOf(aProvider) - order.indexOf(bProvider)
+ }}
+ onSelect={(x) => local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined)}
+ trigger={
+ <Button as="div" variant="ghost">
+ {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" />
+ </Button>
+ }
+ >
+ {(i) => (
+ <div class="w-full flex items-center justify-between gap-x-3">
+ <div class="flex items-center gap-x-2.5 text-text-muted grow min-w-0">
+ <img src={`https://models.dev/logos/${i.provider.id}.svg`} class="size-6 p-0.5 shrink-0 " />
+ <div class="flex gap-x-3 items-baseline flex-[1_0_0]">
+ <span class="text-14-medium text-text-strong overflow-hidden text-ellipsis">{i.name}</span>
+ <span class="text-12-medium text-text-weak overflow-hidden text-ellipsis truncate min-w-0">
+ {DateTime.fromFormat(i.release_date, "yyyy-MM-dd").toFormat("LLL yyyy")}
+ </span>
+ </div>
+ </div>
+ <Show when={!i.cost || i.cost?.input === 0}>
+ <div class="overflow-hidden text-12-medium text-text-strong">Free</div>
+ </Show>
+ </div>
+ )}
+ </SelectDialog>
</div>
- </Show>
- </div>
- <div class="p-3" />
+ <IconButton type="submit" disabled={isEmpty()} icon="arrow-up" variant="primary" />
+ </div>
+ </form>
</div>
)
}
@@ -223,7 +367,7 @@ function isEqual(arrA: ContentPart[], arrB: ContentPart[]): boolean {
if (partA.type === "text" && partA.content !== (partB as TextPart).content) {
return false
}
- if (partA.type === "attachment" && partA.fileId !== (partB as AttachmentPart).fileId) {
+ if (partA.type === "file" && partA.path !== (partB as FileAttachmentPart).path) {
return false
}
}
@@ -241,24 +385,48 @@ function getCursorPosition(parent: HTMLElement): number {
}
function setCursorPosition(parent: HTMLElement, position: number) {
- let child = parent.firstChild
- let offset = position
- while (child) {
- if (offset > child.textContent!.length) {
- offset -= child.textContent!.length
- child = child.nextSibling
- } else {
- try {
- const range = document.createRange()
- const sel = window.getSelection()
- range.setStart(child, offset)
- range.collapse(true)
- sel?.removeAllRanges()
- sel?.addRange(range)
- } catch (e) {
- console.error("Failed to set cursor position.", e)
- }
+ let remaining = position
+ let node = parent.firstChild
+ while (node) {
+ const length = node.textContent ? node.textContent.length : 0
+ const isText = node.nodeType === Node.TEXT_NODE
+ const isFile = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type === "file"
+
+ if (isText && remaining <= length) {
+ const range = document.createRange()
+ const selection = window.getSelection()
+ range.setStart(node, remaining)
+ range.collapse(true)
+ selection?.removeAllRanges()
+ selection?.addRange(range)
return
}
+
+ if (isFile && remaining <= length) {
+ const range = document.createRange()
+ const selection = window.getSelection()
+ range.setStartAfter(node)
+ range.collapse(true)
+ selection?.removeAllRanges()
+ selection?.addRange(range)
+ return
+ }
+
+ remaining -= length
+ node = node.nextSibling
+ }
+
+ const fallbackRange = document.createRange()
+ const fallbackSelection = window.getSelection()
+ const last = parent.lastChild
+ if (last && last.nodeType === Node.TEXT_NODE) {
+ const len = last.textContent ? last.textContent.length : 0
+ fallbackRange.setStart(last, len)
+ }
+ if (!last || last.nodeType !== Node.TEXT_NODE) {
+ fallbackRange.selectNodeContents(parent)
}
+ fallbackRange.collapse(false)
+ fallbackSelection?.removeAllRanges()
+ fallbackSelection?.addRange(fallbackRange)
}
diff --git a/packages/desktop/src/components/select-dialog.tsx b/packages/desktop/src/components/select-dialog.tsx
deleted file mode 100644
index bf9aa0dbd..000000000
--- a/packages/desktop/src/components/select-dialog.tsx
+++ /dev/null
@@ -1,226 +0,0 @@
-import { createEffect, Show, For, createMemo, type JSX, createResource } from "solid-js"
-import { Dialog } from "@kobalte/core/dialog"
-import { Icon } from "@opencode-ai/ui"
-import { IconButton } from "@/ui"
-import { createStore } from "solid-js/store"
-import { entries, flatMap, groupBy, map, pipe } from "remeda"
-import { createList } from "solid-list"
-import fuzzysort from "fuzzysort"
-
-interface SelectDialogProps<T> {
- items: T[] | ((filter: string) => Promise<T[]>)
- key: (item: T) => string
- render: (item: T) => JSX.Element
- filter?: string[]
- current?: T
- placeholder?: string
- groupBy?: (x: T) => string
- onSelect?: (value: T | undefined) => void
- onClose?: () => void
-}
-
-export function SelectDialog<T>(props: SelectDialogProps<T>) {
- let scrollRef: HTMLDivElement | undefined
- const [store, setStore] = createStore({
- filter: "",
- mouseActive: false,
- })
-
- const [grouped] = createResource(
- () => store.filter,
- async (filter) => {
- const needle = filter.toLowerCase()
- const all = (typeof props.items === "function" ? await props.items(needle) : props.items) || []
- const result = pipe(
- all,
- (x) => {
- if (!needle) return x
- if (!props.filter && Array.isArray(x) && x.every((e) => typeof e === "string")) {
- return fuzzysort.go(needle, x).map((x) => x.target) as T[]
- }
- return fuzzysort.go(needle, x, { keys: props.filter! }).map((x) => x.obj)
- },
- groupBy((x) => (props.groupBy ? props.groupBy(x) : "")),
- // mapValues((x) => x.sort((a, b) => props.key(a).localeCompare(props.key(b)))),
- entries(),
- map(([k, v]) => ({ category: k, items: v })),
- )
- return result
- },
- )
- const flat = createMemo(() => {
- return pipe(
- grouped() || [],
- flatMap((x) => x.items),
- )
- })
- const list = createList({
- items: () => flat().map(props.key),
- initialActive: props.current ? props.key(props.current) : undefined,
- loop: true,
- })
- const resetSelection = () => {
- const all = flat()
- if (all.length === 0) return
- list.setActive(props.key(all[0]))
- }
-
- createEffect(() => {
- store.filter
- scrollRef?.scrollTo(0, 0)
- resetSelection()
- })
-
- createEffect(() => {
- const all = flat()
- if (store.mouseActive || all.length === 0) return
- if (list.active() === props.key(all[0])) {
- scrollRef?.scrollTo(0, 0)
- return
- }
- const element = scrollRef?.querySelector(`[data-key="${list.active()}"]`)
- element?.scrollIntoView({ block: "nearest", behavior: "smooth" })
- })
-
- const handleInput = (value: string) => {
- setStore("filter", value)
- resetSelection()
- }
-
- const handleSelect = (item: T) => {
- props.onSelect?.(item)
- props.onClose?.()
- }
-
- const handleKey = (e: KeyboardEvent) => {
- setStore("mouseActive", false)
-
- if (e.key === "Enter") {
- e.preventDefault()
- const selected = flat().find((x) => props.key(x) === list.active())
- if (selected) handleSelect(selected)
- } else if (e.key === "Escape") {
- e.preventDefault()
- props.onClose?.()
- } else {
- list.onKeyDown(e)
- }
- }
-
- return (
- <Dialog defaultOpen modal onOpenChange={(open) => open || props.onClose?.()}>
- <Dialog.Portal>
- <Dialog.Overlay class="fixed inset-0 bg-black/50 backdrop-blur-sm z-[100]" />
- <Dialog.Content
- class="fixed top-[20%] left-1/2 -translate-x-1/2 w-[90vw] max-w-2xl
- shadow-[0_0_33px_rgba(0,0,0,0.8)]
- bg-background border border-border-subtle/30 rounded-lg z-[101]
- max-h-[60vh] flex flex-col"
- >
- <div class="border-b border-border-subtle/30">
- <div class="relative">
- <Icon name="command" size={16} class="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted/80" />
- <input
- type="text"
- value={store.filter}
- onInput={(e) => handleInput(e.currentTarget.value)}
- onKeyDown={handleKey}
- placeholder={props.placeholder}
- class="w-full pl-10 pr-4 py-2 rounded-t-md
- text-sm text-text placeholder-text-muted/70
- focus:outline-none"
- autofocus
- spellcheck={false}
- autocorrect="off"
- autocomplete="off"
- autocapitalize="off"
- />
- <div class="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-2">
- {/* <Show when={fileResults.loading && mode() === "files"}>
- <div class="text-text-muted">
- <Icon name="refresh" size={14} class="animate-spin" />
- </div>
- </Show> */}
- <Show when={store.filter}>
- <IconButton
- size="xs"
- variant="ghost"
- class="text-text-muted hover:text-text"
- onClick={() => {
- setStore("filter", "")
- resetSelection()
- }}
- >
- <Icon name="close" size={14} />
- </IconButton>
- </Show>
- </div>
- </div>
- </div>
- <div ref={(el) => (scrollRef = el)} class="relative flex-1 overflow-y-auto">
- <Show
- when={flat().length > 0}
- fallback={<div class="text-center py-8 text-text-muted text-sm">No results</div>}
- >
- <For each={grouped()}>
- {(group) => (
- <>
- <Show when={group.category}>
- <div class="top-0 sticky z-10 bg-background-panel p-2 text-xs text-text-muted/60 tracking-wider uppercase">
- {group.category}
- </div>
- </Show>
- <div class="p-2">
- <For each={group.items}>
- {(item) => (
- <button
- data-key={props.key(item)}
- onClick={() => handleSelect(item)}
- onMouseMove={() => {
- setStore("mouseActive", true)
- list.setActive(props.key(item))
- }}
- classList={{
- "w-full px-3 py-2 flex items-center gap-3": true,
- "rounded-md text-left transition-colors group": true,
- "bg-background-element": props.key(item) === list.active(),
- }}
- >
- {props.render(item)}
- </button>
- )}
- </For>
- </div>
- </>
- )}
- </For>
- </Show>
- </div>
- <div class="p-3 border-t border-border-subtle/30 flex items-center justify-between text-xs text-text-muted">
- <div class="flex items-center gap-5">
- <span class="flex items-center gap-1.5">
- <kbd class="px-1.5 py-0.5 bg-background-element border border-border-subtle/30 rounded text-[10px]">
- ↑↓
- </kbd>
- Navigate
- </span>
- <span class="flex items-center gap-1.5">
- <kbd class="px-1.5 py-0.5 bg-background-element border border-border-subtle/30 rounded text-[10px]">
- ↵
- </kbd>
- Select
- </span>
- <span class="flex items-center gap-1.5">
- <kbd class="px-1.5 py-0.5 bg-background-element border border-border-subtle/30 rounded text-[10px]">
- ESC
- </kbd>
- Close
- </span>
- </div>
- <span>{`${flat().length} results`}</span>
- </div>
- </Dialog.Content>
- </Dialog.Portal>
- </Dialog>
- )
-}
diff --git a/packages/desktop/src/components/session-timeline.tsx b/packages/desktop/src/components/session-timeline.tsx
index 2474b3101..0d8a7cd3c 100644
--- a/packages/desktop/src/components/session-timeline.tsx
+++ b/packages/desktop/src/components/session-timeline.tsx
@@ -1,11 +1,10 @@
import { useLocal, useSync } from "@/context"
import { Icon, Tooltip } from "@opencode-ai/ui"
import { Collapsible } from "@/ui"
-import type { AssistantMessage, Part, ToolPart } from "@opencode-ai/sdk"
+import type { AssistantMessage, Message, Part, ToolPart } from "@opencode-ai/sdk"
import { DateTime } from "luxon"
import {
createSignal,
- onMount,
For,
Match,
splitProps,
@@ -67,7 +66,7 @@ function ReadToolPart(props: { part: ToolPart }) {
{(state) => {
const path = state().input["filePath"] as string
return (
- <Part class="cursor-pointer" onClick={() => local.file.open(path)}>
+ <Part onClick={() => local.file.open(path)}>
<span class="">Read</span> {getFilename(path)}
</Part>
)
@@ -253,7 +252,7 @@ export default function SessionTimeline(props: { session: string; class?: string
case "patch":
return false
case "text":
- return !part.synthetic
+ return !part.synthetic && part.text.trim()
case "reasoning":
return part.text.trim()
case "tool":
@@ -270,8 +269,17 @@ export default function SessionTimeline(props: { session: string; class?: string
}
}
+ const hasValidParts = (message: Message) => {
+ return sync.data.part[message.id]?.filter(valid).length > 0
+ }
+
+ const hasTextPart = (message: Message) => {
+ return !!sync.data.part[message.id]?.filter(valid).find((p) => p.type === "text")
+ }
+
const session = createMemo(() => sync.session.get(props.session))
const messages = createMemo(() => sync.data.message[props.session] ?? [])
+ const messagesWithValidParts = createMemo(() => sync.data.message[props.session]?.filter(hasValidParts) ?? [])
const working = createMemo(() => {
const last = messages()[messages().length - 1]
if (!last) return false
@@ -386,7 +394,7 @@ export default function SessionTimeline(props: { session: string; class?: string
[props.class ?? ""]: !!props.class,
}}
>
- <div class="py-1.5 px-10 flex justify-end items-center self-stretch">
+ <div class="py-1.5 px-6 flex justify-end items-center self-stretch">
<div class="flex items-center gap-6">
<Tooltip value={`${tokens()} Tokens`} class="flex items-center gap-1.5">
<Show when={context()}>
@@ -397,11 +405,16 @@ export default function SessionTimeline(props: { session: string; class?: string
<div class="text-14-regular text-text-strong text-right">{cost()}</div>
</div>
</div>
- <ul role="list" class="flex flex-col gap-6 items-start self-stretch px-10 pt-2 pb-6">
- <For each={messages()}>
+ <ul role="list" class="flex flex-col items-start self-stretch px-6 pt-2 pb-6 gap-1">
+ <For each={messagesWithValidParts()}>
{(message) => (
- <div class="flex flex-col gap-1 justify-center items-start self-stretch">
- <For each={sync.data.part[message.id]?.filter(valid)}>
+ <div
+ classList={{
+ "flex flex-col gap-1 justify-center items-start self-stretch": true,
+ "mt-6": hasTextPart(message),
+ }}
+ >
+ <For each={sync.data.part[message.id]?.filter(valid) ?? []}>
{(part) => (
<li class="group/li">
<Switch fallback={<div class="">{part.type}</div>}>
@@ -449,9 +462,9 @@ export default function SessionTimeline(props: { session: string; class?: string
<Collapsible defaultOpen={false}>
<Collapsible.Trigger>
<div class="mt-12 ml-1 flex items-center gap-x-2 text-xs text-text-muted">
- <Icon name="file-code" size={16} />
+ <Icon name="file-code" />
<span>Raw Session Data</span>
- <Collapsible.Arrow size={18} class="text-text-muted" />
+ <Collapsible.Arrow class="text-text-muted" />
</div>
</Collapsible.Trigger>
<Collapsible.Content class="mt-5">
@@ -460,9 +473,9 @@ export default function SessionTimeline(props: { session: string; class?: string
<Collapsible>
<Collapsible.Trigger>
<div class="flex items-center gap-x-2 text-xs text-text-muted ml-1">
- <Icon name="file-code" size={16} />
+ <Icon name="file-code" />
<span>session</span>
- <Collapsible.Arrow size={18} class="text-text-muted" />
+ <Collapsible.Arrow class="text-text-muted" />
</div>
</Collapsible.Trigger>
<Collapsible.Content>
@@ -477,9 +490,9 @@ export default function SessionTimeline(props: { session: string; class?: string
<Collapsible>
<Collapsible.Trigger>
<div class="flex items-center gap-x-2 text-xs text-text-muted ml-1">
- <Icon name="file-code" size={16} />
+ <Icon name="file-code" />
<span>{message.role === "user" ? "user" : "assistant"}</span>
- <Collapsible.Arrow size={18} class="text-text-muted" />
+ <Collapsible.Arrow class="text-text-muted" />
</div>
</Collapsible.Trigger>
<Collapsible.Content>
@@ -493,9 +506,9 @@ export default function SessionTimeline(props: { session: string; class?: string
<Collapsible>
<Collapsible.Trigger>
<div class="flex items-center gap-x-2 text-xs text-text-muted ml-1">
- <Icon name="file-code" size={16} />
+ <Icon name="file-code" />
<span>{part.type}</span>
- <Collapsible.Arrow size={18} class="text-text-muted" />
+ <Collapsible.Arrow class="text-text-muted" />
</div>
</Collapsible.Trigger>
<Collapsible.Content>
diff --git a/packages/desktop/src/pages/index.tsx b/packages/desktop/src/pages/index.tsx
index 80473d84a..58d479111 100644
--- a/packages/desktop/src/pages/index.tsx
+++ b/packages/desktop/src/pages/index.tsx
@@ -1,16 +1,14 @@
-import { Button, Icon, List, Tooltip } from "@opencode-ai/ui"
-import { FileIcon, IconButton } from "@/ui"
+import { Button, Icon, List, SelectDialog, Tooltip } from "@opencode-ai/ui"
+import { FileIcon } from "@/ui"
import FileTree from "@/components/file-tree"
import EditorPane from "@/components/editor-pane"
-import { For, Match, onCleanup, onMount, Show, Switch } from "solid-js"
-import { SelectDialog } from "@/components/select-dialog"
+import { For, onCleanup, onMount, Show } from "solid-js"
import { useSync, useSDK, useLocal } from "@/context"
import type { LocalFile, TextSelection } from "@/context/local"
import SessionTimeline from "@/components/session-timeline"
-import { type PromptContentPart, type PromptSubmitValue } from "@/components/prompt-form"
import { createStore } from "solid-js/store"
import { getDirectory, getFilename } from "@/utils"
-import { PromptInput } from "@/components/prompt-input"
+import { ContentPart, PromptInput } from "@/components/prompt-input"
import { DateTime } from "luxon"
export default function Page() {
@@ -22,8 +20,7 @@ export default function Page() {
modelSelectOpen: false,
fileSelectOpen: false,
})
-
- let inputRef: HTMLTextAreaElement | undefined = undefined
+ let inputRef!: HTMLDivElement
const MOD = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control"
@@ -50,7 +47,7 @@ export default function Page() {
const focused = document.activeElement === inputRef
if (focused) {
if (event.key === "Escape") {
- // inputRef?.blur()
+ inputRef?.blur()
}
return
}
@@ -77,7 +74,7 @@ export default function Page() {
}
if (event.key.length === 1 && event.key !== "Unidentified") {
- // inputRef?.focus()
+ inputRef?.focus()
}
}
@@ -104,9 +101,7 @@ export default function Page() {
}
}
- const handlePromptSubmit2 = () => {}
-
- const handlePromptSubmit = async (prompt: PromptSubmitValue) => {
+ const handlePromptSubmit = async (parts: ContentPart[]) => {
const existingSession = local.session.active()
let session = existingSession
if (!session) {
@@ -134,6 +129,7 @@ export default function Page() {
const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path))
+ const text = parts.map((part) => part.content).join("")
const attachments = new Map<string, SubmissionAttachment>()
const registerAttachment = (path: string, selection: TextSelection | undefined, label?: string) => {
@@ -147,30 +143,27 @@ export default function Page() {
})
}
- const promptAttachments = prompt.parts.filter(
- (part): part is Extract<PromptContentPart, { kind: "attachment" }> => part.kind === "attachment",
- )
-
+ const promptAttachments = parts.filter((part) => part.type === "file")
for (const part of promptAttachments) {
- registerAttachment(part.path, part.selection, part.display)
+ registerAttachment(part.path, part.selection, part.content)
}
- const activeFile = local.context.active()
- if (activeFile) {
- registerAttachment(
- activeFile.path,
- activeFile.selection,
- activeFile.name ?? formatAttachmentLabel(activeFile.path, activeFile.selection),
- )
- }
+ // const activeFile = local.context.active()
+ // if (activeFile) {
+ // registerAttachment(
+ // activeFile.path,
+ // activeFile.selection,
+ // activeFile.name ?? formatAttachmentLabel(activeFile.path, activeFile.selection),
+ // )
+ // }
- for (const contextFile of local.context.all()) {
- registerAttachment(
- contextFile.path,
- contextFile.selection,
- formatAttachmentLabel(contextFile.path, contextFile.selection),
- )
- }
+ // for (const contextFile of local.context.all()) {
+ // registerAttachment(
+ // contextFile.path,
+ // contextFile.selection,
+ // formatAttachmentLabel(contextFile.path, contextFile.selection),
+ // )
+ // }
const attachmentParts = Array.from(attachments.values()).map((attachment) => {
const absolute = toAbsolutePath(attachment.path)
@@ -205,7 +198,7 @@ export default function Page() {
parts: [
{
type: "text",
- text: prompt.text,
+ text,
},
...attachmentParts,
],
@@ -213,16 +206,10 @@ export default function Page() {
})
}
- const plus = (
- <IconButton
- class="text-text-muted/60 peer-data-[selected]/tab:opacity-100 peer-data-[selected]/tab:text-text peer-data-[selected]/tab:hover:bg-border-subtle hover:opacity-100 peer-hover/tab:opacity-100"
- size="xs"
- variant="secondary"
- onClick={() => setStore("fileSelectOpen", true)}
- >
- <Icon name="plus" size={12} />
- </IconButton>
- )
+ const handleNewSession = () => {
+ local.session.setActive(undefined)
+ inputRef?.focus()
+ }
return (
<div class="relative h-screen flex flex-col">
@@ -234,7 +221,8 @@ export default function Page() {
</div>
<div class="flex flex-col items-start gap-4 self-stretch flex-1">
<div class="px-3 py-1.5 w-full">
- <Button class="w-full" size="large">
+ <Button class="w-full" size="large" onClick={handleNewSession}>
+ <Icon name="plus" />
New Session
</Button>
</div>
@@ -268,25 +256,30 @@ export default function Page() {
</List>
</div>
</div>
- <div class="relative grid grid-cols-2 bg-background-base">
+ <div class="relative grid grid-cols-2 bg-background-base w-full">
<div class="pt-1.5 min-w-0 overflow-y-auto no-scrollbar flex justify-center">
<Show when={local.session.active()}>
{(activeSession) => <SessionTimeline session={activeSession().id} class="w-full" />}
</Show>
</div>
<div class="p-1.5 pl-px flex flex-col items-center justify-center overflow-y-auto no-scrollbar">
- <EditorPane onFileClick={handleFileClick} />
+ <Show when={local.session.active()}>
+ <EditorPane onFileClick={handleFileClick} />
+ </Show>
</div>
- <div class="absolute bottom-4 inset-x-0 p-2 flex flex-col justify-center items-center z-50">
- <PromptInput onSubmit={handlePromptSubmit2} />
- {/* <PromptForm */}
- {/* class="w-2xl" */}
- {/* onSubmit={handlePromptSubmit} */}
- {/* onOpenModelSelect={() => setStore("modelSelectOpen", true)} */}
- {/* onInputRefChange={(element: HTMLTextAreaElement | undefined) => { */}
- {/* inputRef = element ?? undefined */}
- {/* }} */}
- {/* /> */}
+ <div
+ classList={{
+ "absolute inset-x-0 px-8 flex flex-col justify-center items-center z-50": true,
+ "bottom-8": !!local.session.active(),
+ "bottom-1/2 translate-y-1/2": !local.session.active(),
+ }}
+ >
+ <PromptInput
+ ref={(el) => {
+ inputRef = el
+ }}
+ onSubmit={handlePromptSubmit}
+ />
</div>
<div class="hidden shrink-0 w-56 p-2 h-full overflow-y-auto">
<FileTree path="" onFileClick={handleFileClick} />
@@ -302,7 +295,7 @@ export default function Page() {
<li>
<button
onClick={() => local.file.open(path, { view: "diff-unified", pinned: true })}
- class="w-full flex items-center px-2 py-0.5 gap-x-2 text-text-muted grow min-w-0 cursor-pointer hover:bg-background-element"
+ class="w-full flex items-center px-2 py-0.5 gap-x-2 text-text-muted grow min-w-0 hover:bg-background-element"
>
<FileIcon node={{ path, type: "file" }} class="shrink-0 size-3" />
<span class="text-xs text-text whitespace-nowrap">{getFilename(path)}</span>
@@ -318,59 +311,16 @@ export default function Page() {
</div>
</div>
</main>
- <Show when={store.modelSelectOpen}>
- <SelectDialog
- key={(x) => `${x.provider.id}:${x.id}`}
- items={local.model.list()}
- current={local.model.current()}
- render={(i) => (
- <div class="w-full flex items-center justify-between">
- <div class="flex items-center gap-x-2 text-text-muted grow min-w-0">
- <img src={`https://models.dev/logos/${i.provider.id}.svg`} class="size-4 invert opacity-40" />
- <span class="text-xs text-text whitespace-nowrap">{i.name}</span>
- <span class="text-xs text-text-muted/80 whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
- {i.id}
- </span>
- </div>
- <div class="flex items-center gap-x-1 text-text-muted/40 shrink-0">
- <Tooltip forceMount={false} value="Reasoning">
- <Icon name="brain" size={16} classList={{ "text-accent": i.reasoning }} />
- </Tooltip>
- <Tooltip forceMount={false} value="Tools">
- <Icon name="hammer" size={16} classList={{ "text-secondary": i.tool_call }} />
- </Tooltip>
- <Tooltip forceMount={false} value="Attachments">
- <Icon name="photo" size={16} classList={{ "text-success": i.attachment }} />
- </Tooltip>
- <div class="rounded-full bg-text-muted/20 text-text-muted/80 w-9 h-4 flex items-center justify-center text-[10px]">
- {new Intl.NumberFormat("en-US", {
- notation: "compact",
- compactDisplay: "short",
- }).format(i.limit.context)}
- </div>
- <Tooltip forceMount={false} value={`$${i.cost?.input}/1M input, $${i.cost?.output}/1M output`}>
- <div class="rounded-full bg-success/20 text-success/80 w-9 h-4 flex items-center justify-center text-[10px]">
- <Switch fallback="FREE">
- <Match when={i.cost?.input > 10}>$$$</Match>
- <Match when={i.cost?.input > 1}>$$</Match>
- <Match when={i.cost?.input > 0.1}>$</Match>
- </Switch>
- </div>
- </Tooltip>
- </div>
- </div>
- )}
- filter={["provider.name", "name", "id"]}
- groupBy={(x) => x.provider.name}
- onClose={() => setStore("modelSelectOpen", false)}
- onSelect={(x) => local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined)}
- />
- </Show>
<Show when={store.fileSelectOpen}>
<SelectDialog
+ defaultOpen
+ title="Select file"
items={local.file.search}
key={(x) => x}
- render={(i) => (
+ onOpenChange={(open) => setStore("fileSelectOpen", open)}
+ onSelect={(x) => (x ? local.file.open(x, { pinned: true }) : undefined)}
+ >
+ {(i) => (
<div class="w-full flex items-center justify-between">
<div class="flex items-center gap-x-2 text-text-muted grow min-w-0">
<FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
@@ -382,9 +332,7 @@ export default function Page() {
<div class="flex items-center gap-x-1 text-text-muted/40 shrink-0"></div>
</div>
)}
- onClose={() => setStore("fileSelectOpen", false)}
- onSelect={(x) => (x ? local.file.open(x, { pinned: true }) : undefined)}
- />
+ </SelectDialog>
</Show>
</div>
)
diff --git a/packages/desktop/src/ui/collapsible.tsx b/packages/desktop/src/ui/collapsible.tsx
index d17b3e623..fbc6fcbfe 100644
--- a/packages/desktop/src/ui/collapsible.tsx
+++ b/packages/desktop/src/ui/collapsible.tsx
@@ -16,7 +16,7 @@ function CollapsibleTrigger(props: CollapsibleTriggerProps) {
return (
<KobalteCollapsible.Trigger
classList={{
- "w-full group/collapsible cursor-pointer": true,
+ "w-full group/collapsible": true,
[local.class ?? ""]: !!local.class,
}}
{...others}
diff --git a/packages/desktop/src/ui/icon-button.tsx b/packages/desktop/src/ui/icon-button.tsx
deleted file mode 100644
index 7e9e3e6e2..000000000
--- a/packages/desktop/src/ui/icon-button.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-import { Button as KobalteButton } from "@kobalte/core/button"
-import { splitProps } from "solid-js"
-import type { ComponentProps, JSX } from "solid-js"
-
-export interface IconButtonProps extends ComponentProps<typeof KobalteButton> {
- variant?: "primary" | "secondary" | "outline" | "ghost"
- size?: "xs" | "sm" | "md" | "lg"
- children: JSX.Element
-}
-
-export function IconButton(props: IconButtonProps) {
- const [local, others] = splitProps(props, ["variant", "size", "class", "classList"])
- return (
- <KobalteButton
- classList={{
- ...(local.classList || {}),
- "inline-flex items-center justify-center rounded-md font-medium cursor-pointer": true,
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2": true,
- "disabled:pointer-events-none disabled:opacity-50": true,
- "bg-primary text-background hover:bg-secondary focus-visible:ring-primary data-[disabled]:opacity-50":
- (local.variant || "primary") === "primary",
- "bg-background-panel text-text hover:bg-background-element focus-visible:ring-secondary data-[disabled]:opacity-50":
- local.variant === "secondary",
- "border border-border bg-transparent text-text hover:bg-background-panel": local.variant === "outline",
- "focus-visible:ring-border-active data-[disabled]:border-border-subtle data-[disabled]:text-text-muted":
- local.variant === "outline",
- "text-text hover:bg-background-panel focus-visible:ring-border-active data-[disabled]:text-text-muted":
- local.variant === "ghost",
- "h-5 w-5 text-xs": local.size === "xs",
- "h-8 w-8 text-sm": local.size === "sm",
- "h-10 w-10 text-sm": (local.size || "md") === "md",
- "h-12 w-12 text-base": local.size === "lg",
- [local.class ?? ""]: !!local.class,
- }}
- {...others}
- />
- )
-}
diff --git a/packages/desktop/src/ui/index.ts b/packages/desktop/src/ui/index.ts
index a6ade6ff3..e273e8efe 100644
--- a/packages/desktop/src/ui/index.ts
+++ b/packages/desktop/src/ui/index.ts
@@ -5,4 +5,3 @@ export {
type CollapsibleContentProps,
} from "./collapsible"
export { FileIcon, type FileIconProps } from "./file-icon"
-export { IconButton, type IconButtonProps } from "./icon-button"