From 121ce372547ce51df329ca25bf705dc0a38c82ac Mon Sep 17 00:00:00 2001 From: Lucas Tonon Date: Sat, 18 Jul 2026 23:47:19 -0300 Subject: [PATCH 1/5] fix(frontend): add error boundaries so one render throw doesn't blank the app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There was no app/error.tsx, app/global-error.tsx, or any ErrorBoundary in src/ — with untyped SSE payloads and live-rendered agent-authored markdown + self-contained HTML artifacts, one bad payload throwing in render took down the entire SPA with a blank screen. - src/app/error.tsx: route-level boundary (Next.js App Router convention) with a "Try again" / "Reload page" recovery screen. - src/app/global-error.tsx: root-layout boundary with its own / and inline styles (no dependency on globals.css/Tailwind/app components, since those may be what failed to mount). - src/components/ErrorBoundary.tsx: reusable class-component boundary (no hook equivalent exists for getDerivedStateFromError/componentDidCatch) with an optional custom fallback + reset callback. - Wrapped the workspace canvas (WorkspaceSidebar, keyed by sessionId) and every markdown renderer (chat assistant bubbles, sub-agent result summaries, the report tab, and markdown file previews) so a crash is contained to that panel/bubble instead of the whole app. Closes #102 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Wp66uSq9saSpRq9Bbmjxj4 --- frontend/src/app/error.tsx | 57 +++++++++++ frontend/src/app/global-error.tsx | 100 ++++++++++++++++++ frontend/src/app/page.tsx | 118 +++++++++++++--------- frontend/src/components/ErrorBoundary.tsx | 80 +++++++++++++++ 4 files changed, 308 insertions(+), 47 deletions(-) create mode 100644 frontend/src/app/error.tsx create mode 100644 frontend/src/app/global-error.tsx create mode 100644 frontend/src/components/ErrorBoundary.tsx diff --git a/frontend/src/app/error.tsx b/frontend/src/app/error.tsx new file mode 100644 index 0000000..39b011a --- /dev/null +++ b/frontend/src/app/error.tsx @@ -0,0 +1,57 @@ +'use client'; + +import { useEffect } from 'react'; +import { AlertCircle, RefreshCw } from 'lucide-react'; + +// Route-level error boundary (Next.js App Router convention: a file named +// `error.tsx` automatically wraps its route segment — here, the whole app, +// since this lives directly under `src/app/`). Catches render errors thrown +// anywhere in the tree below it that aren't already contained by a nested +// boundary (see `src/components/ErrorBoundary.tsx`, used around the +// workspace canvas and chat markdown renderers) and shows a recoverable +// screen instead of Next's default error overlay / a blank page. +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + console.error('Route-level error boundary caught:', error); + }, [error]); + + return ( +
+ +
+

Something went wrong

+

+ Trainable hit an unexpected error. You can try again, or reload the page if it keeps + happening. +

+ {error.message && ( +

+ {error.message} +

+ )} +
+
+ + +
+
+ ); +} diff --git a/frontend/src/app/global-error.tsx b/frontend/src/app/global-error.tsx new file mode 100644 index 0000000..de518a1 --- /dev/null +++ b/frontend/src/app/global-error.tsx @@ -0,0 +1,100 @@ +'use client'; + +// Root-layout error boundary (Next.js App Router convention). `error.tsx` +// only catches errors thrown *below* the root layout — if `layout.tsx` +// itself (or anything providers it mounts, e.g. AppProvider/ToastProvider) +// throws during render, only `global-error.tsx` can catch it, and to do so +// it must render its own / since it replaces the root layout +// entirely while active. It intentionally does NOT depend on +// globals.css/Tailwind or any app component — those are exactly what may +// have failed to mount — so everything here is inline-styled and +// dependency-free. +export default function GlobalError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( + + +
+

+ Trainable failed to load +

+

+ Something went wrong before the app could start. Try again, or reload the page if it + keeps happening. +

