Prechádzať zdrojové kódy

Fix session error toasts and display session state messages

paviko 3 mesiacov pred
rodič
commit
ae04b6b48a

+ 1 - 0
hosts/jetbrains-plugin/CHANGELOG.md

@@ -4,6 +4,7 @@
 
 - Providers can be configured from Settings panel - can be added/removed, also OAuth
 - Fixed context size bug when session what aborted
+- Fix session error toasts and display session state messages
 
 ## 25.11.19
 

+ 1 - 0
hosts/vscode-plugin/CHANGELOG.md

@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 - Providers can be configured from Settings panel - can be added/removed, also OAuth
 - Fixed context size bug when session what aborted
+- Fix session error toasts and display session state messages
 
 ## [25.11.19] - 2025-11-19
 

+ 16 - 4
packages/opencode/webgui/src/App.tsx

@@ -270,11 +270,23 @@ function AppContent() {
 
       // Handle session errors
       if (event.type === "session.error") {
-        const { sessionID, error } = event.properties
+        const { sessionID, error } = event.properties as { sessionID: string; error: unknown }
         if (currentSession?.id === sessionID) {
-          const errorMsg = error?.message || "An error occurred in the session"
-          console.error("[App] Session error:", errorMsg)
-          showToast(errorMsg, {
+          const message = (() => {
+            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"
+          })()
+
+          console.error("[App] Session error:", message)
+          showToast(message, {
             title: "Session Error",
             variant: "error",
             duration: 8000,

+ 56 - 2
packages/opencode/webgui/src/components/TypingIndicator.tsx

@@ -1,8 +1,12 @@
+import { useEffect, useState } from "react"
+import { useSession } from "../state/SessionContext"
+
 /**
  * Typing indicator component
  *
  * Shows animated dots while the assistant is generating a response.
- * Follows the compact UI design pattern with theme support.
+ * Also renders a compact status banner for session.status events
+ * (busy / retry with countdown) similar to the TUI.
  */
 
 interface TypingIndicatorProps {
@@ -11,8 +15,47 @@ interface TypingIndicatorProps {
 }
 
 export function TypingIndicator({ visible }: TypingIndicatorProps) {
+  const { currentStatus } = useSession()
+  const [seconds, setSeconds] = useState<number | null>(null)
+
+  useEffect(() => {
+    if (currentStatus.type !== "retry") {
+      setSeconds(null)
+      return
+    }
+
+    const update = () => {
+      const diff = currentStatus.next - Date.now()
+      if (diff <= 0) {
+        setSeconds(0)
+        return
+      }
+      setSeconds(Math.round(diff / 1000))
+    }
+
+    update()
+    const id = window.setInterval(update, 1000)
+    return () => window.clearInterval(id)
+  }, [currentStatus])
+
+  const showStatus = currentStatus.type !== "idle" && currentStatus.type !== "busy"
+
+  const statusText = currentStatus.message
+
+  const bannerClass = (() => {
+    if (currentStatus.type === "retry") {
+      return "inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs bg-orange-50 text-orange-800 dark:bg-orange-900/30 dark:text-orange-100 border border-orange-200 dark:border-orange-700"
+    }
+    if (currentStatus.type === "busy") {
+      return "inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs bg-blue-50 text-blue-800 dark:bg-blue-900/30 dark:text-blue-100 border border-blue-200 dark:border-blue-700"
+    }
+    return ""
+  })()
+
+  const countdown = currentStatus.type === "retry" && typeof seconds === "number" && seconds > 0 ? seconds : null
+
   return (
-    <div className="my-1 h-4">
+    <div className="my-1 space-y-1 min-h-[1rem]">
       {visible && (
         <button className="relative inline-flex items-center gap-0.5 pr-4 text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300">
           <span className="leading-none">Generating</span>
@@ -32,6 +75,17 @@ export function TypingIndicator({ visible }: TypingIndicatorProps) {
           </div>
         </button>
       )}
+
+      {showStatus && bannerClass && statusText && (
+        <div className={bannerClass}>
+          <span>{statusText}</span>
+          {currentStatus.type === "retry" && (
+            <span className="text-[10px] text-orange-700 dark:text-orange-200">
+              {countdown !== null ? `retrying in ${countdown}s` : "retrying soon"} · attempt #{currentStatus.attempt}
+            </span>
+          )}
+        </div>
+      )}
     </div>
   )
 }

+ 12 - 0
packages/opencode/webgui/src/lib/api/events.ts

@@ -7,6 +7,18 @@ export type ServerEvent =
   | { type: "session.updated"; properties: { sessionID: string; session: any } }
   | { type: "session.deleted"; properties: { sessionID: string } }
   | { type: "session.error"; properties: { sessionID: string; error: any } }
+  | {
+      type: "session.status"
+      properties: {
+        sessionID: string
+        status: {
+          type: string
+          attempt: number
+          message: string
+          next: number
+        }
+      }
+    }
   | { type: "session.idle"; properties: { sessionID: string } }
   | { type: "session.compacted"; properties: { sessionID: string } }
   | { type: "message.updated"; properties: { info: any } }

+ 33 - 0
packages/opencode/webgui/src/state/SessionContext.tsx

@@ -6,6 +6,13 @@ import { eventEmitter } from "../lib/api/events"
 /**
  * Session context state
  */
+type SessionStatusInfo = {
+  type: string
+  attempt: number
+  message: string
+  next: number
+}
+
 interface SessionContextState {
   // Current active session
   currentSession: Session | null
@@ -28,6 +35,9 @@ interface SessionContextState {
   isReasoning: boolean
   setReasoning: (sessionId: string, active: boolean) => void
 
+  // Session status for current session
+  currentStatus: SessionStatusInfo
+
   // Model and Agent selection
   selectedProviderId: string | undefined
   selectedModelId: string | undefined
@@ -109,6 +119,7 @@ export function SessionProvider({ children }: SessionProviderProps) {
   const [error, setError] = useState<Error | null>(null)
   const [isIdle, setIsIdle] = useState(true)
   const [reasoningMap, setReasoningMap] = useState<Record<string, boolean>>({})
+  const [statusMap, setStatusMap] = useState<Record<string, SessionStatusInfo>>({})
 
   // Model and Agent selection state (synced with server state + localStorage fallback)
   const [selectedProviderId, setSelectedProviderId] = useState<string | undefined>()
@@ -117,6 +128,9 @@ export function SessionProvider({ children }: SessionProviderProps) {
   const [agentModelMap, setAgentModelMap] = useState<Record<string, { provider_id: string; model_id: string }>>({})
 
   const isReasoning = currentSession?.id ? Boolean(reasoningMap[currentSession.id]) : false
+  const currentStatus: SessionStatusInfo = currentSession?.id && statusMap[currentSession.id]
+    ? statusMap[currentSession.id]
+    : { type: "idle", attempt: 0, message: "", next: Date.now() }
 
   const setReasoning = useCallback((sessionId: string, active: boolean) => {
     if (!sessionId) return
@@ -797,14 +811,32 @@ export function SessionProvider({ children }: SessionProviderProps) {
       }
     }
 
+    const handleSessionStatus = (event: any) => {
+      if (event.type !== "session.status" || !event.properties) return
+      const { sessionID, status } = event.properties as {
+        sessionID: string
+        status: SessionStatusInfo
+      }
+      setStatusMap((prev) => {
+        if (status.type === "idle") {
+          const next = { ...prev }
+          delete next[sessionID]
+          return next
+        }
+        return { ...prev, [sessionID]: status }
+      })
+    }
+
     const unsubscribeCreated = eventEmitter.on("session.created", handleSessionCreated)
     const unsubscribeUpdated = eventEmitter.on("session.updated", handleSessionUpdated)
     const unsubscribeDeleted = eventEmitter.on("session.deleted", handleSessionDeleted)
+    const unsubscribeStatus = eventEmitter.on("session.status", handleSessionStatus)
 
     return () => {
       unsubscribeCreated()
       unsubscribeUpdated()
       unsubscribeDeleted()
+      unsubscribeStatus()
     }
   }, [currentSession?.id, setReasoning, newVirtual])
 
@@ -820,6 +852,7 @@ export function SessionProvider({ children }: SessionProviderProps) {
     setIsIdle,
     isReasoning,
     setReasoning,
+    currentStatus,
     selectedProviderId,
     selectedModelId,
     selectedAgent,