Adam 4 месяцев назад
Родитель
Сommit
89b703c387
38 измененных файлов с 1353 добавлено и 638 удалено
  1. 27 0
      bun.lock
  2. 3 2
      packages/desktop/package.json
  3. 1 1
      packages/desktop/src/components/code.tsx
  4. 12 25
      packages/desktop/src/components/editor-pane.tsx
  5. 2 2
      packages/desktop/src/components/file-tree.tsx
  6. 3 3
      packages/desktop/src/components/prompt-form-helpers.ts
  7. 1 1
      packages/desktop/src/components/prompt-form-hooks.ts
  8. 298 130
      packages/desktop/src/components/prompt-input.tsx
  9. 0 226
      packages/desktop/src/components/select-dialog.tsx
  10. 30 17
      packages/desktop/src/components/session-timeline.tsx
  11. 58 110
      packages/desktop/src/pages/index.tsx
  12. 1 1
      packages/desktop/src/ui/collapsible.tsx
  13. 0 38
      packages/desktop/src/ui/icon-button.tsx
  14. 0 1
      packages/desktop/src/ui/index.ts
  15. 6 3
      packages/ui/package.json
  16. 3 10
      packages/ui/src/components/button.css
  17. 4 2
      packages/ui/src/components/button.tsx
  18. 129 0
      packages/ui/src/components/dialog.css
  19. 91 0
      packages/ui/src/components/dialog.tsx
  20. 117 0
      packages/ui/src/components/icon-button.css
  21. 27 0
      packages/ui/src/components/icon-button.tsx
  22. 23 0
      packages/ui/src/components/icon.css
  23. 44 17
      packages/ui/src/components/icon.tsx
  24. 4 0
      packages/ui/src/components/index.ts
  25. 23 0
      packages/ui/src/components/input.css
  26. 27 0
      packages/ui/src/components/input.tsx
  27. 3 1
      packages/ui/src/components/list.css
  28. 4 3
      packages/ui/src/components/list.tsx
  29. 109 0
      packages/ui/src/components/select-dialog.css
  30. 156 0
      packages/ui/src/components/select-dialog.tsx
  31. 29 33
      packages/ui/src/components/select.css
  32. 2 2
      packages/ui/src/components/select.tsx
  33. 4 4
      packages/ui/src/components/tabs.css
  34. 1 1
      packages/ui/src/components/tooltip.tsx
  35. 1 0
      packages/ui/src/hooks/index.ts
  36. 89 0
      packages/ui/src/hooks/use-filtered-list.tsx
  37. 4 0
      packages/ui/src/styles/index.css
  38. 17 5
      packages/ui/src/styles/utilities.css

+ 27 - 0
bun.lock

@@ -115,6 +115,7 @@
         "@opencode-ai/sdk": "workspace:*",
         "@opencode-ai/ui": "workspace:*",
         "@shikijs/transformers": "3.9.2",
+        "@solid-primitives/active-element": "2.1.3",
         "@solid-primitives/event-bus": "1.1.2",
         "@solid-primitives/resize-observer": "2.1.3",
         "@solid-primitives/scroll": "2.1.3",
