Răsfoiți Sursa

fix(app): non-fatal error handling

adamelmore 3 săptămâni în urmă
părinte
comite
095328faf4

+ 35 - 8
packages/app/src/components/dialog-select-server.tsx

@@ -14,6 +14,7 @@ import { useLanguage } from "@/context/language"
 import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
 import { Tooltip } from "@opencode-ai/ui/tooltip"
 import { useGlobalSDK } from "@/context/global-sdk"
+import { showToast } from "@opencode-ai/ui/toast"
 
 type ServerStatus = { healthy: boolean; version?: string }
 
@@ -40,10 +41,11 @@ interface EditRowProps {
 }
 
 async function checkHealth(url: string, platform: ReturnType<typeof usePlatform>): Promise<ServerStatus> {
+  const signal = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout?.(3000)
   const sdk = createOpencodeClient({
     baseUrl: url,
     fetch: platform.fetch,
-    signal: AbortSignal.timeout(3000),
+    signal,
   })
   return sdk.global
     .health()
@@ -149,9 +151,18 @@ export function DialogSelectServer() {
   })
   const [defaultUrl, defaultUrlActions] = createResource(
     async () => {
-      const url = await platform.getDefaultServerUrl?.()
-      if (!url) return null
-      return normalizeServerUrl(url) ?? null
+      try {
+        const url = await platform.getDefaultServerUrl?.()
+        if (!url) return null
+        return normalizeServerUrl(url) ?? null
+      } catch (err) {
+        showToast({
+          variant: "error",
+          title: language.t("common.requestFailed"),
+          description: err instanceof Error ? err.message : String(err),
+        })
+        return null
+      }
     },
     { initialValue: null },
   )
