Просмотр исходного кода

fix(app): prompt input improvements

Adam 1 месяц назад
Родитель
Сommit
12ffb270fb
1 измененных файлов с 440 добавлено и 404 удалено
  1. 440 404
      packages/app/src/components/prompt-input.tsx

+ 440 - 404
packages/app/src/components/prompt-input.tsx

@@ -448,32 +448,59 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     onSelect: handleSlashSelect,
   })
 
+  const createPill = (part: FileAttachmentPart | AgentPart) => {
+    const pill = document.createElement("span")
+    pill.textContent = part.content
+    pill.setAttribute("data-type", part.type)
+    if (part.type === "file") pill.setAttribute("data-path", part.path)
+    if (part.type === "agent") pill.setAttribute("data-name", part.name)
+    pill.setAttribute("contenteditable", "false")
+    pill.style.userSelect = "text"
+    pill.style.cursor = "default"
+    return pill
+  }
+
+  const isNormalizedEditor = () =>
+    Array.from(editorRef.childNodes).every((node) => {
+      if (node.nodeType === Node.TEXT_NODE) {
+        const text = node.textContent ?? ""
+        if (!text.includes("\u200B")) return true
+        if (text !== "\u200B") return false
+
+        const prev = node.previousSibling
+        const next = node.nextSibling
+        const prevIsBr = prev?.nodeType === Node.ELEMENT_NODE && (prev as HTMLElement).tagName === "BR"
+        const nextIsBr = next?.nodeType === Node.ELEMENT_NODE && (next as HTMLElement).tagName === "BR"
+        if (!prevIsBr && !nextIsBr) return false
+        if (nextIsBr && !prevIsBr && prev) return false
+        return true
+      }
+      if (node.nodeType !== Node.ELEMENT_NODE) return false
+      const el = node as HTMLElement
+      if (el.dataset.type === "file") return true
+      if (el.dataset.type === "agent") return true
+      return el.tagName === "BR"
+    })
+
+  const renderEditor = (parts: Prompt) => {
+    editorRef.innerHTML = ""
+    for (const part of parts) {
+      if (part.type === "text") {
+        editorRef.appendChild(createTextFragment(part.content))
+        continue
+      }
+      if (part.type === "file" || part.type === "agent") {
+        editorRef.appendChild(createPill(part))
+      }
+    }
+  }
+
   createEffect(
     on(
       () => prompt.current(),
       (currentParts) => {
         const domParts = parseFromDOM()
-        const normalized = Array.from(editorRef.childNodes).every((node) => {
-          if (node.nodeType === Node.TEXT_NODE) {
-            const text = node.textContent ?? ""
-            if (!text.includes("\u200B")) return true
-            if (text !== "\u200B") return false
-
-            const prev = node.previousSibling
-            const next = node.nextSibling
-            const prevIsBr = prev?.nodeType === Node.ELEMENT_NODE && (prev as HTMLElement).tagName === "BR"
-            const nextIsBr = next?.nodeType === Node.ELEMENT_NODE && (next as HTMLElement).tagName === "BR"
-            if (!prevIsBr && !nextIsBr) return false
-            if (nextIsBr && !prevIsBr && prev) return false
-            return true
-          }
-          if (node.nodeType !== Node.ELEMENT_NODE) return false
-          const el = node as HTMLElement
-          if (el.dataset.type === "file") return true
-          if (el.dataset.type === "agent") return true
-          return el.tagName === "BR"
-        })
-        if (normalized && isPromptEqual(currentParts, domParts)) return
+        if (isNormalizedEditor() && isPromptEqual(currentParts, domParts)) return
 
         const selection = window.getSelection()
         let cursorPosition: number | null = null
@@ -481,30 +508,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
           cursorPosition = getCursorPosition(editorRef)
         }
 
-        editorRef.innerHTML = ""
-        currentParts.forEach((part) => {
-          if (part.type === "text") {
-            editorRef.appendChild(createTextFragment(part.content))
-          } else if (part.type === "file") {
-            const pill = document.createElement("span")
-            pill.textContent = part.content
-            pill.setAttribute("data-type", "file")
-            pill.setAttribute("data-path", part.path)
-            pill.setAttribute("contenteditable", "false")
-            pill.style.userSelect = "text"
-            pill.style.cursor = "default"
-            editorRef.appendChild(pill)
-          } else if (part.type === "agent") {
-            const pill = document.createElement("span")
-            pill.textContent = part.content
-            pill.setAttribute("data-type", "agent")
-            pill.setAttribute("data-name", part.name)
-            pill.setAttribute("contenteditable", "false")
-            pill.style.userSelect = "text"
-            pill.style.cursor = "default"
-            editorRef.appendChild(pill)
-          }
-        })
+        renderEditor(currentParts)
 
         if (cursorPosition !== null) {
           setCursorPosition(editorRef, cursorPosition)
@@ -682,40 +686,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     const textBeforeCursor = rawText.substring(0, cursorPosition)
     const atMatch = textBeforeCursor.match(/@(\S*)$/)
 
-    if (part.type === "file") {
-      const pill = document.createElement("span")
-      pill.textContent = part.content
-      pill.setAttribute("data-type", "file")
-      pill.setAttribute("data-path", part.path)
-      pill.setAttribute("contenteditable", "false")
-      pill.style.userSelect = "text"
-      pill.style.cursor = "default"
-
-      const gap = document.createTextNode(" ")
-      const range = selection.getRangeAt(0)
-
-      if (atMatch) {
-        const start = atMatch.index ?? cursorPosition - atMatch[0].length
-        setRangeEdge(range, "start", start)
-        setRangeEdge(range, "end", cursorPosition)
-      }
-
-      range.deleteContents()
-      range.insertNode(gap)
-      range.insertNode(pill)
-      range.setStartAfter(gap)
-      range.collapse(true)
-      selection.removeAllRanges()
-      selection.addRange(range)
-    } else if (part.type === "agent") {
-      const pill = document.createElement("span")
-      pill.textContent = part.content
-      pill.setAttribute("data-type", "agent")
-      pill.setAttribute("data-name", part.name)
-      pill.setAttribute("contenteditable", "false")
-      pill.style.userSelect = "text"
-      pill.style.cursor = "default"
-
+    if (part.type === "file" || part.type === "agent") {
+      const pill = createPill(part)
       const gap = document.createTextNode(" ")
       const range = selection.getRangeAt(0)
 
@@ -761,77 +733,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     setStore("popover", null)
   }
 
-  const setSelectionOffsets = (start: number, end: number) => {
-    const selection = window.getSelection()
-    if (!selection) return false
-
-    const length = promptLength(prompt.current())
-    const a = Math.max(0, Math.min(start, length))
-    const b = Math.max(0, Math.min(end, length))
-    const rangeStart = Math.min(a, b)
-    const rangeEnd = Math.max(a, b)
-
-    const range = document.createRange()
-    range.selectNodeContents(editorRef)
-
-    const setEdge = (edge: "start" | "end", offset: number) => {
-      let remaining = offset
-      const nodes = Array.from(editorRef.childNodes)
-
-      for (const node of nodes) {
-        const length = getNodeLength(node)
-        const isText = node.nodeType === Node.TEXT_NODE
-        const isFile = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type === "file"
-        const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
-
-        if (isText && remaining <= length) {
-          if (edge === "start") range.setStart(node, remaining)
-          if (edge === "end") range.setEnd(node, remaining)
-          return
-        }
-
-        if ((isFile || isBreak) && remaining <= length) {
-          if (edge === "start" && remaining === 0) range.setStartBefore(node)
-          if (edge === "start" && remaining > 0) range.setStartAfter(node)
-          if (edge === "end" && remaining === 0) range.setEndBefore(node)
-          if (edge === "end" && remaining > 0) range.setEndAfter(node)
-          return
-        }
-
-        remaining -= length
-      }
-
-      const last = editorRef.lastChild
-      if (!last) {
-        if (edge === "start") range.setStart(editorRef, 0)
-        if (edge === "end") range.setEnd(editorRef, 0)
-        return
-      }
-      if (edge === "start") range.setStartAfter(last)
-      if (edge === "end") range.setEndAfter(last)
-    }
-
-    setEdge("start", rangeStart)
-    setEdge("end", rangeEnd)
-    selection.removeAllRanges()
-    selection.addRange(range)
-    return true
-  }
-
-  const replaceOffsets = (start: number, end: number, content: string) => {
-    if (!setSelectionOffsets(start, end)) return false
-    addPart({ type: "text", content, start: 0, end: 0 })
-    return true
-  }
-
-  const killText = (start: number, end: number) => {
-    if (start === end) return
-    const current = prompt.current()
-    if (!current.every((part) => part.type === "text")) return
-    const text = current.map((part) => part.content).join("")
-    setStore("killBuffer", text.slice(start, end))
-  }
-
   const abort = () =>
     sdk.client.session
       .abort({
@@ -900,6 +801,242 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     return false
   }
 
+  const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
+  const IS_WIN = typeof navigator === "object" && /Win/.test(navigator.platform)
+
+  const textOnly = () => {
+    const parts = prompt.current()
+    if (!parts.every((part) => part.type === "text")) return
+    return parts.map((part) => part.content).join("")
+  }
+
+  const exitHistoryMode = () => {
+    if (store.historyIndex < 0) return
+    if (store.applyingHistory) return
+    setStore("historyIndex", -1)
+    setStore("savedPrompt", null)
+  }
+
+  const applyText = (content: string, cursorPosition: number) => {
+    exitHistoryMode()
+    setStore("popover", null)
+
+    const part = {
+      type: "text" as const,
+      content,
+      start: 0,
+      end: content.length,
+    }
+
+    prompt.set([part], cursorPosition)
+    requestAnimationFrame(() => {
+      editorRef.focus()
+      setCursorPosition(editorRef, cursorPosition)
+      queueScroll()
+    })
+  }
+
+  const handleReadlineKeyDown = (event: KeyboardEvent) => {
+    if (event.metaKey) return false
+
+    const ctrl = event.ctrlKey && !event.altKey && !event.shiftKey
+    const alt = event.altKey && !event.ctrlKey && !event.shiftKey
+
+    if (!ctrl && !alt) return false
+
+    if (alt && IS_WIN) return false
+
+    if (ctrl && IS_WIN) {
+      const blocked = new Set(["KeyA", "KeyC", "KeyV", "KeyX", "KeyZ", "KeyY", "KeyF", "KeyT"])
+      if (blocked.has(event.code)) return false
+    }
+
+    const { collapsed, cursorPosition, textLength } = getCaretState()
+    if (!collapsed) return false
+
+    const text = textOnly()
+    if (text === undefined) return false
+
+    const moveCursor = (pos: number) => {
+      setCursorPosition(editorRef, pos)
+      queueScroll()
+    }
+
+    const saveKillBuffer = (start: number, end: number) => {
+      if (start === end) return false
+      setStore("killBuffer", text.slice(start, end))
+      return true
+    }
+
+    const killRange = (start: number, end: number) => {
+      if (!saveKillBuffer(start, end)) return
+      applyText(text.slice(0, start) + text.slice(end), start)
+    }
+
+    if (ctrl) {
+      if (event.code === "KeyA" && IS_MAC) {
+        const pos = text.lastIndexOf("\n", cursorPosition - 1) + 1
+        moveCursor(pos)
+        event.preventDefault()
+        event.stopPropagation()
+        return true
+      }
+
+      if (event.code === "KeyE") {
+        const next = text.indexOf("\n", cursorPosition)
+        const pos = next === -1 ? textLength : next
+        moveCursor(pos)
+        event.preventDefault()
+        event.stopPropagation()
+        return true
+      }
+
+      if (event.code === "KeyB") {
+        const pos = Math.max(0, cursorPosition - 1)
+        moveCursor(pos)
+        event.preventDefault()
+        event.stopPropagation()
+        return true
+      }
+
+      if (event.code === "KeyF" && IS_MAC) {
+        const pos = Math.min(textLength, cursorPosition + 1)
+        moveCursor(pos)
+        event.preventDefault()
+        event.stopPropagation()
+        return true
+      }
+
+      if (event.code === "KeyD") {
+        if (cursorPosition >= textLength) {
+          event.preventDefault()
+          event.stopPropagation()
+          return true
+        }
+
+        applyText(text.slice(0, cursorPosition) + text.slice(cursorPosition + 1), cursorPosition)
+        event.preventDefault()
+        event.stopPropagation()
+        return true
+      }
+
+      if (event.code === "KeyK") {
+        const next = text.indexOf("\n", cursorPosition)
+        const lineEnd = next === -1 ? textLength : next
+        const end = lineEnd === cursorPosition && lineEnd < textLength ? lineEnd + 1 : lineEnd
+        if (end === cursorPosition) {
+          event.preventDefault()
+          event.stopPropagation()
+          return true
+        }
+
+        killRange(cursorPosition, end)
+        event.preventDefault()
+        event.stopPropagation()
+        return true
+      }
+
+      if (event.code === "KeyU" && IS_MAC) {
+        const start = text.lastIndexOf("\n", cursorPosition - 1) + 1
+        if (start === cursorPosition) {
+          event.preventDefault()
+          event.stopPropagation()
+          return true
+        }
+
+        killRange(start, cursorPosition)
+        event.preventDefault()
+        event.stopPropagation()
+        return true
+      }
+
+      if (event.code === "KeyY" && IS_MAC) {
+        if (!store.killBuffer) {
+          event.preventDefault()
+          event.stopPropagation()
+          return true
+        }
+
+        applyText(
+          text.slice(0, cursorPosition) + store.killBuffer + text.slice(cursorPosition),
+          cursorPosition + store.killBuffer.length,
+        )
+        event.preventDefault()
+        event.stopPropagation()
+        return true
+      }
+
+      if (event.code === "KeyT" && IS_MAC) {
+        if (textLength < 2 || cursorPosition === 0) {
+          event.preventDefault()
+          event.stopPropagation()
+          return true
+        }
+
+        const atEnd = cursorPosition === textLength
+        const first = atEnd ? cursorPosition - 2 : cursorPosition - 1
+        const second = atEnd ? cursorPosition - 1 : cursorPosition
+
+        if (text[first] === "\n" || text[second] === "\n") {
+          event.preventDefault()
+          event.stopPropagation()
+          return true
+        }
+
+        const nextText = text.slice(0, first) + text[second] + text[first] + text.slice(second + 1)
+        const nextCursor = atEnd ? cursorPosition : cursorPosition + 1
+        applyText(nextText, nextCursor)
+        event.preventDefault()
+        event.stopPropagation()
+        return true
+      }
+
+      return false
+    }
+
+    if (alt) {
+      if (event.code === "KeyB") {
+        let pos = cursorPosition
+        while (pos > 0 && /\s/.test(text[pos - 1])) pos -= 1
+        while (pos > 0 && !/\s/.test(text[pos - 1])) pos -= 1
+        moveCursor(pos)
+        event.preventDefault()
+        event.stopPropagation()
+        return true
+      }
+
+      if (event.code === "KeyF") {
+        let pos = cursorPosition
+        while (pos < textLength && /\s/.test(text[pos])) pos += 1
+        while (pos < textLength && !/\s/.test(text[pos])) pos += 1
+        moveCursor(pos)
+        event.preventDefault()
+        event.stopPropagation()
+        return true
+      }
+
+      if (event.code === "KeyD") {
+        let end = cursorPosition
+        while (end < textLength && /\s/.test(text[end])) end += 1
+        while (end < textLength && !/\s/.test(text[end])) end += 1
+        if (end === cursorPosition) {
+          event.preventDefault()
+          event.stopPropagation()
+          return true
+        }
+
+        killRange(cursorPosition, end)
+        event.preventDefault()
+        event.stopPropagation()
+        return true
+      }
+
+      return false
+    }
+
+    return false
+  }
+
   const handleKeyDown = (event: KeyboardEvent) => {
     if (event.key === "Backspace") {
       const selection = window.getSelection()
@@ -953,7 +1090,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     }
 
     const ctrl = event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey
-    const alt = event.altKey && !event.metaKey && !event.ctrlKey && !event.shiftKey
 
     if (ctrl && event.code === "KeyG") {
       if (store.popover) {
@@ -968,148 +1104,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       return
     }
 
-    if (ctrl || alt) {
-      const { collapsed, cursorPosition, textLength } = getCaretState()
-      if (collapsed) {
-        const current = prompt.current()
-        const text = current.map((part) => ("content" in part ? part.content : "")).join("")
-
-        if (ctrl) {
-          if (event.code === "KeyA") {
-            if (navigator.platform.includes("Win")) return
-            const pos = text.lastIndexOf("\n", cursorPosition - 1) + 1
-            setCursorPosition(editorRef, pos)
-            event.preventDefault()
-            queueScroll()
-            return
-          }
-
-          if (event.code === "KeyE") {
-            const next = text.indexOf("\n", cursorPosition)
-            const pos = next === -1 ? textLength : next
-            setCursorPosition(editorRef, pos)
-            event.preventDefault()
-            queueScroll()
-            return
-          }
-
-          if (event.code === "KeyB") {
-            const pos = Math.max(0, cursorPosition - 1)
-            setCursorPosition(editorRef, pos)
-            event.preventDefault()
-            queueScroll()
-            return
-          }
-
-          if (event.code === "KeyF") {
-            const pos = Math.min(textLength, cursorPosition + 1)
-            setCursorPosition(editorRef, pos)
-            event.preventDefault()
-            queueScroll()
-            return
-          }
-
-          if (event.code === "KeyD") {
-            if (store.mode === "shell" && cursorPosition === 0 && textLength === 0) {
-              setStore("mode", "normal")
-              event.preventDefault()
-              return
-            }
-            if (cursorPosition >= textLength) return
-            replaceOffsets(cursorPosition, cursorPosition + 1, "")
-            event.preventDefault()
-            return
-          }
-
-          if (event.code === "KeyK") {
-            const next = text.indexOf("\n", cursorPosition)
-            const lineEnd = next === -1 ? textLength : next
-            const end = lineEnd === cursorPosition && lineEnd < textLength ? lineEnd + 1 : lineEnd
-            if (end === cursorPosition) return
-            killText(cursorPosition, end)
-            replaceOffsets(cursorPosition, end, "")
-            event.preventDefault()
-            return
-          }
-
-          if (event.code === "KeyU") {
-            const start = text.lastIndexOf("\n", cursorPosition - 1) + 1
-            if (start === cursorPosition) return
-            killText(start, cursorPosition)
-            replaceOffsets(start, cursorPosition, "")
-            event.preventDefault()
-            return
-          }
-
-          if (event.code === "KeyW") {
-            let start = cursorPosition
-            while (start > 0 && /\s/.test(text[start - 1])) start -= 1
-            while (start > 0 && !/\s/.test(text[start - 1])) start -= 1
-            if (start === cursorPosition) return
-            killText(start, cursorPosition)
-            replaceOffsets(start, cursorPosition, "")
-            event.preventDefault()
-            return
-          }
-
-          if (event.code === "KeyY") {
-            if (!store.killBuffer) return
-            addPart({ type: "text", content: store.killBuffer, start: 0, end: 0 })
-            event.preventDefault()
-            return
-          }
-
-          if (event.code === "KeyT") {
-            if (!current.every((part) => part.type === "text")) return
-            if (textLength < 2) return
-            if (cursorPosition === 0) return
-
-            const atEnd = cursorPosition === textLength
-            const first = atEnd ? cursorPosition - 2 : cursorPosition - 1
-            const second = atEnd ? cursorPosition - 1 : cursorPosition
-
-            if (text[first] === "\n" || text[second] === "\n") return
-
-            replaceOffsets(first, second + 1, `${text[second]}${text[first]}`)
-            event.preventDefault()
-            return
-          }
-        }
-
-        if (alt) {
-          if (event.code === "KeyB") {
-            let pos = cursorPosition
-            while (pos > 0 && /\s/.test(text[pos - 1])) pos -= 1
-            while (pos > 0 && !/\s/.test(text[pos - 1])) pos -= 1
-            setCursorPosition(editorRef, pos)
-            event.preventDefault()
-            queueScroll()
-            return
-          }
-
-          if (event.code === "KeyF") {
-            let pos = cursorPosition
-            while (pos < textLength && /\s/.test(text[pos])) pos += 1
-            while (pos < textLength && !/\s/.test(text[pos])) pos += 1
-            setCursorPosition(editorRef, pos)
-            event.preventDefault()
-            queueScroll()
-            return
-          }
-
-          if (event.code === "KeyD") {
-            let end = cursorPosition
-            while (end < textLength && /\s/.test(text[end])) end += 1
-            while (end < textLength && !/\s/.test(text[end])) end += 1
-            if (end === cursorPosition) return
-            killText(cursorPosition, end)
-            replaceOffsets(cursorPosition, end, "")
-            event.preventDefault()
-            return
-          }
-        }
-      }
-    }
+    if (handleReadlineKeyDown(event)) return
 
     if (event.key === "ArrowUp" || event.key === "ArrowDown") {
       if (event.altKey || event.ctrlKey || event.metaKey) return
@@ -1164,15 +1159,37 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
 
   const handleSubmit = async (event: Event) => {
     event.preventDefault()
+
     const currentPrompt = prompt.current()
     const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("")
-    const hasImageAttachments = store.imageAttachments.length > 0
-    if (text.trim().length === 0 && !hasImageAttachments) {
+    const images = store.imageAttachments.slice()
+    const mode = store.mode
+
+    if (text.trim().length === 0 && images.length === 0) {
       if (working()) abort()
       return
     }
 
-    addToHistory(currentPrompt, store.mode)
+    const currentModel = local.model.current()
+    const currentAgent = local.agent.current()
+    if (!currentModel || !currentAgent) {
+      showToast({
+        title: "Select an agent and model",
+        description: "Choose an agent and model before sending a prompt.",
+      })
+      return
+    }
+
+    const errorMessage = (err: unknown) => {
+      if (err && typeof err === "object" && "data" in err) {
+        const data = (err as { data?: { message?: string } }).data
+        if (data?.message) return data.message
+      }
+      if (err instanceof Error) return err.message
+      return "Request failed"
+    }
+
+    addToHistory(currentPrompt, mode)
     setStore("historyIndex", -1)
     setStore("savedPrompt", null)
 
@@ -1191,7 +1208,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
           .catch((err) => {
             showToast({
               title: "Failed to create worktree",
-              description: err?.data?.message ?? (err instanceof Error ? err.message : "Request failed"),
+              description: errorMessage(err),
             })
             return undefined
           })
@@ -1204,7 +1221,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
           return
         }
         sessionDirectory = createdWorktree.directory
-      } else if (worktreeSelection !== "main") {
+      }
+
+      if (worktreeSelection !== "main" && worktreeSelection !== "create") {
         sessionDirectory = worktreeSelection
       }
 
@@ -1215,26 +1234,93 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
           directory: sessionDirectory,
           throwOnError: true,
         })
-      }
-    }
-
-    if (isNewSession) {
-      if (sessionDirectory !== projectDirectory) {
         globalSync.child(sessionDirectory)
       }
+
       props.onNewSessionWorktreeReset?.()
     }
 
-    let existing = info()
-    if (!existing && isNewSession) {
-      const created = await client.session.create()
-      existing = created.data ?? undefined
-      if (existing) navigate(`/${base64Encode(sessionDirectory)}/session/${existing.id}`)
+    let session = info()
+    if (!session && isNewSession) {
+      session = await client.session.create().then((x) => x.data ?? undefined)
+      if (session) navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
+    }
+    if (!session) return
+
+    const model = {
+      modelID: currentModel.id,
+      providerID: currentModel.provider.id,
+    }
+    const agent = currentAgent.name
+    const variant = local.model.variant.current()
+
+    const clearInput = () => {
+      prompt.reset()
+      setStore("imageAttachments", [])
+      setStore("mode", "normal")
+      setStore("popover", null)
+    }
+
+    const restoreInput = () => {
+      prompt.set(currentPrompt, promptLength(currentPrompt))
+      setStore("imageAttachments", images)
+      setStore("mode", mode)
+      setStore("popover", null)
+      requestAnimationFrame(() => {
+        editorRef.focus()
+        setCursorPosition(editorRef, promptLength(currentPrompt))
+        queueScroll()
+      })
+    }
+
+    if (mode === "shell") {
+      clearInput()
+      client.session
+        .shell({
+          sessionID: session.id,
+          agent,
+          model,
+          command: text,
+        })
+        .catch((err) => {
+          showToast({
+            title: "Failed to send shell command",
+            description: errorMessage(err),
+          })
+          restoreInput()
+        })
+      return
+    }
+
+    if (text.startsWith("/")) {
+      const [cmdName, ...args] = text.split(" ")
+      const commandName = cmdName.slice(1)
+      const customCommand = sync.data.command.find((c) => c.name === commandName)
+      if (customCommand) {
+        clearInput()
+        client.session
+          .command({
+            sessionID: session.id,
+            command: commandName,
+            arguments: args.join(" "),
+            agent,
+            model: `${model.providerID}/${model.modelID}`,
+            variant,
+          })
+          .catch((err) => {
+            showToast({
+              title: "Failed to send command",
+              description: errorMessage(err),
+            })
+            restoreInput()
+          })
+        return
+      }
     }
-    if (!existing) return
 
     const toAbsolutePath = (path: string) =>
       path.startsWith("/") ? path : (sessionDirectory + "/" + path).replace("//", "/")
+
     const fileAttachments = currentPrompt.filter((part) => part.type === "file") as FileAttachmentPart[]
     const agentAttachments = currentPrompt.filter((part) => part.type === "agent") as AgentPart[]
 
@@ -1307,7 +1393,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       addContextFile(item.path, item.selection)
     }
 
-    const imageAttachmentParts = store.imageAttachments.map((attachment) => ({
+    const imageAttachmentParts = images.map((attachment) => ({
       id: Identifier.ascending("part"),
       type: "file" as const,
       mime: attachment.mime,
@@ -1315,60 +1401,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       filename: attachment.filename,
     }))
 
-    const isShellMode = store.mode === "shell"
-    editorRef.innerHTML = ""
-    prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
-    setStore("imageAttachments", [])
-    setStore("mode", "normal")
-
-    const currentModel = local.model.current()
-    const currentAgent = local.agent.current()
-    if (!currentModel || !currentAgent) {
-      console.warn("No agent or model available for prompt submission")
-      return
-    }
-    const model = {
-      modelID: currentModel.id,
-      providerID: currentModel.provider.id,
-    }
-    const agent = currentAgent.name
-    const variant = local.model.variant.current()
-
-    if (isShellMode) {
-      client.session
-        .shell({
-          sessionID: existing.id,
-          agent,
-          model,
-          command: text,
-        })
-        .catch((e) => {
-          console.error("Failed to send shell command", e)
-        })
-      return
-    }
-
-    if (text.startsWith("/")) {
-      const [cmdName, ...args] = text.split(" ")
-      const commandName = cmdName.slice(1)
-      const customCommand = sync.data.command.find((c) => c.name === commandName)
-      if (customCommand) {
-        client.session
-          .command({
-            sessionID: existing.id,
-            command: commandName,
-            arguments: args.join(" "),
-            agent,
-            model: `${model.providerID}/${model.modelID}`,
-            variant,
-          })
-          .catch((e) => {
-            console.error("Failed to send command", e)
-          })
-        return
-      }
-    }
-
     const messageID = Identifier.ascending("message")
     const textPart = {
       id: Identifier.ascending("part"),
@@ -1382,44 +1414,35 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       ...agentAttachmentParts,
       ...imageAttachmentParts,
     ]
+
     const optimisticParts = requestParts.map((part) => ({
       ...part,
-      sessionID: existing.id,
+      sessionID: session.id,
       messageID,
-    }))
+    })) as unknown as Part[]
 
-    const addOptimisticMessage = (input: {
-      sessionID: string
-      messageID: string
-      parts: Part[]
-      agent: string
-      model: { providerID: string; modelID: string }
-    }) => {
-      if (sessionDirectory === projectDirectory) {
-        sync.session.addOptimisticMessage(input)
-        return
-      }
+    const optimisticMessage: Message = {
+      id: messageID,
+      sessionID: session.id,
+      role: "user",
+      time: { created: Date.now() },
+      agent,
+      model,
+    }
 
-      const [, setStore] = globalSync.child(sessionDirectory)
-      const message: Message = {
-        id: input.messageID,
-        sessionID: input.sessionID,
-        role: "user",
-        time: { created: Date.now() },
-        agent: input.agent,
-        model: input.model,
-      }
+    const setSyncStore = sessionDirectory === projectDirectory ? sync.set : globalSync.child(sessionDirectory)[1]
 
-      setStore(
+    const addOptimisticMessage = () => {
+      setSyncStore(
         produce((draft) => {
-          const messages = draft.message[input.sessionID]
+          const messages = draft.message[session.id]
           if (!messages) {
-            draft.message[input.sessionID] = [message]
+            draft.message[session.id] = [optimisticMessage]
           } else {
-            const result = Binary.search(messages, input.messageID, (m) => m.id)
-            messages.splice(result.index, 0, message)
+            const result = Binary.search(messages, messageID, (m) => m.id)
+            messages.splice(result.index, 0, optimisticMessage)
           }
-          draft.part[input.messageID] = input.parts
+          draft.part[messageID] = optimisticParts
             .filter((p) => !!p?.id)
             .slice()
             .sort((a, b) => a.id.localeCompare(b.id))
@@ -1427,25 +1450,38 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       )
     }
 
-    addOptimisticMessage({
-      sessionID: existing.id,
-      messageID,
-      parts: optimisticParts as unknown as Part[],
-      agent,
-      model,
-    })
+    const removeOptimisticMessage = () => {
+      setSyncStore(
+        produce((draft) => {
+          const messages = draft.message[session.id]
+          if (messages) {
+            const result = Binary.search(messages, messageID, (m) => m.id)
+            if (result.found) messages.splice(result.index, 1)
+          }
+          delete draft.part[messageID]
+        }),
+      )
+    }
+
+    clearInput()
+    addOptimisticMessage()
 
     client.session
       .prompt({
-        sessionID: existing.id,
+        sessionID: session.id,
         agent,
         model,
         messageID,
         parts: requestParts,
         variant,
       })
-      .catch((e) => {
-        console.error("Failed to send prompt", e)
+      .catch((err) => {
+        showToast({
+          title: "Failed to send prompt",
+          description: errorMessage(err),
+        })
+        removeOptimisticMessage()
+        restoreInput()
       })
   }
 
@@ -1764,7 +1800,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
             <input
               ref={fileInputRef}
               type="file"
-              accept={ACCEPTED_IMAGE_TYPES.join(",")}
+              accept={ACCEPTED_FILE_TYPES.join(",")}
               class="hidden"
               onChange={(e) => {
                 const file = e.currentTarget.files?.[0]
@@ -1775,7 +1811,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
             <div class="flex items-center gap-2">
               <SessionContextUsage />
               <Show when={store.mode === "normal"}>
-                <Tooltip placement="top" value="Attach image">
+                <Tooltip placement="top" value="Attach file">
                   <Button type="button" variant="ghost" class="size-6" onClick={() => fileInputRef.click()}>
                     <Icon name="photo" class="size-4.5" />
                   </Button>