Adam 3 месяцев назад
Родитель
Сommit
545f345848

+ 20 - 13
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 (
     <div class="w-full flex flex-col items-start gap-4">
       <For each={filteredParts()}>
@@ -71,17 +78,14 @@ function ToolPart(props: { part: ToolPart; message: AssistantMessage }) {
     // const permission = permissions[permissionIndex]
 
     return (
-      <>
-        <Dynamic
-          component={render}
-          input={input}
-          tool={props.part.tool}
-          metadata={metadata}
-          // permission={permission?.metadata ?? {}}
-          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> */}
-      </>
+      <Dynamic
+        component={render}
+        input={input}
+        tool={props.part.tool}
+        metadata={metadata}
+        // permission={permission?.metadata ?? {}}
+        output={props.part.state.status === "completed" ? props.part.state.output : undefined}
+      />
     )
   })
 
@@ -166,6 +170,9 @@ function BasicTool(props: { icon: IconProps["name"]; trigger: TriggerTitle | JSX
         <Collapsible.Content>{props.children}</Collapsible.Content>
       </Show>
     </Collapsible>
+    // <>
+    //   <Show when={props.part.state.status === "error"}>{props.part.state.error.replace("Error: ", "")}</Show>
+    // </>
   )
 }
 

+ 42 - 17
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<PromptInputProps> = (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<PromptInputProps> = (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<PromptInputProps> = (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<PromptInputProps> = (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<PromptInputProps> = (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<PromptInputProps> = (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<PromptInputProps> = (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<PromptInputProps> = (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)

+ 4 - 8
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)

+ 68 - 68
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<string, SubmissionAttachment>()
-
-    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() {
                   </Show>
                 </div>
               </div>
-              <Tabs.Content value="chat" class="select-text flex flex-col flex-1 min-h-0">
-                <div class="p-6 pt-12 max-w-[904px] w-full mx-auto 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="px-6 pt-12 max-w-[904px] w-full mx-auto flex flex-col flex-1 min-h-0">
                   <Show
                     when={local.session.active()}
                     fallback={
@@ -537,7 +516,7 @@ export default function Page() {
                     }
                   >
                     {(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">
                           <Show when={local.session.userMessages().length > 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,
                                     ) 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 (
-                                    <div
-                                      data-message={message.id}
-                                      class="flex flex-col items-start self-stretch gap-14 pt-1.5"
-                                    >
+                                    <div data-message={message.id} class="flex flex-col items-start self-stretch gap-8">
                                       {/* 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">
                                           {title() ?? prompt()}
                                         </h1>
-                                        <Show when={title}>
-                                          <div class="text-12-regular text-text-base">{prompt()}</div>
+                                      </div>
+                                      <Show when={title}>
+                                        <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>
                                       </div>
                                       {/* Summary */}
-                                      <div class="w-full flex flex-col gap-6 items-start self-stretch">
-                                        <Show when={summary}>
+                                      <Show when={!working()}>
+                                        <div class="w-full flex flex-col gap-6 items-start self-stretch">
                                           <div class="flex flex-col items-start gap-1 self-stretch">
                                             <h2 class="text-12-medium text-text-weak">Summary</h2>
                                             <div class="text-14-regular text-text-base self-stretch">{summary()}</div>
                                           </div>
-                                        </Show>
-                                        <Show when={message.summary?.diffs.length}>
                                           <Accordion class="w-full" multiple>
                                             <For each={message.summary?.diffs || []}>
                                               {(diff) => (
@@ -735,22 +749,8 @@ export default function Page() {
                                               )}
                                             </For>
                                           </Accordion>
-                                        </Show>
-                                      </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>
+                                      </Show>
                                     </div>
                                   )
                                 }}

+ 21 - 0
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 {

+ 3 - 1
packages/ui/src/components/collapsible.tsx

@@ -5,13 +5,15 @@ import { Icon } from "./icon"
 export interface CollapsibleProps extends ParentProps<CollapsibleRootProps> {
   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 (
     <Kobalte
       data-component="collapsible"
+      data-variant={local.variant || "normal"}
       classList={{
         ...(local.classList ?? {}),
         [local.class ?? ""]: !!local.class,

+ 29 - 0
packages/ui/src/styles/utilities.css

@@ -48,6 +48,35 @@
   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 {
   text-overflow: ellipsis;
   overflow: hidden;