wip: desktop work

This commit is contained in:
Adam
2025-10-22 17:31:44 -05:00
parent eff12cb484
commit 89b703c387
38 changed files with 1353 additions and 638 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,

View File

@@ -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
// Create new structured content
const newParts: ContentPart[] = []
const textBeforeTrigger = rawText.substring(0, startIndex)
if (textBeforeTrigger) newParts.push({ type: "text", content: textBeforeTrigger })
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
}
newParts.push({ type: "attachment", fileId: attachment.id, name: attachment.name })
const nextIndex = acc.runningIndex + item.content.length
if (nextIndex <= startIndex) {
acc.parts.push(item)
acc.runningIndex = nextIndex
return acc
}
// Add a space after the pill for better UX
newParts.push({ type: "text", content: " " })
if (item.type !== "text") {
acc.parts.push(item)
acc.runningIndex = nextIndex
return acc
}
const textAfterCursor = rawText.substring(cursorPosition)
if (textAfterCursor) newParts.push({ type: "text", content: textAfterCursor })
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)
setStore("contentParts", newParts)
setStore("popover", "isOpen", false)
if (head) acc.parts.push({ type: "text", content: head })
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,
},
)
if (!inserted || cursorIndex === null) return
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)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,4 +5,3 @@ export {
type CollapsibleContentProps,
} from "./collapsible"
export { FileIcon, type FileIconProps } from "./file-icon"
export { IconButton, type IconButtonProps } from "./icon-button"