Browse Source

wip(desktop): progress

Adam 2 months ago
parent
commit
5cf6a1343c

+ 165 - 26
packages/desktop/src/components/prompt-input.tsx

@@ -1,10 +1,10 @@
 import { useFilteredList } from "@opencode-ai/ui/hooks"
 import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match, createMemo } from "solid-js"
-import { createStore } from "solid-js/store"
+import { createStore, produce } from "solid-js/store"
 import { makePersisted } from "@solid-primitives/storage"
 import { createFocusSignal } from "@solid-primitives/active-element"
 import { useLocal } from "@/context/local"
-import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, usePrompt } from "@/context/prompt"
+import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, usePrompt, ImageAttachmentPart } from "@/context/prompt"
 import { useLayout } from "@/context/layout"
 import { useSDK } from "@/context/sdk"
 import { useNavigate, useParams } from "@solidjs/router"
@@ -22,6 +22,9 @@ import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid
 import { useProviders } from "@/hooks/use-providers"
 import { useCommand, formatKeybind } from "@/context/command"
 
+const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
+const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
+
 interface PromptInputProps {
   class?: string
   ref?: (el: HTMLDivElement) => void
@@ -93,11 +96,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     historyIndex: number
     savedPrompt: Prompt | null
     placeholder: number
+    dragging: boolean
+    imageAttachments: ImageAttachmentPart[]
   }>({
     popover: null,
     historyIndex: -1,
     savedPrompt: null,
     placeholder: Math.floor(Math.random() * PLACEHOLDERS.length),
+    dragging: false,
+    imageAttachments: [],
   })
 
   const MAX_HISTORY = 100
@@ -113,16 +120,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   )
 
   const clonePromptParts = (prompt: Prompt): Prompt =>
-    prompt.map((part) =>
-      part.type === "text"
-        ? { ...part }
-        : {
-            ...part,
-            selection: part.selection ? { ...part.selection } : undefined,
-          },
-    )
+    prompt.map((part) => {
+      if (part.type === "text") return { ...part }
+      if (part.type === "image") return { ...part }
+      return {
+        ...part,
+        selection: part.selection ? { ...part.selection } : undefined,
+      }
+    })
 
-  const promptLength = (prompt: Prompt) => prompt.reduce((len, part) => len + part.content.length, 0)
+  const promptLength = (prompt: Prompt) =>
+    prompt.reduce((len, part) => len + ("content" in part ? part.content.length : 0), 0)
 
   const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => {
     const length = position === "start" ? 0 : promptLength(p)
@@ -162,14 +170,89 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
 
   const isFocused = createFocusSignal(() => editorRef)
 
-  const handlePaste = (event: ClipboardEvent) => {
+  const addImageAttachment = async (file: File) => {
+    if (!ACCEPTED_FILE_TYPES.includes(file.type)) return
+
+    const reader = new FileReader()
+    reader.onload = () => {
+      const dataUrl = reader.result as string
+      const attachment: ImageAttachmentPart = {
+        type: "image",
+        id: crypto.randomUUID(),
+        filename: file.name,
+        mime: file.type,
+        dataUrl,
+      }
+      setStore(
+        produce((draft) => {
+          draft.imageAttachments.push(attachment)
+        }),
+      )
+    }
+    reader.readAsDataURL(file)
+  }
+
+  const removeImageAttachment = (id: string) => {
+    setStore(
+      produce((draft) => {
+        draft.imageAttachments = draft.imageAttachments.filter((a) => a.id !== id)
+      }),
+    )
+  }
+
+  const handlePaste = async (event: ClipboardEvent) => {
+    const clipboardData = event.clipboardData
+    if (!clipboardData) return
+
+    const items = Array.from(clipboardData.items)
+    const imageItems = items.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type))
+
+    if (imageItems.length > 0) {
+      event.preventDefault()
+      event.stopPropagation()
+      for (const item of imageItems) {
+        const file = item.getAsFile()
+        if (file) await addImageAttachment(file)
+      }
+      return
+    }
+
     event.preventDefault()
     event.stopPropagation()
-    // @ts-expect-error
-    const plainText = (event.clipboardData || window.clipboardData)?.getData("text/plain") ?? ""
+    const plainText = clipboardData.getData("text/plain") ?? ""
     addPart({ type: "text", content: plainText, start: 0, end: 0 })
   }
 
+  const handleDragOver = (event: DragEvent) => {
+    event.preventDefault()
+    const hasFiles = event.dataTransfer?.types.includes("Files")
+    if (hasFiles) {
+      setStore("dragging", true)
+    }
+  }
+
+  const handleDragLeave = (event: DragEvent) => {
+    const related = event.relatedTarget as Node | null
+    const form = event.currentTarget as HTMLElement
+    if (!related || !form.contains(related)) {
+      setStore("dragging", false)
+    }
+  }
+
+  const handleDrop = async (event: DragEvent) => {
+    event.preventDefault()
+    setStore("dragging", false)
+
+    const files = event.dataTransfer?.files
+    if (!files) return
+
+    for (const file of Array.from(files)) {
+      if (ACCEPTED_FILE_TYPES.includes(file.type)) {
+        await addImageAttachment(file)
+      }
+    }
+  }
+
   onMount(() => {
     editorRef.addEventListener("paste", handlePaste)
   })