@@ -273,7 +274,9 @@
       "version": "0.15.13",
       "dependencies": {
         "@kobalte/core": "catalog:",
+        "@pierre/precision-diffs": "0.0.2-alpha.1-1",
         "@solidjs/meta": "catalog:",
+        "fuzzysort": "catalog:",
         "luxon": "catalog:",
         "remeda": "catalog:",
         "solid-js": "catalog:",
@@ -931,6 +934,8 @@
 
     "@petamoriken/float16": ["@petamoriken/[email protected]", "", {}, "sha512-VgffxawQde93xKxT3qap3OH+meZf7VaSB5Sqd4Rqc+FP5alWbpOyan/7tRbOAvynjpG3GpdtAuGU/NdhQpmrog=="],
 
+    "@pierre/precision-diffs": ["@pierre/[email protected]", "", { "dependencies": { "@shikijs/core": "3.13.0", "@shikijs/transformers": "3.13.0", "diff": "8.0.2", "fast-deep-equal": "3.1.3", "hast-util-to-html": "9.0.5", "shiki": "3.13.0" } }, "sha512-T43cwB7gMnbM+tp9p73NptUm4uUOfmrP5ihMOAHWQPpzBa/oeTjqZlmEmSQLpT8WKKnWG0lbKZPtlw7l0gW0Vw=="],
+
     "@pkgjs/parseargs": ["@pkgjs/[email protected]", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
 
     "@planetscale/database": ["@planetscale/[email protected]", "", {}, "sha512-Tv4jcFUFAFjOWrGSio49H6R2ijALv0ZzVBfJKIdm+kl9X046Fh4LLawrF9OMsglVbK6ukqMJsUCeucGAFTBcMA=="],
@@ -1181,6 +1186,8 @@
 
     "@smithy/uuid": ["@smithy/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw=="],
 
+    "@solid-primitives/active-element": ["@solid-primitives/[email protected]", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-9t5K4aR2naVDj950XU8OjnLgOg94a8k5wr6JNOPK+N5ESLsJDq42c1ZP8UKpewi1R+wplMMxiM6OPKRzbxJY7A=="],
+
     "@solid-primitives/event-bus": ["@solid-primitives/[email protected]", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-l+n10/51neGcMaP3ypYt21bXfoeWh8IaC8k7fYuY3ww2a8S1Zv2N2a7FF5Qn+waTu86l0V8/nRHjkyqVIZBYwA=="],
 
     "@solid-primitives/event-listener": ["@solid-primitives/[email protected]", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-h4VqkYFv6Gf+L7SQj+Y6puigL/5DIi7x5q07VZET7AWcS+9/G3WfIE9WheniHWJs51OEkRB43w6lDys5YeFceg=="],
@@ -3503,6 +3510,12 @@
 
     "@parcel/watcher-wasm/napi-wasm": ["[email protected]", "", { "bundled": true }, "sha512-h/4nMGsHjZDCYmQVNODIrYACVJ+I9KItbG+0si6W/jSjdA9JbWDoU4LLeMXVcEQGHjttI2tuXqDrbGF7qkUHHg=="],
 
+    "@pierre/precision-diffs/@shikijs/core": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3P8rGsg2Eh2qIHekwuQjzWhKI4jV97PhvYjYUzGqjvJfqdQPz+nMlfWahU24GZAyW1FxFI1sYjyhfh5CoLmIUA=="],
+
+    "@pierre/precision-diffs/@shikijs/transformers": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/core": "3.13.0", "@shikijs/types": "3.13.0" } }, "sha512-833lcuVzcRiG+fXvgslWsM2f4gHpjEgui1ipIknSizRuTgMkNZupiXE5/TVJ6eSYfhNBFhBZKkReKWO2GgYmqA=="],
+
+    "@pierre/precision-diffs/shiki": ["[email protected]", "", { "dependencies": { "@shikijs/core": "3.13.0", "@shikijs/engine-javascript": "3.13.0", "@shikijs/engine-oniguruma": "3.13.0", "@shikijs/langs": "3.13.0", "@shikijs/themes": "3.13.0", "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-aZW4l8Og16CokuCLf8CF8kq+KK2yOygapU5m3+hoGw0Mdosc6fPitjM+ujYarppj5ZIKGyPDPP1vqmQhr+5/0g=="],
+
     "@poppinss/dumper/supports-color": ["[email protected]", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
 
     "@rollup/plugin-commonjs/estree-walker": ["[email protected]", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
@@ -4065,6 +4078,20 @@
 
     "@opencode-ai/web/shiki/@shikijs/types": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-zHC1l7L+eQlDXLnxvM9R91Efh2V4+rN3oMVS2swCBssbj2U/FBwybD1eeLaq8yl/iwT+zih8iUbTBCgGZOYlVg=="],
 
+    "@pierre/precision-diffs/@shikijs/core/@shikijs/types": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-oM9P+NCFri/mmQ8LoFGVfVyemm5Hi27330zuOBp0annwJdKH1kOLndw3zCtAVDehPLg9fKqoEx3Ht/wNZxolfw=="],
+
+    "@pierre/precision-diffs/@shikijs/transformers/@shikijs/types": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-oM9P+NCFri/mmQ8LoFGVfVyemm5Hi27330zuOBp0annwJdKH1kOLndw3zCtAVDehPLg9fKqoEx3Ht/wNZxolfw=="],
+
+    "@pierre/precision-diffs/shiki/@shikijs/engine-javascript": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "sha512-Ty7xv32XCp8u0eQt8rItpMs6rU9Ki6LJ1dQOW3V/56PKDcpvfHPnYFbsx5FFUP2Yim34m/UkazidamMNVR4vKg=="],
+
+    "@pierre/precision-diffs/shiki/@shikijs/engine-oniguruma": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-O42rBGr4UDSlhT2ZFMxqM7QzIU+IcpoTMzb3W7AlziI1ZF7R8eS2M0yt5Ry35nnnTX/LTLXFPUjRFCIW+Operg=="],
+
+    "@pierre/precision-diffs/shiki/@shikijs/langs": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/types": "3.13.0" } }, "sha512-672c3WAETDYHwrRP0yLy3W1QYB89Hbpj+pO4KhxK6FzIrDI2FoEXNiNCut6BQmEApYLfuYfpgOZaqbY+E9b8wQ=="],
+
+    "@pierre/precision-diffs/shiki/@shikijs/themes": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/types": "3.13.0" } }, "sha512-Vxw1Nm1/Od8jyA7QuAenaV78BG2nSr3/gCGdBkLpfLscddCkzkL36Q5b67SrLLfvAJTOUzW39x4FHVCFriPVgg=="],
+
+    "@pierre/precision-diffs/shiki/@shikijs/types": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-oM9P+NCFri/mmQ8LoFGVfVyemm5Hi27330zuOBp0annwJdKH1kOLndw3zCtAVDehPLg9fKqoEx3Ht/wNZxolfw=="],
+
     "@slack/web-api/p-queue/eventemitter3": ["[email protected]", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
 
     "@slack/web-api/p-queue/p-timeout": ["[email protected]", "", { "dependencies": { "p-finally": "^1.0.0" } }, "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg=="],

+ 3 - 2
packages/desktop/package.json

@@ -25,7 +25,9 @@
   "dependencies": {
     "@kobalte/core": "catalog:",
     "@opencode-ai/sdk": "workspace:*",
+    "@opencode-ai/ui": "workspace:*",
     "@shikijs/transformers": "3.9.2",
+    "@solid-primitives/active-element": "2.1.3",
     "@solid-primitives/event-bus": "1.1.2",
     "@solid-primitives/resize-observer": "2.1.3",
     "@solid-primitives/scroll": "2.1.3",
@@ -33,14 +35,13 @@
     "@solidjs/router": "0.15.3",
     "@thisbeyond/solid-dnd": "0.7.5",
     "diff": "catalog:",
-    "@opencode-ai/ui": "workspace:*",
     "fuzzysort": "catalog:",
     "luxon": "catalog:",
     "marked": "16.2.0",
     "marked-shiki": "1.2.1",
     "remeda": "catalog:",
-    "solid-js": "catalog:",
     "shiki": "3.9.2",
+    "solid-js": "catalog:",
     "solid-list": "catalog:",
     "tailwindcss": "catalog:",
     "virtua": "catalog:"

+ 1 - 1
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

+ 12 - 25
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>

+ 2 - 2
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}

+ 3 - 3
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) {

+ 1 - 1
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,

+ 298 - 130
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)
 }

+ 0 - 226
packages/desktop/src/components/select-dialog.tsx

@@ -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>
-  )
-}

+ 30 - 17
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>

+ 58 - 110
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>
   )

+ 1 - 1
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}

+ 0 - 38
packages/desktop/src/ui/icon-button.tsx

@@ -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}
-    />
-  )
-}

+ 0 - 1
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"

+ 6 - 3
packages/ui/package.json

@@ -5,6 +5,7 @@
   "exports": {
     ".": "./src/components/index.ts",
     "./*": "./src/components/*.tsx",
+    "./hooks": "./src/hooks/index.ts",
     "./styles": "./src/styles/index.css",
     "./styles/tailwind": "./src/styles/tailwind/index.css",
     "./fonts/*": "./src/assets/fonts/*"
@@ -23,11 +24,13 @@
   },
   "dependencies": {
     "@kobalte/core": "catalog:",
+    "@pierre/precision-diffs": "0.0.2-alpha.1-1",
     "@solidjs/meta": "catalog:",
-    "remeda": "catalog:",
+    "fuzzysort": "catalog:",
     "luxon": "catalog:",
-    "virtua": "catalog:",
+    "remeda": "catalog:",
     "solid-js": "catalog:",
-    "solid-list": "catalog:"
+    "solid-list": "catalog:",
+    "virtua": "catalog:"
   }
 }

