Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 38 additions & 30 deletions apps/agor-ui/src/components/App/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ import { CreateDialog, type CreateDialogProgress } from '../CreateDialog';
import type { AssistantTabResult } from '../CreateDialog/tabs/AssistantTab';
import type { BranchTabConfig } from '../CreateDialog/tabs/BranchTab';
import { EnvironmentLogsModal } from '../EnvironmentLogsModal';
import { ErrorBoundary } from '../ErrorBoundary';
import { EventStreamPanel } from '../EventStreamPanel';
import { HomePage } from '../HomePage';
import { NewSessionButton } from '../NewSessionButton';
Expand Down Expand Up @@ -1252,36 +1253,43 @@ export const App: React.FC<AppProps> = ({
onOpenSettings={openSettings}
/>
) : (
<SessionCanvas
ref={sessionCanvasRef}
board={currentBoard || null}
client={client}
branches={boardBranches}
primaryAssistantId={primaryAssistantId}
currentUserId={user?.user_id}
selectedSessionId={effectiveSelectedSessionId}
activeUrlTargetBranchId={activeUrlTargetBranchId}
activeUrlTargetArtifactId={activeUrlTargetArtifactId}
availableAgents={availableAgents}
onSessionClick={handleSessionClick}
onSessionUpdate={stableOnSessionUpdate}
onSessionDelete={stableOnSessionDelete}
onForkSession={stableOnForkSession}
onSpawnSession={stableOnSpawnSession}
onUpdateSessionMcpServers={stableOnUpdateSessionMcpServers}
onOpenSettings={setSessionSettingsId}
onCreateSessionForBranch={setNewSessionBranchId}
onOpenBranch={setBranchModalBranchId}
onArchiveOrDeleteBranch={stableOnArchiveOrDeleteBranch}
onOpenTerminal={canOpenTerminal ? handleOpenTerminal : undefined}
onStartEnvironment={stableOnStartEnvironment}
onStopEnvironment={stableOnStopEnvironment}
onViewLogs={setLogsModalBranchId}
onNukeEnvironment={stableOnNukeEnvironment}
onOpenCommentsPanel={handleOpenCommentsPanel}
onCommentHover={setHoveredCommentId}
onCommentSelect={handleCommentSelect}
/>
// Canvas-scoped boundary: a render loop (e.g. React #185
// from ResizeObserver re-measurement on tab focus) is
// contained here so the app shell survives instead of
// white-screening. resetKey clears a stuck error when the
// user switches boards.
<ErrorBoundary variant="canvas" resetKey={currentBoard?.board_id}>
<SessionCanvas
ref={sessionCanvasRef}
board={currentBoard || null}
client={client}
branches={boardBranches}
primaryAssistantId={primaryAssistantId}
currentUserId={user?.user_id}
selectedSessionId={effectiveSelectedSessionId}
activeUrlTargetBranchId={activeUrlTargetBranchId}
activeUrlTargetArtifactId={activeUrlTargetArtifactId}
availableAgents={availableAgents}
onSessionClick={handleSessionClick}
onSessionUpdate={stableOnSessionUpdate}
onSessionDelete={stableOnSessionDelete}
onForkSession={stableOnForkSession}
onSpawnSession={stableOnSpawnSession}
onUpdateSessionMcpServers={stableOnUpdateSessionMcpServers}
onOpenSettings={setSessionSettingsId}
onCreateSessionForBranch={setNewSessionBranchId}
onOpenBranch={setBranchModalBranchId}
onArchiveOrDeleteBranch={stableOnArchiveOrDeleteBranch}
onOpenTerminal={canOpenTerminal ? handleOpenTerminal : undefined}
onStartEnvironment={stableOnStartEnvironment}
onStopEnvironment={stableOnStopEnvironment}
onViewLogs={setLogsModalBranchId}
onNukeEnvironment={stableOnNukeEnvironment}
onOpenCommentsPanel={handleOpenCommentsPanel}
onCommentHover={setHoveredCommentId}
onCommentSelect={handleCommentSelect}
/>
</ErrorBoundary>
)}
<NewSessionButton
onClick={() => {
Expand Down
86 changes: 86 additions & 0 deletions apps/agor-ui/src/components/ErrorBoundary/CanvasCrashScreen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { CopyOutlined, GithubOutlined, ReloadOutlined, WarningOutlined } from '@ant-design/icons';
import { Button, Card, Space, Typography, theme } from 'antd';
import type { ErrorInfo } from 'react';
import { useState } from 'react';
import { copyToClipboard } from '../../utils/clipboard';
import { buildGitHubIssueUrl, buildMarkdownReport, firstComponentFromStack } from './crashReport';

const { Title, Paragraph, Text } = Typography;

interface CanvasCrashScreenProps {
error: Error;
errorInfo: ErrorInfo | null;
// Clears the boundary's error state so the canvas subtree re-mounts, without
// reloading the whole page (which would drop the rest of the app shell).
onReset: () => void;
}

// Scoped fallback for the canvas boundary: fills the canvas panel while the
// surrounding app shell (header, panels, nav) stays interactive. Shares the
// same crash-report helpers as the global screen so auto-issue reporting is
// preserved, but recovers in place instead of forcing a full reload.
export function CanvasCrashScreen({ error, errorInfo, onReset }: CanvasCrashScreenProps) {
const { token } = theme.useToken();
const [copied, setCopied] = useState(false);

const handleCopy = async () => {
const ok = await copyToClipboard(buildMarkdownReport(error, errorInfo));
if (ok) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};

const githubUrl = buildGitHubIssueUrl(error, errorInfo);
const component = firstComponentFromStack(errorInfo?.componentStack);

return (
<div
style={{
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '2rem',
backgroundColor: token.colorBgLayout,
}}
>
<Card style={{ maxWidth: 520, width: '100%', textAlign: 'center' }}>
<WarningOutlined
style={{ fontSize: 56, color: token.colorWarning, marginBottom: token.marginMD }}
/>

<Title level={4} style={{ marginTop: 0, marginBottom: token.marginXS }}>
The canvas stopped rendering.
</Title>
<Paragraph style={{ color: token.colorTextSecondary, marginBottom: token.marginMD }}>
The rest of Agor is still working. Reloading the canvas usually recovers it. If it keeps
happening, the report below helps us fix it.
</Paragraph>

<Space wrap style={{ justifyContent: 'center' }}>
<Button icon={<CopyOutlined />} onClick={handleCopy}>
{copied ? 'Copied!' : 'Copy error details'}
</Button>
<Button
icon={<GithubOutlined />}
href={githubUrl}
target="_blank"
rel="noopener noreferrer"
>
Report on GitHub
</Button>
<Button type="primary" icon={<ReloadOutlined />} onClick={onReset}>
Reload canvas
</Button>
</Space>

<Paragraph style={{ marginTop: token.marginMD, marginBottom: 0 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
{component}: {error.message || String(error)}
</Text>
</Paragraph>
</Card>
</div>
);
}
67 changes: 67 additions & 0 deletions apps/agor-ui/src/components/ErrorBoundary/ErrorBoundary.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ErrorBoundary } from './ErrorBoundary';

// A child that throws on demand, so we can drive the boundary from "crashed"
// back to "healthy" the way the "Reload canvas" action does.
function Bomb({ shouldThrow }: { shouldThrow: boolean }) {
if (shouldThrow) {
throw new Error('canvas render loop');
}
return <div>canvas content</div>;
}

describe('ErrorBoundary canvas variant', () => {
beforeEach(() => {
// React logs caught render errors to console.error; silence the expected
// noise so a green run stays readable.
vi.spyOn(console, 'error').mockImplementation(() => {});
});

afterEach(() => {
vi.restoreAllMocks();
});

it('keeps the surrounding shell alive and shows a recoverable fallback', () => {
render(
<div>
<div>app shell</div>
<ErrorBoundary variant="canvas">
<Bomb shouldThrow />
</ErrorBoundary>
</div>
);

// Shell outside the boundary survives the canvas crash.
expect(screen.getByText('app shell')).toBeInTheDocument();
expect(screen.getByText('The canvas stopped rendering.')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /reload canvas/i })).toBeInTheDocument();
// The crashed subtree is not rendered.
expect(screen.queryByText('canvas content')).not.toBeInTheDocument();
});

it('recovers in place when "Reload canvas" is clicked after the error clears', () => {
let live = true;
function LiveBomb() {
if (live) throw new Error('canvas render loop');
return <div>canvas content</div>;
}

render(
<ErrorBoundary variant="canvas">
<LiveBomb />
</ErrorBoundary>
);

expect(screen.getByText('The canvas stopped rendering.')).toBeInTheDocument();

// Simulate the underlying condition clearing (tab settled, dimensions
// stable) before the user retries; the reset re-mounts the now-healthy
// subtree in place, no page reload.
live = false;
fireEvent.click(screen.getByRole('button', { name: /reload canvas/i }));

expect(screen.getByText('canvas content')).toBeInTheDocument();
expect(screen.queryByText('The canvas stopped rendering.')).not.toBeInTheDocument();
});
});
15 changes: 14 additions & 1 deletion apps/agor-ui/src/components/ErrorBoundary/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
import { Alert } from 'antd';
import { Component, type ErrorInfo, type ReactNode } from 'react';
import { CanvasCrashScreen } from './CanvasCrashScreen';
import { GlobalCrashScreen } from './GlobalCrashScreen';

interface ErrorBoundaryProps {
children: ReactNode;
// Visual mode for the fallback.
// 'scoped' (default): small inline antd <Alert> — good for section-level
// boundaries that wrap one piece of UI (e.g. the logs modal).
// 'canvas': subtree-scoped crash card that fills its container so the rest
// of the app shell stays alive; offers an in-place "Reload canvas" reset
// instead of a full page reload. For the board canvas subtree.
// 'global': full-screen friendly crash screen with a copy-paste markdown
// report and GitHub issue link — for the top-level boundary around the
// entire app.
variant?: 'scoped' | 'global';
variant?: 'scoped' | 'canvas' | 'global';
fallbackTitle?: ReactNode;
// When this value changes, the boundary clears its error state and re-renders
// children. Useful when fresh data may unblock the failed render (e.g. a
Expand Down Expand Up @@ -51,6 +55,12 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
this.setState({ errorInfo: info });
}

// Clears the caught error so children re-mount in place. Used by the canvas
// fallback's "Reload canvas" action to recover without a full page reload.
handleReset = () => {
this.setState({ error: null, errorInfo: null });
};

render() {
const { error, errorInfo } = this.state;
const { variant = 'scoped', fallbackTitle, children } = this.props;
Expand All @@ -59,6 +69,9 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
if (variant === 'global') {
return <GlobalCrashScreen error={error} errorInfo={errorInfo} />;
}
if (variant === 'canvas') {
return <CanvasCrashScreen error={error} errorInfo={errorInfo} onReset={this.handleReset} />;
}
return (
<Alert
type="error"
Expand Down
1 change: 1 addition & 0 deletions apps/agor-ui/src/components/ErrorBoundary/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { CanvasCrashScreen } from './CanvasCrashScreen';
export { getCrashContext, setCrashContext } from './crashContext';
export { ErrorBoundary } from './ErrorBoundary';
export { GlobalCrashScreen } from './GlobalCrashScreen';
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { DeleteOutlined, LockOutlined, SettingOutlined, UnlockOutlined } from '@
import { ColorPicker, theme } from 'antd';
import type { Color } from 'antd/es/color-picker';
import { AggregationColor } from 'antd/es/color-picker/color';
import React, { useEffect, useRef, useState } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { NodeResizer, useViewport } from 'reactflow';
import { useMutationGate } from '../../../contexts/ConnectionContext';
import { DeleteZoneModal } from './DeleteZoneModal';
Expand Down Expand Up @@ -89,7 +89,28 @@ const ZoneNodeComponent = ({ data, selected }: { data: ZoneNodeData; selected?:
const [toolbarVisible, setToolbarVisible] = useState(false);
const [recentColors, setRecentColors] = useState<string[]>(getRecentColors());
const labelInputRef = useRef<HTMLInputElement>(null);
const colors = getColorPalette(token);
const colors = useMemo(() => getColorPalette(token), [token]);

// The two toolbar ColorPickers are always mounted (visibility is CSS-only to
// avoid mount/unmount flicker), so their `presets` must be referentially
// stable — a fresh array every render churns antd's picker internals and
// feeds the ResizeObserver re-measurement loop behind React #185.
const borderColorPresets = useMemo(
() => [
{ label: 'Presets', colors },
...(recentColors.length > 0 ? [{ label: 'Recent', colors: recentColors }] : []),
],
[colors, recentColors]
);
const backgroundColorPresets = useMemo(() => {
const opacitySuffix = Math.round(ZONE_CONTENT_OPACITY * 255)
.toString(16)
.padStart(2, '0');
return [
{ label: 'Presets', colors: colors.map((c) => `${c}${opacitySuffix}`) },
...(recentColors.length > 0 ? [{ label: 'Recent', colors: recentColors }] : []),
];
}, [colors, recentColors]);

// Connection gate: when disconnected / reconnecting / out-of-sync, every
// mutator inside the zone (resize, label edit, color, lock, config, delete)
Expand Down Expand Up @@ -351,20 +372,7 @@ const ZoneNodeComponent = ({ data, selected }: { data: ZoneNodeData; selected?:
destroyTooltipOnHide
showText={false}
format="hex"
presets={[
{
label: 'Presets',
colors: colors,
},
...(recentColors.length > 0
? [
{
label: 'Recent',
colors: recentColors,
},
]
: []),
]}
presets={borderColorPresets}
>
<button
type="button"
Expand Down Expand Up @@ -424,25 +432,7 @@ const ZoneNodeComponent = ({ data, selected }: { data: ZoneNodeData; selected?:
destroyTooltipOnHide
showText={false}
format="hex"
presets={[
{
label: 'Presets',
colors: colors.map(
(c) =>
`${c}${Math.round(ZONE_CONTENT_OPACITY * 255)
.toString(16)
.padStart(2, '0')}`
),
},
...(recentColors.length > 0
? [
{
label: 'Recent',
colors: recentColors,
},
]
: []),
]}
presets={backgroundColorPresets}
>
<button
type="button"
Expand Down