@@ -508,8 +519,16 @@ export function DialogSelectServer() {
                           <Show when={canDefault() && defaultUrl() !== i}>
                             <DropdownMenu.Item
                               onSelect={async () => {
-                                await platform.setDefaultServerUrl?.(i)
-                                defaultUrlActions.mutate(i)
+                                try {
+                                  await platform.setDefaultServerUrl?.(i)
+                                  defaultUrlActions.mutate(i)
+                                } catch (err) {
+                                  showToast({
+                                    variant: "error",
+                                    title: language.t("common.requestFailed"),
+                                    description: err instanceof Error ? err.message : String(err),
+                                  })
+                                }
                               }}
                             >
                               <DropdownMenu.ItemLabel>
@@ -520,8 +539,16 @@ export function DialogSelectServer() {
                           <Show when={canDefault() && defaultUrl() === i}>
                             <DropdownMenu.Item
                               onSelect={async () => {
-                                await platform.setDefaultServerUrl?.(null)
-                                defaultUrlActions.mutate(null)
+                                try {
+                                  await platform.setDefaultServerUrl?.(null)
+                                  defaultUrlActions.mutate(null)
+                                } catch (err) {
+                                  showToast({
+                                    variant: "error",
+                                    title: language.t("common.requestFailed"),
+                                    description: err instanceof Error ? err.message : String(err),
+                                  })
+                                }
                               }}
                             >
                               <DropdownMenu.ItemLabel>

+ 2 - 2
packages/app/src/components/session/session-header.tsx

@@ -9,7 +9,7 @@ import { usePlatform } from "@/context/platform"
 import { useSync } from "@/context/sync"
 import { useGlobalSDK } from "@/context/global-sdk"
 import { getFilename } from "@opencode-ai/util/path"
-import { base64Decode } from "@opencode-ai/util/encode"
+import { decode64 } from "@/utils/base64"
 
 import { Icon } from "@opencode-ai/ui/icon"
 import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -29,7 +29,7 @@ export function SessionHeader() {
   const platform = usePlatform()
   const language = useLanguage()
 
-  const projectDirectory = createMemo(() => base64Decode(params.dir ?? ""))
+  const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
   const project = createMemo(() => {
     const directory = projectDirectory()
     if (!directory) return

+ 17 - 9
packages/app/src/components/status-popover.tsx

@@ -15,14 +15,16 @@ import { usePlatform } from "@/context/platform"
 import { useLanguage } from "@/context/language"
 import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
 import { DialogSelectServer } from "./dialog-select-server"
+import { showToast } from "@opencode-ai/ui/toast"
 
 type ServerStatus = { healthy: boolean; version?: string }
 
 async function checkHealth(url: string, platform: ReturnType<typeof usePlatform>): Promise<ServerStatus> {
+  const signal = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout?.(3000)
   const sdk = createOpencodeClient({
     baseUrl: url,
     fetch: platform.fetch,
-    signal: AbortSignal.timeout(3000),
+    signal,
   })
   return sdk.global
     .health()
@@ -100,15 +102,21 @@ export function StatusPopover() {
   const toggleMcp = async (name: string) => {
     if (store.loading) return
     setStore("loading", name)
-    const status = sync.data.mcp[name]
-    if (status?.status === "connected") {
-      await sdk.client.mcp.disconnect({ name })
-    } else {
-      await sdk.client.mcp.connect({ name })
+
+    try {
+      const status = sync.data.mcp[name]
+      await (status?.status === "connected" ? sdk.client.mcp.disconnect({ name }) : sdk.client.mcp.connect({ name }))
+      const result = await sdk.client.mcp.status()
+      if (result.data) sync.set("mcp", result.data)
+    } catch (err) {
+      showToast({
+        variant: "error",
+        title: language.t("common.requestFailed"),
+        description: err instanceof Error ? err.message : String(err),
+      })
+    } finally {
+      setStore("loading", null)
     }
-    const result = await sdk.client.mcp.status()
-    if (result.data) sync.set("mcp", result.data)
-    setStore("loading", null)
   }
 
   const lspItems = createMemo(() => sync.data.lsp ?? [])

+ 162 - 141
packages/app/src/components/terminal.tsx

@@ -5,6 +5,8 @@ import { monoFontFamily, useSettings } from "@/context/settings"
 import { SerializeAddon } from "@/addons/serialize"
 import { LocalPTY } from "@/context/terminal"
 import { resolveThemeVariant, useTheme, withAlpha, type HexColor } from "@opencode-ai/ui/theme"
+import { useLanguage } from "@/context/language"
+import { showToast } from "@opencode-ai/ui/toast"
 
 export interface TerminalProps extends ComponentProps<"div"> {
   pty: LocalPTY
@@ -40,6 +42,7 @@ export const Terminal = (props: TerminalProps) => {
   const sdk = useSDK()
   const settings = useSettings()
   const theme = useTheme()
+  const language = useLanguage()
   let container!: HTMLDivElement
   const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnect", "onConnectError"])
   let ws: WebSocket | undefined
@@ -107,173 +110,185 @@ export const Terminal = (props: TerminalProps) => {
     focusTerminal()
   }
 
-  onMount(async () => {
-    const mod = await import("ghostty-web")
-    ghostty = await mod.Ghostty.load()
+  onMount(() => {
+    const run = async () => {
+      const mod = await import("ghostty-web")
+      ghostty = await mod.Ghostty.load()
 
-    const once = { value: false }
+      const once = { value: false }
 
-    const url = new URL(sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`)
-    if (window.__OPENCODE__?.serverPassword) {
-      url.username = "opencode"
-      url.password = window.__OPENCODE__?.serverPassword
-    }
-    const socket = new WebSocket(url)
-    ws = socket
-
-    const t = new mod.Terminal({
-      cursorBlink: true,
-      cursorStyle: "bar",
-      fontSize: 14,
-      fontFamily: monoFontFamily(settings.appearance.font()),
-      allowTransparency: true,
-      theme: terminalColors(),
-      scrollback: 10_000,
-      ghostty,
-    })
-    term = t
-
-    const copy = () => {
-      const selection = t.getSelection()
-      if (!selection) return false
-
-      const body = document.body
-      if (body) {
-        const textarea = document.createElement("textarea")
-        textarea.value = selection
-        textarea.setAttribute("readonly", "")
-        textarea.style.position = "fixed"
-        textarea.style.opacity = "0"
-        body.appendChild(textarea)
-        textarea.select()
-        const copied = document.execCommand("copy")
-        body.removeChild(textarea)
-        if (copied) return true
+      const url = new URL(sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`)
+      if (window.__OPENCODE__?.serverPassword) {
+        url.username = "opencode"
+        url.password = window.__OPENCODE__?.serverPassword
       }
+      const socket = new WebSocket(url)
+      ws = socket
+
+      const t = new mod.Terminal({
+        cursorBlink: true,
+        cursorStyle: "bar",
+        fontSize: 14,
+        fontFamily: monoFontFamily(settings.appearance.font()),
+        allowTransparency: true,
+        theme: terminalColors(),
+        scrollback: 10_000,
+        ghostty,
+      })
+      term = t
+
+      const copy = () => {
+        const selection = t.getSelection()
+        if (!selection) return false
+
+        const body = document.body
+        if (body) {
+          const textarea = document.createElement("textarea")
+          textarea.value = selection
+          textarea.setAttribute("readonly", "")
+          textarea.style.position = "fixed"
+          textarea.style.opacity = "0"
+          body.appendChild(textarea)
+          textarea.select()
+          const copied = document.execCommand("copy")
+          body.removeChild(textarea)
+          if (copied) return true
+        }
 
-      const clipboard = navigator.clipboard
-      if (clipboard?.writeText) {
-        clipboard.writeText(selection).catch(() => {})
-        return true
-      }
+        const clipboard = navigator.clipboard
+        if (clipboard?.writeText) {
+          clipboard.writeText(selection).catch(() => {})
+          return true
+        }
 
-      return false
-    }
+        return false
+      }
 
-    t.attachCustomKeyEventHandler((event) => {
-      const key = event.key.toLowerCase()
+      t.attachCustomKeyEventHandler((event) => {
+        const key = event.key.toLowerCase()
 
-      if (event.ctrlKey && event.shiftKey && !event.metaKey && key === "c") {
-        copy()
-        return true
-      }
+        if (event.ctrlKey && event.shiftKey && !event.metaKey && key === "c") {
+          copy()
+          return true
+        }
 
-      if (event.metaKey && !event.ctrlKey && !event.altKey && key === "c") {
-        if (!t.hasSelection()) return true
-        copy()
-        return true
-      }
+        if (event.metaKey && !event.ctrlKey && !event.altKey && key === "c") {
+          if (!t.hasSelection()) return true
+          copy()
+          return true
+        }
 
-      // allow for ctrl-` to toggle terminal in parent
-      if (event.ctrlKey && key === "`") {
-        return true
-      }
+        // allow for ctrl-` to toggle terminal in parent
+        if (event.ctrlKey && key === "`") {
+          return true
+        }
 
-      return false
-    })
+        return false
+      })
 
-    fitAddon = new mod.FitAddon()
-    serializeAddon = new SerializeAddon()
-    t.loadAddon(serializeAddon)
-    t.loadAddon(fitAddon)
+      fitAddon = new mod.FitAddon()
+      serializeAddon = new SerializeAddon()
+      t.loadAddon(serializeAddon)
+      t.loadAddon(fitAddon)
 
-    t.open(container)
-    container.addEventListener("pointerdown", handlePointerDown)
+      t.open(container)
+      container.addEventListener("pointerdown", handlePointerDown)
 
-    handleTextareaFocus = () => {
-      t.options.cursorBlink = true
-    }
-    handleTextareaBlur = () => {
-      t.options.cursorBlink = false
-    }
+      handleTextareaFocus = () => {
+        t.options.cursorBlink = true
+      }
+      handleTextareaBlur = () => {
+        t.options.cursorBlink = false
+      }
 
-    t.textarea?.addEventListener("focus", handleTextareaFocus)
-    t.textarea?.addEventListener("blur", handleTextareaBlur)
+      t.textarea?.addEventListener("focus", handleTextareaFocus)
+      t.textarea?.addEventListener("blur", handleTextareaBlur)
 
-    focusTerminal()
+      focusTerminal()
 
-    if (local.pty.buffer) {
-      if (local.pty.rows && local.pty.cols) {
-        t.resize(local.pty.cols, local.pty.rows)
+      if (local.pty.buffer) {
+        if (local.pty.rows && local.pty.cols) {
+          t.resize(local.pty.cols, local.pty.rows)
+        }
+        t.write(local.pty.buffer, () => {
+          if (local.pty.scrollY) {
+            t.scrollToLine(local.pty.scrollY)
+          }
+          fitAddon.fit()
+        })
       }
-      t.write(local.pty.buffer, () => {
-        if (local.pty.scrollY) {
-          t.scrollToLine(local.pty.scrollY)
+
+      fitAddon.observeResize()
+      handleResize = () => fitAddon.fit()
+      window.addEventListener("resize", handleResize)
+      t.onResize(async (size) => {
+        if (socket.readyState === WebSocket.OPEN) {
+          await sdk.client.pty
+            .update({
+              ptyID: local.pty.id,
+              size: {
+                cols: size.cols,
+                rows: size.rows,
+              },
+            })
+            .catch(() => {})
         }
-        fitAddon.fit()
       })
-    }
-
-    fitAddon.observeResize()
-    handleResize = () => fitAddon.fit()
-    window.addEventListener("resize", handleResize)
-    t.onResize(async (size) => {
-      if (socket.readyState === WebSocket.OPEN) {
-        await sdk.client.pty
+      t.onData((data) => {
+        if (socket.readyState === WebSocket.OPEN) {
+          socket.send(data)
+        }
+      })
+      t.onKey((key) => {
+        if (key.key == "Enter") {
+          props.onSubmit?.()
+        }
+      })
+      // t.onScroll((ydisp) => {
+      // console.log("Scroll position:", ydisp)
+      // })
+      socket.addEventListener("open", () => {
+        local.onConnect?.()
+        sdk.client.pty
           .update({
             ptyID: local.pty.id,
             size: {
-              cols: size.cols,
-              rows: size.rows,
+              cols: t.cols,
+              rows: t.rows,
             },
           })
           .catch(() => {})
-      }
-    })
-    t.onData((data) => {
-      if (socket.readyState === WebSocket.OPEN) {
-        socket.send(data)
-      }
-    })
-    t.onKey((key) => {
-      if (key.key == "Enter") {
-        props.onSubmit?.()
-      }
-    })
-    // t.onScroll((ydisp) => {
-    // console.log("Scroll position:", ydisp)
-    // })
-    socket.addEventListener("open", () => {
-      local.onConnect?.()
-      sdk.client.pty
-        .update({
-          ptyID: local.pty.id,
-          size: {
-            cols: t.cols,
-            rows: t.rows,
-          },
-        })
-        .catch(() => {})
-    })
-    socket.addEventListener("message", (event) => {
-      t.write(event.data)
-    })
-    socket.addEventListener("error", (error) => {
-      if (disposed) return
-      if (once.value) return
-      once.value = true
-      console.error("WebSocket error:", error)
-      local.onConnectError?.(error)
-    })
-    socket.addEventListener("close", (event) => {
-      if (disposed) return
-      // Normal closure (code 1000) means PTY process exited - server event handles cleanup
-      // For other codes (network issues, server restart), trigger error handler
-      if (event.code !== 1000) {
+      })
+      socket.addEventListener("message", (event) => {
+        t.write(event.data)
+      })
+      socket.addEventListener("error", (error) => {
+        if (disposed) return
         if (once.value) return
         once.value = true
-        local.onConnectError?.(new Error(`WebSocket closed abnormally: ${event.code}`))
-      }
+        console.error("WebSocket error:", error)
+        local.onConnectError?.(error)
+      })
+      socket.addEventListener("close", (event) => {
+        if (disposed) return
+        // Normal closure (code 1000) means PTY process exited - server event handles cleanup
+        // For other codes (network issues, server restart), trigger error handler
+        if (event.code !== 1000) {
+          if (once.value) return
+          once.value = true
+          local.onConnectError?.(new Error(`WebSocket closed abnormally: ${event.code}`))
+        }
+      })
+    }
+
+    void run().catch((err) => {
+      if (disposed) return
+      showToast({
+        variant: "error",
+        title: language.t("terminal.connectionLost.title"),
+        description: err instanceof Error ? err.message : language.t("terminal.connectionLost.description"),
+      })
+      local.onConnectError?.(err)
     })
   })
 
@@ -288,7 +303,13 @@ export const Terminal = (props: TerminalProps) => {
 
     const t = term
     if (serializeAddon && props.onCleanup && t) {
-      const buffer = serializeAddon.serialize()
+      const buffer = (() => {
+        try {
+          return serializeAddon.serialize()
+        } catch {
+          return ""
+        }
+      })()
       props.onCleanup({
         ...local.pty,
         buffer,

+ 24 - 9
packages/app/src/context/global-sync.tsx

@@ -23,7 +23,7 @@ import { createStore, produce, reconcile, type SetStoreFunction, type Store } fr
 import { Binary } from "@opencode-ai/util/binary"
 import { retry } from "@opencode-ai/util/retry"
 import { useGlobalSDK } from "./global-sdk"
-import { ErrorPage, type InitError } from "../pages/error"
+import type { InitError } from "../pages/error"
 import {
   batch,
   createContext,
@@ -823,11 +823,16 @@ function createGlobalSync() {
       .then((x) => x.data)
       .catch(() => undefined)
     if (!health?.healthy) {
-      setGlobalStore("error", new Error(language.t("error.globalSync.connectFailed", { url: globalSDK.url })))
+      showToast({
+        variant: "error",
+        title: language.t("dialog.server.add.error"),
+        description: language.t("error.globalSync.connectFailed", { url: globalSDK.url }),
+      })
+      setGlobalStore("ready", true)
       return
     }
 
-    return Promise.all([
+    const tasks = [
       retry(() =>
         globalSDK.client.path.get().then((x) => {
           setGlobalStore("path", x.data!)
@@ -858,9 +863,22 @@ function createGlobalSync() {
           setGlobalStore("provider_auth", x.data ?? {})
         }),
       ),
-    ])
-      .then(() => setGlobalStore("ready", true))
-      .catch((e) => setGlobalStore("error", e))
+    ]
+
+    const results = await Promise.allSettled(tasks)
+    const errors = results.filter((r): r is PromiseRejectedResult => r.status === "rejected").map((r) => r.reason)
+
+    if (errors.length) {
+      const message = errors[0] instanceof Error ? errors[0].message : String(errors[0])
+      const more = errors.length > 1 ? ` (+${errors.length - 1} more)` : ""
+      showToast({
+        variant: "error",
+        title: language.t("common.requestFailed"),
+        description: message + more,
+      })
+    }
+
+    setGlobalStore("ready", true)
   }
 
   onMount(() => {
@@ -926,9 +944,6 @@ export function GlobalSyncProvider(props: ParentProps) {
   const value = createGlobalSync()
   return (
     <Switch>
-      <Match when={value.error}>
-        <ErrorPage error={value.error} />
-      </Match>
       <Match when={value.ready}>
         <GlobalSyncContext.Provider value={value}>{props.children}</GlobalSyncContext.Provider>
       </Match>

+ 3 - 3
packages/app/src/context/notification.tsx

@@ -8,7 +8,8 @@ import { usePlatform } from "@/context/platform"
 import { useLanguage } from "@/context/language"
 import { useSettings } from "@/context/settings"
 import { Binary } from "@opencode-ai/util/binary"
-import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
+import { base64Encode } from "@opencode-ai/util/encode"
+import { decode64 } from "@/utils/base64"
 import { EventSessionError } from "@opencode-ai/sdk/v2"
 import { Persist, persisted } from "@/utils/persist"
 import { playSound, soundSrc } from "@/utils/sound"
@@ -55,8 +56,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
     const empty: Notification[] = []
 
     const currentDirectory = createMemo(() => {
-      if (!params.dir) return
-      return base64Decode(params.dir)
+      return decode64(params.dir)
     })
 
     const currentSession = createMemo(() => params.id)

+ 3 - 2
packages/app/src/context/permission.tsx

@@ -6,7 +6,8 @@ import { Persist, persisted } from "@/utils/persist"
 import { useGlobalSDK } from "@/context/global-sdk"
 import { useGlobalSync } from "./global-sync"
 import { useParams } from "@solidjs/router"
-import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
+import { base64Encode } from "@opencode-ai/util/encode"
+import { decode64 } from "@/utils/base64"
 
 type PermissionRespondFn = (input: {
   sessionID: string
@@ -53,7 +54,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
     const globalSync = useGlobalSync()
 
     const permissionsEnabled = createMemo(() => {
-      const directory = params.dir ? base64Decode(params.dir) : undefined
+      const directory = decode64(params.dir)
       if (!directory) return false
       const [store] = globalSync.child(directory)
       return hasAutoAcceptPermissionConfig(store.config.permission)

+ 2 - 1
packages/app/src/context/server.tsx

@@ -95,10 +95,11 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
     const isReady = createMemo(() => ready() && !!state.active)
 
     const check = (url: string) => {
+      const signal = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout?.(3000)
       const sdk = createOpencodeClient({
         baseUrl: url,
         fetch: platform.fetch,
-        signal: AbortSignal.timeout(3000),
+        signal,
       })
       return sdk.global
         .health()

+ 2 - 2
packages/app/src/hooks/use-providers.ts

@@ -1,5 +1,5 @@
 import { useGlobalSync } from "@/context/global-sync"
-import { base64Decode } from "@opencode-ai/util/encode"
+import { decode64 } from "@/utils/base64"
 import { useParams } from "@solidjs/router"
 import { createMemo } from "solid-js"
 
@@ -8,7 +8,7 @@ export const popularProviders = ["opencode", "anthropic", "github-copilot", "ope
 export function useProviders() {
   const globalSync = useGlobalSync()
   const params = useParams()
-  const currentDirectory = createMemo(() => base64Decode(params.dir ?? ""))
+  const currentDirectory = createMemo(() => decode64(params.dir) ?? "")
   const providers = createMemo(() => {
     if (currentDirectory()) {
       const [projectStore] = globalSync.child(currentDirectory())

+ 18 - 4
packages/app/src/pages/directory-layout.tsx

@@ -1,22 +1,36 @@
-import { createMemo, Show, type ParentProps } from "solid-js"
+import { createEffect, createMemo, Show, type ParentProps } from "solid-js"
 import { useNavigate, useParams } from "@solidjs/router"
 import { SDKProvider, useSDK } from "@/context/sdk"
 import { SyncProvider, useSync } from "@/context/sync"
 import { LocalProvider } from "@/context/local"
 
-import { base64Decode } from "@opencode-ai/util/encode"
 import { DataProvider } from "@opencode-ai/ui/context"
 import { iife } from "@opencode-ai/util/iife"
 import type { QuestionAnswer } from "@opencode-ai/sdk/v2"
+import { decode64 } from "@/utils/base64"
+import { showToast } from "@opencode-ai/ui/toast"
+import { useLanguage } from "@/context/language"
 
 export default function Layout(props: ParentProps) {
   const params = useParams()
   const navigate = useNavigate()
+  const language = useLanguage()
   const directory = createMemo(() => {
-    return base64Decode(params.dir!)
+    return decode64(params.dir) ?? ""
+  })
+
+  createEffect(() => {
+    if (!params.dir) return
+    if (directory()) return
+    showToast({
+      variant: "error",
+      title: language.t("common.requestFailed"),
+      description: "Invalid directory in URL.",
+    })
+    navigate("/")
   })
   return (
-    <Show when={params.dir}>
+    <Show when={directory()}>
       <SDKProvider directory={directory()}>
         <SyncProvider>
           {iife(() => {

+ 13 - 11
packages/app/src/pages/layout.tsx

@@ -19,7 +19,8 @@ import { A, useNavigate, useParams } from "@solidjs/router"
 import { useLayout, getAvatarColors, LocalProject } from "@/context/layout"
 import { useGlobalSync } from "@/context/global-sync"
 import { Persist, persisted } from "@/utils/persist"
-import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
+import { base64Encode } from "@opencode-ai/util/encode"
+import { decode64 } from "@/utils/base64"
 import { Avatar } from "@opencode-ai/ui/avatar"
 import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
 import { Button } from "@opencode-ai/ui/button"
@@ -420,7 +421,7 @@ export default function Layout(props: ParentProps) {
         }
       }
 
-      const currentDir = params.dir ? base64Decode(params.dir) : undefined
+      const currentDir = decode64(params.dir)
       const currentSession = params.id
       if (directory === currentDir && props.sessionID === currentSession) return
       if (directory === currentDir && session?.parentID === currentSession) return
@@ -449,7 +450,7 @@ export default function Layout(props: ParentProps) {
     onCleanup(unsub)
 
     createEffect(() => {
-      const currentDir = params.dir ? base64Decode(params.dir) : undefined
+      const currentDir = decode64(params.dir)
       const currentSession = params.id
       if (!currentDir || !currentSession) return
       const sessionKey = `${currentDir}:${currentSession}`
@@ -503,7 +504,7 @@ export default function Layout(props: ParentProps) {
   }
 
   const currentProject = createMemo(() => {
-    const directory = params.dir ? base64Decode(params.dir) : undefined
+    const directory = decode64(params.dir)
     if (!directory) return
 
     const projects = layout.projects.list()
@@ -638,7 +639,7 @@ export default function Layout(props: ParentProps) {
     const compare = sortSessions(Date.now())
     if (workspaceSetting()) {
       const dirs = workspaceIds(project)
-      const activeDir = params.dir ? base64Decode(params.dir) : ""
+      const activeDir = decode64(params.dir) ?? ""
       const result: Session[] = []
       for (const dir of dirs) {
         const expanded = store.workspaceExpanded[dir] ?? dir === project.worktree
@@ -1188,7 +1189,7 @@ export default function Layout(props: ParentProps) {
     layout.projects.close(directory)
     layout.projects.open(root)
 
-    if (params.dir && base64Decode(params.dir) === directory) {
+    if (params.dir && decode64(params.dir) === directory) {
       navigateToProject(root)
     }
   }
@@ -1431,7 +1432,8 @@ export default function Layout(props: ParentProps) {
         const dir = value.dir
         const id = value.id
         if (!dir || !id) return
-        const directory = base64Decode(dir)
+        const directory = decode64(dir)
+        if (!directory) return
         setStore("lastSession", directory, id)
         notification.session.markViewed(id)
         const expanded = untrack(() => store.workspaceExpanded[directory])
@@ -1454,7 +1456,7 @@ export default function Layout(props: ParentProps) {
     if (!project) return
 
     if (workspaceSetting()) {
-      const activeDir = params.dir ? base64Decode(params.dir) : ""
+      const activeDir = decode64(params.dir) ?? ""
       const dirs = [project.worktree, ...(project.sandboxes ?? [])]
       for (const directory of dirs) {
         const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree
@@ -1504,7 +1506,7 @@ export default function Layout(props: ParentProps) {
     const local = project.worktree
     const dirs = [local, ...(project.sandboxes ?? [])]
     const active = currentProject()
-    const directory = active?.worktree === project.worktree && params.dir ? base64Decode(params.dir) : undefined
+    const directory = active?.worktree === project.worktree ? decode64(params.dir) : undefined
     const extra = directory && directory !== local && !dirs.includes(directory) ? directory : undefined
     const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false
 
@@ -1930,7 +1932,7 @@ export default function Layout(props: ParentProps) {
     })
     const local = createMemo(() => props.directory === props.project.worktree)
     const active = createMemo(() => {
-      const current = params.dir ? base64Decode(params.dir) : ""
+      const current = decode64(params.dir) ?? ""
       return current === props.directory
     })
     const workspaceValue = createMemo(() => {
@@ -2131,7 +2133,7 @@ export default function Layout(props: ParentProps) {
   const SortableProject = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => {
     const sortable = createSortable(props.project.worktree)
     const selected = createMemo(() => {
-      const current = params.dir ? base64Decode(params.dir) : ""
+      const current = decode64(params.dir) ?? ""
       return props.project.worktree === current || props.project.sandboxes?.includes(current)
     })
 

+ 24 - 3
packages/app/src/pages/session.tsx

@@ -28,7 +28,7 @@ import { useSync } from "@/context/sync"
 import { useTerminal, type LocalPTY } from "@/context/terminal"
 import { useLayout } from "@/context/layout"
 import { Terminal } from "@/components/terminal"
-import { checksum, base64Encode, base64Decode } from "@opencode-ai/util/encode"
+import { checksum, base64Encode } from "@opencode-ai/util/encode"
 import { findLast } from "@opencode-ai/util/array"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { DialogSelectFile } from "@/components/dialog-select-file"
@@ -47,6 +47,7 @@ import { useComments, type LineComment } from "@/context/comments"
 import { extractPromptFromParts } from "@/utils/prompt"
 import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
 import { usePermission } from "@/context/permission"
+import { decode64 } from "@/utils/base64"
 import { showToast } from "@opencode-ai/ui/toast"
 import {
   SessionHeader,
@@ -2126,8 +2127,28 @@ export default function Page() {
                             if (!isSvg()) return
                             const c = state()?.content
                             if (!c) return
-                            if (c.encoding === "base64") return base64Decode(c.content)
-                            return c.content
+                            if (c.encoding !== "base64") return c.content
+                            return decode64(c.content)
+                          })
+
+                          const svgDecodeFailed = createMemo(() => {
+                            if (!isSvg()) return false
+                            const c = state()?.content
+                            if (!c) return false
+                            if (c.encoding !== "base64") return false
+                            return svgContent() === undefined
+                          })
+
+                          const svgToast = { shown: false }
+                          createEffect(() => {
+                            if (!svgDecodeFailed()) return
+                            if (svgToast.shown) return
+                            svgToast.shown = true
+                            showToast({
+                              variant: "error",
+                              title: language.t("toast.file.loadFailed.title"),
+                              description: "Invalid base64 content.",
+                            })
                           })
                           const svgPreviewUrl = createMemo(() => {
                             if (!isSvg()) return

+ 10 - 0
packages/app/src/utils/base64.ts

@@ -0,0 +1,10 @@
+import { base64Decode } from "@opencode-ai/util/encode"
+
+export function decode64(value: string | undefined) {
+  if (value === undefined) return
+  try {
+    return base64Decode(value)
+  } catch {
+    return
+  }
+}

+ 26 - 4
packages/app/src/utils/persist.ts

@@ -151,7 +151,14 @@ function localStorageWithPrefix(prefix: string): SyncStorage {
       const cached = cache.get(name)
       if (fallback.disabled && cached !== undefined) return cached
 
-      const stored = localStorage.getItem(name)
+      const stored = (() => {
+        try {
+          return localStorage.getItem(name)
+        } catch {
+          fallback.disabled = true
+          return null
+        }
+      })()
       if (stored === null) return cached ?? null
       cache.set(name, stored)
       return stored
@@ -172,7 +179,11 @@ function localStorageWithPrefix(prefix: string): SyncStorage {
       const name = item(key)
       cache.delete(name)
       if (fallback.disabled) return
-      localStorage.removeItem(name)
+      try {
+        localStorage.removeItem(name)
+      } catch {
+        fallback.disabled = true
+      }
     },
   }
 }
@@ -183,7 +194,14 @@ function localStorageDirect(): SyncStorage {
       const cached = cache.get(key)
       if (fallback.disabled && cached !== undefined) return cached
 
-      const stored = localStorage.getItem(key)
+      const stored = (() => {
+        try {
+          return localStorage.getItem(key)
+        } catch {
+          fallback.disabled = true
+          return null
+        }
+      })()
       if (stored === null) return cached ?? null
       cache.set(key, stored)
       return stored
@@ -202,7 +220,11 @@ function localStorageDirect(): SyncStorage {
     removeItem: (key) => {
       cache.delete(key)
       if (fallback.disabled) return
-      localStorage.removeItem(key)
+      try {
+        localStorage.removeItem(key)
+      } catch {
+        fallback.disabled = true
+      }
     },
   }
 }