Skip to content
1 change: 1 addition & 0 deletions STAGING-v0.0.5.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
57 changes: 57 additions & 0 deletions frontend/src/app/error.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-black p-8 text-center">
<AlertCircle className="w-10 h-10 text-red-400" />
<div>
<h1 className="text-lg font-medium text-gray-100">Something went wrong</h1>
<p className="mt-2 max-w-md text-sm text-gray-400">
Trainable hit an unexpected error. You can try again, or reload the page if it keeps
happening.
</p>
{error.message && (
<p className="mt-3 max-w-lg break-words rounded-md border border-white/[0.08] bg-white/[0.04] px-3 py-2 font-mono text-xs text-gray-500">
{error.message}
</p>
)}
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => reset()}
className="inline-flex items-center gap-1.5 rounded-lg bg-primary-600 px-4 py-2 text-sm text-white transition-colors hover:bg-primary-500"
>
<RefreshCw className="w-3.5 h-3.5" /> Try again
</button>
<button
type="button"
onClick={() => window.location.reload()}
className="inline-flex items-center gap-1.5 rounded-lg border border-white/[0.08] bg-white/[0.06] px-4 py-2 text-sm text-gray-300 transition-colors hover:bg-white/[0.1]"
>
Reload page
</button>
</div>
</div>
);
}
104 changes: 104 additions & 0 deletions frontend/src/app/global-error.tsx
Original file line number Diff line number Diff line change
@@ -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 <html>/<body> 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 (
Comment thread
greptile-apps[bot] marked this conversation as resolved.
<html lang="en">
<body
style={{
margin: 0,
minHeight: '100vh',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '16px',
padding: '32px',
textAlign: 'center',
backgroundColor: '#000',
color: '#e5e5e5',
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
}}
>
<div>
<h1 style={{ fontSize: '18px', fontWeight: 500, margin: 0 }}>Trainable failed to load</h1>
<p style={{ marginTop: '8px', maxWidth: '420px', fontSize: '14px', color: '#a3a3a3' }}>
Something went wrong before the app could start. Try again, or reload the page if it
keeps happening.
</p>
{error.message && (
<p
style={{
marginTop: '12px',
maxWidth: '480px',
wordBreak: 'break-word',
borderRadius: '6px',
border: '1px solid rgba(255,255,255,0.08)',
backgroundColor: 'rgba(255,255,255,0.04)',
padding: '8px 12px',
fontFamily: 'ui-monospace, monospace',
fontSize: '12px',
color: '#737373',
}}
>
{error.message}
</p>
)}
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button
type="button"
onClick={() => reset()}
style={{
borderRadius: '8px',
border: 'none',
backgroundColor: '#4f46e5',
color: '#fff',
padding: '8px 16px',
fontSize: '14px',
cursor: 'pointer',
}}
>
Try again
</button>
<button
type="button"
onClick={() => window.location.reload()}
style={{
borderRadius: '8px',
border: '1px solid rgba(255,255,255,0.08)',
backgroundColor: 'rgba(255,255,255,0.06)',
color: '#d4d4d4',
padding: '8px 16px',
fontSize: '14px',
cursor: 'pointer',
}}
>
Reload page
</button>
</div>
</body>
</html>
);
}
119 changes: 71 additions & 48 deletions frontend/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -2395,20 +2396,27 @@ function HomePageContent() {
onExpand={() => setCanvasOpen(true)}
>
{canvasOpen && (
<WorkspaceSidebar
experimentId={activeExperimentId || ''}
sessionId={activeSessionId || ''}
canvasContent={canvasContent}
canvasTitle={canvasTitle}
generatedFiles={generatedFiles}
fileTree={fileTree}
metricPoints={metricPoints}
chartConfig={chartConfig}
logEvents={logEvents}
htmlArtifacts={htmlArtifacts}
sessionState={sessionState}
onClose={() => 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.
<ErrorBoundary key={activeSessionId} label="the workspace">
<WorkspaceSidebar
experimentId={activeExperimentId || ''}
sessionId={activeSessionId || ''}
canvasContent={canvasContent}
canvasTitle={canvasTitle}
generatedFiles={generatedFiles}
fileTree={fileTree}
metricPoints={metricPoints}
chartConfig={chartConfig}
logEvents={logEvents}
htmlArtifacts={htmlArtifacts}
sessionState={sessionState}
onClose={() => workspacePanelRef.current?.collapse()}
/>
</ErrorBoundary>
)}
</Panel>
</PanelGroup>
Expand Down Expand Up @@ -2774,30 +2782,32 @@ const FileViewer = memo(function FileViewer({
</SyntaxHighlighter>
) : isMarkdown ? (
<div className="p-6 markdown-content">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
img: ({ src, alt }) => {
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
<img
src={imgSrc}
alt={alt || ''}
className="max-w-full rounded-lg shadow-md my-4"
/>
);
},
}}
>
{content || ''}
</ReactMarkdown>
<ErrorBoundary key={filePath} label="this file">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
img: ({ src, alt }) => {
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
<img
src={imgSrc}
alt={alt || ''}
className="max-w-full rounded-lg shadow-md my-4"
/>
);
},
}}
>
{content || ''}
</ReactMarkdown>
</ErrorBoundary>
</div>
) : (
<pre className="p-4 text-[13px] text-gray-300 font-mono whitespace-pre-wrap leading-relaxed">
Expand Down Expand Up @@ -2845,9 +2855,11 @@ const ReportMarkdown = memo(function ReportMarkdown({
return (
<div className="h-full overflow-y-auto p-6 bg-black">
<div className="markdown-content">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
{content}
</ReactMarkdown>
<ErrorBoundary label="this report">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
{content}
</ReactMarkdown>
</ErrorBoundary>
</div>
</div>
);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -3844,15 +3861,17 @@ function SubAgentCard({ item }: { item: ChatItem }) {
{item.meta.task}
</div>
)}
{item.meta?.summary && (
{truncatedSummary && (
<div className="text-xs text-gray-400 max-h-48 overflow-y-auto">
<span className={`${colors.text} font-medium`}>Result: </span>
<div className="mt-1 markdown-chat">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{item.meta.summary.length > 800
? item.meta.summary.slice(0, 800) + '\n\n...'
: item.meta.summary}
</ReactMarkdown>
<ErrorBoundary
fallback={() => (
<div className="whitespace-pre-wrap break-words">{truncatedSummary}</div>
)}
>
Comment thread
greptile-apps[bot] marked this conversation as resolved.
<ReactMarkdown remarkPlugins={[remarkGfm]}>{truncatedSummary}</ReactMarkdown>
</ErrorBoundary>
</div>
</div>
)}
Expand Down Expand Up @@ -4320,7 +4339,11 @@ const ChatItemView = memo(function ChatItemView({
{agentMeta && (
<div className={`text-[10px] ${avatarText} font-medium mb-1`}>{agentMeta.label}</div>
)}
<ReactMarkdown remarkPlugins={CHAT_MARKDOWN_PLUGINS}>{item.content}</ReactMarkdown>
<ErrorBoundary
fallback={() => <div className="whitespace-pre-wrap break-words">{item.content}</div>}
>
<ReactMarkdown remarkPlugins={CHAT_MARKDOWN_PLUGINS}>{item.content}</ReactMarkdown>
</ErrorBoundary>
{isStreaming && (
<span className="inline-block w-2 h-5 bg-primary-400 rounded-sm ml-0.5 animate-blink align-text-bottom" />
)}
Expand Down
Loading