+ {error.message && ( +

+ {error.message} +

+ )} +
+
+ + +
+ + + ); +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index f7632c4..2de30ad 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -70,6 +70,7 @@ import { ExternalLink, } from 'lucide-react'; import Sidebar from '@/components/Sidebar'; +import ErrorBoundary from '@/components/ErrorBoundary'; import Notebook from '@/components/notebook/Notebook'; import AgentStatusIndicator, { ActiveAgent } from '@/components/AgentStatusIndicator'; import CostBadge, { UsageTotals } from '@/components/CostBadge'; @@ -2351,20 +2352,27 @@ function HomePageContent() { onExpand={() => setCanvasOpen(true)} > {canvasOpen && ( - workspacePanelRef.current?.collapse()} - /> + // Keyed by sessionId so switching sessions also clears any + // prior crash state, in addition to the panel's own "Try + // again" button. Agent-authored file content, markdown, and + // self-contained HTML artifacts all render inside here — + // without this boundary, a bad one blanks the whole SPA. + + workspacePanelRef.current?.collapse()} + /> + )} @@ -2730,30 +2738,32 @@ const FileViewer = memo(function FileViewer({ ) : isMarkdown ? (
- { - let imgSrc = src || ''; - if (imgSrc.startsWith('/data/')) { - imgSrc = `/api/files/raw?path=${encodeURIComponent(imgSrc)}`; - } else if (imgSrc && !imgSrc.startsWith('http')) { - const dir = filePath.substring(0, filePath.lastIndexOf('/')); - imgSrc = `/api/files/raw?path=${encodeURIComponent(dir + '/' + imgSrc)}`; - } - return ( - // eslint-disable-next-line @next/next/no-img-element - {alt - ); - }, - }} - > - {content || ''} - + + { + let imgSrc = src || ''; + if (imgSrc.startsWith('/data/')) { + imgSrc = `/api/files/raw?path=${encodeURIComponent(imgSrc)}`; + } else if (imgSrc && !imgSrc.startsWith('http')) { + const dir = filePath.substring(0, filePath.lastIndexOf('/')); + imgSrc = `/api/files/raw?path=${encodeURIComponent(dir + '/' + imgSrc)}`; + } + return ( + // eslint-disable-next-line @next/next/no-img-element + {alt + ); + }, + }} + > + {content || ''} + +
) : (
@@ -2801,9 +2811,11 @@ const ReportMarkdown = memo(function ReportMarkdown({
   return (
     
- - {content} - + + + {content} + +
); @@ -3789,11 +3801,17 @@ function SubAgentCard({ item }: { item: ChatItem }) {
Result:
- - {item.meta.summary.length > 800 - ? item.meta.summary.slice(0, 800) + '\n\n...' - : item.meta.summary} - + ( +
{item.meta?.summary}
+ )} + > + + {item.meta.summary.length > 800 + ? item.meta.summary.slice(0, 800) + '\n\n...' + : item.meta.summary} + +
)} @@ -4259,7 +4277,13 @@ const ChatItemView = memo(function ChatItemView({ {agentMeta && (
{agentMeta.label}
)} - {item.content} + ( +
{item.content}
+ )} + > + {item.content} +
{isStreaming && ( )} diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..9988c35 --- /dev/null +++ b/frontend/src/components/ErrorBoundary.tsx @@ -0,0 +1,80 @@ +'use client'; + +import { Component, type ReactNode } from 'react'; +import { AlertCircle, RefreshCw } from 'lucide-react'; + +interface ErrorBoundaryProps { + children: ReactNode; + /** Rendered instead of `children` once a render error is caught. Receives + * the error and a `reset` callback that clears the boundary's error + * state and re-renders `children` — useful when the error was + * transient or the underlying data (e.g. a chat item, a workspace tab) + * has since changed. Falls back to a generic panel when omitted. */ + fallback?: (error: Error, reset: () => void) => ReactNode; + /** Short noun used in the generic fallback's headline, e.g. "the + * workspace" or "this message". Ignored when `fallback` is provided. */ + label?: string; + /** Called once when a render error is caught, e.g. to log/report it. */ + onError?: (error: Error, info: { componentStack: string }) => void; +} + +interface ErrorBoundaryState { + error: Error | null; +} + +/** + * Generic React error boundary. Untyped/unvalidated SSE payloads and + * live-rendered agent-authored markdown + self-contained HTML artifacts + * mean a render throw is a real risk — without a boundary, one bad payload + * takes down the entire SPA with a blank screen (React unmounts the whole + * tree above the nearest boundary, and the root layout has none). Wrap the + * workspace canvas and individual markdown renderers with this so a crash + * is contained to that panel/bubble instead. + * + * Must be a class component — there is no hook equivalent for + * `getDerivedStateFromError`/`componentDidCatch` as of React 18. + */ +export default class ErrorBoundary extends Component { + state: ErrorBoundaryState = { error: null }; + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { error }; + } + + componentDidCatch(error: Error, info: { componentStack: string }) { + console.error('ErrorBoundary caught a render error:', error, info.componentStack); + this.props.onError?.(error, info); + } + + reset = () => { + this.setState({ error: null }); + }; + + render() { + const { error } = this.state; + if (!error) return this.props.children; + + if (this.props.fallback) return this.props.fallback(error, this.reset); + + return ( +
+ +
+

+ {this.props.label ? `Couldn't render ${this.props.label}.` : 'Something went wrong rendering this.'} +

+

+ {error.message} +

+
+ +
+ ); + } +} From 11a197e61e4b7a5bfb55b067831d1841245323b1 Mon Sep 17 00:00:00 2001 From: Lucas Tonon Date: Mon, 20 Jul 2026 10:05:06 -0300 Subject: [PATCH 2/5] fix(review): address Greptile findings on #159 - ErrorBoundary: default export -> named export, per the components/ style guide (default export is reserved for page components). - global-error.tsx: log the caught error via useEffect + console.error (matching error.tsx) so root-layout crashes aren't silent; both are zero-dependency builtins, keeping the boundary dependency-free. - SubAgentCard: hoist the 800-char summary truncation so the error fallback renders the same bounded text as the ReactMarkdown path instead of dumping the full untruncated summary. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Wp66uSq9saSpRq9Bbmjxj4 --- frontend/src/app/global-error.tsx | 6 ++++++ frontend/src/app/page.tsx | 17 +++++++++-------- frontend/src/components/ErrorBoundary.tsx | 2 +- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/frontend/src/app/global-error.tsx b/frontend/src/app/global-error.tsx index de518a1..222fd4a 100644 --- a/frontend/src/app/global-error.tsx +++ b/frontend/src/app/global-error.tsx @@ -1,5 +1,7 @@ 'use client'; +import { useEffect } from 'react'; + // Root-layout error boundary (Next.js App Router convention). `error.tsx` // only catches errors thrown *below* the root layout — if `layout.tsx` // itself (or anything providers it mounts, e.g. AppProvider/ToastProvider) @@ -16,6 +18,10 @@ export default function GlobalError({ error: Error & { digest?: string }; reset: () => void; }) { + useEffect(() => { + console.error('Global error boundary caught:', error); + }, [error]); + return ( 800 ? summary.slice(0, 800) + '\n\n...' : summary; useEffect(() => { if (!isStart) return; @@ -3797,20 +3802,16 @@ function SubAgentCard({ item }: { item: ChatItem }) { {item.meta.task} )} - {item.meta?.summary && ( + {truncatedSummary && (
Result:
( -
{item.meta?.summary}
+
{truncatedSummary}
)} > - - {item.meta.summary.length > 800 - ? item.meta.summary.slice(0, 800) + '\n\n...' - : item.meta.summary} - + {truncatedSummary}
diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx index 9988c35..0dd2df2 100644 --- a/frontend/src/components/ErrorBoundary.tsx +++ b/frontend/src/components/ErrorBoundary.tsx @@ -34,7 +34,7 @@ interface ErrorBoundaryState { * Must be a class component — there is no hook equivalent for * `getDerivedStateFromError`/`componentDidCatch` as of React 18. */ -export default class ErrorBoundary extends Component { +export class ErrorBoundary extends Component { state: ErrorBoundaryState = { error: null }; static getDerivedStateFromError(error: Error): ErrorBoundaryState { From 6ec0868303603a59773dd5c33d22cd2f41c1198d Mon Sep 17 00:00:00 2001 From: Lucas Tonon Date: Wed, 29 Jul 2026 15:38:40 -0300 Subject: [PATCH 3/5] =?UTF-8?q?chore(staging):=20ledger=20row=20=E2=80=94?= =?UTF-8?q?=20PR=20#157?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- STAGING-v0.0.5.md | 1 + 1 file changed, 1 insertion(+) diff --git a/STAGING-v0.0.5.md b/STAGING-v0.0.5.md index c8fbcf0..05f11a5 100644 --- a/STAGING-v0.0.5.md +++ b/STAGING-v0.0.5.md @@ -51,6 +51,7 @@ branch → run relevant tests → record below. | 31 | #148 | fix/100-dedupe-eventsource | #100 | round-1 findings (P2 second-context vs lib/AGENTS.md; P2 cross-session event bleed) already fixed by author's fixup `3f73570` (session-scoped `publish` + documented stateless bus context + 5 vitest tests); round-2 P1 (throwing bus listener silently kills `connectSSE`'s UI switch — `publish` ran inside the try/catch before the switch) outstanding → fixed on PR branch in `cfbfb40` (per-listener try/catch in `SSEStreamContext.publish`, logs and continues) | branch repaired against staging in `8f74aab`: conflict only in `frontend/package-lock.json` (lockfile churn) — took staging's via `--theirs`, regenerated with `npm install` against merged package.json; `frontend/node_modules` verified real directory (not symlink) | branch: vitest **9/9** (4 api + 5 bus/notebook), tsc clean, prettier clean; post-merge staging: vitest **9/9**, tsc clean, prettier clean | `page.tsx` auto-merged — verified #148's `SSEStreamProvider`/`publish(sid, event)` AND #169's `takeSuggestedPrompt`/`Sparkles` wiring AND #87's `/api/sessions/{id}/download` UI all present; pushed `8f74aab` (contains P1 fix `cfbfb40`) verified as merge parent | | 32 | #150 | fix/98-memoize-chat | #98 | both P2 findings (shared `streamingItemId` string prop breaks `memo` bailout for ALL items on stream start/end) already fixed by author's fixup `b37907e` (per-item `isStreaming` boolean + manual-test tutorial `docs/manual-tests/chat-memoization.md`) | branch repaired against staging in `3e48748` — ort auto-merged CLEAN (no conflicts; branch was stacked on #148's original commit, staging already carried #148's fixup + P1 repair) | branch: vitest 9/9, tsc clean, prettier clean; post-merge staging: vitest 9/9, tsc clean, prettier clean | `page.tsx` merge kept memoized `ChatItemView` (`isStreaming={cur.id === streamingItemId}` verified) + #148 bus + #169/#87 wiring; pushed `3e48748` verified as merge parent | | 33 | #152 | fix/99-autoscroll | #99 | both findings already fixed by author's fixup `05cb539`: P1 smooth scroll after send un-pinned mid-animation (intermediate scroll events saw distance > threshold) → `behavior: 'auto'` unconditionally; P2 `AUTO_SCROLL_PIN_THRESHOLD_PX` re-created per render → hoisted to module level | branch repaired against staging in `8ca8e96`: one conflict in `page.tsx` — #152's `handleChatScroll` pin-tracking callback and staging's #169 suggested-prompt effect appended at the same anchor, kept both | branch: vitest 9/9, tsc clean, prettier clean; post-merge staging: vitest 9/9, tsc clean, prettier clean | `chatScrollRef` + `onScroll={handleChatScroll}` wiring verified at page.tsx:2110-2111; pushed `8ca8e96` verified as merge parent | +| 34 | #157 | fix/101-typed-sse | #101 | single P2 (`chart_config` mapped to `ChartConfig` with required `charts`, making the handler's defensive guard look dead) already fixed by author's fixup `c8a1bba` (wire-type `ChartConfigSSEData` with optional `charts` + `types.sse.test-d.ts` type-level assertions) | branch repaired against staging in `ba33265`: textual conflict in `MetricsTab.tsx` (#157 typed `ChartTooltip` props, staging's #161 exports it for the compare page — kept typed props AND `export`); semantic conflicts from the tightened union: extended `SSEEvent` with `budget_exceeded` (#165, new `BudgetExceededSSEData`) and the `notebook.kernel.state`/`notebook.structure.changed`/`notebook.cell.*` events (#148's typed bus requires them — payload interfaces reused from `lib/notebook/types.ts`, `StructureChangedEvent` moved there and re-exported from `useNotebookSSE`); `page.tsx` `budget_exceeded` case adapted to #157's per-case `const data = event.data` block; #148's `useNotebookSSE.test.tsx` cell.completed payload gained `exec_count: null` to satisfy the typed `CellCompletedEvent` | branch: vitest 9/9, tsc clean (incl. `types.sse.test-d.ts` via tsconfig `**/*.ts` include), prettier clean; post-merge staging: vitest 9/9, tsc clean, prettier clean | pushed `ba33265` verified as merge parent | ## Deferred / not merged From a71d33e75922ff75d9227d7bf31354be0a95deb4 Mon Sep 17 00:00:00 2001 From: Lucas Tonon Date: Wed, 29 Jul 2026 15:39:44 -0300 Subject: [PATCH 4/5] fix(review): key FileViewer's markdown ErrorBoundary by filePath (Greptile P1 on #159) FileViewer is memoized and keeps its component instance across file switches, so a markdown render crash left the boundary stuck in its error fallback for every subsequent file. key={filePath} remounts the boundary per file. --- frontend/src/app/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 45dc410..492134f 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -2738,7 +2738,7 @@ const FileViewer = memo(function FileViewer({ ) : isMarkdown ? (
- + Date: Wed, 29 Jul 2026 15:40:06 -0300 Subject: [PATCH 5/5] style: prettier --write page.tsx (branch predates CI format gate) --- frontend/src/app/page.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 492134f..28cc107 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1296,8 +1296,7 @@ function HomePageContent() { type: 'subagent_start', content: (msg.metadata?.agent_type as string) || 'sub-agent', meta: { - task: - metaStr(msg.metadata?.task) || metaStr(msg.metadata?.description) || '', + task: metaStr(msg.metadata?.task) || metaStr(msg.metadata?.description) || '', model: metaStr(msg.metadata?.model) || '', depth: metaNum(msg.metadata?.depth) || 1, agent_id: metaStr(msg.metadata?.agent_id) || '', @@ -4279,9 +4278,7 @@ const ChatItemView = memo(function ChatItemView({
{agentMeta.label}
)} ( -
{item.content}
- )} + fallback={() =>
{item.content}
} > {item.content}