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