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 */} -
    - + +

    Summary

    {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 (