- Plan and build anything
+
+
+
+
+ {(i) => (
+
+
+
+
+
+ {getDirectory(i)}/
+
+ {getFilename(i)}
+
+
+
+
+ )}
+
+
+
+
)
}
@@ -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
{
- items: T[] | ((filter: string) => Promise)
- 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(props: SelectDialogProps) {
- 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 (
-
- )
-}
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 (
- local.file.open(path)}>
+ local.file.open(path)}>
Read {getFilename(path)}
)
@@ -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,
}}
>
-
+
@@ -397,11 +405,16 @@ export default function SessionTimeline(props: { session: string; class?: string
{cost()}
-
-
+
+
{(message) => (
-
-
+
+
{(part) => (
-
{part.type}
}>
@@ -449,9 +462,9 @@ export default function SessionTimeline(props: { session: string; class?: string
-
+
Raw Session Data
-
+
@@ -460,9 +473,9 @@ export default function SessionTimeline(props: { session: string; class?: string
-
+
session
-
+
@@ -477,9 +490,9 @@ export default function SessionTimeline(props: { session: string; class?: string
-
+
{message.role === "user" ? "user" : "assistant"}
-
+
@@ -493,9 +506,9 @@ export default function SessionTimeline(props: { session: string; class?: string
-
+
{part.type}
-
+
diff --git a/packages/desktop/src/pages/index.tsx b/packages/desktop/src/pages/index.tsx
index 80473d84a..58d479111 100644
--- a/packages/desktop/src/pages/index.tsx
+++ b/packages/desktop/src/pages/index.tsx
@@ -1,16 +1,14 @@
-import { Button, Icon, List, Tooltip } from "@opencode-ai/ui"
-import { FileIcon, IconButton } from "@/ui"
+import { Button, Icon, List, SelectDialog, Tooltip } from "@opencode-ai/ui"
+import { FileIcon } from "@/ui"
import FileTree from "@/components/file-tree"
import EditorPane from "@/components/editor-pane"
-import { For, Match, onCleanup, onMount, Show, Switch } from "solid-js"
-import { SelectDialog } from "@/components/select-dialog"
+import { For, onCleanup, onMount, Show } from "solid-js"
import { useSync, useSDK, useLocal } from "@/context"
import type { LocalFile, TextSelection } from "@/context/local"
import SessionTimeline from "@/components/session-timeline"
-import { type PromptContentPart, type PromptSubmitValue } from "@/components/prompt-form"
import { createStore } from "solid-js/store"
import { getDirectory, getFilename } from "@/utils"
-import { PromptInput } from "@/components/prompt-input"
+import { ContentPart, PromptInput } from "@/components/prompt-input"
import { DateTime } from "luxon"
export default function Page() {
@@ -22,8 +20,7 @@ export default function Page() {
modelSelectOpen: false,
fileSelectOpen: false,
})
-
- let inputRef: HTMLTextAreaElement | undefined = undefined
+ let inputRef!: HTMLDivElement
const MOD = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control"
@@ -50,7 +47,7 @@ export default function Page() {
const focused = document.activeElement === inputRef
if (focused) {
if (event.key === "Escape") {
- // inputRef?.blur()
+ inputRef?.blur()
}
return
}
@@ -77,7 +74,7 @@ export default function Page() {
}
if (event.key.length === 1 && event.key !== "Unidentified") {
- // inputRef?.focus()
+ inputRef?.focus()
}
}
@@ -104,9 +101,7 @@ export default function Page() {
}
}
- const handlePromptSubmit2 = () => {}
-
- const handlePromptSubmit = async (prompt: PromptSubmitValue) => {
+ const handlePromptSubmit = async (parts: ContentPart[]) => {
const existingSession = local.session.active()
let session = existingSession
if (!session) {
@@ -134,6 +129,7 @@ export default function Page() {
const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path))
+ const text = parts.map((part) => part.content).join("")
const attachments = new Map()
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 => 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 = (
- setStore("fileSelectOpen", true)}
- >
-
-
- )
+ const handleNewSession = () => {
+ local.session.setActive(undefined)
+ inputRef?.focus()
+ }
return (
@@ -234,7 +221,8 @@ export default function Page() {
-
@@ -268,25 +256,30 @@ export default function Page() {
-
+
{(activeSession) => }
-
+
+
+
-
-
- {/*
setStore("modelSelectOpen", true)} */}
- {/* onInputRefChange={(element: HTMLTextAreaElement | undefined) => { */}
- {/* inputRef = element ?? undefined */}
- {/* }} */}
- {/* /> */}
+
+
{
+ inputRef = el
+ }}
+ onSubmit={handlePromptSubmit}
+ />
@@ -302,7 +295,7 @@ export default function Page() {
-
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"
>
{getFilename(path)}
@@ -318,59 +311,16 @@ export default function Page() {
-
- `${x.provider.id}:${x.id}`}
- items={local.model.list()}
- current={local.model.current()}
- render={(i) => (
-
-
-

-
{i.name}
-
- {i.id}
-
-
-
-
-
-
-
-
-
-
-
-
-
- {new Intl.NumberFormat("en-US", {
- notation: "compact",
- compactDisplay: "short",
- }).format(i.limit.context)}
-
-
-
-
- 10}>$$$
- 1}>$$
- 0.1}>$
-
-
-
-
-
- )}
- 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)}
- />
-
x}
- render={(i) => (
+ onOpenChange={(open) => setStore("fileSelectOpen", open)}
+ onSelect={(x) => (x ? local.file.open(x, { pinned: true }) : undefined)}
+ >
+ {(i) => (
@@ -382,9 +332,7 @@ export default function Page() {
)}
- onClose={() => setStore("fileSelectOpen", false)}
- onSelect={(x) => (x ? local.file.open(x, { pinned: true }) : undefined)}
- />
+
)
diff --git a/packages/desktop/src/ui/collapsible.tsx b/packages/desktop/src/ui/collapsible.tsx
index d17b3e623..fbc6fcbfe 100644
--- a/packages/desktop/src/ui/collapsible.tsx
+++ b/packages/desktop/src/ui/collapsible.tsx
@@ -16,7 +16,7 @@ function CollapsibleTrigger(props: CollapsibleTriggerProps) {
return (
{
- 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 (
-
- )
-}
diff --git a/packages/desktop/src/ui/index.ts b/packages/desktop/src/ui/index.ts
index a6ade6ff3..e273e8efe 100644
--- a/packages/desktop/src/ui/index.ts
+++ b/packages/desktop/src/ui/index.ts
@@ -5,4 +5,3 @@ export {
type CollapsibleContentProps,
} from "./collapsible"
export { FileIcon, type FileIconProps } from "./file-icon"
-export { IconButton, type IconButtonProps } from "./icon-button"
--
cgit v1.2.3