From 2f2882906b2feb98d4be140d0b6acd4e08e7ac38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sun, 12 Jul 2026 03:12:35 +0200 Subject: [PATCH 1/2] fix(ui): surface session loading failures Show detailed OpenCode errors and retry actions when the session list or a known session's messages cannot be loaded, instead of presenting an empty state or relying only on logs. Other instance data continues hydrating when the list request fails. Share error extraction and error-state rendering across list and message loading while keeping their state scoped by instance and session. Request IDs prevent stale overlapping list requests from replacing newer results, and retries without cached sessions display loading feedback. Add localized copy for all supported locales and tests for nested SDK errors, fallback messages, structured bodies, cyclic causes, and session-list error state. Validated with targeted Bun tests, UI typecheck, production build, and diff checks. --- .../ui/src/components/load-error-state.tsx | 40 ++++++++++++++ .../ui/src/components/message-section.tsx | 16 +++--- packages/ui/src/components/session-list.tsx | 36 ++++++++++-- .../ui/src/lib/i18n/messages/de/messaging.ts | 1 + .../ui/src/lib/i18n/messages/de/session.ts | 4 ++ .../ui/src/lib/i18n/messages/en/messaging.ts | 1 + .../ui/src/lib/i18n/messages/en/session.ts | 4 ++ .../ui/src/lib/i18n/messages/es/messaging.ts | 1 + .../ui/src/lib/i18n/messages/es/session.ts | 4 ++ .../ui/src/lib/i18n/messages/fr/messaging.ts | 1 + .../ui/src/lib/i18n/messages/fr/session.ts | 4 ++ .../ui/src/lib/i18n/messages/he/messaging.ts | 1 + .../ui/src/lib/i18n/messages/he/session.ts | 4 ++ .../ui/src/lib/i18n/messages/ja/messaging.ts | 1 + .../ui/src/lib/i18n/messages/ja/session.ts | 4 ++ .../ui/src/lib/i18n/messages/ne/messaging.ts | 1 + .../ui/src/lib/i18n/messages/ne/session.ts | 4 ++ .../ui/src/lib/i18n/messages/ru/messaging.ts | 1 + .../ui/src/lib/i18n/messages/ru/session.ts | 4 ++ .../lib/i18n/messages/zh-Hans/messaging.ts | 1 + .../src/lib/i18n/messages/zh-Hans/session.ts | 4 ++ packages/ui/src/lib/opencode-api.ts | 23 ++++++++ packages/ui/src/stores/instances.ts | 4 +- packages/ui/src/stores/session-api.ts | 51 ++++++++--------- .../ui/src/stores/session-list-error.test.ts | 55 +++++++++++++++++++ packages/ui/src/stores/session-state.ts | 19 +++++++ packages/ui/src/stores/sessions.ts | 2 + .../ui/src/styles/panels/empty-loading.css | 30 ---------- 28 files changed, 252 insertions(+), 69 deletions(-) create mode 100644 packages/ui/src/components/load-error-state.tsx create mode 100644 packages/ui/src/stores/session-list-error.test.ts diff --git a/packages/ui/src/components/load-error-state.tsx b/packages/ui/src/components/load-error-state.tsx new file mode 100644 index 000000000..9da704d7f --- /dev/null +++ b/packages/ui/src/components/load-error-state.tsx @@ -0,0 +1,40 @@ +import type { Component } from "solid-js" +import { AlertTriangle } from "lucide-solid" + +interface LoadErrorStateProps { + title: string + error: string + retryLabel: string + onRetry: () => void + variant?: "compact" | "panel" +} + +const LoadErrorState: Component = (props) => { + const content = ( +
+
+ ) + + if (props.variant === "compact") { + return + } + + return ( + + ) +} + +export default LoadErrorState diff --git a/packages/ui/src/components/message-section.tsx b/packages/ui/src/components/message-section.tsx index e56e7b341..a5c86e81d 100644 --- a/packages/ui/src/components/message-section.tsx +++ b/packages/ui/src/components/message-section.tsx @@ -2,6 +2,7 @@ import { Show, createEffect, createMemo, createSignal, onCleanup, on, untrack } import { ArrowUpDown, ChevronDown, ChevronUp, MoreHorizontal, Pause, Search, Trash, X } from "lucide-solid" import Kbd from "./kbd" import BrandedEmptyState from "./branded-empty-state" +import LoadErrorState from "./load-error-state" import MessageBlock from "./message-block" import { getMessageAnchorId } from "./message-anchors" import MessageTimeline, { buildTimelineSegments, type TimelineSegment } from "./message-timeline" @@ -1522,15 +1523,12 @@ export default function MessageSection(props: MessageSectionProps) { {(loadError) => ( -
-
-

{t("messageSection.loadError.title")}

-

{loadError()}

- -
-
+ props.onReloadMessages?.()} + /> )}
diff --git a/packages/ui/src/components/session-list.tsx b/packages/ui/src/components/session-list.tsx index 7f144076b..7f20d76de 100644 --- a/packages/ui/src/components/session-list.tsx +++ b/packages/ui/src/components/session-list.tsx @@ -4,6 +4,7 @@ import type { SessionThread } from "../stores/session-state" import { getRetrySeconds, getSessionIdleFadeClass, getSessionRetry, getSessionStatus, shouldShowSessionStatus } from "../stores/session-status" import { Bot, User, Copy, Trash2, Pencil, ShieldAlert, ChevronDown, Search, Square, CheckSquare, MinusSquare, Split, RotateCw } from "lucide-solid" import KeyboardHint from "./keyboard-hint" +import LoadErrorState from "./load-error-state" import SessionRenameDialog from "./session-rename-dialog" import { keyboardRegistry } from "../lib/keyboard-registry" import { showToastNotification } from "../lib/notifications" @@ -23,7 +24,9 @@ import { loadMoreSessions, searchSessions, getSessionHasMore, + getSessionListError, clearSessionSearch, + fetchSessions, getSessionSearchQuery, getSessionSearchThreads, isSessionSearchLoading, @@ -83,6 +86,13 @@ const SessionList: Component = (props) => { const isFetchingSessions = createMemo(() => { return loading().fetchingSessions.get(props.instanceId) ?? false }) + const sessionListError = createMemo(() => getSessionListError(props.instanceId)) + + const handleRetrySessions = () => { + void fetchSessions(props.instanceId, { reset: true }).catch((error) => { + log.error("Failed to retry session list:", error) + }) + } createEffect(() => { const el = sentinelEl() @@ -871,9 +881,27 @@ const SessionList: Component = (props) => {
listEl[1](el)}> - 0}> -
- + + {(error) => ( + + )} + + + +
+ {t("sessionList.loading.initial")} +
+
+ + 0}> +
+ {(thread, index) => ( = (props) => { isLastChild={index() === filteredThreads().length - 1} /> )} - +
setSentinelEl(el)} diff --git a/packages/ui/src/lib/i18n/messages/de/messaging.ts b/packages/ui/src/lib/i18n/messages/de/messaging.ts index da8a4e95d..f458cd175 100644 --- a/packages/ui/src/lib/i18n/messages/de/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/de/messaging.ts @@ -17,6 +17,7 @@ export const messagingMessages = { "messageSection.empty.tips.attachFilesPrefix": "Dateien anhängen mit", "messageSection.loading.messages": "Nachrichten werden geladen...", "messageSection.loadError.title": "Nachrichten konnten nicht geladen werden", + "messageSection.loadError.detail": "OpenCode hat die Sitzungsnachrichten nicht zurückgegeben.", "messageSection.loadError.reload": "Nachrichten neu laden", "messageSection.scroll.toFirstAriaLabel": "Zur ersten Nachricht scrollen", "messageSection.scroll.toLatestAriaLabel": "Zur neuesten Nachricht scrollen", diff --git a/packages/ui/src/lib/i18n/messages/de/session.ts b/packages/ui/src/lib/i18n/messages/de/session.ts index ddf29af30..71e4ea8e0 100644 --- a/packages/ui/src/lib/i18n/messages/de/session.ts +++ b/packages/ui/src/lib/i18n/messages/de/session.ts @@ -48,6 +48,10 @@ export const sessionMessages = { "sessionList.filter.placeholder": "Sitzungen suchen...", "sessionList.filter.ariaLabel": "Sitzungen suchen", "sessionList.loading.more": "Weitere Sitzungen werden geladen...", + "sessionList.loading.initial": "Sitzungen werden geladen...", + "sessionList.loadError.title": "Sitzungen konnten nicht geladen werden", + "sessionList.loadError.detail": "OpenCode hat keine Sitzungsliste zurückgegeben.", + "sessionList.loadError.retry": "Erneut versuchen", "sessionList.selection.selectAllLabel": "Alle auswählen", "sessionList.selection.selectAllAriaLabel": "Alle Sitzungen auswählen", "sessionList.selection.clearLabel": "Leeren", diff --git a/packages/ui/src/lib/i18n/messages/en/messaging.ts b/packages/ui/src/lib/i18n/messages/en/messaging.ts index b4e02141c..dd0005c92 100644 --- a/packages/ui/src/lib/i18n/messages/en/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/en/messaging.ts @@ -17,6 +17,7 @@ export const messagingMessages = { "messageSection.empty.tips.attachFilesPrefix": "Attach files with", "messageSection.loading.messages": "Loading messages...", "messageSection.loadError.title": "Could not load messages", + "messageSection.loadError.detail": "OpenCode did not return the session messages.", "messageSection.loadError.reload": "Reload messages", "messageSection.scroll.toFirstAriaLabel": "Scroll to first message", "messageSection.scroll.toLatestAriaLabel": "Scroll to latest message", diff --git a/packages/ui/src/lib/i18n/messages/en/session.ts b/packages/ui/src/lib/i18n/messages/en/session.ts index fa7a40217..cae0ab78d 100644 --- a/packages/ui/src/lib/i18n/messages/en/session.ts +++ b/packages/ui/src/lib/i18n/messages/en/session.ts @@ -48,6 +48,10 @@ export const sessionMessages = { "sessionList.filter.placeholder": "Search sessions…", "sessionList.filter.ariaLabel": "Search sessions", "sessionList.loading.more": "Loading more sessions…", + "sessionList.loading.initial": "Loading sessions…", + "sessionList.loadError.title": "Unable to load sessions", + "sessionList.loadError.detail": "OpenCode did not return the session list.", + "sessionList.loadError.retry": "Retry", "sessionList.selection.selectAllLabel": "Select all", "sessionList.selection.selectAllAriaLabel": "Select all sessions", "sessionList.selection.clearLabel": "Clear", diff --git a/packages/ui/src/lib/i18n/messages/es/messaging.ts b/packages/ui/src/lib/i18n/messages/es/messaging.ts index 3582ec38d..051d9f2ff 100644 --- a/packages/ui/src/lib/i18n/messages/es/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/es/messaging.ts @@ -17,6 +17,7 @@ export const messagingMessages = { "messageSection.empty.tips.attachFilesPrefix": "Adjunta archivos con", "messageSection.loading.messages": "Cargando mensajes...", "messageSection.loadError.title": "No se pudieron cargar los mensajes", + "messageSection.loadError.detail": "OpenCode no devolvió los mensajes de la sesión.", "messageSection.loadError.reload": "Recargar mensajes", "messageSection.scroll.toFirstAriaLabel": "Desplazarse al primer mensaje", "messageSection.scroll.toLatestAriaLabel": "Desplazarse al último mensaje", diff --git a/packages/ui/src/lib/i18n/messages/es/session.ts b/packages/ui/src/lib/i18n/messages/es/session.ts index 7ce3e3eb0..0f9e6ba7d 100644 --- a/packages/ui/src/lib/i18n/messages/es/session.ts +++ b/packages/ui/src/lib/i18n/messages/es/session.ts @@ -48,6 +48,10 @@ export const sessionMessages = { "sessionList.filter.placeholder": "Buscar sesiones…", "sessionList.filter.ariaLabel": "Buscar sesiones", "sessionList.loading.more": "Cargando más sesiones…", + "sessionList.loading.initial": "Cargando sesiones…", + "sessionList.loadError.title": "No se pudieron cargar las sesiones", + "sessionList.loadError.detail": "OpenCode no devolvió la lista de sesiones.", + "sessionList.loadError.retry": "Reintentar", "sessionList.selection.selectAllLabel": "Seleccionar todo", "sessionList.selection.selectAllAriaLabel": "Seleccionar todas las sesiones", "sessionList.selection.clearLabel": "Limpiar", diff --git a/packages/ui/src/lib/i18n/messages/fr/messaging.ts b/packages/ui/src/lib/i18n/messages/fr/messaging.ts index a42be9075..d02c6835a 100644 --- a/packages/ui/src/lib/i18n/messages/fr/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/fr/messaging.ts @@ -17,6 +17,7 @@ export const messagingMessages = { "messageSection.empty.tips.attachFilesPrefix": "Joindre des fichiers avec", "messageSection.loading.messages": "Chargement des messages...", "messageSection.loadError.title": "Impossible de charger les messages", + "messageSection.loadError.detail": "OpenCode n'a pas renvoyé les messages de la session.", "messageSection.loadError.reload": "Recharger les messages", "messageSection.scroll.toFirstAriaLabel": "Aller au premier message", "messageSection.scroll.toLatestAriaLabel": "Aller au dernier message", diff --git a/packages/ui/src/lib/i18n/messages/fr/session.ts b/packages/ui/src/lib/i18n/messages/fr/session.ts index 5273f0fea..ff3672d4c 100644 --- a/packages/ui/src/lib/i18n/messages/fr/session.ts +++ b/packages/ui/src/lib/i18n/messages/fr/session.ts @@ -48,6 +48,10 @@ export const sessionMessages = { "sessionList.filter.placeholder": "Rechercher des sessions…", "sessionList.filter.ariaLabel": "Rechercher des sessions", "sessionList.loading.more": "Chargement de plus de sessions…", + "sessionList.loading.initial": "Chargement des sessions…", + "sessionList.loadError.title": "Impossible de charger les sessions", + "sessionList.loadError.detail": "OpenCode n'a pas renvoyé la liste des sessions.", + "sessionList.loadError.retry": "Réessayer", "sessionList.selection.selectAllLabel": "Tout sélectionner", "sessionList.selection.selectAllAriaLabel": "Sélectionner toutes les sessions", "sessionList.selection.clearLabel": "Effacer", diff --git a/packages/ui/src/lib/i18n/messages/he/messaging.ts b/packages/ui/src/lib/i18n/messages/he/messaging.ts index e7ce05051..f8baf246c 100644 --- a/packages/ui/src/lib/i18n/messages/he/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/he/messaging.ts @@ -17,6 +17,7 @@ export const messagingMessages = { "messageSection.empty.tips.attachFilesPrefix": "צרף קבצים עם", "messageSection.loading.messages": "טוען הודעות...", "messageSection.loadError.title": "לא ניתן לטעון הודעות", + "messageSection.loadError.detail": "OpenCode לא החזיר את הודעות הסשן.", "messageSection.loadError.reload": "טען הודעות מחדש", "messageSection.scroll.toFirstAriaLabel": "גלול להודעה הראשונה", "messageSection.scroll.toLatestAriaLabel": "גלול להודעה האחרונה", diff --git a/packages/ui/src/lib/i18n/messages/he/session.ts b/packages/ui/src/lib/i18n/messages/he/session.ts index 55ba4a79d..f8c775436 100644 --- a/packages/ui/src/lib/i18n/messages/he/session.ts +++ b/packages/ui/src/lib/i18n/messages/he/session.ts @@ -48,6 +48,10 @@ export const sessionMessages = { "sessionList.filter.placeholder": "חפש סשנים…", "sessionList.filter.ariaLabel": "חפש סשנים", "sessionList.loading.more": "טוען עוד סשנים…", + "sessionList.loading.initial": "טוען סשנים…", + "sessionList.loadError.title": "לא ניתן לטעון את הסשנים", + "sessionList.loadError.detail": "OpenCode לא החזיר את רשימת הסשנים.", + "sessionList.loadError.retry": "נסה שוב", "sessionList.selection.selectAllLabel": "בחר הכל", "sessionList.selection.selectAllAriaLabel": "בחר את כל הסשנים", "sessionList.selection.clearLabel": "נקה", diff --git a/packages/ui/src/lib/i18n/messages/ja/messaging.ts b/packages/ui/src/lib/i18n/messages/ja/messaging.ts index ce2322c71..c71aea59f 100644 --- a/packages/ui/src/lib/i18n/messages/ja/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/ja/messaging.ts @@ -17,6 +17,7 @@ export const messagingMessages = { "messageSection.empty.tips.attachFilesPrefix": "次でファイルを添付:", "messageSection.loading.messages": "メッセージを読み込み中...", "messageSection.loadError.title": "メッセージを読み込めませんでした", + "messageSection.loadError.detail": "OpenCode からセッションメッセージが返されませんでした。", "messageSection.loadError.reload": "メッセージを再読み込み", "messageSection.scroll.toFirstAriaLabel": "最初のメッセージへスクロール", "messageSection.scroll.toLatestAriaLabel": "最新のメッセージへスクロール", diff --git a/packages/ui/src/lib/i18n/messages/ja/session.ts b/packages/ui/src/lib/i18n/messages/ja/session.ts index 64fe690d8..4b31b820e 100644 --- a/packages/ui/src/lib/i18n/messages/ja/session.ts +++ b/packages/ui/src/lib/i18n/messages/ja/session.ts @@ -48,6 +48,10 @@ export const sessionMessages = { "sessionList.filter.placeholder": "セッションを検索…", "sessionList.filter.ariaLabel": "セッションを検索", "sessionList.loading.more": "セッションをさらに読み込んでいます…", + "sessionList.loading.initial": "セッションを読み込んでいます…", + "sessionList.loadError.title": "セッションを読み込めません", + "sessionList.loadError.detail": "OpenCode からセッション一覧が返されませんでした。", + "sessionList.loadError.retry": "再試行", "sessionList.selection.selectAllLabel": "すべて選択", "sessionList.selection.selectAllAriaLabel": "すべてのセッションを選択", "sessionList.selection.clearLabel": "クリア", diff --git a/packages/ui/src/lib/i18n/messages/ne/messaging.ts b/packages/ui/src/lib/i18n/messages/ne/messaging.ts index dd4579ed9..023a31e93 100644 --- a/packages/ui/src/lib/i18n/messages/ne/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/ne/messaging.ts @@ -17,6 +17,7 @@ export const messagingMessages = { "messageSection.empty.tips.attachFilesPrefix": "यसको साथ फाइलहरू संलग्न गर्नुहोस्", "messageSection.loading.messages": "सन्देशहरू लोड गर्दै...", "messageSection.loadError.title": "सन्देशहरू लोड गर्न सकिएन", + "messageSection.loadError.detail": "OpenCode ले सत्रका सन्देशहरू फिर्ता गरेन।", "messageSection.loadError.reload": "सन्देशहरू फेरि लोड गर्नुहोस्", "messageSection.scroll.toFirstAriaLabel": "पहिलो सन्देशमा जानुहोस्", "messageSection.scroll.toLatestAriaLabel": "भर्खरको सन्देशमा जानुहोस्", diff --git a/packages/ui/src/lib/i18n/messages/ne/session.ts b/packages/ui/src/lib/i18n/messages/ne/session.ts index 267dac5f7..318edb182 100644 --- a/packages/ui/src/lib/i18n/messages/ne/session.ts +++ b/packages/ui/src/lib/i18n/messages/ne/session.ts @@ -48,6 +48,10 @@ export const sessionMessages = { "sessionList.filter.placeholder": "सत्रहरू खोज्नुहोस्...", "sessionList.filter.ariaLabel": "सत्रहरू खोज्नुहोस्", "sessionList.loading.more": "थप सत्रहरू लोड गर्दै...", + "sessionList.loading.initial": "सत्रहरू लोड गर्दै...", + "sessionList.loadError.title": "सत्रहरू लोड गर्न सकिएन", + "sessionList.loadError.detail": "OpenCode ले सत्र सूची फिर्ता गरेन।", + "sessionList.loadError.retry": "पुन: प्रयास गर्नुहोस्", "sessionList.selection.selectAllLabel": "सबै चयन गर्नुहोस्", "sessionList.selection.selectAllAriaLabel": "सबै सत्रहरू चयन गर्नुहोस्", "sessionList.selection.clearLabel": "सफा गर्नुहोस्", diff --git a/packages/ui/src/lib/i18n/messages/ru/messaging.ts b/packages/ui/src/lib/i18n/messages/ru/messaging.ts index ee9ee7b5f..3cdb943eb 100644 --- a/packages/ui/src/lib/i18n/messages/ru/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/ru/messaging.ts @@ -17,6 +17,7 @@ export const messagingMessages = { "messageSection.empty.tips.attachFilesPrefix": "Прикрепляйте файлы через", "messageSection.loading.messages": "Загрузка сообщений…", "messageSection.loadError.title": "Не удалось загрузить сообщения", + "messageSection.loadError.detail": "OpenCode не вернул сообщения сессии.", "messageSection.loadError.reload": "Перезагрузить сообщения", "messageSection.scroll.toFirstAriaLabel": "Прокрутить к первому сообщению", "messageSection.scroll.toLatestAriaLabel": "Прокрутить к последнему сообщению", diff --git a/packages/ui/src/lib/i18n/messages/ru/session.ts b/packages/ui/src/lib/i18n/messages/ru/session.ts index 888ee06bd..21abbca27 100644 --- a/packages/ui/src/lib/i18n/messages/ru/session.ts +++ b/packages/ui/src/lib/i18n/messages/ru/session.ts @@ -48,6 +48,10 @@ export const sessionMessages = { "sessionList.filter.placeholder": "Поиск сессий…", "sessionList.filter.ariaLabel": "Поиск сессий", "sessionList.loading.more": "Загрузка дополнительных сессий…", + "sessionList.loading.initial": "Загрузка сессий…", + "sessionList.loadError.title": "Не удалось загрузить сессии", + "sessionList.loadError.detail": "OpenCode не вернул список сессий.", + "sessionList.loadError.retry": "Повторить", "sessionList.selection.selectAllLabel": "Выбрать все", "sessionList.selection.selectAllAriaLabel": "Выбрать все сессии", "sessionList.selection.clearLabel": "Очистить", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/messaging.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/messaging.ts index 4e28fd932..5b32cbaa9 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/messaging.ts @@ -17,6 +17,7 @@ export const messagingMessages = { "messageSection.empty.tips.attachFilesPrefix": "通过以下方式附加文件", "messageSection.loading.messages": "正在加载消息...", "messageSection.loadError.title": "无法加载消息", + "messageSection.loadError.detail": "OpenCode 未返回会话消息。", "messageSection.loadError.reload": "重新加载消息", "messageSection.scroll.toFirstAriaLabel": "滚动到第一条消息", "messageSection.scroll.toLatestAriaLabel": "滚动到最新消息", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/session.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/session.ts index f4dc3d969..55a0faf59 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/session.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/session.ts @@ -48,6 +48,10 @@ export const sessionMessages = { "sessionList.filter.placeholder": "搜索会话…", "sessionList.filter.ariaLabel": "搜索会话", "sessionList.loading.more": "正在加载更多会话…", + "sessionList.loading.initial": "正在加载会话…", + "sessionList.loadError.title": "无法加载会话", + "sessionList.loadError.detail": "OpenCode 未返回会话列表。", + "sessionList.loadError.retry": "重试", "sessionList.selection.selectAllLabel": "全选", "sessionList.selection.selectAllAriaLabel": "选择所有会话", "sessionList.selection.clearLabel": "清除", diff --git a/packages/ui/src/lib/opencode-api.ts b/packages/ui/src/lib/opencode-api.ts index 58c4f24a9..70bbd73d8 100644 --- a/packages/ui/src/lib/opencode-api.ts +++ b/packages/ui/src/lib/opencode-api.ts @@ -10,6 +10,29 @@ export class OpencodeApiError extends Error { } } +export function getOpencodeErrorMessage(error: unknown, fallback: string): string { + const seen = new Set() + + const extract = (value: unknown): string | undefined => { + if (typeof value === "string") return value.trim() || undefined + if (!value || typeof value !== "object" || seen.has(value)) return undefined + seen.add(value) + + const candidate = value as any + const direct = [candidate.data?.message, candidate.body?.message, candidate.error] + .find((item) => typeof item === "string" && item.trim()) + if (direct) return direct.trim() + + const nested = extract(candidate.cause) ?? extract(candidate.error) ?? extract(candidate.body) + if (nested) return nested + + if (typeof candidate.message === "string" && candidate.message.trim()) return candidate.message.trim() + return undefined + } + + return extract(error) ?? fallback +} + type RequestResultLike = | { data: T diff --git a/packages/ui/src/stores/instances.ts b/packages/ui/src/stores/instances.ts index 056ebbeae..21db295bc 100644 --- a/packages/ui/src/stores/instances.ts +++ b/packages/ui/src/stores/instances.ts @@ -419,7 +419,9 @@ async function hydrateInstanceData(instanceId: string, options?: { force?: boole } await syncOpenCodeWorkspaces(instanceId) resetSessionPagination(instanceId) - await fetchSessions(instanceId) + await fetchSessions(instanceId).catch((error) => { + log.error("Failed to hydrate sessions", { instanceId, error }) + }) await fetchAgents(instanceId) await fetchProviders(instanceId) await ensureInstanceConfigLoaded(instanceId) diff --git a/packages/ui/src/stores/session-api.ts b/packages/ui/src/stores/session-api.ts index ef716b856..27431743a 100644 --- a/packages/ui/src/stores/session-api.ts +++ b/packages/ui/src/stores/session-api.ts @@ -43,6 +43,7 @@ import { clearSessionSearch, isLatestSessionSearch, setSessionSearchResults, + setSessionListError, } from "./session-state" import { DEFAULT_MODEL_OUTPUT_LIMIT, getDefaultModel, isModelValid } from "./session-models" import { normalizeMessagePart } from "./message-v2/normalizers" @@ -51,8 +52,9 @@ import { seedSessionMessagesV2, reconcilePendingPermissionsV2, reconcilePendingQ import { messageStoreBus } from "./message-v2/bus" import { clearCacheForSession } from "../lib/global-cache" import { getLogger } from "../lib/logger" -import { requestData } from "../lib/opencode-api" +import { getOpencodeErrorMessage, requestData } from "../lib/opencode-api" import { getRootClient } from "./opencode-client" +import { tGlobal } from "../lib/i18n" import { getWorktreeSlugForSession, getWorktrees, @@ -66,26 +68,16 @@ import { hydrateSessionMetadataWithClient } from "./session-metadata" import { PROJECT_SESSION_LIST_LIMIT, buildProjectSessionListOptions, filterProjectScopedSessions } from "./session-list-options" const log = getLogger("api") +const sessionListRequestIds = new Map() -function getErrorMessage(error: unknown): string { - if (!error) return "Failed to load messages" - - const cause = (error as any)?.cause - if (cause && cause !== error) { - const causeMessage = getErrorMessage(cause) - if (causeMessage) return causeMessage - } - - const dataMessage = (error as any)?.data?.message - if (typeof dataMessage === "string" && dataMessage.trim()) return dataMessage - - const errorMessage = (error as any)?.message - if (typeof errorMessage === "string" && errorMessage.trim()) return errorMessage - - const errorText = (error as any)?.error - if (typeof errorText === "string" && errorText.trim()) return errorText +function beginSessionListRequest(instanceId: string): number { + const requestId = (sessionListRequestIds.get(instanceId) ?? 0) + 1 + sessionListRequestIds.set(instanceId, requestId) + return requestId +} - return "Failed to load messages" +function isLatestSessionListRequest(instanceId: string, requestId: number): boolean { + return sessionListRequestIds.get(instanceId) === requestId } async function getSessionWorkspacePayload(instanceId: string, sessionId: string): Promise<{ workspace?: string }> { @@ -209,18 +201,21 @@ async function fetchSessions(instanceId: string, options?: { reset?: boolean }): } const rootClient = getRootClient(instanceId) + const requestId = beginSessionListRequest(instanceId) setLoading((prev) => { const next = { ...prev } next.fetchingSessions.set(instanceId, true) return next }) + setSessionListError(instanceId, null) try { const sessionListOptions = instance.folder ? { directory: instance.folder } : {} log.info("session.list", { instanceId, limit: PROJECT_SESSION_LIST_LIMIT, directory: sessionListOptions.directory, scope: "project" }) const response = await fetchV2Sessions(instanceId, sessionListOptions) + if (!isLatestSessionListRequest(instanceId, requestId)) return let statusById: Record = {} try { @@ -231,6 +226,7 @@ async function fetchSessions(instanceId: string, options?: { reset?: boolean }): } catch (error) { log.error("Failed to fetch session status:", error) } + if (!isLatestSessionListRequest(instanceId, requestId)) return const existingSessions = sessions().get(instanceId) const sessionMap = new Map() @@ -320,13 +316,18 @@ async function fetchSessions(instanceId: string, options?: { reset?: boolean }): }) } catch (error) { log.error("Failed to fetch sessions:", error) + if (isLatestSessionListRequest(instanceId, requestId)) { + setSessionListError(instanceId, getOpencodeErrorMessage(error, tGlobal("sessionList.loadError.detail"))) + } throw error } finally { - setLoading((prev) => { - const next = { ...prev } - next.fetchingSessions.set(instanceId, false) - return next - }) + if (isLatestSessionListRequest(instanceId, requestId)) { + setLoading((prev) => { + const next = { ...prev } + next.fetchingSessions.set(instanceId, false) + return next + }) + } } } @@ -970,7 +971,7 @@ async function loadMessages( } catch (error) { log.error("Failed to load messages:", error) - setSessionMessagesLoadError(instanceId, sessionId, getErrorMessage(error)) + setSessionMessagesLoadError(instanceId, sessionId, getOpencodeErrorMessage(error, tGlobal("messageSection.loadError.detail"))) throw error } finally { setLoading((prev) => { diff --git a/packages/ui/src/stores/session-list-error.test.ts b/packages/ui/src/stores/session-list-error.test.ts new file mode 100644 index 000000000..dd197d630 --- /dev/null +++ b/packages/ui/src/stores/session-list-error.test.ts @@ -0,0 +1,55 @@ +import assert from "node:assert/strict" +import { afterEach, describe, it } from "node:test" + +import { getOpencodeErrorMessage } from "../lib/opencode-api.ts" +import { getSessionListError, setSessionListError } from "./session-state.ts" + +const instanceId = "session-list-error-test" + +afterEach(() => setSessionListError(instanceId, null)) + +describe("session list errors", () => { + it("stores and clears errors per instance", () => { + setSessionListError(instanceId, "Invalid session data") + assert.equal(getSessionListError(instanceId), "Invalid session data") + + setSessionListError(instanceId, null) + assert.equal(getSessionListError(instanceId), undefined) + }) +}) + +describe("OpenCode error messages", () => { + it("uses the detailed message from a nested SDK error", () => { + const error = new Error("session.list failed") + ;(error as Error & { cause: unknown }).cause = { + data: { message: 'Expected DateTime.Utc at ["items"][37]["time"]["archived"]' }, + } + + assert.equal( + getOpencodeErrorMessage(error, "Unable to load sessions"), + 'Expected DateTime.Utc at ["items"][37]["time"]["archived"]', + ) + }) + + it("uses the contextual fallback when no detail is available", () => { + assert.equal(getOpencodeErrorMessage({}, "Unable to load sessions"), "Unable to load sessions") + }) + + it("keeps a useful outer message when the cause has no detail", () => { + const error = new Error("Network request failed") + ;(error as Error & { cause: unknown }).cause = { code: "ECONNRESET" } + assert.equal(getOpencodeErrorMessage(error, "Unable to load sessions"), "Network request failed") + }) + + it("reads structured response bodies and handles cyclic causes", () => { + assert.equal( + getOpencodeErrorMessage({ body: { message: "Invalid session timestamp" } }, "Unable to load sessions"), + "Invalid session timestamp", + ) + + const first: { cause?: unknown } = {} + const second: { cause?: unknown } = { cause: first } + first.cause = second + assert.equal(getOpencodeErrorMessage(first, "Unable to load sessions"), "Unable to load sessions") + }) +}) diff --git a/packages/ui/src/stores/session-state.ts b/packages/ui/src/stores/session-state.ts index 0b5b57233..ccd953c7a 100644 --- a/packages/ui/src/stores/session-state.ts +++ b/packages/ui/src/stores/session-state.ts @@ -54,6 +54,7 @@ const [loading, setLoading] = createSignal({ const [messagesLoaded, setMessagesLoaded] = createSignal>>(new Map()) const [messageLoadErrors, setMessageLoadErrors] = createSignal>>(new Map()) +const [sessionListErrors, setSessionListErrors] = createSignal>(new Map()) const [sessionInfoByInstance, setSessionInfoByInstance] = createSignal>>(new Map()) const [threadTotalsByInstance, setThreadTotalsByInstance] = createSignal>>(new Map()) @@ -727,6 +728,22 @@ function getSessionMessagesLoadError(instanceId: string, sessionId: string): str return messageLoadErrors().get(instanceId)?.get(sessionId) } +function getSessionListError(instanceId: string): string | undefined { + return sessionListErrors().get(instanceId) +} + +function setSessionListError(instanceId: string, error: string | null): void { + setSessionListErrors((prev) => { + const next = new Map(prev) + if (error) { + next.set(instanceId, error) + } else { + next.delete(instanceId) + } + return next + }) +} + function setSessionMessagesLoadError(instanceId: string, sessionId: string, error: string | null): void { setMessageLoadErrors((prev) => { const next = new Map(prev) @@ -907,6 +924,8 @@ export { setLoading, messagesLoaded, setMessagesLoaded, + getSessionListError, + setSessionListError, setSessionMessagesLoadError, sessionInfoByInstance, setSessionInfoByInstance, diff --git a/packages/ui/src/stores/sessions.ts b/packages/ui/src/stores/sessions.ts index 6b5a5ff4d..dcf8807f4 100644 --- a/packages/ui/src/stores/sessions.ts +++ b/packages/ui/src/stores/sessions.ts @@ -20,6 +20,7 @@ import { getSessionDraftPrompt, getSessionFamily, getSessionInfo, + getSessionListError, getSessionMessagesLoadError, getSessionSearchQuery, getSessionSearchThreads, @@ -134,6 +135,7 @@ export { getSessionDraftPrompt, getSessionFamily, getSessionInfo, + getSessionListError, getSessionMessagesLoadError, getSessionSearchQuery, getSessionSearchThreads, diff --git a/packages/ui/src/styles/panels/empty-loading.css b/packages/ui/src/styles/panels/empty-loading.css index c558262d4..dd4f6e7af 100644 --- a/packages/ui/src/styles/panels/empty-loading.css +++ b/packages/ui/src/styles/panels/empty-loading.css @@ -64,33 +64,3 @@ border-top-color: var(--accent-primary); animation: spin 1s linear infinite; } - -.message-load-error-state { - @apply flex-1 flex items-center justify-center p-12; -} - -.message-load-error-card { - @apply max-w-md rounded-xl border p-6 text-center shadow-sm; - background: var(--surface-secondary); - border-color: var(--border-base); -} - -.message-load-error-card h3 { - @apply text-base font-semibold; - color: var(--text-primary); -} - -.message-load-error-card p { - @apply mt-3 text-sm leading-6 break-words; - color: var(--text-muted); -} - -.message-load-error-retry { - @apply mt-5 rounded-md px-4 py-2 text-sm font-medium transition-colors; - background: var(--accent-primary); - color: var(--text-on-accent); -} - -.message-load-error-retry:hover { - background: var(--accent-hover); -} From 822c4f44164121cf88fd5ef01783b69fc922e5ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sun, 12 Jul 2026 21:19:28 +0200 Subject: [PATCH 2/2] fix(ui): reconcile pending session indicators Restore pending permission and question indicators when interruption queues hydrate before a failed session list is retried. Reconcile the queue-derived state after list and queue synchronization so recovery no longer depends on another SSE event. Clear per-instance request state during teardown and use a monotonic request generation to keep late responses stale even if an instance ID is reused. Move error extraction coverage out of the client-only store graph and add server-safe tests for pending-state reconciliation. Validated with focused Bun tests, UI typechecking, the production UI build, and git diff checks. --- .../opencode-api.test.ts} | 21 ++-------- packages/ui/src/stores/instances.ts | 20 +++++++++- packages/ui/src/stores/session-api.ts | 19 +++++++-- .../src/stores/session-pending-state.test.ts | 40 +++++++++++++++++++ .../ui/src/stores/session-pending-state.ts | 24 +++++++++++ packages/ui/src/stores/session-state.ts | 21 ++++++++++ packages/ui/src/stores/sessions.ts | 2 + 7 files changed, 125 insertions(+), 22 deletions(-) rename packages/ui/src/{stores/session-list-error.test.ts => lib/opencode-api.test.ts} (67%) create mode 100644 packages/ui/src/stores/session-pending-state.test.ts create mode 100644 packages/ui/src/stores/session-pending-state.ts diff --git a/packages/ui/src/stores/session-list-error.test.ts b/packages/ui/src/lib/opencode-api.test.ts similarity index 67% rename from packages/ui/src/stores/session-list-error.test.ts rename to packages/ui/src/lib/opencode-api.test.ts index dd197d630..0897e435a 100644 --- a/packages/ui/src/stores/session-list-error.test.ts +++ b/packages/ui/src/lib/opencode-api.test.ts @@ -1,24 +1,9 @@ import assert from "node:assert/strict" -import { afterEach, describe, it } from "node:test" +import { describe, it } from "node:test" -import { getOpencodeErrorMessage } from "../lib/opencode-api.ts" -import { getSessionListError, setSessionListError } from "./session-state.ts" +import { getOpencodeErrorMessage } from "./opencode-api.ts" -const instanceId = "session-list-error-test" - -afterEach(() => setSessionListError(instanceId, null)) - -describe("session list errors", () => { - it("stores and clears errors per instance", () => { - setSessionListError(instanceId, "Invalid session data") - assert.equal(getSessionListError(instanceId), "Invalid session data") - - setSessionListError(instanceId, null) - assert.equal(getSessionListError(instanceId), undefined) - }) -}) - -describe("OpenCode error messages", () => { +describe("getOpencodeErrorMessage", () => { it("uses the detailed message from a nested SDK error", () => { const error = new Error("session.list failed") ;(error as Error & { cause: unknown }).cause = { diff --git a/packages/ui/src/stores/instances.ts b/packages/ui/src/stores/instances.ts index 21db295bc..9fc4cdcd0 100644 --- a/packages/ui/src/stores/instances.ts +++ b/packages/ui/src/stores/instances.ts @@ -17,6 +17,7 @@ import { fetchAgents, fetchProviders, clearInstanceDraftPrompts, + clearSessionListRequestState, resetSessionPagination, } from "./sessions" import { @@ -30,7 +31,12 @@ import { getRootClient } from "./opencode-client" import { clearOpenCodeWorkspaceCache, getOpenCodeWorkspaceIdForSession, getOpenCodeWorkspaceIdForWorktree, syncOpenCodeWorkspaces } from "./opencode-workspaces" import { fetchCommands, clearCommands } from "./commands" import { serverSettings } from "./preferences" -import { sessions, setSessionPendingPermission, setSessionPendingQuestion } from "./session-state" +import { + reconcileSessionPendingState, + sessions, + setSessionPendingPermission, + setSessionPendingQuestion, +} from "./session-state" import { setHasInstances } from "./ui" import { messageStoreBus } from "./message-v2/bus" import { upsertPermissionV2, removePermissionV2, upsertQuestionV2, removeQuestionV2 } from "./message-v2/bridge" @@ -207,6 +213,14 @@ const MAX_LOG_ENTRIES = 1000 const pendingDisposeRequests = new Map>() const pendingRehydrations = new Map>() +function reconcilePendingSessionIndicators(instanceId: string): void { + reconcileSessionPendingState( + instanceId, + new Set(permissionSessionCounts.get(instanceId)?.keys() ?? []), + new Set(questionSessionCounts.get(instanceId)?.keys() ?? []), + ) +} + function workspaceDescriptorToInstance(descriptor: WorkspaceDescriptor, projectName?: string): Instance { const existing = instances().get(descriptor.id) return { @@ -351,6 +365,7 @@ async function syncPendingPermissions(instanceId: string): Promise { const queuedPermission = addPermissionToQueue(instanceId, permission, source) ?? permission upsertPermissionV2(instanceId, queuedPermission) } + reconcilePendingSessionIndicators(instanceId) } catch (error) { log.warn("Failed to sync pending permissions", { instanceId, error }) } @@ -403,6 +418,7 @@ async function syncPendingQuestions(instanceId: string): Promise { addQuestionToQueue(instanceId, request, source) upsertQuestionV2(instanceId, request) } + reconcilePendingSessionIndicators(instanceId) } catch (error) { log.warn("Failed to sync pending questions", { instanceId, error }) } @@ -696,6 +712,7 @@ function removeInstance(id: string) { clearCacheForInstance(id) messageStoreBus.unregisterInstance(id) clearInstanceDraftPrompts(id) + clearSessionListRequestState(id) syncHasInstancesFlag() } @@ -1476,4 +1493,5 @@ export { acknowledgeDisconnectedInstance, fetchLspStatus, disposeInstance, + reconcilePendingSessionIndicators, } diff --git a/packages/ui/src/stores/session-api.ts b/packages/ui/src/stores/session-api.ts index 27431743a..9b5978252 100644 --- a/packages/ui/src/stores/session-api.ts +++ b/packages/ui/src/stores/session-api.ts @@ -9,7 +9,7 @@ import { import type { Message } from "../types/message" import type { Session as SDKSession, SessionListResponse } from "@opencode-ai/sdk/v2/client" -import { instances } from "./instances" +import { instances, reconcilePendingSessionIndicators } from "./instances" import { preferences, setAgentModelPreference } from "./preferences" import { activeSessionId, @@ -69,9 +69,10 @@ import { PROJECT_SESSION_LIST_LIMIT, buildProjectSessionListOptions, filterProje const log = getLogger("api") const sessionListRequestIds = new Map() +let nextSessionListRequestId = 0 function beginSessionListRequest(instanceId: string): number { - const requestId = (sessionListRequestIds.get(instanceId) ?? 0) + 1 + const requestId = ++nextSessionListRequestId sessionListRequestIds.set(instanceId, requestId) return requestId } @@ -80,6 +81,17 @@ function isLatestSessionListRequest(instanceId: string, requestId: number): bool return sessionListRequestIds.get(instanceId) === requestId } +function clearSessionListRequestState(instanceId: string): void { + sessionListRequestIds.delete(instanceId) + setSessionListError(instanceId, null) + setLoading((prev) => { + if (!prev.fetchingSessions.has(instanceId)) return prev + const fetchingSessions = new Map(prev.fetchingSessions) + fetchingSessions.delete(instanceId) + return { ...prev, fetchingSessions } + }) +} + async function getSessionWorkspacePayload(instanceId: string, sessionId: string): Promise<{ workspace?: string }> { const workspace = await getOpenCodeWorkspaceIdForSession(instanceId, sessionId) return workspace ? { workspace } : {} @@ -288,7 +300,7 @@ async function fetchSessions(instanceId: string, options?: { reset?: boolean }): setSessionPage(instanceId, rootIds, false, options?.reset ?? true) - syncInstanceSessionIndicator(instanceId) + reconcilePendingSessionIndicators(instanceId) setMessagesLoaded((prev) => { const next = new Map(prev) @@ -1011,4 +1023,5 @@ export { searchSessions, forkSession, loadMessages, + clearSessionListRequestState, } diff --git a/packages/ui/src/stores/session-pending-state.test.ts b/packages/ui/src/stores/session-pending-state.test.ts new file mode 100644 index 000000000..bacb17910 --- /dev/null +++ b/packages/ui/src/stores/session-pending-state.test.ts @@ -0,0 +1,40 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { applySessionPendingState } from "./session-pending-state.ts" + +describe("applySessionPendingState", () => { + it("reconciles interruptions that arrived before sessions", () => { + const sessions = new Map([ + ["permission", { id: "permission" }], + ["question", { id: "question" }], + ["idle", { id: "idle", pendingPermission: true }], + ]) + + const result = applySessionPendingState(sessions, new Set(["permission"]), new Set(["question"])) + + assert.deepEqual(result.get("permission"), { + id: "permission", + pendingPermission: true, + pendingQuestion: false, + }) + assert.deepEqual(result.get("question"), { + id: "question", + pendingPermission: false, + pendingQuestion: true, + }) + assert.deepEqual(result.get("idle"), { + id: "idle", + pendingPermission: false, + pendingQuestion: false, + }) + }) + + it("preserves the map when pending state already matches", () => { + const sessions = new Map([ + ["session", { id: "session", pendingPermission: true, pendingQuestion: false }], + ]) + + assert.equal(applySessionPendingState(sessions, new Set(["session"]), new Set()), sessions) + }) +}) diff --git a/packages/ui/src/stores/session-pending-state.ts b/packages/ui/src/stores/session-pending-state.ts new file mode 100644 index 000000000..10413f9f9 --- /dev/null +++ b/packages/ui/src/stores/session-pending-state.ts @@ -0,0 +1,24 @@ +type SessionPendingState = { + id: string + pendingPermission?: boolean + pendingQuestion?: boolean +} + +export function applySessionPendingState( + sessions: Map, + permissionSessionIds: ReadonlySet, + questionSessionIds: ReadonlySet, +): Map { + let next = sessions + + for (const [sessionId, session] of sessions) { + const pendingPermission = permissionSessionIds.has(sessionId) + const pendingQuestion = questionSessionIds.has(sessionId) + if (session.pendingPermission === pendingPermission && session.pendingQuestion === pendingQuestion) continue + + if (next === sessions) next = new Map(sessions) + next.set(sessionId, { ...session, pendingPermission, pendingQuestion }) + } + + return next +} diff --git a/packages/ui/src/stores/session-state.ts b/packages/ui/src/stores/session-state.ts index ccd953c7a..212c23b7d 100644 --- a/packages/ui/src/stores/session-state.ts +++ b/packages/ui/src/stores/session-state.ts @@ -13,6 +13,7 @@ import { getOpenCodeWorkspaceIdForSession } from "./opencode-workspaces" import { tGlobal } from "../lib/i18n" import { computeThreadTotals, type ThreadTotals } from "../lib/thread-totals" import { applySessionPage, getDefaultSessionPaginationState, type SessionPaginationState } from "./session-pagination-model" +import { applySessionPendingState } from "./session-pending-state" import { buildSessionThreadsFromMap, collectVisibleSessionIds, @@ -436,6 +437,25 @@ function setSessionPendingQuestion(instanceId: string, sessionId: string, pendin }) } +function reconcileSessionPendingState( + instanceId: string, + permissionSessionIds: ReadonlySet, + questionSessionIds: ReadonlySet, +): void { + setSessions((prev) => { + const instanceSessions = prev.get(instanceId) + if (!instanceSessions) return prev + + const reconciled = applySessionPendingState(instanceSessions, permissionSessionIds, questionSessionIds) + if (reconciled === instanceSessions) return prev + + const next = new Map(prev) + next.set(instanceId, reconciled) + return next + }) + syncInstanceSessionIndicator(instanceId) +} + function markSessionIdleSeen(instanceId: string, sessionId: string): void { withSession(instanceId, sessionId, (session) => { if (session.status !== "idle") return false @@ -941,6 +961,7 @@ export { withSession, setSessionPendingPermission, setSessionPendingQuestion, + reconcileSessionPendingState, markSessionIdleSeen, markViewedSessionIdleSeen, setSessionStatus, diff --git a/packages/ui/src/stores/sessions.ts b/packages/ui/src/stores/sessions.ts index dcf8807f4..123e4837d 100644 --- a/packages/ui/src/stores/sessions.ts +++ b/packages/ui/src/stores/sessions.ts @@ -61,6 +61,7 @@ import { searchSessions, forkSession, loadMessages, + clearSessionListRequestState, } from "./session-api" import { abortSession, @@ -147,6 +148,7 @@ export { isSessionMessagesLoading, isSessionExpanded, loadMessages, + clearSessionListRequestState, loading, markSessionIdleSeen, markViewedSessionIdleSeen,