Browse Source

Copy to clipboard.
Improved Session errors report.

paviko 2 months ago
parent
commit
5834533752

+ 13 - 11
packages/opencode/webgui/src/components/CompactHeader/ActionButtons.tsx

@@ -91,17 +91,7 @@ export function ActionButtons({
         }
       />
 
-      {/* New session button */}
-      <button
-        onClick={onNewSession}
-        disabled={isCreatingSession}
-        className="w-5 h-5 flex items-center justify-center text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
-        title="New Session (Cmd/Ctrl+N)"
-      >
-        <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
-          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
-        </svg>
-      </button>
+      <div aria-hidden="true" className="mx-1 h-4 w-px bg-gray-200 dark:bg-gray-700" />
 
       {/* Share/Unshare session button */}
       <button
@@ -130,6 +120,18 @@ export function ActionButtons({
           </svg>
         )}
       </button>
+
+      {/* New session button */}
+      <button
+        onClick={onNewSession}
+        disabled={isCreatingSession}
+        className="w-5 h-5 flex items-center justify-center text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
+        title="New Session (Cmd/Ctrl+N)"
+      >
+        <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
+        </svg>
+      </button>
     </div>
   )
 }

+ 119 - 42
packages/opencode/webgui/src/components/MessageList/ActionButtons.tsx

@@ -1,3 +1,4 @@
+import { useEffect, useRef, useState } from "react"
 import { IconButton } from "../common"
 import { MessageStats } from "./MessageStats"
 
@@ -18,53 +19,129 @@ interface ActionButtonsProps {
   tokens?: TokenData
   cost?: number
   isUser?: boolean
+  copyText?: string
 }
 
