wip: desktop work

This commit is contained in:
Adam
2025-10-28 15:29:11 -05:00
parent 77ae0b527e
commit 545f345848
7 changed files with 187 additions and 107 deletions

View File

@@ -17,8 +17,15 @@ import type { WriteTool } from "opencode/tool/write"
import type { TodoWriteTool } from "opencode/tool/todo" import type { TodoWriteTool } from "opencode/tool/todo"
import { DiffChanges } from "./diff-changes" import { DiffChanges } from "./diff-changes"
export function AssistantMessage(props: { message: AssistantMessage; parts: Part[] }) { export function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; lastToolOnly?: boolean }) {
const filteredParts = createMemo(() => props.parts?.filter((x) => x.type !== "tool" || x.tool !== "todoread")) 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 ( return (
<div class="w-full flex flex-col items-start gap-4"> <div class="w-full flex flex-col items-start gap-4">
<For each={filteredParts()}> <For each={filteredParts()}>
@@ -71,7 +78,6 @@ function ToolPart(props: { part: ToolPart; message: AssistantMessage }) {
// const permission = permissions[permissionIndex] // const permission = permissions[permissionIndex]
return ( return (
<>
<Dynamic <Dynamic
component={render} component={render}
input={input} input={input}
@@ -80,8 +86,6 @@ function ToolPart(props: { part: ToolPart; message: AssistantMessage }) {
// permission={permission?.metadata ?? {}} // permission={permission?.metadata ?? {}}
output={props.part.state.status === "completed" ? props.part.state.output : undefined} output={props.part.state.status === "completed" ? props.part.state.output : undefined}
/> />
{/* <Show when={props.part.state.status === "error"}>{props.part.state.error.replace("Error: ", "")}</Show> */}
</>
) )
}) })
@@ -166,6 +170,9 @@ function BasicTool(props: { icon: IconProps["name"]; trigger: TriggerTitle | JSX
<Collapsible.Content>{props.children}</Collapsible.Content> <Collapsible.Content>{props.children}</Collapsible.Content>
</Show> </Show>
</Collapsible> </Collapsible>
// <>
// <Show when={props.part.state.status === "error"}>{props.part.state.error.replace("Error: ", "")}</Show>
// </>
) )
} }

View File