+ 3 - 10
packages/ui/src/components/button.css

@@ -1,5 +1,4 @@
 [data-component="button"] {
-  cursor: pointer;
   display: inline-flex;
   align-items: center;
   justify-content: center;
@@ -32,12 +31,7 @@
     border-color: var(--border-weak-base);
     background-color: var(--button-secondary-base);
     color: var(--text-strong);
-
-    /* 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);
+    box-shadow: var(--shadow-xs);
 
     &:hover:not(:disabled) {
       border-color: var(--border-hover);
@@ -84,12 +78,11 @@
     padding: 0 8px 0 6px;
     gap: 8px;
 
-    /* text-12-medium */
     font-family: var(--font-family-sans);
-    font-size: var(--font-size-small);
+    font-size: var(--font-size-base);
     font-style: normal;
     font-weight: var(--font-weight-medium);
-    line-height: var(--line-height-large); /* 166.667% */
+    line-height: var(--line-height-large); /* 171.429% */
     letter-spacing: var(--letter-spacing-normal);
   }
 

+ 4 - 2
packages/ui/src/components/button.tsx

@@ -1,12 +1,14 @@
 import { Button as Kobalte } from "@kobalte/core/button"
 import { type ComponentProps, splitProps } from "solid-js"
 
-export interface ButtonProps {
+export interface ButtonProps
+  extends ComponentProps<typeof Kobalte>,
+    Pick<ComponentProps<"button">, "class" | "classList" | "children"> {
   size?: "normal" | "large"
   variant?: "primary" | "secondary" | "ghost"
 }
 
-export function Button(props: ComponentProps<"button"> & ButtonProps) {
+export function Button(props: ButtonProps) {
   const [split, rest] = splitProps(props, ["variant", "size", "class", "classList"])
   return (
     <Kobalte

+ 129 - 0
packages/ui/src/components/dialog.css

@@ -0,0 +1,129 @@
+/* [data-component="dialog-trigger"] { } */
+
+[data-component="dialog-overlay"] {
+  position: fixed;
+  inset: 0;
+  z-index: 50;
+  background-color: transparent;
+
+  /* animation: overlayHide 250ms ease 100ms forwards; */
+  /**/
+  /* &[data-expanded] { */
+  /*   animation: overlayShow 250ms ease; */
+  /* } */
+}
+
+[data-component="dialog"] {
+  position: fixed;
+  inset: 0;
+  z-index: 50;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+
+  [data-slot="container"] {
+    position: relative;
+    z-index: 50;
+    width: min(calc(100vw - 16px), 624px);
+    height: min(calc(100vh - 16px), 512px);
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-items: start;
+
+    [data-slot="content"] {
+      display: flex;
+      flex-direction: column;
+      align-items: flex-start;
+      align-self: stretch;
+      gap: 8px;
+      width: 100%;
+      max-height: 100%;
+
+      /* padding: 8px; */
+      padding: 8px 8px 0 8px;
+      border: 1px solid var(--border-base);
+      border-radius: 16px;
+      background: var(--surface-raised-stronger-non-alpha);
+      box-shadow:
+        0 15px 45px 0 rgba(19, 16, 16, 0.22),
+        0 3.35px 10.051px 0 rgba(19, 16, 16, 0.13),
+        0 0.998px 2.993px 0 rgba(19, 16, 16, 0.09);
+
+      /* animation: contentHide 300ms ease-in forwards; */
+      /**/
+      /* &[data-expanded] { */
+      /*   animation: contentShow 300ms ease-out; */
+      /* } */
+
+      [data-slot="header"] {
+        display: flex;
+        height: 40px;
+        padding: 4px 4px 4px 8px;
+        justify-content: space-between;
+        align-items: center;
+        flex-shrink: 0;
+        align-self: stretch;
+
+        [data-slot="title"] {
+          color: var(--text-strong);
+
+          /* text-16-medium */
+          font-family: var(--font-family-sans);
+          font-size: var(--font-size-large);
+          font-style: normal;
+          font-weight: var(--font-weight-medium);
+          line-height: var(--line-height-x-large); /* 150% */
+          letter-spacing: var(--letter-spacing-tight);
+        }
+        /* [data-slot="close-button"] {} */
+      }
+      /* [data-slot="description"] {} */
+      [data-slot="body"] {
+        width: 100%;
+        position: relative;
+        display: flex;
+        flex-direction: column;
+        flex: 1;
+        overflow-y: auto;
+      }
+    }
+  }
+}
+
+@keyframes overlayShow {
+  from {
+    opacity: 0;
+  }
+  to {
+    opacity: 1;
+  }
+}
+@keyframes overlayHide {
+  from {
+    opacity: 1;
+  }
+  to {
+    opacity: 0;
+  }
+}
+@keyframes contentShow {
+  from {
+    opacity: 0;
+    transform: scale(0.96);
+  }
+  to {
+    opacity: 1;
+    transform: scale(1);
+  }
+}
+@keyframes contentHide {
+  from {
+    opacity: 1;
+    transform: scale(1);
+  }
+  to {
+    opacity: 0;
+    transform: scale(0.96);
+  }
+}

+ 91 - 0
packages/ui/src/components/dialog.tsx

@@ -0,0 +1,91 @@
+import {
+  Dialog as Kobalte,
+  DialogRootProps,
+  DialogTitleProps,
+  DialogCloseButtonProps,
+  DialogDescriptionProps,
+} from "@kobalte/core/dialog"
+import { ComponentProps, type JSX, onCleanup, Show, splitProps } from "solid-js"
+import { IconButton } from "./icon-button"
+
+export interface DialogProps extends DialogRootProps {
+  trigger?: JSX.Element
+  class?: ComponentProps<"div">["class"]
+  classList?: ComponentProps<"div">["classList"]
+}
+
+export function DialogRoot(props: DialogProps) {
+  let trigger!: HTMLElement
+  const [local, others] = splitProps(props, ["trigger", "class", "classList", "children"])
+
+  const resetTabIndex = () => {
+    trigger.tabIndex = 0
+  }
+
+  const handleTriggerFocus = (e: FocusEvent & { currentTarget: HTMLElement | null }) => {
+    const firstChild = e.currentTarget?.firstElementChild as HTMLElement
+    if (!firstChild) return
+
+    firstChild.focus()
+    trigger.tabIndex = -1
+
+    firstChild.addEventListener("focusout", resetTabIndex)
+    onCleanup(() => {
+      firstChild.removeEventListener("focusout", resetTabIndex)
+    })
+  }
+
+  return (
+    <Kobalte {...others}>
+      <Show when={props.trigger}>
+        <Kobalte.Trigger ref={trigger} data-component="dialog-trigger" onFocusIn={handleTriggerFocus}>
+          {props.trigger}
+        </Kobalte.Trigger>
+      </Show>
+      <Kobalte.Portal>
+        <Kobalte.Overlay data-component="dialog-overlay" />
+        <div data-component="dialog">
+          <div data-slot="container">
+            <Kobalte.Content
+              data-slot="content"
+              classList={{
+                ...(local.classList ?? {}),
+                [local.class ?? ""]: !!local.class,
+              }}
+            >
+              {local.children}
+            </Kobalte.Content>
+          </div>
+        </div>
+      </Kobalte.Portal>
+    </Kobalte>
+  )
+}
+
+function DialogHeader(props: ComponentProps<"div">) {
+  return <div data-slot="header" {...props} />
+}
+
+function DialogBody(props: ComponentProps<"div">) {
+  return <div data-slot="body" {...props} />
+}
+
+function DialogTitle(props: DialogTitleProps & ComponentProps<"h2">) {
+  return <Kobalte.Title data-slot="title" {...props} />
+}
+
+function DialogDescription(props: DialogDescriptionProps & ComponentProps<"p">) {
+  return <Kobalte.Description data-slot="description" {...props} />
+}
+
+function DialogCloseButton(props: DialogCloseButtonProps & ComponentProps<"button">) {
+  return <Kobalte.CloseButton data-slot="close-button" as={IconButton} icon="close" {...props} />
+}
+
+export const Dialog = Object.assign(DialogRoot, {
+  Header: DialogHeader,
+  Title: DialogTitle,
+  Description: DialogDescription,
+  CloseButton: DialogCloseButton,
+  Body: DialogBody,
+})

+ 117 - 0
packages/ui/src/components/icon-button.css

@@ -0,0 +1,117 @@
+[data-component="icon-button"] {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  border-radius: 100%;
+  text-decoration: none;
+  user-select: none;
+  aspect-ratio: 1;
+
+  &:disabled {
+    background-color: var(--icon-strong-disabled);
+    color: var(--icon-invert-base);
+    cursor: not-allowed;
+  }
+
+  &:focus {
+    outline: none;
+  }
+
+  &[data-variant="primary"] {
+    background-color: var(--icon-strong-base);
+
+    [data-slot="icon"] {
+      /* color: var(--icon-weak-base); */
+      color: var(--icon-invert-base);
+
+      /* &:hover:not(:disabled) { */
+      /*   color: var(--icon-weak-hover); */
+      /* } */
+      /* &:active:not(:disabled) { */
+      /*   color: var(--icon-string-active); */
+      /* } */
+    }
+
+    &:hover:not(:disabled) {
+      background-color: var(--icon-strong-hover);
+    }
+    &:active:not(:disabled) {
+      background-color: var(--icon-string-active);
+    }
+    &:focus:not(:disabled) {
+      background-color: var(--icon-strong-focus);
+    }
+    &:disabled {
+      background-color: var(--icon-strong-disabled);
+
+      [data-slot="icon"] {
+        color: var(--icon-invert-base);
+      }
+    }
+  }
+
+  &[data-variant="secondary"] {
+    background-color: var(--button-secondary-base);
+    color: var(--text-strong);
+
+    &:hover:not(:disabled) {
+      background-color: var(--surface-hover);
+    }
+    &:active:not(:disabled) {
+      background-color: var(--surface-active);
+    }
+    &:focus:not(:disabled) {
+      background-color: var(--surface-focus);
+    }
+  }
+
+  &[data-variant="ghost"] {
+    background-color: transparent;
+
+    [data-slot="icon"] {
+      color: var(--icon-weak-base);
+
+      &:hover:not(:disabled) {
+        color: var(--icon-weak-hover);
+      }
+      &:active:not(:disabled) {
+        color: var(--icon-string-active);
+      }
+    }
+
+    /* color: var(--text-strong); */
+    /**/
+    /* &:hover:not(:disabled) { */
+    /*   background-color: var(--surface-hover); */
+    /* } */
+    /* &:active:not(:disabled) { */
+    /*   background-color: var(--surface-active); */
+    /* } */
+    /* &:focus:not(:disabled) { */
+    /*   background-color: var(--surface-focus); */
+    /* } */
+  }
+
+  &[data-size="normal"] {
+    width: 24px;
+    height: 24px;
+
+    font-size: var(--font-size-small);
+    line-height: var(--line-height-large);
+    gap: calc(var(--spacing) * 0.5);
+  }
+
+  &[data-size="large"] {
+    height: 32px;
+    padding: 0 8px 0 6px;
+    gap: 8px;
+
+    /* text-12-medium */
+    font-family: var(--font-family-sans);
+    font-size: var(--font-size-small);
+    font-style: normal;
+    font-weight: var(--font-weight-medium);
+    line-height: var(--line-height-large); /* 166.667% */
+    letter-spacing: var(--letter-spacing-normal);
+  }
+}

+ 27 - 0
packages/ui/src/components/icon-button.tsx

@@ -0,0 +1,27 @@
+import { Button as Kobalte } from "@kobalte/core/button"
+import { type ComponentProps, splitProps } from "solid-js"
+import { Icon, IconProps } from "./icon"
+
+export interface IconButtonProps {
+  icon: IconProps["name"]
+  size?: "normal" | "large"
+  variant?: "primary" | "secondary" | "ghost"
+}
+
+export function IconButton(props: ComponentProps<"button"> & IconButtonProps) {
+  const [split, rest] = splitProps(props, ["variant", "size", "class", "classList"])
+  return (
+    <Kobalte
+      {...rest}
+      data-component="icon-button"
+      data-size={split.size || "normal"}
+      data-variant={split.variant || "secondary"}
+      classList={{
+        ...(split.classList ?? {}),
+        [split.class ?? ""]: !!split.class,
+      }}
+    >
+      <Icon data-slot="icon" name={props.icon} size={split.size === "large" ? "normal" : "small"} />
+    </Kobalte>
+  )
+}

+ 23 - 0
packages/ui/src/components/icon.css

@@ -3,4 +3,27 @@
   align-items: center;
   justify-content: center;
   flex-shrink: 0;
+  /* resize: both; */
+  aspect-ratio: 1/1;
+  color: var(--icon-base);
+
+  &[data-size="small"] {
+    width: 16px;
+    height: 16px;
+  }
+
+  &[data-size="normal"] {
+    width: 20px;
+    height: 20px;
+  }
+
+  &[data-size="large"] {
+    width: 32px;
+    height: 32px;
+  }
+
+  [data-slot="svg"] {
+    width: 100%;
+    height: auto;
+  }
 }

+ 44 - 17
packages/ui/src/components/icon.tsx

@@ -128,28 +128,55 @@ const icons = {
   mic: '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M8.75 8C8.75 6.20507 10.2051 4.75 12 4.75C13.7949 4.75 15.25 6.20507 15.25 8V11C15.25 12.7949 13.7949 14.25 12 14.25C10.2051 14.25 8.75 12.7949 8.75 11V8Z"></path><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M5.75 12.75C5.75 12.75 6 17.25 12 17.25C18 17.25 18.25 12.75 18.25 12.75"></path><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 17.75V19.25"></path>',
 } as const
 
+const newIcons = {
+  "circle-x": `<path fill-rule="evenodd" clip-rule="evenodd" d="M1.6665 10.0003C1.6665 5.39795 5.39746 1.66699 9.99984 1.66699C14.6022 1.66699 18.3332 5.39795 18.3332 10.0003C18.3332 14.6027 14.6022 18.3337 9.99984 18.3337C5.39746 18.3337 1.6665 14.6027 1.6665 10.0003ZM7.49984 6.91107L6.91058 7.50033L9.41058 10.0003L6.91058 12.5003L7.49984 13.0896L9.99984 10.5896L12.4998 13.0896L13.0891 12.5003L10.5891 10.0003L13.0891 7.50033L12.4998 6.91107L9.99984 9.41107L7.49984 6.91107Z" fill="currentColor"/>`,
+  "magnifying-glass": `<path d="M15.8332 15.8337L13.0819 13.0824M14.6143 9.39088C14.6143 12.2759 12.2755 14.6148 9.39039 14.6148C6.50532 14.6148 4.1665 12.2759 4.1665 9.39088C4.1665 6.5058 6.50532 4.16699 9.39039 4.16699C12.2755 4.16699 14.6143 6.5058 14.6143 9.39088Z" stroke="currentColor" stroke-linecap="square"/>`,
+  "plus-small": `<path d="M9.99984 5.41699V10.0003M9.99984 10.0003V14.5837M9.99984 10.0003H5.4165M9.99984 10.0003H14.5832" stroke="currentColor" stroke-linecap="square"/>`,
+  "chevron-down": `<path d="M6.6665 8.33325L9.99984 11.6666L13.3332 8.33325" stroke="currentColor" stroke-linecap="square"/>`,
+  "arrow-up": `<path fill-rule="evenodd" clip-rule="evenodd" d="M9.99991 2.24121L16.0921 8.33343L15.2083 9.21731L10.6249 4.63397V17.5001H9.37492V4.63398L4.7916 9.21731L3.90771 8.33343L9.99991 2.24121Z" fill="currentColor"/>`,
+}
+
 export interface IconProps extends ComponentProps<"svg"> {
-  name: keyof typeof icons
-  size?: number
+  name: keyof typeof icons | keyof typeof newIcons
+  size?: "small" | "normal" | "large"
 }
 
 export function Icon(props: IconProps) {
   const [local, others] = splitProps(props, ["name", "size", "class", "classList"])
-  const size = local.size ?? 24
+
+  if (local.name in newIcons) {
+    return (
+      <div data-component="icon" data-size={local.size || "normal"}>
+        <svg
+          data-slot="svg"
+          classList={{
+            ...(local.classList || {}),
+            [local.class ?? ""]: !!local.class,
+          }}
+          fill="none"
+          viewBox="0 0 20 20"
+          innerHTML={newIcons[local.name as keyof typeof newIcons]}
+          aria-hidden="true"
+          {...others}
+        />
+      </div>
+    )
+  }
+
   return (
-    <svg
-      data-component="icon"
-      classList={{
-        ...(local.classList || {}),
-        [local.class ?? ""]: !!local.class,
-      }}
-      width={size}
-      height={size}
-      fill="none"
-      viewBox="0 0 24 24"
-      innerHTML={icons[local.name]}
-      aria-hidden="true"
-      {...others}
-    />
+    <div data-component="icon" data-size={local.size || "normal"}>
+      <svg
+        data-slot="svg"
+        classList={{
+          ...(local.classList || {}),
+          [local.class ?? ""]: !!local.class,
+        }}
+        fill="none"
+        viewBox="0 0 24 24"
+        innerHTML={icons[local.name as keyof typeof icons]}
+        aria-hidden="true"
+        {...others}
+      />
+    </div>
   )
 }

+ 4 - 0
packages/ui/src/components/index.ts

@@ -1,7 +1,11 @@
 export * from "./button"
+export * from "./dialog"
 export * from "./icon"
+export * from "./icon-button"
+export * from "./input"
 export * from "./fonts"
 export * from "./list"
 export * from "./select"
+export * from "./select-dialog"
 export * from "./tabs"
 export * from "./tooltip"

+ 23 - 0
packages/ui/src/components/input.css

@@ -0,0 +1,23 @@
+[data-component="input"] {
+  /* [data-slot="label"] {} */
+
+  [data-slot="input"] {
+    color: var(--text-strong);
+
+    /* 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);
+
+    &:focus {
+      outline: none;
+    }
+
+    &::placeholder {
+      color: var(--text-weak);
+    }
+  }
+}

+ 27 - 0
packages/ui/src/components/input.tsx

@@ -0,0 +1,27 @@
+import { TextField as Kobalte } from "@kobalte/core/text-field"
+import { Show, splitProps } from "solid-js"
+import type { ComponentProps } from "solid-js"
+
+export interface InputProps extends ComponentProps<typeof Kobalte> {
+  label?: string
+  hideLabel?: boolean
+  description?: string
+}
+
+export function Input(props: InputProps) {
+  const [local, others] = splitProps(props, ["class", "label", "hideLabel", "description", "placeholder"])
+  return (
+    <Kobalte {...others} data-component="input">
+      <Show when={local.label}>
+        <Kobalte.Label data-slot="label" classList={{ "sr-only": local.hideLabel }}>
+          {local.label}
+        </Kobalte.Label>
+      </Show>
+      <Kobalte.Input data-slot="input" class={local.class} placeholder={local.placeholder} />
+      <Show when={local.description}>
+        <Kobalte.Description data-slot="description">{local.description}</Kobalte.Description>
+      </Show>
+      <Kobalte.ErrorMessage data-slot="error" />
+    </Kobalte>
+  )
+}

+ 3 - 1
packages/ui/src/components/list.css

@@ -12,7 +12,6 @@
   scrollbar-width: none;
 
   [data-slot="item"] {
-    cursor: pointer;
     width: 100%;
     padding: 4px 12px;
     text-align: left;
@@ -23,6 +22,9 @@
     &[data-active="true"] {
       background-color: var(--surface-raised-base-hover);
     }
+    &:hover {
+      background-color: var(--surface-raised-base-hover);
+    }
     &:focus {
       outline: none;
     }

+ 4 - 3
packages/ui/src/components/list.tsx

@@ -29,6 +29,7 @@ export function List<T>(props: ListProps<T>) {
   // }
   const handleSelect = (item: T) => {
     props.onSelect?.(item)
+    list.setActive(props.key(item))
   }
 
   const handleKey = (e: KeyboardEvent) => {
@@ -64,10 +65,10 @@ export function List<T>(props: ListProps<T>) {
           data-key={props.key(item)}
           data-active={props.key(item) === list.active()}
           onClick={() => handleSelect(item)}
-          onMouseMove={(e) => {
-            e.currentTarget.focus()
+          onMouseMove={() => {
+            // e.currentTarget.focus()
             setStore("mouseActive", true)
-            list.setActive(props.key(item))
+            // list.setActive(props.key(item))
           }}
         >
           {props.children(item)}

+ 109 - 0
packages/ui/src/components/select-dialog.css

@@ -0,0 +1,109 @@
+[data-component="select-dialog-input"] {
+  display: flex;
+  height: 40px;
+  flex-shrink: 0;
+  padding: 4px 10px 4px 6px;
+  align-items: center;
+  gap: 12px;
+  align-self: stretch;
+
+  border-radius: 8px;
+  background: var(--surface-base);
+
+  [data-slot="input-container"] {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+    flex: 1 0 0;
+
+    /* [data-slot="icon"] {} */
+
+    [data-slot="input"] {
+      width: 100%;
+    }
+  }
+
+  /* [data-slot="clear-button"] {} */
+}
+
+[data-component="select-dialog"] {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+
+  [data-slot="empty-state"] {
+    display: flex;
+    padding: 32px 160px;
+    flex-direction: column;
+    justify-content: center;
+    align-items: center;
+    gap: 8px;
+    align-self: stretch;
+
+    [data-slot="message"] {
+      display: flex;
+      justify-content: center;
+      align-items: center;
+      gap: 2px;
+      color: var(--text-weak);
+      text-align: center;
+
+      /* 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="filter"] {
+      color: var(--text-strong);
+    }
+  }
+
+  [data-slot="group"] {
+    display: flex;
+    flex-direction: column;
+    gap: 4px;
+
+    [data-slot="header"] {
+      display: flex;
+      padding: 4px 8px;
+      justify-content: space-between;
+      align-items: center;
+      align-self: stretch;
+
+      color: var(--text-weak);
+
+      /* text-12-medium */
+      font-family: var(--font-family-sans);
+      font-size: var(--font-size-small);
+      font-style: normal;
+      font-weight: var(--font-weight-medium);
+      line-height: var(--line-height-large); /* 166.667% */
+      letter-spacing: var(--letter-spacing-normal);
+    }
+
+    [data-slot="list"] {
+      display: flex;
+      flex-direction: column;
+      align-items: flex-start;
+      gap: 4px;
+      align-self: stretch;
+
+      [data-slot="item"] {
+        display: flex;
+        width: 100%;
+        height: 32px;
+        padding: 4px 8px 4px 4px;
+        align-items: center;
+
+        &[data-active="true"] {
+          border-radius: 8px;
+          background: var(--surface-raised-base-hover);
+        }
+      }
+    }
+  }
+}

+ 156 - 0
packages/ui/src/components/select-dialog.tsx

@@ -0,0 +1,156 @@
+import { createEffect, Show, For, type JSX, splitProps } from "solid-js"
+import { Dialog, DialogProps, Icon, IconButton, Input } from "@opencode-ai/ui"
+import { createStore } from "solid-js/store"
+import { FilteredListProps, useFilteredList } from "@opencode-ai/ui/hooks"
+
+interface SelectDialogProps<T>
+  extends FilteredListProps<T>,
+    Pick<DialogProps, "trigger" | "onOpenChange" | "defaultOpen"> {
+  title: string
+  placeholder?: string
+  emptyMessage?: string
+  children: (item: T) => JSX.Element
+  onSelect?: (value: T | undefined) => void
+}
+
+export function SelectDialog<T>(props: SelectDialogProps<T>) {
+  const [dialog, others] = splitProps(props, ["trigger", "onOpenChange", "defaultOpen"])
+  let closeButton!: HTMLButtonElement
+  let scrollRef: HTMLDivElement | undefined
+  const [store, setStore] = createStore({
+    mouseActive: false,
+  })
+
+  const { filter, grouped, flat, reset, clear, active, setActive, onKeyDown, onInput } = useFilteredList<T>({
+    items: others.items,
+    key: others.key,
+    filterKeys: others.filterKeys,
+    current: others.current,
+    groupBy: others.groupBy,
+    sortBy: others.sortBy,
+    sortGroupsBy: others.sortGroupsBy,
+  })
+
+  createEffect(() => {
+    filter()
+    scrollRef?.scrollTo(0, 0)
+    reset()
+  })
+
+  createEffect(() => {
+    const all = flat()
+    if (store.mouseActive || all.length === 0) return
+    if (active() === others.key(all[0])) {
+      scrollRef?.scrollTo(0, 0)
+      return
+    }
+    const element = scrollRef?.querySelector(`[data-key="${active()}"]`)
+    element?.scrollIntoView({ block: "nearest", behavior: "smooth" })
+  })
+
+  const handleInput = (value: string) => {
+    onInput(value)
+    reset()
+  }
+
+  const handleSelect = (item: T | undefined) => {
+    others.onSelect?.(item)
+    closeButton.click()
+  }
+
+  const handleKey = (e: KeyboardEvent) => {
+    setStore("mouseActive", false)
+    if (e.key === "Escape") return
+
+    if (e.key === "Enter") {
+      e.preventDefault()
+      const selected = flat().find((x) => others.key(x) === active())
+      if (selected) handleSelect(selected)
+    } else {
+      onKeyDown(e)
+    }
+  }
+
+  const handleOpenChange = (open: boolean) => {
+    if (!open) clear()
+    props.onOpenChange?.(open)
+  }
+
+  return (
+    <Dialog modal {...dialog} onOpenChange={handleOpenChange}>
+      <Dialog.Header>
+        <Dialog.Title>{others.title}</Dialog.Title>
+        <Dialog.CloseButton ref={closeButton} style={{ display: "none" }} />
+      </Dialog.Header>
+      <div data-component="select-dialog-input">
+        <div data-slot="input-container">
+          <Icon data-slot="icon" name="magnifying-glass" />
+          <Input
+            data-slot="input"
+            type="text"
+            value={filter()}
+            onChange={(value) => handleInput(value)}
+            onKeyDown={handleKey}
+            placeholder={others.placeholder}
+            autofocus
+            spellcheck={false}
+            autocorrect="off"
+            autocomplete="off"
+            autocapitalize="off"
+          />
+        </div>
+        <Show when={filter()}>
+          <IconButton
+            data-slot="clear-button"
+            icon="circle-x"
+            variant="ghost"
+            onClick={() => {
+              onInput("")
+              reset()
+            }}
+          />
+        </Show>
+      </div>
+      <Dialog.Body ref={scrollRef} data-component="select-dialog" class="no-scrollbar">
+        <Show
+          when={flat().length > 0}
+          fallback={
+            <div data-slot="empty-state">
+              <div data-slot="message">
+                {props.emptyMessage ?? "No search results"} for <span data-slot="filter">&quot;{filter()}&quot;</span>
+              </div>
+            </div>
+          }
+        >
+          <For each={grouped()}>
+            {(group) => (
+              <div data-slot="group">
+                <Show when={group.category}>
+                  <div data-slot="header">{group.category}</div>
+                </Show>
+                <div data-slot="list">
+                  <For each={group.items}>
+                    {(item) => (
+                      <button
+                        data-slot="item"
+                        data-key={others.key(item)}
+                        data-active={others.key(item) === active()}
+                        onClick={() => handleSelect(item)}
+                        onMouseMove={() => {
+                          setStore("mouseActive", true)
+                          setActive(others.key(item))
+                        }}
+                      >
+                        {others.children(item)}
+                      </button>
+                    )}
+                  </For>
+                </div>
+              </div>
+            )}
+          </For>
+        </Show>
+      </Dialog.Body>
+    </Dialog>
+  )
+}

+ 29 - 33
packages/ui/src/components/select.css

@@ -1,6 +1,7 @@
 [data-component="select"] {
   [data-slot="trigger"] {
     padding: 0 4px 0 8px;
+    box-shadow: none;
 
     [data-slot="value"] {
       overflow: hidden;
@@ -8,8 +9,8 @@
       white-space: nowrap;
     }
     [data-slot="icon"] {
-      width: fit-content;
-      height: fit-content;
+      width: 16px;
+      height: 16px;
       flex-shrink: 0;
       color: var(--text-weak);
       transition: transform 0.1s ease-in-out;
@@ -18,15 +19,15 @@
 }
 
 [data-component="select-content"] {
-  min-width: 8rem;
+  min-width: 4rem;
   overflow: hidden;
-  border-radius: var(--radius-md);
+  border-radius: 8px;
   border-width: 1px;
   border-style: solid;
   border-color: var(--border-weak-base);
-  background-color: var(--surface-raised-base);
-  padding: calc(var(--spacing) * 1);
-  box-shadow: var(--shadow-md);
+  background-color: var(--surface-raised-stronger-non-alpha);
+  padding: 2px;
+  box-shadow: var(--shadow-xs);
   z-index: 50;
 
   &[data-closed] {
@@ -42,36 +43,35 @@
     max-height: 12rem;
     white-space: nowrap;
     overflow-x: hidden;
+    display: flex;
+    flex-direction: column;
+    gap: 2px;
 
     &:focus {
       outline: none;
     }
   }
 
-  [data-slot="section"] {
-    font-size: var(--text-xs);
-    line-height: var(--text-xs--line-height);
-    font-weight: var(--font-weight-light);
-    text-transform: uppercase;
-    color: var(--text-weak);
-    opacity: 0.6;
-    margin-top: calc(var(--spacing) * 3);
-    margin-left: calc(var(--spacing) * 2);
-    &:first-child {
-      margin-top: 0;
-    }
-  }
+  /* [data-slot="section"] { */
+  /* } */
 
   [data-slot="item"] {
     position: relative;
     display: flex;
     align-items: center;
-    padding: calc(var(--spacing) * 2) calc(var(--spacing) * 2);
-    border-radius: var(--radius-sm);
-    font-size: var(--text-xs);
-    line-height: var(--text-xs--line-height);
-    color: var(--text-base);
-    cursor: pointer;
+    padding: 0 6px 0 6px;
+    border-radius: 6px;
+
+    /* text-12-medium */
+    font-family: var(--font-family-sans);
+    font-size: var(--font-size-small);
+    font-style: normal;
+    font-weight: var(--font-weight-medium);
+    line-height: var(--line-height-large); /* 166.667% */
+    letter-spacing: var(--letter-spacing-normal);
+
+    color: var(--text-strong);
+
     transition:
       background-color 0.2s ease-in-out,
       color 0.2s ease-in-out;
@@ -79,24 +79,20 @@
     user-select: none;
 
     &[data-highlighted] {
-      background-color: var(--surface-base);
+      background: var(--surface-raised-base-hover);
     }
-
     &[data-disabled] {
-      background-color: var(--surface-disabled);
+      background-color: var(--surface-raised-base);
       pointer-events: none;
     }
-
     [data-slot="item-indicator"] {
       margin-left: auto;
     }
-
     &:focus {
       outline: none;
     }
-
     &:hover {
-      background-color: var(--surface-hover);
+      background: var(--surface-raised-base-hover);
     }
   }
 }

+ 2 - 2
packages/ui/src/components/select.tsx

@@ -52,7 +52,7 @@ export function Select<T>(props: SelectProps<T> & ButtonProps) {
             {props.label ? props.label(itemProps.item.rawValue) : (itemProps.item.rawValue as string)}
           </Kobalte.ItemLabel>
           <Kobalte.ItemIndicator data-slot="item-indicator">
-            <Icon name="checkmark" size={16} />
+            <Icon name="checkmark" />
           </Kobalte.ItemIndicator>
         </Kobalte.Item>
       )}
@@ -79,7 +79,7 @@ export function Select<T>(props: SelectProps<T> & ButtonProps) {
           }}
         </Kobalte.Value>
         <Kobalte.Icon data-slot="icon">
-          <Icon name="chevron-down" size={16} />
+          <Icon name="chevron-down" size="small" />
         </Kobalte.Icon>
       </Kobalte.Trigger>
       <Kobalte.Portal>

+ 4 - 4
packages/ui/src/components/tabs.css

@@ -10,7 +10,7 @@
   background-color: var(--background-stronger);
   overflow: clip;
 
-  & [data-slot="list"] {
+  [data-slot="list"] {
     width: 100%;
     position: relative;
     display: flex;
@@ -40,7 +40,7 @@
     }
   }
 
-  & [data-slot="trigger"] {
+  [data-slot="trigger"] {
     position: relative;
     height: 36px;
     padding: 8px 12px;
@@ -49,7 +49,7 @@
     font-size: var(--text-sm);
     font-weight: var(--font-weight-medium);
     color: var(--text-weak);
-    cursor: pointer;
+
     white-space: nowrap;
     flex-shrink: 0;
     border-bottom: 1px solid var(--border-weak-base);
@@ -77,7 +77,7 @@
     }
   }
 
-  & [data-slot="content"] {
+  [data-slot="content"] {
     overflow-y: auto;
     flex: 1;
 

+ 1 - 1
packages/ui/src/components/tooltip.tsx

@@ -36,7 +36,7 @@ export function Tooltip(props: TooltipProps) {
       <KobalteTooltip.Portal>
         <KobalteTooltip.Content data-component="tooltip" data-placement={props.placement}>
           {typeof others.value === "function" ? others.value() : others.value}
-          {/* <KobalteTooltip.Arrow data-slot="arrow" size={18} /> */}
+          {/* <KobalteTooltip.Arrow data-slot="arrow" /> */}
         </KobalteTooltip.Content>
       </KobalteTooltip.Portal>
     </KobalteTooltip>

+ 1 - 0
packages/ui/src/hooks/index.ts

@@ -0,0 +1 @@
+export * from "./use-filtered-list"

+ 89 - 0
packages/ui/src/hooks/use-filtered-list.tsx

@@ -0,0 +1,89 @@
+import fuzzysort from "fuzzysort"
+import { entries, flatMap, groupBy, map, pipe } from "remeda"
+import { createMemo, createResource } from "solid-js"
+import { createStore } from "solid-js/store"
+import { createList } from "solid-list"
+
+export interface FilteredListProps<T> {
+  items: T[] | ((filter: string) => Promise<T[]>)
+  key: (item: T) => string
+  filterKeys?: string[]
+  current?: T
+  groupBy?: (x: T) => string
+  sortBy?: (a: T, b: T) => number
+  sortGroupsBy?: (a: { category: string; items: T[] }, b: { category: string; items: T[] }) => number
+  onSelect?: (value: T | undefined) => void
+}
+
+export function useFilteredList<T>(props: FilteredListProps<T>) {
+  const [store, setStore] = createStore<{ filter: string }>({ filter: "" })
+
+  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.filterKeys && 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.filterKeys! }).map((x) => x.obj)
+        },
+        groupBy((x) => (props.groupBy ? props.groupBy(x) : "")),
+        entries(),
+        map(([k, v]) => ({ category: k, items: props.sortBy ? v.sort(props.sortBy) : v })),
+        (groups) => (props.sortGroupsBy ? groups.sort(props.sortGroupsBy) : groups),
+      )
+      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) : props.key(flat()[0]),
+    loop: true,
+  })
+
+  const reset = () => {
+    const all = flat()
+    if (all.length === 0) return
+    list.setActive(props.key(all[0]))
+  }
+
+  const onKeyDown = (event: KeyboardEvent) => {
+    if (event.key === "Enter") {
+      event.preventDefault()
+      const selected = flat().find((x) => props.key(x) === list.active())
+      if (selected) props.onSelect?.(selected)
+    } else {
+      list.onKeyDown(event)
+    }
+  }
+
+  const onInput = (value: string) => {
+    setStore("filter", value)
+    reset()
+  }
+
+  return {
+    filter: () => store.filter,
+    grouped,
+    flat,
+    reset,
+    clear: () => setStore("filter", ""),
+    onKeyDown,
+    onInput,
+    active: list.active,
+    setActive: list.setActive,
+  }
+}

