From 545f34584847e3216ecf95ab493dacac282ec465 Mon Sep 17 00:00:00 2001
From: Adam <2363879+adamdotdevin@users.noreply.github.com>
Date: Tue, 28 Oct 2025 15:29:11 -0500
Subject: [PATCH] wip: desktop work
---
.../src/components/assistant-message.tsx | 33 +++--
.../desktop/src/components/prompt-input.tsx | 59 +++++---
packages/desktop/src/context/local.tsx | 12 +-
packages/desktop/src/pages/index.tsx | 136 +++++++++---------
packages/ui/src/components/collapsible.css | 21 +++
packages/ui/src/components/collapsible.tsx | 4 +-
packages/ui/src/styles/utilities.css | 29 ++++
7 files changed, 187 insertions(+), 107 deletions(-)
diff --git a/packages/desktop/src/components/assistant-message.tsx b/packages/desktop/src/components/assistant-message.tsx
index 73829340..224e3d39 100644
--- a/packages/desktop/src/components/assistant-message.tsx
+++ b/packages/desktop/src/components/assistant-message.tsx
@@ -17,8 +17,15 @@ import type { WriteTool } from "opencode/tool/write"
import type { TodoWriteTool } from "opencode/tool/todo"
import { DiffChanges } from "./diff-changes"
-export function AssistantMessage(props: { message: AssistantMessage; parts: Part[] }) {
- const filteredParts = createMemo(() => props.parts?.filter((x) => x.type !== "tool" || x.tool !== "todoread"))
+export function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; lastToolOnly?: boolean }) {
+ const filteredParts = createMemo(() => {
+ let tool = false
+ return props.parts?.filter((x) => {
+ if (x.type === "tool" && props.lastToolOnly && tool) return false
+ if (x.type === "tool") tool = true
+ return x.type !== "tool" || x.tool !== "todoread"
+ })
+ })
return (
@@ -71,17 +78,14 @@ function ToolPart(props: { part: ToolPart; message: AssistantMessage }) {
// const permission = permissions[permissionIndex]
return (
- <>
-
- {/* {props.part.state.error.replace("Error: ", "")} */}
- >
+
)
})
@@ -166,6 +170,9 @@ function BasicTool(props: { icon: IconProps["name"]; trigger: TriggerTitle | JSX
{props.children}
+ // <>
+ // {props.part.state.error.replace("Error: ", "")}
+ // >
)
}
diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx
index dd549a04..e6701bdd 100644
--- a/packages/desktop/src/components/prompt-input.tsx
+++ b/packages/desktop/src/components/prompt-input.tsx
@@ -10,13 +10,15 @@ import { DateTime } from "luxon"
interface PartBase {
content: string
+ start: number
+ end: number
}
-interface TextPart extends PartBase {
+export interface TextPart extends PartBase {
type: "text"
}
-interface FileAttachmentPart extends PartBase {
+export interface FileAttachmentPart extends PartBase {
type: "file"
path: string
selection?: TextSelection
@@ -34,7 +36,7 @@ export const PromptInput: Component = (props) => {
const local = useLocal()
let editorRef!: HTMLDivElement
- const defaultParts = [{ type: "text", content: "" } as const]
+ const defaultParts = [{ type: "text", content: "", start: 0, end: 0 } as const]
const [store, setStore] = createStore<{
contentParts: ContentPart[]
popoverIsOpen: boolean
@@ -51,7 +53,7 @@ export const PromptInput: Component = (props) => {
event.stopPropagation()
// @ts-expect-error
const plainText = (event.clipboardData || window.clipboardData)?.getData("text/plain") ?? ""
- addPart({ type: "text", content: plainText })
+ addPart({ type: "text", content: plainText, start: 0, end: 0 })
}
onMount(() => {
@@ -74,7 +76,7 @@ export const PromptInput: Component = (props) => {
key: (x) => x,
onSelect: (path) => {
if (!path) return
- addPart({ type: "file", path, content: "@" + getFilename(path) })
+ addPart({ type: "file", path, content: "@" + getFilename(path), start: 0, end: 0 })
setStore("popoverIsOpen", false)
},
})
@@ -117,17 +119,26 @@ export const PromptInput: Component = (props) => {
const parseFromDOM = (): ContentPart[] => {
const newParts: ContentPart[] = []
+ let position = 0
editorRef.childNodes.forEach((node) => {
if (node.nodeType === Node.TEXT_NODE) {
- if (node.textContent) newParts.push({ type: "text", content: node.textContent })
+ if (node.textContent) {
+ const content = node.textContent
+ newParts.push({ type: "text", content, start: position, end: position + content.length })
+ position += content.length
+ }
} else if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type) {
switch ((node as HTMLElement).dataset.type) {
case "file":
+ const content = node.textContent!
newParts.push({
type: "file",
path: (node as HTMLElement).dataset.path!,
- content: node.textContent!,
+ content,
+ start: position,
+ end: position + content.length,
})
+ position += content.length
break
default:
break
@@ -163,17 +174,19 @@ export const PromptInput: Component = (props) => {
const startIndex = atMatch ? atMatch.index! : cursorPosition
const endIndex = atMatch ? cursorPosition : cursorPosition
- const pushText = (acc: { parts: ContentPart[] }, value: string) => {
+ const pushText = (acc: { parts: ContentPart[]; runningIndex: number }, value: string) => {
if (!value) return
const last = acc.parts[acc.parts.length - 1]
if (last && last.type === "text") {
acc.parts[acc.parts.length - 1] = {
type: "text",
content: last.content + value,
+ start: last.start,
+ end: last.end + value.length,
}
return
}
- acc.parts.push({ type: "text", content: value })
+ acc.parts.push({ type: "text", content: value, start: acc.runningIndex, end: acc.runningIndex + value.length })
}
const {
@@ -183,20 +196,20 @@ export const PromptInput: Component = (props) => {
} = store.contentParts.reduce(
(acc, item) => {
if (acc.inserted) {
- acc.parts.push(item)
+ acc.parts.push({ ...item, start: acc.runningIndex, end: acc.runningIndex + item.content.length })
acc.runningIndex += item.content.length
return acc
}
const nextIndex = acc.runningIndex + item.content.length
if (nextIndex <= startIndex) {
- acc.parts.push(item)
+ acc.parts.push({ ...item, start: acc.runningIndex, end: acc.runningIndex + item.content.length })
acc.runningIndex = nextIndex
return acc
}
if (item.type !== "text") {
- acc.parts.push(item)
+ acc.parts.push({ ...item, start: acc.runningIndex, end: acc.runningIndex + item.content.length })
acc.runningIndex = nextIndex
return acc
}
@@ -207,24 +220,27 @@ export const PromptInput: Component = (props) => {
const tail = item.content.slice(tailLength)
pushText(acc, head)
+ acc.runningIndex += head.length
if (part.type === "text") {
pushText(acc, part.content)
+ acc.runningIndex += part.content.length
}
if (part.type !== "text") {
- acc.parts.push({ ...part })
+ acc.parts.push({ ...part, start: acc.runningIndex, end: acc.runningIndex + part.content.length })
+ acc.runningIndex += part.content.length
}
const needsGap = Boolean(atMatch)
const rest = needsGap ? (tail ? (/^\s/.test(tail) ? tail : ` ${tail}`) : " ") : tail
pushText(acc, rest)
+ acc.runningIndex += rest.length
const baseCursor = startIndex + part.content.length
const cursorAddition = needsGap && rest.length > 0 ? 1 : 0
acc.cursorPositionAfter = baseCursor + cursorAddition
acc.inserted = true
- acc.runningIndex = nextIndex
return acc
},
{
@@ -237,9 +253,18 @@ export const PromptInput: Component = (props) => {
if (!inserted) {
const baseParts = store.contentParts.filter((item) => !(item.type === "text" && item.content === ""))
- const appendedAcc = { parts: [...baseParts] as ContentPart[] }
- if (part.type === "text") pushText(appendedAcc, part.content)
- if (part.type !== "text") appendedAcc.parts.push({ ...part })
+ const runningIndex = baseParts.reduce((sum, p) => sum + p.content.length, 0)
+ const appendedAcc = { parts: [...baseParts] as ContentPart[], runningIndex }
+ if (part.type === "text") {
+ pushText(appendedAcc, part.content)
+ }
+ if (part.type !== "text") {
+ appendedAcc.parts.push({
+ ...part,
+ start: appendedAcc.runningIndex,
+ end: appendedAcc.runningIndex + part.content.length,
+ })
+ }
const next = appendedAcc.parts.length > 0 ? appendedAcc.parts : defaultParts
setStore("contentParts", next)
setStore("popoverIsOpen", false)
diff --git a/packages/desktop/src/context/local.tsx b/packages/desktop/src/context/local.tsx
index 978dbfbc..91c1d688 100644
--- a/packages/desktop/src/context/local.tsx
+++ b/packages/desktop/src/context/local.tsx
@@ -429,13 +429,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
.sort((a, b) => b.id.localeCompare(a.id)),
)
- const working = createMemo(() => {
- const last = messages()[messages().length - 1]
- if (!last) return false
- if (last.role === "user") return true
- return !last.time.completed
- })
-
const cost = createMemo(() => {
const total = pipe(
messages(),
@@ -487,6 +480,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const getMessageText = (message: Message | Message[] | undefined): string => {
if (!message) return ""
if (Array.isArray(message)) return message.map((m) => getMessageText(m)).join(" ")
+ const fileParts = sync.data.part[message.id]?.filter((p) => p.type === "file")
+ console.log(fileParts)
+
return sync.data.part[message.id]
?.filter((p) => p.type === "text")
?.filter((p) => !p.synthetic)
@@ -506,7 +502,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
messages,
messagesWithValidParts,
userMessages,
- working,
+ // working,
getMessageText,
setActive(sessionId: string | undefined) {
setStore("active", sessionId)
diff --git a/packages/desktop/src/pages/index.tsx b/packages/desktop/src/pages/index.tsx
index b4bd3616..c1884b2c 100644
--- a/packages/desktop/src/pages/index.tsx
+++ b/packages/desktop/src/pages/index.tsx
@@ -1,8 +1,19 @@
-import { Button, List, SelectDialog, Tooltip, IconButton, Tabs, Icon, Accordion, Diff } from "@opencode-ai/ui"
+import {
+ Button,
+ List,
+ SelectDialog,
+ Tooltip,
+ IconButton,
+ Tabs,
+ Icon,
+ Accordion,
+ Diff,
+ Collapsible,
+} from "@opencode-ai/ui"
import { FileIcon } from "@/ui"
import FileTree from "@/components/file-tree"
import { For, onCleanup, onMount, Show, Match, Switch, createSignal, createEffect, createMemo } from "solid-js"
-import { useLocal, type LocalFile, type TextSelection } from "@/context/local"
+import { useLocal, type LocalFile } from "@/context/local"
import { createStore } from "solid-js/store"
import { getDirectory, getFilename } from "@/utils"
import { ContentPart, PromptInput } from "@/components/prompt-input"
@@ -185,42 +196,10 @@ export default function Page() {
}
if (!session) return
- interface SubmissionAttachment {
- path: string
- selection?: TextSelection
- label: string
- }
-
- const createAttachmentKey = (path: string, selection?: TextSelection) => {
- if (!selection) return path
- return `${path}:${selection.startLine}:${selection.startChar}:${selection.endLine}:${selection.endChar}`
- }
-
- const formatAttachmentLabel = (path: string, selection?: TextSelection) => {
- if (!selection) return getFilename(path)
- return `${getFilename(path)} (${selection.startLine}-${selection.endLine})`
- }
-
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) => {
- if (!path) return
- const key = createAttachmentKey(path, selection)
- if (attachments.has(key)) return
- attachments.set(key, {
- path,
- selection,
- label: label ?? formatAttachmentLabel(path, selection),
- })
- }
-
- const promptAttachments = parts.filter((part) => part.type === "file")
- for (const part of promptAttachments) {
- registerAttachment(part.path, part.selection, part.content)
- }
+ const attachments = parts.filter((part) => part.type === "file")
// const activeFile = local.context.active()
// if (activeFile) {
@@ -239,7 +218,7 @@ export default function Page() {
// )
// }
- const attachmentParts = Array.from(attachments.values()).map((attachment) => {
+ const attachmentParts = attachments.map((attachment) => {
const absolute = toAbsolutePath(attachment.path)
const query = attachment.selection
? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}`
@@ -252,9 +231,9 @@ export default function Page() {
source: {
type: "file" as const,
text: {
- value: `@${attachment.label}`,
- start: 0,
- end: 0,
+ value: attachment.content,
+ start: attachment.start,
+ end: attachment.end,
},
path: absolute,
},
@@ -510,8 +489,8 @@ export default function Page() {
-
-
+
+
{(activeSession) => (
-
+
1}>
@@ -665,30 +644,65 @@ export default function Page() {
(m) => m.role === "assistant" && m.parentID == message.id,
) as AssistantMessageType[]
})
+ const working = createMemo(() => {
+ const last = assistantMessages()[assistantMessages().length - 1]
+ if (!last) return false
+ return !last.time.completed
+ })
+ const lastWithContent = createMemo(() =>
+ assistantMessages().findLast((m) => {
+ const parts = sync.data.part[m.id]
+ return parts?.find((p) => p.type === "text" || p.type === "tool")
+ }),
+ )
return (
-
+
{/* Title */}
-
+
{title() ?? prompt()}
-
- {prompt()}
+
+
+ {prompt()}
+
+ {/* Response */}
+
+
+
+
+
Show steps
+
+
+
+
+
+
+ {(assistantMessage) => {
+ const parts = createMemo(() => sync.data.part[assistantMessage.id])
+ return
+ }}
+
+
+
+
+
+ {(last) => {
+ const lastParts = createMemo(() => sync.data.part[last().id])
+ return (
+
+ )
+ }}
{/* Summary */}
-
-
+
+
-
-
{(diff) => (
@@ -735,22 +749,8 @@ export default function Page() {
)}
-
-
- {/* Response */}
-
-
-
Response
-
-
- {(assistantMessage) => {
- const parts = createMemo(() => sync.data.part[assistantMessage.id])
- return
- }}
-
-
-
+
)
}}
diff --git a/packages/ui/src/components/collapsible.css b/packages/ui/src/components/collapsible.css
index 34699fc2..3d8c8ebe 100644
--- a/packages/ui/src/components/collapsible.css
+++ b/packages/ui/src/components/collapsible.css
@@ -57,6 +57,27 @@
/* animation: slideDown 250ms ease-out; */
/* } */
}
+
+ &[data-variant="ghost"] {
+ background-color: transparent;
+ border: none;
+
+ > [data-slot="collapsible-trigger"] {
+ background-color: transparent;
+ border: none;
+ padding: 0;
+
+ /* &:hover { */
+ /* color: var(--text-strong); */
+ /* } */
+ &:focus-visible {
+ outline: none;
+ }
+ &[data-disabled] {
+ cursor: not-allowed;
+ }
+ }
+ }
}
@keyframes slideDown {
diff --git a/packages/ui/src/components/collapsible.tsx b/packages/ui/src/components/collapsible.tsx
index d2e4a139..17fad0b9 100644
--- a/packages/ui/src/components/collapsible.tsx
+++ b/packages/ui/src/components/collapsible.tsx
@@ -5,13 +5,15 @@ import { Icon } from "./icon"
export interface CollapsibleProps extends ParentProps
{
class?: string
classList?: ComponentProps<"div">["classList"]
+ variant?: "normal" | "ghost"
}
function CollapsibleRoot(props: CollapsibleProps) {
- const [local, others] = splitProps(props, ["class", "classList"])
+ const [local, others] = splitProps(props, ["class", "classList", "variant"])
return (