-export function ActionButtons({ onFork, onRevert, revertBusy, tokens, cost, isUser }: ActionButtonsProps) {
+export function ActionButtons({ onFork, onRevert, revertBusy, tokens, cost, isUser, copyText }: ActionButtonsProps) {
+  const [copied, setCopied] = useState(false)
+  const [isVisible, setIsVisible] = useState(false)
+  const timeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
+
   const hasTokens =
-    tokens && (tokens.input > 0 || tokens.output > 0 || tokens.reasoning > 0 || tokens.cache.read > 0 || tokens.cache.write > 0)
+    tokens &&
+    (tokens.input > 0 || tokens.output > 0 || tokens.reasoning > 0 || tokens.cache.read > 0 || tokens.cache.write > 0)
+
+  const canCopy = typeof copyText === "string" && copyText.length > 0
+
+  useEffect(() => {
+    const anyOther = canCopy || !!(isUser && onFork) || !!(isUser && onRevert)
+    const delay = anyOther ? 500 : 3000
+
+    const timer = setTimeout(() => {
+      setIsVisible(true)
+    }, delay)
+
+    return () => {
+      clearTimeout(timer)
+      if (timeoutRef.current) {
+        clearTimeout(timeoutRef.current)
+      }
+    }
+  }, [canCopy, isUser, onFork, onRevert])
+
+  const handleCopy = () => {
+    if (!canCopy) return
+
+    const promise = navigator.clipboard?.writeText(copyText)
+    if (!promise) return
+
+    void promise
+      .then(() => {
+        setCopied(true)
+        if (timeoutRef.current) {
+          clearTimeout(timeoutRef.current)
+        }
+        timeoutRef.current = setTimeout(() => setCopied(false), 1500)
+      })
+      .catch((err) => {
+        console.error("Failed to copy message:", err)
+      })
+  }
+
+  if (!isVisible) return null
 
   return (
-    <div className="absolute left-1/2 -translate-x-1/2 top-0 flex gap-2 bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md p-px z-50">
-      {isUser && onFork && (
-        <IconButton
-          onClick={onFork}
-          size="md"
-          aria-label="Fork session at this message"
-          title="Fork session at this message"
-          icon={
-            <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
-              <path
-                strokeLinecap="round"
-                strokeLinejoin="round"
-                strokeWidth={2}
-                d="M7 4v4a4 4 0 004 4h2a4 4 0 014 4v4M7 4h4M7 4H3M17 20h4M17 20l-3-3"
-              />
-            </svg>
-          }
-        />
-      )}
-      {isUser && onRevert && (
-        <IconButton
-          onClick={onRevert}
-          size="md"
-          disabled={revertBusy}
-          aria-label="Undo from this message (revert)"
-          title="Undo from this message (revert)"
-          className="hover:text-red-600 dark:hover:text-red-400"
-          icon={
-            <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
-              <path
-                strokeLinecap="round"
-                strokeLinejoin="round"
-                strokeWidth={2}
-                d="M9 5H5v4m0-4l4 4m2-4h3a5 5 0 010 10H9"
-              />
-            </svg>
-          }
-        />
-      )}
-      {hasTokens && tokens && typeof cost === "number" && <MessageStats tokens={tokens} cost={cost} />}
+    <div className="absolute inset-x-0 top-0 bottom-0 pointer-events-none z-50">
+      <div className="sticky top-1 h-0 w-full overflow-visible">
+        <div className="absolute left-1/2 -translate-x-1/2 top-0 flex gap-0.5 bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md p-px pointer-events-auto">
+          {canCopy && (
+          <IconButton
+            onClick={handleCopy}
+            size="sm"
+            className="p-0.5"
+            aria-label={copied ? "Copied to clipboard" : "Copy to clipboard"}
+            title={copied ? "Copied!" : "Copy to clipboard"}
+            icon={
+              copied ? (
+                <svg className="w-full h-full" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                  <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
+                </svg>
+              ) : (
+                <svg className="w-full h-full" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                  <path
+                    strokeLinecap="round"
+                    strokeLinejoin="round"
+                    strokeWidth={2}
+                    d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
+                  />
+                </svg>
+              )
+            }
+          />
+        )}
+
+        {isUser && onFork && (
+          <IconButton
+            onClick={onFork}
+            size="sm"
+            className="p-0.5"
+            aria-label="Fork session at this message"
+            title="Fork session at this message"
+            icon={
+              <svg className="w-full h-full" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                <path
+                  strokeLinecap="round"
+                  strokeLinejoin="round"
+                  strokeWidth={2}
+                  d="M7 4v4a4 4 0 004 4h2a4 4 0 014 4v4M7 4h4M7 4H3M17 20h4M17 20l-3-3"
+                />
+              </svg>
+            }
+          />
+        )}
+        {isUser && onRevert && (
+          <IconButton
+            onClick={onRevert}
+            size="sm"
+            className="p-0.5 hover:text-red-600 dark:hover:text-red-400"
+            disabled={revertBusy}
+            aria-label="Undo from this message (revert)"
+            title="Undo from this message (revert)"
+            icon={
+              <svg className="w-full h-full" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                <path
+                  strokeLinecap="round"
+                  strokeLinejoin="round"
+                  strokeWidth={2}
+                  d="M9 5H5v4m0-4l4 4m2-4h3a5 5 0 010 10H9"
+                />
+              </svg>
+            }
+          />
+        )}
+        {hasTokens && tokens && typeof cost === "number" && <MessageStats tokens={tokens} cost={cost} />}
+      </div>
     </div>
+  </div>
   )
 }

+ 46 - 3
packages/opencode/webgui/src/components/MessageList/MessageRow.tsx

@@ -2,6 +2,7 @@ import { useState } from "react"
 import type { Message } from "../../state/MessagesContext"
 import { isAssistantMessage, type AssistantMessage } from "../../types/messages"
 import { MessagePart } from "./MessagePart"
+import { SessionErrorPart } from "./SessionErrorPart"
 import { ActionButtons } from "./ActionButtons"
 import { getPartStart, getPartEnd } from "./utils"
 import { cn } from "../../utils/classNames"
@@ -12,20 +13,48 @@ interface MessageRowProps {
   onRevert: (messageId: string) => void
   revertBusy: boolean
   sessionID?: string
+  isLast?: boolean
 }
 
-export function MessageRow({ message, onFork, onRevert, revertBusy, sessionID }: MessageRowProps) {
+export function MessageRow({ message, onFork, onRevert, revertBusy, sessionID, isLast }: MessageRowProps) {
   const [isHovered, setIsHovered] = useState(false)
   const isUser = message.info.role === "user"
   const isAssistant = isAssistantMessage(message.info)
   const skipPartIds = new Set<string>()
 
+  const copyText = message.parts
+    .flatMap((p) => {
+      if (p.type !== "text") return []
+      const synthetic = (p as { synthetic?: boolean }).synthetic
+      if (synthetic) return []
+      const text = p.text || ""
+      return text.length > 0 ? [text] : []
+    })
+    .join("")
+
+  const canCopy = copyText.length > 0
+
   const assistantInfo = isAssistant ? (message.info as AssistantMessage) : null
   const tokens = assistantInfo?.tokens
   const cost = assistantInfo?.cost
 
   const hasTokens =
-    tokens && (tokens.input > 0 || tokens.output > 0 || tokens.reasoning > 0 || tokens.cache.read > 0 || tokens.cache.write > 0)
+    tokens &&
+    (tokens.input > 0 || tokens.output > 0 || tokens.reasoning > 0 || tokens.cache.read > 0 || tokens.cache.write > 0)
+
+  const error = (message.info as any)?.error as
+    | { name?: string; data?: { message?: string }; message?: string }
+    | undefined
+  const errorMessage =
+    typeof error?.data?.message === "string"
+      ? error.data.message
+      : typeof error?.message === "string"
+        ? error.message
+        : undefined
+
+  const showMessageLevelError = Boolean(
+    isLast && !isUser && error?.name && errorMessage && !message.parts.some((p) => p.type === "session-error"),
+  )
 
   // Calculate durations for reasoning parts using timestamps when available
   const partsWithDurations = message.parts.map((part) => {
@@ -53,7 +82,7 @@ export function MessageRow({ message, onFork, onRevert, revertBusy, sessionID }:
       onMouseLeave={() => setIsHovered(false)}
     >
       {/* Action buttons (visible on hover) */}
-      {isHovered && (isUser || hasTokens) && (
+      {isHovered && (isUser || hasTokens || canCopy) && (
         <ActionButtons
           onFork={() => onFork(message.info.id)}
           onRevert={() => onRevert(message.info.id)}
@@ -61,6 +90,7 @@ export function MessageRow({ message, onFork, onRevert, revertBusy, sessionID }:
           tokens={tokens}
           cost={cost}
           isUser={isUser}
+          copyText={copyText}
         />
       )}
 
@@ -79,6 +109,19 @@ export function MessageRow({ message, onFork, onRevert, revertBusy, sessionID }:
           />
         ))}
 
+        {/* Message-level errors (e.g. MessageAbortedError) */}
+        {showMessageLevelError && (
+          <SessionErrorPart
+            part={{
+              id: `message-error-${message.info.id}`,
+              type: "session-error",
+              sessionID: message.info.sessionID,
+              messageID: message.info.id,
+              message: errorMessage!,
+            }}
+          />
+        )}
+
         {/* Show placeholder if no parts yet (streaming start) */}
         {message.parts.length === 0 && !isUser && (
           <div className="relative inline-flex items-center gap-1 pr-4 text-xs text-gray-500 dark:text-gray-400">

+ 12 - 10
packages/opencode/webgui/src/components/MessageList/MessageStats.tsx

@@ -62,18 +62,20 @@ export function MessageStats({ tokens, cost }: MessageStatsProps) {
       <button
         ref={buttonRef}
         onClick={() => setShowDetails((v) => !v)}
-        className="flex items-center justify-center w-7 h-7 rounded-md text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
+        className="modern-icon-button w-6 h-6 p-0.5 flex items-center justify-center"
         aria-label="Show token usage"
         title="Show token usage"
       >
-        <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
-          <path
-            strokeLinecap="round"
-            strokeLinejoin="round"
-            strokeWidth={2}
-            d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
-          />
-        </svg>
+        <div className="w-3 h-3">
+          <svg className="w-full h-full" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+            <path
+              strokeLinecap="round"
+              strokeLinejoin="round"
+              strokeWidth={1.5}
+              d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
+            />
+          </svg>
+        </div>
       </button>
 
       {showDetails && (
@@ -81,7 +83,7 @@ export function MessageStats({ tokens, cost }: MessageStatsProps) {
           ref={popoverRef}
           className={cn(
             "modern-card absolute left-1/2 -translate-x-1/2 w-48 z-50 overflow-hidden ring-1 ring-black/5 p-2 text-xs",
-            position === "above" ? "bottom-full mb-2" : "top-full mt-2"
+            position === "above" ? "bottom-full mb-2" : "top-full mt-2",
           )}
         >
           <div className="space-y-1">

+ 158 - 7
packages/opencode/webgui/src/components/MessageList/TextPart.tsx

@@ -1,3 +1,4 @@
+import { useRef } from "react"
 import type { Part } from "../../state/MessagesContext"
 import { MarkdownRenderer } from "../MarkdownRenderer"
 import { FilePart } from "../parts/FilePart"
@@ -12,18 +13,54 @@ function renderTextWithMentions(text: string, mentions: Array<{ start: number; e
   const sortedMentions = [...mentions].sort((a, b) => a.start - b.start)
   const elements: React.ReactNode[] = []
   let lastIndex = 0
+  let key = 0
 
   for (const mention of sortedMentions) {
     // Add text before mention
     if (mention.start > lastIndex) {
-      elements.push(text.substring(lastIndex, mention.start))
+      const chunk = text.substring(lastIndex, mention.start)
+      elements.push(
+        <span
+          key={`t-${key++}`}
+          data-rawpart="1"
+          data-raw={chunk}
+          data-raw-start={lastIndex}
+          data-raw-end={mention.start}
+        >
+          {chunk}
+        </span>,
+      )
     }
 
-    // Add mention component
+    const raw = text.substring(mention.start, mention.end)
+
+    // Add mention component (wrapped so copy can map back to raw text)
     if (mention.part.type === "file") {
-      elements.push(<FilePart key={mention.part.id} part={mention.part as any} />)
+      elements.push(
+        <span
+          key={`m-${key++}`}
+          data-rawpart="1"
+          data-raw-mention="1"
+          data-raw={raw}
+          data-raw-start={mention.start}
+          data-raw-end={mention.end}
+        >
+          <FilePart part={mention.part as any} />
+        </span>,
+      )
     } else if (mention.part.type === "agent") {
-      elements.push(<AgentPart key={mention.part.id} part={mention.part as any} />)
+      elements.push(
+        <span
+          key={`m-${key++}`}
+          data-rawpart="1"
+          data-raw-mention="1"
+          data-raw={raw}
+          data-raw-start={mention.start}
+          data-raw-end={mention.end}
+        >
+          <AgentPart part={mention.part as any} />
+        </span>,
+      )
     }
 
     lastIndex = mention.end
@@ -31,7 +68,12 @@ function renderTextWithMentions(text: string, mentions: Array<{ start: number; e
 
   // Add remaining text
   if (lastIndex < text.length) {
-    elements.push(text.substring(lastIndex))
+    const chunk = text.substring(lastIndex)
+    elements.push(
+      <span key={`t-${key++}`} data-rawpart="1" data-raw={chunk} data-raw-start={lastIndex} data-raw-end={text.length}>
+        {chunk}
+      </span>,
+    )
   }
 
   return <>{elements}</>
@@ -44,6 +86,8 @@ interface TextPartProps {
 }
 
 export function TextPart({ part, isUser, attachedParts }: TextPartProps) {
+  const ref = useRef<HTMLDivElement | null>(null)
+
   if (part.type !== "text") return null
 
   // Skip synthetic text parts (like tool call descriptions)
@@ -70,16 +114,123 @@ export function TextPart({ part, isUser, attachedParts }: TextPartProps) {
   if (isUser) {
     const handleCopy = (e: React.ClipboardEvent<HTMLDivElement>) => {
       if (!e.clipboardData) return
+
+      const selection = window.getSelection()
+      const wrapper = ref.current
+      if (!selection || !wrapper || selection.rangeCount === 0) return
+
+      const range = selection.getRangeAt(0)
+      if (!wrapper.contains(range.commonAncestorContainer)) return
+
+      if (range.collapsed) {
+        e.preventDefault()
+        e.stopPropagation()
+        e.clipboardData.setData("text/plain", text)
+        return
+      }
+
       e.preventDefault()
       e.stopPropagation()
-      e.clipboardData.setData("text/plain", text)
+
+      const parts = Array.from(wrapper.querySelectorAll<HTMLElement>("[data-rawpart]"))
+
+      const containsNode = (needle: Node, element: HTMLElement): boolean => {
+        return needle === element || element.contains(needle)
+      }
+
+      // Find start part and offset
+      let startPartIndex = parts.findIndex((p) => containsNode(range.startContainer, p))
+      if (startPartIndex === -1 && range.startContainer === wrapper) {
+        startPartIndex = Math.min(range.startOffset, parts.length - 1)
+      }
+
+      // Find end part - use focusNode for accurate end detection (fixes Firefox)
+      // In Firefox, when selection ends inside non-text elements (like SVG in mentions),
+      // range.endContainer points to the SVG but focusNode points to actual selection end
+      const focusNode = selection.focusNode
+      const focusOffset = selection.focusOffset
+      let endPartIndex = focusNode ? parts.findIndex((p) => containsNode(focusNode, p)) : -1
+
+      // Fallback to range.endContainer if focusNode didn't match
+      if (endPartIndex === -1) {
+        endPartIndex = parts.findIndex((p) => containsNode(range.endContainer, p))
+      }
+      if (endPartIndex === -1 && range.endContainer === wrapper) {
+        endPartIndex = Math.min(range.endOffset, parts.length) - 1
+      }
+
+      // Calculate start offset within first part
+      let startOffset = 0
+      if (startPartIndex >= 0 && !parts[startPartIndex].hasAttribute("data-raw-mention")) {
+        const partEl = parts[startPartIndex]
+        if (containsNode(range.startContainer, partEl)) {
+          const tempRange = document.createRange()
+          tempRange.selectNodeContents(partEl)
+          tempRange.setEnd(range.startContainer, range.startOffset)
+          startOffset = tempRange.toString().length
+        }
+      }
+
+      // Calculate end offset within last part
+      let endOffset = 0
+      if (endPartIndex >= 0) {
+        const partEl = parts[endPartIndex]
+        if (partEl.hasAttribute("data-raw-mention")) {
+          endOffset = partEl.textContent?.length || 0
+        } else if (focusNode && containsNode(focusNode, partEl) && focusNode.nodeType === Node.TEXT_NODE) {
+          const tempRange = document.createRange()
+          tempRange.selectNodeContents(partEl)
+          tempRange.setEnd(focusNode, focusOffset)
+          endOffset = tempRange.toString().length
+        } else if (containsNode(range.endContainer, partEl)) {
+          const tempRange = document.createRange()
+          tempRange.selectNodeContents(partEl)
+          tempRange.setEnd(range.endContainer, range.endOffset)
+          endOffset = tempRange.toString().length
+        } else {
+          endOffset = partEl.textContent?.length || 0
+        }
+      }
+
+      // Map to raw text indices
+      let rawStart = text.length
+      let rawEnd = 0
+
+      for (let i = startPartIndex; i <= endPartIndex && i >= 0 && i < parts.length; i++) {
+        const partEl = parts[i]
+        const partStart = Number(partEl.getAttribute("data-raw-start"))
+        const partEnd = Number(partEl.getAttribute("data-raw-end"))
+        if (Number.isNaN(partStart) || Number.isNaN(partEnd)) continue
+
+        if (partEl.hasAttribute("data-raw-mention")) {
+          rawStart = Math.min(rawStart, partStart)
+          rawEnd = Math.max(rawEnd, partEnd)
+          continue
+        }
+
+        let localStart = i === startPartIndex ? startOffset : 0
+        let localEnd = i === endPartIndex ? endOffset : partEnd - partStart
+
+        localStart = Math.max(0, Math.min(localStart, partEnd - partStart))
+        localEnd = Math.max(0, Math.min(localEnd, partEnd - partStart))
+
+        if (localEnd > localStart) {
+          rawStart = Math.min(rawStart, partStart + localStart)
+          rawEnd = Math.max(rawEnd, partStart + localEnd)
+        }
+      }
+
+      if (rawEnd > rawStart) {
+        e.clipboardData.setData("text/plain", text.slice(rawStart, rawEnd))
+      }
     }
+
     return (
       <div
         key={part.id}
         className="inline-block modern-card px-3 py-2 text-sm text-gray-900 dark:text-gray-100 bg-gray-50 dark:bg-gray-800/50 border-transparent dark:border-gray-800"
       >
-        <div className="whitespace-pre-wrap" onCopy={handleCopy}>
+        <div ref={ref} className="whitespace-pre-wrap" onCopy={handleCopy}>
           {mentions.length > 0 ? renderTextWithMentions(text, mentions) : text}
         </div>
       </div>

+ 11 - 0
packages/opencode/webgui/src/components/MessageList/index.tsx

@@ -71,6 +71,15 @@ export function MessageList({ sessionID, onUndoToInput }: MessageListProps) {
   // Inline revert handling: if session has a revert boundary, hide messages at/after it
   const revertBoundaryID = currentSession?.revert?.messageID
 
+  const visibleMessages = (() => {
+    if (!revertBoundaryID) return sortedMessages
+    const index = sortedMessages.findIndex((m) => m.info.id === revertBoundaryID)
+    if (index === -1) return sortedMessages
+    return sortedMessages.slice(0, index)
+  })()
+
+  const lastMessageID = visibleMessages.at(-1)?.info.id
+
   // Build rows with optional inline reverted summary block
   const rows: React.ReactNode[] = []
   let insertedRevertSummary = false
@@ -97,6 +106,7 @@ export function MessageList({ sessionID, onUndoToInput }: MessageListProps) {
           onRevert={handleRevert}
           revertBusy={isRevertBusy}
           sessionID={sessionID || undefined}
+          isLast={message.info.id === lastMessageID}
         />,
       )
     }
@@ -125,6 +135,7 @@ export function MessageList({ sessionID, onUndoToInput }: MessageListProps) {
           onRevert={handleRevert}
           revertBusy={isRevertBusy}
           sessionID={sessionID || undefined}
+          isLast={message.info.id === lastMessageID}
         />,
       )
     }

+ 97 - 37
packages/opencode/webgui/src/state/MessagesContext.tsx

@@ -40,10 +40,7 @@ interface MessagesContextValue {
   // permissions
   permissions: PermissionRequest[]
   getPermissionForCall: (sessionID: string, callID?: string | null) => PermissionRequest | undefined
-  respondPermission: (
-    requestID: string,
-    reply: "once" | "always" | "reject",
-  ) => Promise<boolean>
+  respondPermission: (requestID: string, reply: "once" | "always" | "reject") => Promise<boolean>
   // questions
   questions: Map<string, QuestionRequest[]>
   getQuestionsBySession: (sessionID: string) => QuestionRequest[]
@@ -59,6 +56,46 @@ interface MessagesProviderProps {
   emitter?: EventEmitter | null | undefined
 }
 
+function sessionErrorText(error: unknown): string {
+  if (!error) return "An error occurred in the session"
+  if (typeof error === "string") return error
+  if (typeof error !== "object") return "An error occurred in the session"
+
+  const data = (error as { data?: { message?: unknown }; message?: unknown }).data
+  const dataMessage = data && typeof data.message === "string" ? data.message : undefined
+  if (dataMessage) return dataMessage
+
+  const msg = (error as { message?: unknown }).message
+  if (typeof msg === "string" && msg.length > 0) return msg
+
+  return "An error occurred in the session"
+}
+
+function sessionErrorKey(error: unknown): string | undefined {
+  if (!error || typeof error !== "object") return undefined
+
+  const name = (error as { name?: unknown }).name
+  const safeName = typeof name === "string" && name.length > 0 ? name : ""
+
+  // Prefer data.message when present (MessageAbortedError etc.)
+  const dataMessage = (error as { data?: { message?: unknown } })?.data?.message
+  if (typeof dataMessage === "string" && dataMessage.length > 0) {
+    return safeName ? `${safeName}:${dataMessage}` : `:${dataMessage}`
+  }
+
+  const msg = (error as { message?: unknown }).message
+  if (typeof msg === "string" && msg.length > 0) {
+    return safeName ? `${safeName}:${msg}` : `:${msg}`
+  }
+
+  return safeName.length > 0 ? `${safeName}:` : undefined
+}
+
+function infoSessionErrorKey(info: unknown): string | undefined {
+  const error = (info as { error?: unknown })?.error
+  return sessionErrorKey(error)
+}
+
 export function MessagesProvider({ children, emitter }: MessagesProviderProps) {
   const [messages, setMessages] = useState<Message[]>([])
   const [permissions, setPermissions] = useState<PermissionRequest[]>([])
@@ -71,24 +108,14 @@ export function MessagesProvider({ children, emitter }: MessagesProviderProps) {
     setMessages((prev) => Store.upsertMessage(prev, message))
   }, [])
 
-  // Add an error message for a session
+  // Add an error message for a session (synthetic message)
   const addSessionError = useCallback((sessionID: string, error: unknown) => {
-    const text = (() => {
-      if (!error) return "An error occurred in the session"
-      if (typeof error === "string") return error
-      if (typeof error === "object") {
-        const data = (error as { data?: { message?: unknown }; message?: unknown }).data
-        const dataMessage = data && typeof data.message === "string" ? data.message : undefined
-        if (dataMessage) return dataMessage
-        const topMessage = (error as { message?: unknown }).message
-        if (typeof topMessage === "string" && topMessage.length > 0) return topMessage
-      }
-      return "An error occurred in the session"
-    })()
+    const text = sessionErrorText(error)
+    const key = sessionErrorKey(error)
 
     const errorID = `error-${Date.now()}`
     const errorMessage: Message = {
-      info: ({
+      info: {
         id: errorID,
         sessionID,
         role: "assistant",
@@ -96,7 +123,8 @@ export function MessagesProvider({ children, emitter }: MessagesProviderProps) {
           created: Date.now(),
           updated: Date.now(),
         },
-      } as unknown) as SDKMessage,
+        syntheticErrorKey: key,
+      } as unknown as SDKMessage,
       parts: [
         {
           id: `part-${errorID}`,
@@ -108,7 +136,29 @@ export function MessagesProvider({ children, emitter }: MessagesProviderProps) {
       ],
     }
 
-    setMessages((prev) => Store.upsertMessage(prev, errorMessage))
+    setMessages((prev) => {
+      const alreadyShownOnMessage =
+        key &&
+        prev.some((m) => {
+          if (m.info.sessionID !== sessionID) return false
+          if (m.info.role !== "assistant") return false
+          return infoSessionErrorKey(m.info) === key
+        })
+
+      if (alreadyShownOnMessage) return prev
+
+      const existingSynthetic =
+        key &&
+        prev.some((m) => {
+          if (m.info.sessionID !== sessionID) return false
+          if (!m.info.id.startsWith("error-")) return false
+          return (m.info as any)?.syntheticErrorKey === key
+        })
+
+      if (existingSynthetic) return prev
+
+      return Store.upsertMessage(prev, errorMessage)
+    })
   }, [])
 
   // Remove session errors for a specific session, optionally after a certain timestamp
@@ -166,8 +216,21 @@ export function MessagesProvider({ children, emitter }: MessagesProviderProps) {
     if (event.type === "message.updated") {
       const { info } = event.properties as { info: SDKMessage }
       console.log("[MessagesContext] Message updated:", info.id, info.role)
+
+      const key = infoSessionErrorKey(info)
+
       // updateMessageInfo creates the message if it doesn't exist
-      setMessages((prev) => Store.updateMessageInfo(prev, info.id, info))
+      setMessages((prev) => {
+        const next = Store.updateMessageInfo(prev, info.id, info)
+        if (!key) return next
+
+        // If we later receive a message-level error, remove any synthetic session error we created for it.
+        return next.filter((m) => {
+          if (m.info.sessionID !== info.sessionID) return true
+          if (!m.info.id.startsWith("error-")) return true
+          return (m.info as any)?.syntheticErrorKey !== key
+        })
+      })
     }
   }, [])
 
@@ -334,22 +397,19 @@ export function MessagesProvider({ children, emitter }: MessagesProviderProps) {
     [permissions],
   )
 
-  const respondPermission = useCallback(
-    async (requestID: string, reply: "once" | "always" | "reject") => {
-      try {
-        const result = await sdk.permissions.respond({
-          path: { requestID },
-          body: { reply },
-        })
-        const ok = Boolean(result && "data" in result && result.data === true)
-        if (ok) setPermissions((prev) => prev.filter((p) => p.id !== requestID))
-        return ok
-      } catch (e) {
-        return false
-      }
-    },
-    [],
-  )
+  const respondPermission = useCallback(async (requestID: string, reply: "once" | "always" | "reject") => {
+    try {
+      const result = await sdk.permissions.respond({
+        path: { requestID },
+        body: { reply },
+      })
+      const ok = Boolean(result && "data" in result && result.data === true)
+      if (ok) setPermissions((prev) => prev.filter((p) => p.id !== requestID))
+      return ok
+    } catch (e) {
+      return false
+    }
+  }, [])
 
   // Question events
   const handleQuestionAsked = useCallback((event: ServerEvent) => {