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 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..05b4a61 --- /dev/null +++ b/frontend/src/app/global-error.tsx @@ -0,0 +1,104 @@ +'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) +// 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; +}) { + useEffect(() => { + console.error('Global error boundary caught:', error); + }, [error]); + + 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 82a8e5a..1241ab0 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -72,6 +72,7 @@ import { Download, } 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'; @@ -2395,20 +2396,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()} + /> + )} @@ -2774,30 +2782,32 @@ const FileViewer = memo(function FileViewer({ ) : isMarkdown ? (
- { - let imgSrc = src || ''; - if (imgSrc.startsWith('/data/')) { - imgSrc = api.filesRawUrl(imgSrc); - } else if (imgSrc && !imgSrc.startsWith('http')) { - const dir = filePath.substring(0, filePath.lastIndexOf('/')); - imgSrc = api.filesRawUrl(dir + '/' + imgSrc); - } - return ( - // eslint-disable-next-line @next/next/no-img-element - {alt - ); - }, - }} - > - {content || ''} - + + { + let imgSrc = src || ''; + if (imgSrc.startsWith('/data/')) { + imgSrc = api.filesRawUrl(imgSrc); + } else if (imgSrc && !imgSrc.startsWith('http')) { + const dir = filePath.substring(0, filePath.lastIndexOf('/')); + imgSrc = api.filesRawUrl(dir + '/' + imgSrc); + } + return ( + // eslint-disable-next-line @next/next/no-img-element + {alt + ); + }, + }} + > + {content || ''} + +
) : (
@@ -2845,9 +2855,11 @@ const ReportMarkdown = memo(function ReportMarkdown({
   return (
     
- - {content} - + + + {content} + +
); @@ -3785,6 +3797,11 @@ function SubAgentCard({ item }: { item: ChatItem }) { const modelName = item.meta?.model ? item.meta.model.replace('claude-', '').replace(/-/g, ' ') : ''; + // Truncated once here so the markdown path and the error-boundary fallback + // render the same bounded text (a summary can be megabytes of agent output). + const summary = item.meta?.summary; + const truncatedSummary = + summary && summary.length > 800 ? summary.slice(0, 800) + '\n\n...' : summary; useEffect(() => { if (!isStart) return; @@ -3844,15 +3861,17 @@ function SubAgentCard({ item }: { item: ChatItem }) { {item.meta.task} )} - {item.meta?.summary && ( + {truncatedSummary && (
Result:
- - {item.meta.summary.length > 800 - ? item.meta.summary.slice(0, 800) + '\n\n...' - : item.meta.summary} - + ( +
{truncatedSummary}
+ )} + > + {truncatedSummary} +
)} @@ -4320,7 +4339,11 @@ 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..ba67551 --- /dev/null +++ b/frontend/src/components/ErrorBoundary.tsx @@ -0,0 +1,82 @@ +'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 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} +

+
+ +
+ ); + } +}