@@ -328,7 +411,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   const handleInput = () => {
     const rawParts = parseFromDOM()
     const cursorPosition = getCursorPosition(editorRef)
-    const rawText = rawParts.map((p) => p.content).join("")
+    const rawText = rawParts.map((p) => ("content" in p ? p.content : "")).join("")
 
     const atMatch = rawText.substring(0, cursorPosition).match(/@(\S*)$/)
     // Slash commands only trigger when / is at the start of input
@@ -358,7 +441,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
 
     const cursorPosition = getCursorPosition(editorRef)
     const currentPrompt = prompt.current()
-    const rawText = currentPrompt.map((p) => p.content).join("")
+    const rawText = currentPrompt.map((p) => ("content" in p ? p.content : "")).join("")
     const textBeforeCursor = rawText.substring(0, cursorPosition)
     const atMatch = textBeforeCursor.match(/@(\S*)$/)
 
@@ -424,7 +507,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
 
   const addToHistory = (prompt: Prompt) => {
     const text = prompt
-      .map((p) => p.content)
+      .map((p) => ("content" in p ? p.content : ""))
       .join("")
       .trim()
     if (!text) return
@@ -432,7 +515,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     const entry = clonePromptParts(prompt)
     const lastEntry = history.entries[0]
     if (lastEntry) {
-      const lastText = lastEntry.map((p) => p.content).join("")
+      const lastText = lastEntry.map((p) => ("content" in p ? p.content : "")).join("")
       if (lastText === text) return
     }
 
@@ -532,8 +615,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   const handleSubmit = async (event: Event) => {
     event.preventDefault()
     const currentPrompt = prompt.current()
-    const text = currentPrompt.map((part) => part.content).join("")
-    if (text.trim().length === 0) {
+    const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("")
+    const hasImageAttachments = store.imageAttachments.length > 0
+    if (text.trim().length === 0 && !hasImageAttachments) {
       if (working()) abort()
       return
     }
@@ -555,7 +639,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       (part) => part.type === "file",
     ) as import("@/context/prompt").FileAttachmentPart[]
 
-    const attachmentParts = attachments.map((attachment) => {
+    const fileAttachmentParts = attachments.map((attachment) => {
       const absolute = toAbsolutePath(attachment.path)
       const query = attachment.selection
         ? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}`
@@ -577,9 +661,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       }
     })
 
+    const imageAttachmentParts = store.imageAttachments.map((attachment) => ({
+      type: "file" as const,
+      mime: attachment.mime,
+      url: attachment.dataUrl,
+      filename: attachment.filename,
+    }))
+
     tabs().setActive(undefined)
     editorRef.innerHTML = ""
     prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
+    setStore("imageAttachments", [])
 
     if (text.startsWith("/")) {
       const [cmdName, ...args] = text.split(" ")
@@ -609,7 +701,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
           type: "text",
           text,
         },
-        ...attachmentParts,
+        ...fileAttachmentParts,
+        ...imageAttachmentParts,
       ],
     })
   }
@@ -686,12 +779,58 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       </Show>
       <form
         onSubmit={handleSubmit}
+        onDragOver={handleDragOver}
+        onDragLeave={handleDragLeave}
+        onDrop={handleDrop}
         classList={{
-          "bg-surface-raised-stronger-non-alpha shadow-xs-border": true,
+          "bg-surface-raised-stronger-non-alpha shadow-xs-border relative": true,
           "rounded-md overflow-clip focus-within:shadow-xs-border": true,
+          "border-icon-info-active border-dashed": store.dragging,
           [props.class ?? ""]: !!props.class,
         }}
       >
+        <Show when={store.dragging}>
+          <div class="absolute inset-0 z-10 flex items-center justify-center bg-surface-raised-stronger-non-alpha/90 pointer-events-none">
+            <div class="flex flex-col items-center gap-2 text-text-weak">
+              <Icon name="plus" class="size-8" />
+              <span class="text-14-regular">Drop images or PDFs here</span>
+            </div>
+          </div>
+        </Show>
+        <Show when={store.imageAttachments.length > 0}>
+          <div class="flex flex-wrap gap-2 px-3 pt-3">
+            <For each={store.imageAttachments}>
+              {(attachment) => (
+                <div class="relative group">
+                  <Show
+                    when={attachment.mime.startsWith("image/")}
+                    fallback={
+                      <div class="size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base">
+                        <Icon name="folder" class="size-6 text-text-weak" />
+                      </div>
+                    }
+                  >
+                    <img
+                      src={attachment.dataUrl}
+                      alt={attachment.filename}
+                      class="size-16 rounded-md object-cover border border-border-base"
+                    />
+                  </Show>
+                  <button
+                    type="button"
+                    onClick={() => removeImageAttachment(attachment.id)}
+                    class="absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover"
+                  >
+                    <Icon name="close" class="size-3 text-text-weak" />
+                  </button>
+                  <div class="absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md">
+                    <span class="text-10-regular text-white truncate block">{attachment.filename}</span>
+                  </div>
+                </div>
+              )}
+            </For>
+          </div>
+        </Show>
         <div class="relative max-h-[240px] overflow-y-auto">
           <div
             ref={(el) => {
@@ -706,7 +845,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
               "[&>[data-type=file]]:text-icon-info-active": true,
             }}
           />
-          <Show when={!prompt.dirty()}>
+          <Show when={!prompt.dirty() && store.imageAttachments.length === 0}>
             <div class="absolute top-0 left-0 px-5 py-3 text-14-regular text-text-weak pointer-events-none">
               Ask anything... "{PLACEHOLDERS[store.placeholder]}"
             </div>
@@ -735,7 +874,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
           </div>
           <Tooltip
             placement="top"
-            inactive={!session.prompt.dirty() && !session.working()}
+            inactive={!prompt.dirty() && !working()}
             value={
               <Switch>
                 <Match when={working()}>
@@ -755,7 +894,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
           >
             <IconButton
               type="submit"
-              disabled={!prompt.dirty() && !working()}
+              disabled={!prompt.dirty() && store.imageAttachments.length === 0 && !working()}
               icon={working() ? "stop" : "arrow-up"}
               variant="primary"
               class="h-10 w-8 absolute right-2 bottom-2"

+ 38 - 2
packages/desktop/src/context/global-sync.tsx

@@ -100,11 +100,17 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple
 
     async function loadSessions(directory: string) {
       globalSDK.client.session.list({ directory }).then((x) => {
-        const sessions = (x.data ?? [])
+        const oneHourAgo = Date.now() - 60 * 60 * 1000
+        const nonArchived = (x.data ?? [])
           .slice()
           .filter((s) => !s.time.archived)
           .sort((a, b) => a.id.localeCompare(b.id))
-          .slice(0, 5)
+        // Include at least 5 sessions, plus any updated in the last hour
+        const sessions = nonArchived.filter((s, i) => {
+          if (i < 5) return true
+          const updated = new Date(s.time.updated).getTime()
+          return updated > oneHourAgo
+        })
         const [, setStore] = child(directory)
         setStore("session", sessions)
       })
@@ -220,6 +226,21 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple
           )
           break
         }
+        case "message.removed": {
+          const messages = store.message[event.properties.sessionID]
+          if (!messages) break
+          const result = Binary.search(messages, event.properties.messageID, (m) => m.id)
+          if (result.found) {
+            setStore(
+              "message",
+              event.properties.sessionID,
+              produce((draft) => {
+                draft.splice(result.index, 1)
+              }),
+            )
+          }
+          break
+        }
         case "message.part.updated": {
           const part = event.properties.part
           const parts = store.part[part.messageID]
@@ -241,6 +262,21 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple
           )
           break
         }
+        case "message.part.removed": {
+          const parts = store.part[event.properties.messageID]
+          if (!parts) break
+          const result = Binary.search(parts, event.properties.partID, (p) => p.id)
+          if (result.found) {
+            setStore(
+              "part",
+              event.properties.messageID,
+              produce((draft) => {
+                draft.splice(result.index, 1)
+              }),
+            )
+          }
+          break
+        }
       }
     })
 

+ 1 - 1
packages/desktop/src/context/local.tsx

@@ -406,7 +406,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
           case "file.watcher.updated":
             const relativePath = relative(event.properties.file)
             if (relativePath.startsWith(".git/")) return
-            load(relativePath)
+            if (store.node[relativePath]) load(relativePath)
             break
         }
       })

+ 13 - 1
packages/desktop/src/context/prompt.tsx

@@ -21,7 +21,15 @@ export interface FileAttachmentPart extends PartBase {
   selection?: TextSelection
 }
 
-export type ContentPart = TextPart | FileAttachmentPart
+export interface ImageAttachmentPart {
+  type: "image"
+  id: string
+  filename: string
+  mime: string
+  dataUrl: string
+}
+
+export type ContentPart = TextPart | FileAttachmentPart | ImageAttachmentPart
 export type Prompt = ContentPart[]
 
 export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
@@ -38,6 +46,9 @@ export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
     if (partA.type === "file" && partA.path !== (partB as FileAttachmentPart).path) {
       return false
     }
+    if (partA.type === "image" && partA.id !== (partB as ImageAttachmentPart).id) {
+      return false
+    }
   }
   return true
 }
@@ -49,6 +60,7 @@ function cloneSelection(selection?: TextSelection) {
 
 function clonePart(part: ContentPart): ContentPart {
   if (part.type === "text") return { ...part }
+  if (part.type === "image") return { ...part }
   return {
     ...part,
     selection: cloneSelection(part.selection),

+ 138 - 73
packages/desktop/src/pages/layout.tsx

@@ -55,10 +55,32 @@ export default function Layout(props: ParentProps) {
   const dialog = useDialog()
   const command = useCommand()
 
+  function flattenSessions(sessions: Session[]): Session[] {
+    const childrenMap = new Map<string, Session[]>()
+    for (const session of sessions) {
+      if (session.parentID) {
+        const children = childrenMap.get(session.parentID) ?? []
+        children.push(session)
+        childrenMap.set(session.parentID, children)
+      }
+    }
+    const result: Session[] = []
+    function visit(session: Session) {
+      result.push(session)
+      for (const child of childrenMap.get(session.id) ?? []) {
+        visit(child)
+      }
+    }
+    for (const session of sessions) {
+      if (!session.parentID) visit(session)
+    }
+    return result
+  }
+
   const currentSessions = createMemo(() => {
     if (!params.dir) return []
     const directory = base64Decode(params.dir)
-    return globalSync.child(directory)[0].session ?? []
+    return flattenSessions(globalSync.child(directory)[0].session ?? [])
   })
 
   function navigateSessionByOffset(offset: number) {
@@ -98,7 +120,7 @@ export default function Layout(props: ParentProps) {
     const nextProject = projects[nextProjectIndex]
     if (!nextProject) return
 
-    const nextProjectSessions = globalSync.child(nextProject.worktree)[0].session ?? []
+    const nextProjectSessions = flattenSessions(globalSync.child(nextProject.worktree)[0].session ?? [])
     if (nextProjectSessions.length === 0) {
       // Navigate to the project's new session page if no sessions
       navigateToProject(nextProject.worktree)
@@ -375,6 +397,98 @@ export default function Layout(props: ParentProps) {
     )
   }
 
+  const SessionItem = (props: {
+    session: Session
+    slug: string
+    project: Project
+    depth?: number
+    childrenMap: Map<string, Session[]>
+  }): JSX.Element => {
+    const notification = useNotification()
+    const depth = props.depth ?? 0
+    const children = createMemo(() => props.childrenMap.get(props.session.id) ?? [])
+    const updated = createMemo(() => DateTime.fromMillis(props.session.time.updated))
+    const notifications = createMemo(() => notification.session.unseen(props.session.id))
+    const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
+    const isWorking = createMemo(
+      () =>
+        props.session.id !== params.id &&
+        globalSync.child(props.project.worktree)[0].session_status[props.session.id]?.type === "busy",
+    )
+    return (
+      <>
+        <div
+          class="group/session relative w-full pr-2 py-1 rounded-md cursor-default transition-colors
+                 hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-raised-base-hover"
+          style={{ "padding-left": `${16 + depth * 12}px` }}
+        >
+          <Tooltip placement="right" value={props.session.title} gutter={10}>
+            <A
+              href={`${props.slug}/session/${props.session.id}`}
+              class="flex flex-col min-w-0 text-left w-full focus:outline-none"
+            >
+              <div class="flex items-center self-stretch gap-6 justify-between transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7">
+                <span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate">
+                  {props.session.title}
+                </span>
+                <div class="shrink-0 group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
+                  <Switch>
+                    <Match when={isWorking()}>
+                      <Spinner class="size-2.5 mr-0.5" />
+                    </Match>
+                    <Match when={hasError()}>
+                      <div class="size-1.5 mr-1.5 rounded-full bg-text-diff-delete-base" />
+                    </Match>
+                    <Match when={notifications().length > 0}>
+                      <div class="size-1.5 mr-1.5 rounded-full bg-text-interactive-base" />
+                    </Match>
+                    <Match when={true}>
+                      <span class="text-12-regular text-text-weak text-right whitespace-nowrap">
+                        {Math.abs(updated().diffNow().as("seconds")) < 60
+                          ? "Now"
+                          : updated()
+                              .toRelative({
+                                style: "short",
+                                unit: ["days", "hours", "minutes"],
+                              })
+                              ?.replace(" ago", "")
+                              ?.replace(/ days?/, "d")
+                              ?.replace(" min.", "m")
+                              ?.replace(" hr.", "h")}
+                      </span>
+                    </Match>
+                  </Switch>
+                </div>
+              </div>
+              <Show when={props.session.summary?.files}>
+                <div class="flex justify-between items-center self-stretch">
+                  <span class="text-12-regular text-text-weak">{`${props.session.summary?.files || "No"} file${props.session.summary?.files !== 1 ? "s" : ""} changed`}</span>
+                  <Show when={props.session.summary}>{(summary) => <DiffChanges changes={summary()} />}</Show>
+                </div>
+              </Show>
+            </A>
+          </Tooltip>
+          <div class="hidden group-hover/session:flex group-active/session:flex group-focus-within/session:flex text-text-base gap-1 items-center absolute top-1 right-1">
+            <Tooltip placement="right" value="Archive session">
+              <IconButton icon="archive" variant="ghost" onClick={() => archiveSession(props.session)} />
+            </Tooltip>
+          </div>
+        </div>
+        <For each={children()}>
+          {(child) => (
+            <SessionItem
+              session={child}
+              slug={props.slug}
+              project={props.project}
+              depth={depth + 1}
+              childrenMap={props.childrenMap}
+            />
+          )}
+        </For>
+      </>
+    )
+  }
+
   const SortableProject = (props: { project: Project & { expanded: boolean } }): JSX.Element => {
     const notification = useNotification()
     const sortable = createSortable(props.project.worktree)
@@ -382,6 +496,18 @@ export default function Layout(props: ParentProps) {
     const name = createMemo(() => getFilename(props.project.worktree))
     const [store, setStore] = globalSync.child(props.project.worktree)
     const sessions = createMemo(() => store.session ?? [])
+    const rootSessions = createMemo(() => sessions().filter((s) => !s.parentID))
+    const childSessionsByParent = createMemo(() => {
+      const map = new Map<string, Session[]>()
+      for (const session of sessions()) {
+        if (session.parentID) {
+          const children = map.get(session.parentID) ?? []
+          children.push(session)
+          map.set(session.parentID, children)
+        }
+      }
+      return map
+    })
     const [expanded, setExpanded] = createSignal(true)
     return (
       // @ts-ignore
@@ -421,78 +547,17 @@ export default function Layout(props: ParentProps) {
               </Button>
               <Collapsible.Content>
                 <nav class="hidden @[4rem]:flex w-full flex-col gap-1.5">
-                  <For each={sessions()}>
-                    {(session) => {
-                      const updated = createMemo(() => DateTime.fromMillis(session.time.updated))
-                      const notifications = createMemo(() => notification.session.unseen(session.id))
-                      const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
-                      const isWorking = createMemo(
-                        () =>
-                          session.id !== params.id &&
-                          globalSync.child(props.project.worktree)[0].session_status[session.id]?.type === "busy",
-                      )
-                      return (
-                        <div
-                          class="group/session relative w-full pl-4 pr-2 py-1 rounded-md cursor-default transition-colors
-                                 hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-raised-base-hover"
-                        >
-                          <Tooltip placement="right" value={session.title} gutter={10}>
-                            <A
-                              href={`${slug()}/session/${session.id}`}
-                              class="flex flex-col min-w-0 text-left w-full focus:outline-none"
-                            >
-                              <div class="flex items-center self-stretch gap-6 justify-between transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7">
-                                <span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate">
-                                  {session.title}
-                                </span>
-                                <div class="shrink-0 group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
-                                  <Switch>
-                                    <Match when={isWorking()}>
-                                      <Spinner class="size-2.5 mr-0.5" />
-                                    </Match>
-                                    <Match when={hasError()}>
-                                      <div class="size-1.5 mr-1.5 rounded-full bg-text-diff-delete-base" />
-                                    </Match>
-                                    <Match when={notifications().length > 0}>
-                                      <div class="size-1.5 mr-1.5 rounded-full bg-text-interactive-base" />
-                                    </Match>
-                                    <Match when={true}>
-                                      <span class="text-12-regular text-text-weak text-right whitespace-nowrap">
-                                        {Math.abs(updated().diffNow().as("seconds")) < 60
-                                          ? "Now"
-                                          : updated()
-                                              .toRelative({
-                                                style: "short",
-                                                unit: ["days", "hours", "minutes"],
-                                              })
-                                              ?.replace(" ago", "")
-                                              ?.replace(/ days?/, "d")
-                                              ?.replace(" min.", "m")
-                                              ?.replace(" hr.", "h")}
-                                      </span>
-                                    </Match>
-                                  </Switch>
-                                </div>
-                              </div>
-                              <Show when={session.summary?.files}>
-                                <div class="flex justify-between items-center self-stretch">
-                                  <span class="text-12-regular text-text-weak">{`${session.summary?.files || "No"} file${session.summary?.files !== 1 ? "s" : ""} changed`}</span>
-                                  <Show when={session.summary}>{(summary) => <DiffChanges changes={summary()} />}</Show>
-                                </div>
-                              </Show>
-                            </A>
-                          </Tooltip>
-                          <div class="hidden group-hover/session:flex group-active/session:flex group-focus-within/session:flex text-text-base gap-1 items-center absolute top-1 right-1">
-                            {/* <IconButton icon="dot-grid" variant="ghost" /> */}
-                            <Tooltip placement="right" value="Archive session">
-                              <IconButton icon="archive" variant="ghost" onClick={() => archiveSession(session)} />
-                            </Tooltip>
-                          </div>
-                        </div>
-                      )
-                    }}
+                  <For each={rootSessions()}>
+                    {(session) => (
+                      <SessionItem
+                        session={session}
+                        slug={slug()}
+                        project={props.project}
+                        childrenMap={childSessionsByParent()}
+                      />
+                    )}
                   </For>
-                  <Show when={sessions().length === 0}>
+                  <Show when={rootSessions().length === 0}>
                     <div
                       class="group/session relative w-full pl-4 pr-2 py-1 rounded-md cursor-default transition-colors
                              hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-raised-base-hover"

+ 108 - 40
packages/desktop/src/pages/session.tsx

@@ -1,4 +1,4 @@
-import { For, onCleanup, onMount, Show, Match, Switch, createResource, createMemo, createEffect } from "solid-js"
+import { For, onCleanup, onMount, Show, Match, Switch, createResource, createMemo, createEffect, on } from "solid-js"
 import { useLocal, type LocalFile } from "@/context/local"
 import { createStore } from "solid-js/store"
 import { PromptInput } from "@/components/prompt-input"
@@ -38,6 +38,9 @@ import { DialogSelectModel } from "@/components/dialog-select-model"
 import { useCommand } from "@/context/command"
 import { useNavigate, useParams } from "@solidjs/router"
 import { AssistantMessage, UserMessage } from "@opencode-ai/sdk/v2"
+import { useSDK } from "@/context/sdk"
+import { usePrompt } from "@/context/prompt"
+import { extractPromptFromParts } from "@/utils/prompt"
 
 export default function Page() {
   const layout = useLayout()
@@ -48,45 +51,56 @@ export default function Page() {
   const command = useCommand()
   const params = useParams()
   const navigate = useNavigate()
+  const sdk = useSDK()
+  const prompt = usePrompt()
 
   const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
   const tabs = createMemo(() => layout.tabs(sessionKey()))
 
   const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
+  const revertMessageID = createMemo(() => info()?.revert?.messageID)
   const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
   const userMessages = createMemo(() =>
     messages()
       .filter((m) => m.role === "user")
       .sort((a, b) => a.id.localeCompare(b.id)),
   )
-  const lastUserMessage = createMemo(() => userMessages()?.at(-1))
+  // Visible user messages excludes reverted messages (those >= revertMessageID)
+  const visibleUserMessages = createMemo(() => {
+    const revert = revertMessageID()
+    if (!revert) return userMessages()
+    return userMessages().filter((m) => m.id < revert)
+  })
+  const lastUserMessage = createMemo(() => visibleUserMessages()?.at(-1))
 
   const [messageStore, setMessageStore] = createStore<{ messageId?: string }>({})
   const activeMessage = createMemo(() => {
     if (!messageStore.messageId) return lastUserMessage()
-    return userMessages()?.find((m) => m.id === messageStore.messageId)
+    // If the stored message is no longer visible (e.g., was reverted), fall back to last visible
+    const found = visibleUserMessages()?.find((m) => m.id === messageStore.messageId)
+    return found ?? lastUserMessage()
   })
   const setActiveMessage = (message: UserMessage | undefined) => {
     setMessageStore("messageId", message?.id)
   }
 
   function navigateMessageByOffset(offset: number) {
-    const messages = userMessages()
-    if (messages.length === 0) return
+    const msgs = visibleUserMessages()
+    if (msgs.length === 0) return
 
     const current = activeMessage()
-    const currentIndex = current ? messages.findIndex((m) => m.id === current.id) : -1
+    const currentIndex = current ? msgs.findIndex((m) => m.id === current.id) : -1
 
     let targetIndex: number
     if (currentIndex === -1) {
-      targetIndex = offset > 0 ? 0 : messages.length - 1
+      targetIndex = offset > 0 ? 0 : msgs.length - 1
     } else {
       targetIndex = currentIndex + offset
     }
 
-    if (targetIndex < 0 || targetIndex >= messages.length) return
+    if (targetIndex < 0 || targetIndex >= msgs.length) return
 
-    setActiveMessage(messages[targetIndex])
+    setActiveMessage(msgs[targetIndex])
   }
 
   const last = createMemo(
@@ -131,6 +145,24 @@ export default function Page() {
     }
   })
 
+  // Auto-navigate to new messages when they're added
+  // This handles the case after undo + submit where we want to see the new message
+  // We track the last message ID and only navigate when a NEW message is added (ID increases)
+  createEffect(
+    on(
+      () => visibleUserMessages().at(-1)?.id,
+      (lastId, prevLastId) => {
+        // Only navigate if a new message was added (lastId is greater/newer than previous)
+        if (lastId && prevLastId && lastId > prevLastId) {
+          setMessageStore("messageId", undefined)
+        }
+      },
+      { defer: true },
+    ),
+  )
+
+  const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? { type: "idle" })
+
   command.register(() => [
     {
       id: "session.new",
@@ -226,6 +258,66 @@ export default function Page() {
       slash: "agent",
       onSelect: () => local.agent.move(1),
     },
+    {
+      id: "session.undo",
+      title: "Undo",
+      description: "Undo the last message",
+      category: "Session",
+      keybind: "mod+z",
+      slash: "undo",
+      disabled: !params.id || visibleUserMessages().length === 0,
+      onSelect: async () => {
+        const sessionID = params.id
+        if (!sessionID) return
+        if (status()?.type !== "idle") {
+          await sdk.client.session.abort({ sessionID }).catch(() => {})
+        }
+        const revert = info()?.revert?.messageID
+        // Find the last user message that's not already reverted
+        const message = userMessages().findLast((x) => !revert || x.id < revert)
+        if (!message) return
+        await sdk.client.session.revert({ sessionID, messageID: message.id })
+        // Restore the prompt from the reverted message
+        const parts = sync.data.part[message.id]
+        if (parts) {
+          const restored = extractPromptFromParts(parts)
+          prompt.set(restored)
+        }
+        // Navigate to the message before the reverted one (which will be the new last visible message)
+        const priorMessage = userMessages().findLast((x) => x.id < message.id)
+        setActiveMessage(priorMessage)
+      },
+    },
+    {
+      id: "session.redo",
+      title: "Redo",
+      description: "Redo the last undone message",
+      category: "Session",
+      keybind: "mod+shift+z",
+      slash: "redo",
+      disabled: !params.id || !info()?.revert?.messageID,
+      onSelect: async () => {
+        const sessionID = params.id
+        if (!sessionID) return
+        const revertMessageID = info()?.revert?.messageID
+        if (!revertMessageID) return
+        const nextMessage = userMessages().find((x) => x.id > revertMessageID)
+        if (!nextMessage) {
+          // Full unrevert - restore all messages and navigate to last
+          await sdk.client.session.unrevert({ sessionID })
+          prompt.reset()
+          // Navigate to the last message (the one that was at the revert point)
+          const lastMsg = userMessages().findLast((x) => x.id >= revertMessageID)
+          setActiveMessage(lastMsg)
+          return
+        }
+        // Partial redo - move forward to next message
+        await sdk.client.session.revert({ sessionID, messageID: nextMessage.id })
+        // Navigate to the message before the new revert point
+        const priorMsg = userMessages().findLast((x) => x.id < nextMessage.id)
+        setActiveMessage(priorMsg)
+      },
+    },
   ])
 
   const handleKeyDown = (event: KeyboardEvent) => {
@@ -548,7 +640,7 @@ export default function Page() {
                     <Match when={params.id}>
                       <div class="flex items-start justify-start h-full min-h-0">
                         <SessionMessageRail
-                          messages={userMessages()}
+                          messages={visibleUserMessages()}
                           current={activeMessage()}
                           onMessageSelect={setActiveMessage}
                           wide={wide()}
@@ -556,7 +648,7 @@ export default function Page() {
                         <Show when={activeMessage()}>
                           <SessionTurn
                             sessionID={params.id!}
-                            messageID={activeMessage()?.id!}
+                            messageID={activeMessage()!.id}
                             stepsExpanded={store.stepsExpanded}
                             onStepsExpandedChange={(expanded) => setStore("stepsExpanded", expanded)}
                             classes={{
@@ -564,7 +656,11 @@ export default function Page() {
                               content: "pb-20",
                               container:
                                 "w-full " +
-                                (wide() ? "max-w-146 mx-auto px-6" : userMessages().length > 1 ? "pr-6 pl-18" : "px-6"),
+                                (wide()
+                                  ? "max-w-146 mx-auto px-6"
+                                  : visibleUserMessages().length > 1
+                                    ? "pr-6 pl-18"
+                                    : "px-6"),
                             }}
                           />
                         </Show>
@@ -718,34 +814,6 @@ export default function Page() {
             />
           </div>
         </Show>
-        <div class="hidden shrink-0 w-56 p-2 h-full overflow-y-auto">
-          {/* <FileTree path="" onFileClick={ handleTabClick} /> */}
-        </div>
-        <div class="hidden shrink-0 w-56 p-2">
-          <Show
-            when={local.file.changes().length}
-            fallback={<div class="px-2 text-xs text-text-muted">No changes</div>}
-          >
-            <ul class="">
-              <For each={local.file.changes()}>
-                {(path) => (
-                  <li>
-                    <button
-                      onClick={() => 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 hover:bg-background-element"
-                    >
-                      <FileIcon node={{ path, type: "file" }} class="shrink-0 size-3" />
-                      <span class="text-xs text-text whitespace-nowrap">{getFilename(path)}</span>
-                      <span class="text-xs text-text-muted/60 whitespace-nowrap truncate min-w-0">
-                        {getDirectory(path)}
-                      </span>
-                    </button>
-                  </li>
-                )}
-              </For>
-            </ul>
-          </Show>
-        </div>
       </div>
       <Show when={layout.terminal.opened()}>
         <div

+ 47 - 0
packages/desktop/src/utils/prompt.ts

@@ -0,0 +1,47 @@
+import type { Part, TextPart, FilePart } from "@opencode-ai/sdk/v2"
+import type { Prompt, FileAttachmentPart } from "@/context/prompt"
+
+/**
+ * Extract prompt content from message parts for restoring into the prompt input.
+ * This is used by undo to restore the original user prompt.
+ */
+export function extractPromptFromParts(parts: Part[]): Prompt {
+  const result: Prompt = []
+  let position = 0
+
+  for (const part of parts) {
+    if (part.type === "text") {
+      const textPart = part as TextPart
+      if (!textPart.synthetic && textPart.text) {
+        result.push({
+          type: "text",
+          content: textPart.text,
+          start: position,
+          end: position + textPart.text.length,
+        })
+        position += textPart.text.length
+      }
+    } else if (part.type === "file") {
+      const filePart = part as FilePart
+      if (filePart.source?.type === "file") {
+        const path = filePart.source.path
+        const content = "@" + path
+        const attachment: FileAttachmentPart = {
+          type: "file",
+          path,
+          content,
+          start: position,
+          end: position + content.length,
+        }
+        result.push(attachment)
+        position += content.length
+      }
+    }
+  }
+
+  if (result.length === 0) {
+    result.push({ type: "text", content: "", start: 0, end: 0 })
+  }
+
+  return result
+}

+ 71 - 5
packages/ui/src/components/message-part.css

@@ -14,11 +14,77 @@
   line-height: var(--line-height-large);
   letter-spacing: var(--letter-spacing-normal);
   color: var(--text-base);
-  display: -webkit-box;
-  line-clamp: 3;
-  -webkit-line-clamp: 3;
-  -webkit-box-orient: vertical;
-  overflow: hidden;
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+
+  [data-slot="user-message-attachments"] {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 8px;
+  }
+
+  [data-slot="user-message-attachment"] {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    border-radius: 6px;
+    overflow: hidden;
+    background: var(--surface-base);
+    border: 1px solid var(--border-base);
+    transition: border-color 0.15s ease;
+
+    &:hover {
+      border-color: var(--border-strong-base);
+    }
+
+    &[data-type="image"] {
+      width: 48px;
+      height: 48px;
+    }
+
+    &[data-type="file"] {
+      width: 48px;
+      height: 48px;
+    }
+  }
+
+  [data-slot="user-message-attachment-image"] {
+    width: 100%;
+    height: 100%;
+    object-fit: cover;
+  }
+
+  [data-slot="user-message-attachment-icon"] {
+    width: 100%;
+    height: 100%;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    color: var(--icon-weak);
+
+    [data-component="icon"] {
+      width: 20px;
+      height: 20px;
+    }
+  }
+
+  [data-slot="user-message-text"] {
+    display: -webkit-box;
+    line-clamp: 3;
+    -webkit-line-clamp: 3;
+    -webkit-box-orient: vertical;
+    overflow: hidden;
+  }
+
+  .text-text-strong {
+    color: var(--text-strong);
+  }
+
+  .font-medium {
+    font-weight: var(--font-weight-medium);
+  }
 }
 
 [data-component="text-part"] {

+ 88 - 8
packages/ui/src/components/message-part.tsx

@@ -2,6 +2,7 @@ import { Component, createMemo, For, Match, Show, Switch } from "solid-js"
 import { Dynamic } from "solid-js/web"
 import {
   AssistantMessage,
+  FilePart,
   Message as MessageType,
   Part as PartType,
   TextPart,
@@ -74,13 +75,93 @@ export function AssistantMessageDisplay(props: { message: AssistantMessage; part
 }
 
 export function UserMessageDisplay(props: { message: UserMessage; parts: PartType[] }) {
-  const text = createMemo(() =>
-    props.parts
-      ?.filter((p) => p.type === "text" && !(p as TextPart).synthetic)
-      ?.map((p) => (p as TextPart).text)
-      ?.join(""),
+  const textPart = createMemo(
+    () => props.parts?.find((p) => p.type === "text" && !(p as TextPart).synthetic) as TextPart | undefined,
+  )
+
+  const text = createMemo(() => textPart()?.text || "")
+
+  const files = createMemo(() => (props.parts?.filter((p) => p.type === "file") as FilePart[]) ?? [])
+
+  const attachments = createMemo(() =>
+    files()?.filter((f) => {
+      const mime = f.mime
+      return mime.startsWith("image/") || mime === "application/pdf"
+    }),
+  )
+
+  const inlineFiles = createMemo(() =>
+    files().filter((f) => {
+      const mime = f.mime
+      return !mime.startsWith("image/") && mime !== "application/pdf" && f.source?.text?.start !== undefined
+    }),
+  )
+
+  return (
+    <div data-component="user-message">
+      <Show when={attachments().length > 0}>
+        <div data-slot="user-message-attachments">
+          <For each={attachments()}>
+            {(file) => (
+              <div data-slot="user-message-attachment" data-type={file.mime.startsWith("image/") ? "image" : "file"}>
+                <Show
+                  when={file.mime.startsWith("image/") && file.url}
+                  fallback={
+                    <div data-slot="user-message-attachment-icon">
+                      <Icon name="folder" />
+                    </div>
+                  }
+                >
+                  <img data-slot="user-message-attachment-image" src={file.url} alt={file.filename ?? "attachment"} />
+                </Show>
+              </div>
+            )}
+          </For>
+        </div>
+      </Show>
+      <Show when={text()}>
+        <div data-slot="user-message-text">
+          <HighlightedText text={text()} references={inlineFiles()} />
+        </div>
+      </Show>
+    </div>
+  )
+}
+
+function HighlightedText(props: { text: string; references: FilePart[] }) {
+  const segments = createMemo(() => {
+    const text = props.text
+    const refs = [...props.references].sort((a, b) => (a.source?.text?.start ?? 0) - (b.source?.text?.start ?? 0))
+
+    const result: { text: string; highlight?: boolean }[] = []
+    let lastIndex = 0
+
+    for (const ref of refs) {
+      const start = ref.source?.text?.start
+      const end = ref.source?.text?.end
+
+      if (start === undefined || end === undefined || start < lastIndex) continue
+
+      if (start > lastIndex) {
+        result.push({ text: text.slice(lastIndex, start) })
+      }
+
+      result.push({ text: text.slice(start, end), highlight: true })
+      lastIndex = end
+    }
+
+    if (lastIndex < text.length) {
+      result.push({ text: text.slice(lastIndex) })
+    }
+
+    return result
+  })
+
+  return (
+    <For each={segments()}>
+      {(segment) => <span classList={{ "text-text-strong font-medium": segment.highlight }}>{segment.text}</span>}
+    </For>
   )
-  return <div data-component="user-message">{text()}</div>
 }
 
 export function Part(props: MessagePartProps) {
@@ -303,9 +384,8 @@ ToolRegistry.register({
       <BasicTool
         icon="task"
         trigger={{
-          title: `${props.input.subagent_type || props.tool} Agent`,
+          title: `${props.input.subagent_type || props.tool} Agent: ${props.input.description || ""}`,
           titleClass: "capitalize",
-          subtitle: props.input.description,
         }}
       >
         <Show when={false && props.output}>

+ 7 - 3
packages/ui/src/components/session-turn.tsx

@@ -62,11 +62,15 @@ export function SessionTurn(
 
   function handleScroll() {
     if (!scrollRef) return
-    // prevents scroll loops
-    if (working() && scrollRef.scrollTop < 100) return
-    setState("scrollY", scrollRef.scrollTop)
     if (state.autoScrolling) return
     const { scrollTop, scrollHeight, clientHeight } = scrollRef
+    // prevents scroll loops - only update scrollY if we have meaningful scroll room
+    // the gap clamp shrinks by 0.48px per pixel scrolled, hitting min at ~71px scroll
+    // we need at least that much scroll headroom beyond the current scroll position
+    const scrollRoom = scrollHeight - clientHeight
+    if (scrollRoom > 100) {
+      setState("scrollY", scrollTop)
+    }
     const atBottom = scrollHeight - scrollTop - clientHeight < 50
     if (!atBottom && working()) {
       setState("userScrolled", true)