@@ -10,13 +10,15 @@ import { DateTime } from "luxon"
interface PartBase { interface PartBase {
content: string content: string
start: number
end: number
} }
interface TextPart extends PartBase { export interface TextPart extends PartBase {
type: "text" type: "text"
} }
interface FileAttachmentPart extends PartBase { export interface FileAttachmentPart extends PartBase {
type: "file" type: "file"
path: string path: string
selection?: TextSelection selection?: TextSelection
@@ -34,7 +36,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const local = useLocal() const local = useLocal()
let editorRef!: HTMLDivElement 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<{ const [store, setStore] = createStore<{
contentParts: ContentPart[] contentParts: ContentPart[]
popoverIsOpen: boolean popoverIsOpen: boolean
@@ -51,7 +53,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
event.stopPropagation() event.stopPropagation()
// @ts-expect-error // @ts-expect-error
const plainText = (event.clipboardData || window.clipboardData)?.getData("text/plain") ?? "" const plainText = (event.clipboardData || window.clipboardData)?.getData("text/plain") ?? ""
addPart({ type: "text", content: plainText }) addPart({ type: "text", content: plainText, start: 0, end: 0 })
} }
onMount(() => { onMount(() => {
@@ -74,7 +76,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
key: (x) => x, key: (x) => x,
onSelect: (path) => { onSelect: (path) => {
if (!path) return if (!path) return
addPart({ type: "file", path, content: "@" + getFilename(path) }) addPart({ type: "file", path, content: "@" + getFilename(path), start: 0, end: 0 })
setStore("popoverIsOpen", false) setStore("popoverIsOpen", false)
}, },
}) })
@@ -117,17 +119,26 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const parseFromDOM = (): ContentPart[] => { const parseFromDOM = (): ContentPart[] => {
const newParts: ContentPart[] = [] const newParts: ContentPart[] = []
let position = 0
editorRef.childNodes.forEach((node) => { editorRef.childNodes.forEach((node) => {
if (node.nodeType === Node.TEXT_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) { } else if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type) {
switch ((node as HTMLElement).dataset.type) { switch ((node as HTMLElement).dataset.type) {
case "file": case "file":
const content = node.textContent!
newParts.push({ newParts.push({
type: "file", type: "file",
path: (node as HTMLElement).dataset.path!, path: (node as HTMLElement).dataset.path!,
content: node.textContent!, content,
start: position,
end: position + content.length,
}) })
position += content.length
break break
default: default:
break break
@@ -163,17 +174,19 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const startIndex = atMatch ? atMatch.index! : cursorPosition const startIndex = atMatch ? atMatch.index! : cursorPosition
const endIndex = atMatch ? cursorPosition : 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 if (!value) return
const last = acc.parts[acc.parts.length - 1] const last = acc.parts[acc.parts.length - 1]
if (last && last.type === "text") { if (last && last.type === "text") {
acc.parts[acc.parts.length - 1] = { acc.parts[acc.parts.length - 1] = {
type: "text", type: "text",
content: last.content + value, content: last.content + value,
start: last.start,
end: last.end + value.length,
} }
return return
} }
acc.parts.push({ type: "text", content: value }) acc.parts.push({ type: "text", content: value, start: acc.runningIndex, end: acc.runningIndex + value.length })
} }
const { const {
@@ -183,20 +196,20 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} = store.contentParts.reduce( } = store.contentParts.reduce(
(acc, item) => { (acc, item) => {
if (acc.inserted) { 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 acc.runningIndex += item.content.length
return acc return acc
} }
const nextIndex = acc.runningIndex + item.content.length const nextIndex = acc.runningIndex + item.content.length
if (nextIndex <= startIndex) { if (nextIndex <= startIndex) {
acc.parts.push(item) acc.parts.push({ ...item, start: acc.runningIndex, end: acc.runningIndex + item.content.length })
acc.runningIndex = nextIndex acc.runningIndex = nextIndex
return acc return acc
} }
if (item.type !== "text") { if (item.type !== "text") {
acc.parts.push(item) acc.parts.push({ ...item, start: acc.runningIndex, end: acc.runningIndex + item.content.length })
acc.runningIndex = nextIndex acc.runningIndex = nextIndex
return acc return acc
} }
@@ -207,24 +220,27 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const tail = item.content.slice(tailLength) const tail = item.content.slice(tailLength)
pushText(acc, head) pushText(acc, head)
acc.runningIndex += head.length
if (part.type === "text") { if (part.type === "text") {
pushText(acc, part.content) pushText(acc, part.content)
acc.runningIndex += part.content.length
} }
if (part.type !== "text") { 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 needsGap = Boolean(atMatch)
const rest = needsGap ? (tail ? (/^\s/.test(tail) ? tail : ` ${tail}`) : " ") : tail const rest = needsGap ? (tail ? (/^\s/.test(tail) ? tail : ` ${tail}`) : " ") : tail
pushText(acc, rest) pushText(acc, rest)
acc.runningIndex += rest.length
const baseCursor = startIndex + part.content.length const baseCursor = startIndex + part.content.length
const cursorAddition = needsGap && rest.length > 0 ? 1 : 0 const cursorAddition = needsGap && rest.length > 0 ? 1 : 0
acc.cursorPositionAfter = baseCursor + cursorAddition acc.cursorPositionAfter = baseCursor + cursorAddition
acc.inserted = true acc.inserted = true
acc.runningIndex = nextIndex
return acc return acc
}, },
{ {
@@ -237,9 +253,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (!inserted) { if (!inserted) {
const baseParts = store.contentParts.filter((item) => !(item.type === "text" && item.content === "")) const baseParts = store.contentParts.filter((item) => !(item.type === "text" && item.content === ""))
const appendedAcc = { parts: [...baseParts] as ContentPart[] } const runningIndex = baseParts.reduce((sum, p) => sum + p.content.length, 0)
if (part.type === "text") pushText(appendedAcc, part.content) const appendedAcc = { parts: [...baseParts] as ContentPart[], runningIndex }
if (part.type !== "text") appendedAcc.parts.push({ ...part }) 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 const next = appendedAcc.parts.length > 0 ? appendedAcc.parts : defaultParts
setStore("contentParts", next) setStore("contentParts", next)
setStore("popoverIsOpen", false) setStore("popoverIsOpen", false)

View File

@@ -429,13 +429,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
.sort((a, b) => b.id.localeCompare(a.id)), .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 cost = createMemo(() => {
const total = pipe( const total = pipe(
messages(), messages(),
@@ -487,6 +480,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const getMessageText = (message: Message | Message[] | undefined): string => { const getMessageText = (message: Message | Message[] | undefined): string => {
if (!message) return "" if (!message) return ""
if (Array.isArray(message)) return message.map((m) => getMessageText(m)).join(" ") 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] return sync.data.part[message.id]
?.filter((p) => p.type === "text") ?.filter((p) => p.type === "text")
?.filter((p) => !p.synthetic) ?.filter((p) => !p.synthetic)
@@ -506,7 +502,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
messages, messages,
messagesWithValidParts, messagesWithValidParts,
userMessages, userMessages,
working, // working,
getMessageText, getMessageText,
setActive(sessionId: string | undefined) { setActive(sessionId: string | undefined) {
setStore("active", sessionId) setStore("active", sessionId)

View File

@@ -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 { FileIcon } from "@/ui"
import FileTree from "@/components/file-tree" import FileTree from "@/components/file-tree"
import { For, onCleanup, onMount, Show, Match, Switch, createSignal, createEffect, createMemo } from "solid-js" 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 { createStore } from "solid-js/store"
import { getDirectory, getFilename } from "@/utils" import { getDirectory, getFilename } from "@/utils"
import { ContentPart, PromptInput } from "@/components/prompt-input" import { ContentPart, PromptInput } from "@/components/prompt-input"
@@ -185,42 +196,10 @@ export default function Page() {
} }
if (!session) return 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 toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path))
const text = parts.map((part) => part.content).join("") const text = parts.map((part) => part.content).join("")
const attachments = new Map<string, SubmissionAttachment>() const attachments = parts.filter((part) => part.type === "file")
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 activeFile = local.context.active() // const activeFile = local.context.active()
// if (activeFile) { // 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 absolute = toAbsolutePath(attachment.path)
const query = attachment.selection const query = attachment.selection
? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}` ? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}`
@@ -252,9 +231,9 @@ export default function Page() {
source: { source: {
type: "file" as const, type: "file" as const,
text: { text: {
value: `@${attachment.label}`, value: attachment.content,
start: 0, start: attachment.start,
end: 0, end: attachment.end,
}, },
path: absolute, path: absolute,
}, },
@@ -510,8 +489,8 @@ export default function Page() {
</Show> </Show>
</div> </div>
</div> </div>
<Tabs.Content value="chat" class="select-text flex flex-col flex-1 min-h-0"> <Tabs.Content value="chat" class="select-text flex flex-col flex-1 min-h-0 overflow-y-hidden">
<div class="p-6 pt-12 max-w-[904px] w-full mx-auto flex flex-col flex-1 min-h-0"> <div class="px-6 pt-12 max-w-[904px] w-full mx-auto flex flex-col flex-1 min-h-0">
<Show <Show
when={local.session.active()} when={local.session.active()}
fallback={ fallback={
@@ -537,7 +516,7 @@ export default function Page() {
} }
> >
{(activeSession) => ( {(activeSession) => (
<div class="py-3 flex flex-col flex-1 min-h-0"> <div class="pt-3 flex flex-col flex-1 min-h-0">
<div class="flex items-start gap-8 flex-1 min-h-0"> <div class="flex items-start gap-8 flex-1 min-h-0">
<Show when={local.session.userMessages().length > 1}> <Show when={local.session.userMessages().length > 1}>
<ul role="list" class="w-60 shrink-0 flex flex-col items-start gap-1"> <ul role="list" class="w-60 shrink-0 flex flex-col items-start gap-1">
@@ -665,30 +644,65 @@ export default function Page() {
(m) => m.role === "assistant" && m.parentID == message.id, (m) => m.role === "assistant" && m.parentID == message.id,
) as AssistantMessageType[] ) 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 ( return (
<div <div data-message={message.id} class="flex flex-col items-start self-stretch gap-8">
data-message={message.id}
class="flex flex-col items-start self-stretch gap-14 pt-1.5"
>
{/* Title */} {/* Title */}
<div class="flex flex-col items-start gap-2 self-stretch"> <div class="py-2 flex flex-col items-start gap-2 self-stretch sticky top-0 bg-background-stronger">
<h1 class="text-14-medium text-text-strong overflow-hidden text-ellipsis min-w-0"> <h1 class="text-14-medium text-text-strong overflow-hidden text-ellipsis min-w-0">
{title() ?? prompt()} {title() ?? prompt()}
</h1> </h1>
</div>
<Show when={title}> <Show when={title}>
<div class="text-12-regular text-text-base">{prompt()}</div> <div class="-mt-5 text-12-regular text-text-base line-clamp-3">{prompt()}</div>
</Show>
{/* Response */}
<div class="w-full flex flex-col gap-2">
<Collapsible variant="ghost">
<Collapsible.Trigger class="text-text-weak hover:text-text-strong">
<div class="flex items-center gap-1 self-stretch">
<h2 class="text-12-medium">Show steps</h2>
<Collapsible.Arrow />
</div>
</Collapsible.Trigger>
<Collapsible.Content>
<div class="w-full flex flex-col items-start self-stretch gap-8">
<For each={assistantMessages()}>
{(assistantMessage) => {
const parts = createMemo(() => sync.data.part[assistantMessage.id])
return <AssistantMessage message={assistantMessage} parts={parts()} />
}}
</For>
</div>
</Collapsible.Content>
</Collapsible>
<Show when={working() && lastWithContent()}>
{(last) => {
const lastParts = createMemo(() => sync.data.part[last().id])
return (
<AssistantMessage lastToolOnly message={last()} parts={lastParts()} />
)
}}
</Show> </Show>
</div> </div>
{/* Summary */} {/* Summary */}
<Show when={!working()}>
<div class="w-full flex flex-col gap-6 items-start self-stretch"> <div class="w-full flex flex-col gap-6 items-start self-stretch">
<Show when={summary}>
<div class="flex flex-col items-start gap-1 self-stretch"> <div class="flex flex-col items-start gap-1 self-stretch">
<h2 class="text-12-medium text-text-weak">Summary</h2> <h2 class="text-12-medium text-text-weak">Summary</h2>
<div class="text-14-regular text-text-base self-stretch">{summary()}</div> <div class="text-14-regular text-text-base self-stretch">{summary()}</div>
</div> </div>
</Show>
<Show when={message.summary?.diffs.length}>
<Accordion class="w-full" multiple> <Accordion class="w-full" multiple>
<For each={message.summary?.diffs || []}> <For each={message.summary?.diffs || []}>
{(diff) => ( {(diff) => (
@@ -735,23 +749,9 @@ export default function Page() {
)} )}
</For> </For>
</Accordion> </Accordion>
</div>
</Show> </Show>
</div> </div>
{/* Response */}
<div data-todo="Response" class="w-full">
<div class="flex flex-col items-start gap-1 self-stretch">
<h2 class="text-12-medium text-text-weak">Response</h2>
</div>
<div class="w-full flex flex-col items-start self-stretch gap-8">
<For each={assistantMessages()}>
{(assistantMessage) => {
const parts = createMemo(() => sync.data.part[assistantMessage.id])
return <AssistantMessage message={assistantMessage} parts={parts()} />
}}
</For>
</div>
</div>
</div>
) )
}} }}
</For> </For>

View File

@@ -57,6 +57,27 @@
/* animation: slideDown 250ms ease-out; */ /* 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 { @keyframes slideDown {

View File

@@ -5,13 +5,15 @@ import { Icon } from "./icon"
export interface CollapsibleProps extends ParentProps<CollapsibleRootProps> { export interface CollapsibleProps extends ParentProps<CollapsibleRootProps> {
class?: string class?: string
classList?: ComponentProps<"div">["classList"] classList?: ComponentProps<"div">["classList"]
variant?: "normal" | "ghost"
} }
function CollapsibleRoot(props: CollapsibleProps) { function CollapsibleRoot(props: CollapsibleProps) {
const [local, others] = splitProps(props, ["class", "classList"]) const [local, others] = splitProps(props, ["class", "classList", "variant"])
return ( return (
<Kobalte <Kobalte
data-component="collapsible" data-component="collapsible"
data-variant={local.variant || "normal"}
classList={{ classList={{
...(local.classList ?? {}), ...(local.classList ?? {}),
[local.class ?? ""]: !!local.class, [local.class ?? ""]: !!local.class,

View File

@@ -48,6 +48,35 @@
border-width: 0; border-width: 0;
} }
.scroller {
--fade-height: 1.5rem;
--mask-top: linear-gradient(to bottom, transparent, black var(--fade-height));
--mask-bottom: linear-gradient(to top, transparent, black var(--fade-height));
mask-image: var(--mask-top), var(--mask-bottom);
mask-repeat: no-repeat;
mask-size: 100% var(--fade-height);
animation: adjust-masks linear;
animation-timeline: scroll(self);
}
@keyframes adjust-masks {
from {
mask-position:
0 calc(0% - var(--fade-height)),
0 100%;
}
to {
mask-position:
0 0,
0 calc(100% + var(--fade-height));
}
}
.truncate-start { .truncate-start {
text-overflow: ellipsis; text-overflow: ellipsis;
overflow: hidden; overflow: hidden;