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 d21f6efca..4e032d423 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" @@ -1583,15 +1584,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 d441e21a8..3d311c863 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() @@ -875,9 +885,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 6bb420a16..1f35a570d 100644 --- a/packages/ui/src/lib/i18n/messages/de/session.ts +++ b/packages/ui/src/lib/i18n/messages/de/session.ts @@ -49,6 +49,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 0686fe084..50733e0d6 100644 --- a/packages/ui/src/lib/i18n/messages/en/session.ts +++ b/packages/ui/src/lib/i18n/messages/en/session.ts @@ -49,6 +49,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 d91bd663d..d09ef5029 100644 --- a/packages/ui/src/lib/i18n/messages/es/session.ts +++ b/packages/ui/src/lib/i18n/messages/es/session.ts @@ -49,6 +49,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 cf4be3269..ae1c223c7 100644 --- a/packages/ui/src/lib/i18n/messages/fr/session.ts +++ b/packages/ui/src/lib/i18n/messages/fr/session.ts @@ -49,6 +49,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 5ee09ed32..59044fab7 100644 --- a/packages/ui/src/lib/i18n/messages/he/session.ts +++ b/packages/ui/src/lib/i18n/messages/he/session.ts @@ -49,6 +49,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 c42814b3b..36855e8a3 100644 --- a/packages/ui/src/lib/i18n/messages/ja/session.ts +++ b/packages/ui/src/lib/i18n/messages/ja/session.ts @@ -49,6 +49,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 f7a27ade8..96357251d 100644 --- a/packages/ui/src/lib/i18n/messages/ne/session.ts +++ b/packages/ui/src/lib/i18n/messages/ne/session.ts @@ -49,6 +49,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 c22df3387..0c8c29f11 100644 --- a/packages/ui/src/lib/i18n/messages/ru/session.ts +++ b/packages/ui/src/lib/i18n/messages/ru/session.ts @@ -49,6 +49,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 683afcac6..53ff2fde5 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/session.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/session.ts @@ -49,6 +49,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.test.ts b/packages/ui/src/lib/opencode-api.test.ts new file mode 100644 index 000000000..0897e435a --- /dev/null +++ b/packages/ui/src/lib/opencode-api.test.ts @@ -0,0 +1,40 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { getOpencodeErrorMessage } from "./opencode-api.ts" + +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 = { + 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/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 3e330fe69..67c8caf1b 100644 --- a/packages/ui/src/stores/instances.ts +++ b/packages/ui/src/stores/instances.ts @@ -17,6 +17,7 @@ import { fetchAgents, fetchProviders, clearInstanceDraftPrompts, + clearSessionListRequestState, clearInstanceDeletedSessionAuthority, clearInstanceSessionSelection, resetSessionPagination, @@ -32,7 +33,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" @@ -282,6 +288,14 @@ function settleInstanceReadyWaiters(instanceId: string, error?: Error): void { } } +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 { @@ -487,6 +501,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 }) } @@ -539,6 +554,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 }) } @@ -556,7 +572,9 @@ function startInstanceSessionHydration(instanceId: string, force = false): { : ensureWorktreesLoaded(instanceId) const sessions = worktreeHydration.then(async () => { resetSessionPagination(instanceId) - await fetchSessions(instanceId) + await fetchSessions(instanceId).catch((error) => { + log.error("Failed to hydrate sessions", { instanceId, error }) + }) }) const workspaceMetadata = worktreeHydration.then(async () => { await Promise.all([worktreeMapHydration, syncOpenCodeWorkspaces(instanceId)]) @@ -899,6 +917,7 @@ function removeInstance(id: string, options: { authoritative?: boolean } = {}) { clearCacheForInstance(id) messageStoreBus.unregisterInstance(id) clearInstanceDraftPrompts(id) + clearSessionListRequestState(id) clearInstanceAttachments(id) clearInstanceDeletedSessionAuthority(id) clearInstanceSessionSelection(id) @@ -1806,5 +1825,6 @@ export { acknowledgeDisconnectedInstance, fetchLspStatus, disposeInstance, + reconcilePendingSessionIndicators, clearReloadableInstanceState, } diff --git a/packages/ui/src/stores/session-api.ts b/packages/ui/src/stores/session-api.ts index 8b6fe7c6c..578acebb9 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, @@ -48,6 +48,7 @@ import { clearSessionSearch, isLatestSessionSearch, setSessionSearchResults, + setSessionListError, } from "./session-state" import { deleteSessionAttachments } from "./attachments" import { DEFAULT_MODEL_OUTPUT_LIMIT, getDefaultModel, isModelValid } from "./session-models" @@ -57,8 +58,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, @@ -73,26 +75,28 @@ import { PROJECT_SESSION_LIST_LIMIT, buildProjectSessionListOptions, filterProje import { mergeFetchedSessionRuntimeState, resolveAuthoritativeGenerationRecovery } from "./session-generation-recovery" const log = getLogger("api") +const sessionListRequestIds = new Map() +let nextSessionListRequestId = 0 -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 +function beginSessionListRequest(instanceId: string): number { + const requestId = ++nextSessionListRequestId + sessionListRequestIds.set(instanceId, requestId) + return requestId +} - const errorText = (error as any)?.error - if (typeof errorText === "string" && errorText.trim()) return errorText +function isLatestSessionListRequest(instanceId: string, requestId: number): boolean { + return sessionListRequestIds.get(instanceId) === requestId +} - return "Failed to load messages" +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 }> { @@ -216,12 +220,14 @@ 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 } : {} @@ -229,6 +235,7 @@ async function fetchSessions(instanceId: string, options?: { reset?: boolean }): 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 = {} let statusResponseKnown = false @@ -241,6 +248,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 sessionMap = new Map() @@ -315,7 +323,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) @@ -342,13 +350,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 + }) + } } } @@ -1006,7 +1019,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) => { @@ -1047,4 +1060,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 92864020b..cdc2e78e0 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 { resolveAuthoritativeGenerationRecovery, resolveHydratedGenerationRecovery, @@ -81,6 +82,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()) @@ -562,6 +564,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 @@ -1022,6 +1043,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) @@ -1200,6 +1237,8 @@ export { setLoading, messagesLoaded, setMessagesLoaded, + getSessionListError, + setSessionListError, setSessionMessagesLoadError, sessionInfoByInstance, setSessionInfoByInstance, @@ -1223,6 +1262,7 @@ export { withSession, setSessionPendingPermission, setSessionPendingQuestion, + reconcileSessionPendingState, markSessionIdleSeen, markViewedSessionIdleSeen, hydrateSessionIdleMarkers, diff --git a/packages/ui/src/stores/sessions.ts b/packages/ui/src/stores/sessions.ts index 7f7220db5..7955ea3c9 100644 --- a/packages/ui/src/stores/sessions.ts +++ b/packages/ui/src/stores/sessions.ts @@ -32,6 +32,7 @@ import { cancelSessionGenerationAdmissions, getSessionFamily, getSessionInfo, + getSessionListError, getSessionMessagesLoadError, getSessionSearchQuery, getSessionSearchThreads, @@ -76,6 +77,7 @@ import { searchSessions, forkSession, loadMessages, + clearSessionListRequestState, } from "./session-api" import { abortSession, @@ -165,6 +167,7 @@ export { cancelSessionGenerationAdmissions, getSessionFamily, getSessionInfo, + getSessionListError, getSessionMessagesLoadError, getSessionSearchQuery, getSessionSearchThreads, @@ -176,6 +179,7 @@ export { isSessionMessagesLoading, isSessionExpanded, loadMessages, + clearSessionListRequestState, loading, markSessionIdleSeen, markViewedSessionIdleSeen, 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); -}