+ 4 - 0
packages/ui/src/styles/index.css

@@ -6,9 +6,13 @@
 @import "./base.css" layer(base);
 
 @import "../components/button.css" layer(components);
+@import "../components/dialog.css" layer(components);
 @import "../components/icon.css" layer(components);
+@import "../components/icon-button.css" layer(components);
+@import "../components/input.css" layer(components);
 @import "../components/list.css" layer(components);
 @import "../components/select.css" layer(components);
+@import "../components/select-dialog.css" layer(components);
 @import "../components/tabs.css" layer(components);
 @import "../components/tooltip.css" layer(components);
 

+ 17 - 5
packages/ui/src/styles/utilities.css

@@ -5,11 +5,11 @@
     pointer-events: none;
   }
 
-  ::selection {
-    background-color: color-mix(in srgb, var(--color-primary) 33%, transparent);
-    /* background-color: var(--color-primary); */
-    /* color: var(--color-background); */
-  }
+  /* ::selection { */
+  /*   background-color: color-mix(in srgb, var(--color-primary) 33%, transparent); */
+  /*   background-color: var(--color-primary); */
+  /*   color: var(--color-background); */
+  /* } */
 
   ::-webkit-scrollbar-track {
     background: var(--theme-background-panel);
@@ -36,6 +36,18 @@
   }
 }
 
+.sr-only {
+  position: absolute;
+  width: 1px;
+  height: 1px;
+  padding: 0;
+  margin: -1px;
+  overflow: hidden;
+  clip: rect(0, 0, 0, 0);
+  white-space: nowrap;
+  border-width: 0;
+}
+
 .text-12-regular {
   font-family: var(--font-family-sans);
   font-size: var(--font-size-small);