Skip to content

Commit f6cf6c5

Browse files
authored
fix(ui): surface session loading failures (NeuralNomadsAI#582)
## Summary - preserve and display detailed OpenCode errors when the session list cannot be loaded instead of failing silently - share error extraction and retry UI with per-session message loading while keeping each error scoped correctly - continue instance hydration after list failures and ignore stale overlapping list responses - show loading feedback during retries and localize the new states for every supported locale Closes NeuralNomadsAI#543. ## Validation - `bun test --conditions=browser packages/ui/src/stores/session-list-error.test.ts packages/ui/src/stores/session-tree.test.ts` - `npm run typecheck --workspace @codenomad/ui` - `npm run build --workspace @codenomad/ui` - `git diff --check upstream/dev...HEAD`
1 parent 40cee07 commit f6cf6c5

30 files changed

Lines changed: 357 additions & 71 deletions
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import type { Component } from "solid-js"
2+
import { AlertTriangle } from "lucide-solid"
3+
4+
interface LoadErrorStateProps {
5+
title: string
6+
error: string
7+
retryLabel: string
8+
onRetry: () => void
9+
variant?: "compact" | "panel"
10+
}
11+
12+
const LoadErrorState: Component<LoadErrorStateProps> = (props) => {
13+
const content = (
14+
<div class={props.variant === "compact" ? "flex items-start gap-2" : "flex flex-col items-center text-center"}>
15+
<AlertTriangle
16+
class={props.variant === "compact" ? "mt-0.5 h-4 w-4 flex-shrink-0 text-danger" : "mb-3 h-6 w-6 text-danger"}
17+
aria-hidden="true"
18+
/>
19+
<div class="min-w-0 flex-1">
20+
<h3 class="text-sm font-medium text-danger">{props.title}</h3>
21+
<p class="mt-2 break-words text-xs leading-5 text-muted">{props.error}</p>
22+
<button type="button" class="button-tertiary mt-3" onClick={props.onRetry}>
23+
{props.retryLabel}
24+
</button>
25+
</div>
26+
</div>
27+
)
28+
29+
if (props.variant === "compact") {
30+
return <div class="m-3 border border-danger bg-danger/10 p-3" role="alert">{content}</div>
31+
}
32+
33+
return (
34+
<div class="flex flex-1 items-center justify-center p-12" role="alert">
35+
<div class="w-full max-w-md border border-danger bg-danger/10 p-6">{content}</div>
36+
</div>
37+
)
38+
}
39+
40+
export default LoadErrorState

packages/ui/src/components/message-section.tsx

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Show, createEffect, createMemo, createSignal, onCleanup, on, untrack }
22
import { ArrowUpDown, ChevronDown, ChevronUp, MoreHorizontal, Pause, Search, Trash, X } from "lucide-solid"
33
import Kbd from "./kbd"
44
import BrandedEmptyState from "./branded-empty-state"
5+
import LoadErrorState from "./load-error-state"
56
import MessageBlock from "./message-block"
67
import { getMessageAnchorId } from "./message-anchors"
78
import MessageTimeline, { buildTimelineSegments, type TimelineSegment } from "./message-timeline"
@@ -1583,15 +1584,12 @@ export default function MessageSection(props: MessageSectionProps) {
15831584

15841585
<Show when={!props.loading && props.loadError}>
15851586
{(loadError) => (
1586-
<div class="message-load-error-state">
1587-
<div class="message-load-error-card">
1588-
<h3>{t("messageSection.loadError.title")}</h3>
1589-
<p>{loadError()}</p>
1590-
<button type="button" class="message-load-error-retry" onClick={() => props.onReloadMessages?.()}>
1591-
{t("messageSection.loadError.reload")}
1592-
</button>
1593-
</div>
1594-
</div>
1587+
<LoadErrorState
1588+
title={t("messageSection.loadError.title")}
1589+
error={loadError()}
1590+
retryLabel={t("messageSection.loadError.reload")}
1591+
onRetry={() => props.onReloadMessages?.()}
1592+
/>
15951593
)}
15961594
</Show>
15971595
</>

packages/ui/src/components/session-list.tsx

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { SessionThread } from "../stores/session-state"
44
import { getRetrySeconds, getSessionIdleFadeClass, getSessionRetry, getSessionStatus, shouldShowSessionStatus } from "../stores/session-status"
55
import { Bot, User, Copy, Trash2, Pencil, ShieldAlert, ChevronDown, Search, Square, CheckSquare, MinusSquare, Split, RotateCw } from "lucide-solid"
66
import KeyboardHint from "./keyboard-hint"
7+
import LoadErrorState from "./load-error-state"
78
import SessionRenameDialog from "./session-rename-dialog"
89
import { keyboardRegistry } from "../lib/keyboard-registry"
910
import { showToastNotification } from "../lib/notifications"
@@ -23,7 +24,9 @@ import {
2324
loadMoreSessions,
2425
searchSessions,
2526
getSessionHasMore,
27+
getSessionListError,
2628
clearSessionSearch,
29+
fetchSessions,
2730
getSessionSearchQuery,
2831
getSessionSearchThreads,
2932
isSessionSearchLoading,
@@ -83,6 +86,13 @@ const SessionList: Component<SessionListProps> = (props) => {
8386
const isFetchingSessions = createMemo(() => {
8487
return loading().fetchingSessions.get(props.instanceId) ?? false
8588
})
89+
const sessionListError = createMemo(() => getSessionListError(props.instanceId))
90+
91+
const handleRetrySessions = () => {
92+
void fetchSessions(props.instanceId, { reset: true }).catch((error) => {
93+
log.error("Failed to retry session list:", error)
94+
})
95+
}
8696

8797
createEffect(() => {
8898
const el = sentinelEl()
@@ -875,17 +885,35 @@ const SessionList: Component<SessionListProps> = (props) => {
875885

876886
<div class="session-list flex-1 overflow-y-auto" ref={(el) => listEl[1](el)}>
877887

878-
<Show when={filteredThreads().length > 0}>
879-
<div class="session-section">
880-
<For each={filteredThreads()}>
888+
<Show when={sessionListError()}>
889+
{(error) => (
890+
<LoadErrorState
891+
variant="compact"
892+
title={t("sessionList.loadError.title")}
893+
error={error()}
894+
retryLabel={t("sessionList.loadError.retry")}
895+
onRetry={handleRetrySessions}
896+
/>
897+
)}
898+
</Show>
899+
900+
<Show when={!sessionListError() && isFetchingSessions() && filteredThreads().length === 0}>
901+
<div class="flex items-center justify-center p-4 text-xs text-muted" role="status">
902+
<span class="animate-pulse">{t("sessionList.loading.initial")}</span>
903+
</div>
904+
</Show>
905+
906+
<Show when={filteredThreads().length > 0}>
907+
<div class="session-section">
908+
<For each={filteredThreads()}>
881909
{(thread, index) => (
882910
<SessionThreadRow
883911
thread={thread}
884912
depth={0}
885913
isLastChild={index() === filteredThreads().length - 1}
886914
/>
887915
)}
888-
</For>
916+
</For>
889917
<Show when={hasMore() || isFetchingSessions()}>
890918
<div
891919
ref={(el) => setSentinelEl(el)}

packages/ui/src/lib/i18n/messages/de/messaging.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export const messagingMessages = {
1717
"messageSection.empty.tips.attachFilesPrefix": "Dateien anhängen mit",
1818
"messageSection.loading.messages": "Nachrichten werden geladen...",
1919
"messageSection.loadError.title": "Nachrichten konnten nicht geladen werden",
20+
"messageSection.loadError.detail": "OpenCode hat die Sitzungsnachrichten nicht zurückgegeben.",
2021
"messageSection.loadError.reload": "Nachrichten neu laden",
2122
"messageSection.scroll.toFirstAriaLabel": "Zur ersten Nachricht scrollen",
2223
"messageSection.scroll.toLatestAriaLabel": "Zur neuesten Nachricht scrollen",

packages/ui/src/lib/i18n/messages/de/session.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ export const sessionMessages = {
4949
"sessionList.filter.placeholder": "Sitzungen suchen...",
5050
"sessionList.filter.ariaLabel": "Sitzungen suchen",
5151
"sessionList.loading.more": "Weitere Sitzungen werden geladen...",
52+
"sessionList.loading.initial": "Sitzungen werden geladen...",
53+
"sessionList.loadError.title": "Sitzungen konnten nicht geladen werden",
54+
"sessionList.loadError.detail": "OpenCode hat keine Sitzungsliste zurückgegeben.",
55+
"sessionList.loadError.retry": "Erneut versuchen",
5256
"sessionList.selection.selectAllLabel": "Alle auswählen",
5357
"sessionList.selection.selectAllAriaLabel": "Alle Sitzungen auswählen",
5458
"sessionList.selection.clearLabel": "Leeren",

packages/ui/src/lib/i18n/messages/en/messaging.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export const messagingMessages = {
1717
"messageSection.empty.tips.attachFilesPrefix": "Attach files with",
1818
"messageSection.loading.messages": "Loading messages...",
1919
"messageSection.loadError.title": "Could not load messages",
20+
"messageSection.loadError.detail": "OpenCode did not return the session messages.",
2021
"messageSection.loadError.reload": "Reload messages",
2122
"messageSection.scroll.toFirstAriaLabel": "Scroll to first message",
2223
"messageSection.scroll.toLatestAriaLabel": "Scroll to latest message",

packages/ui/src/lib/i18n/messages/en/session.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ export const sessionMessages = {
4949
"sessionList.filter.placeholder": "Search sessions…",
5050
"sessionList.filter.ariaLabel": "Search sessions",
5151
"sessionList.loading.more": "Loading more sessions…",
52+
"sessionList.loading.initial": "Loading sessions…",
53+
"sessionList.loadError.title": "Unable to load sessions",
54+
"sessionList.loadError.detail": "OpenCode did not return the session list.",
55+
"sessionList.loadError.retry": "Retry",
5256
"sessionList.selection.selectAllLabel": "Select all",
5357
"sessionList.selection.selectAllAriaLabel": "Select all sessions",
5458
"sessionList.selection.clearLabel": "Clear",

packages/ui/src/lib/i18n/messages/es/messaging.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export const messagingMessages = {
1717
"messageSection.empty.tips.attachFilesPrefix": "Adjunta archivos con",
1818
"messageSection.loading.messages": "Cargando mensajes...",
1919
"messageSection.loadError.title": "No se pudieron cargar los mensajes",
20+
"messageSection.loadError.detail": "OpenCode no devolvió los mensajes de la sesión.",
2021
"messageSection.loadError.reload": "Recargar mensajes",
2122
"messageSection.scroll.toFirstAriaLabel": "Desplazarse al primer mensaje",
2223
"messageSection.scroll.toLatestAriaLabel": "Desplazarse al último mensaje",

packages/ui/src/lib/i18n/messages/es/session.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ export const sessionMessages = {
4949
"sessionList.filter.placeholder": "Buscar sesiones…",
5050
"sessionList.filter.ariaLabel": "Buscar sesiones",
5151
"sessionList.loading.more": "Cargando más sesiones…",
52+
"sessionList.loading.initial": "Cargando sesiones…",
53+
"sessionList.loadError.title": "No se pudieron cargar las sesiones",
54+
"sessionList.loadError.detail": "OpenCode no devolvió la lista de sesiones.",
55+
"sessionList.loadError.retry": "Reintentar",
5256
"sessionList.selection.selectAllLabel": "Seleccionar todo",
5357
"sessionList.selection.selectAllAriaLabel": "Seleccionar todas las sesiones",
5458
"sessionList.selection.clearLabel": "Limpiar",

packages/ui/src/lib/i18n/messages/fr/messaging.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export const messagingMessages = {
1717
"messageSection.empty.tips.attachFilesPrefix": "Joindre des fichiers avec",
1818
"messageSection.loading.messages": "Chargement des messages...",
1919
"messageSection.loadError.title": "Impossible de charger les messages",
20+
"messageSection.loadError.detail": "OpenCode n'a pas renvoyé les messages de la session.",
2021
"messageSection.loadError.reload": "Recharger les messages",
2122
"messageSection.scroll.toFirstAriaLabel": "Aller au premier message",
2223
"messageSection.scroll.toLatestAriaLabel": "Aller au dernier message",

0 commit comments

Comments
 (0)