Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { BasicSetupOptions } from '@uiw/react-codemirror';
import type { BasicSetupOptions } from '@uiw/react-codemirror';

export enum ContentType {
JSON = 'json',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@
// SPDX-License-Identifier: Apache-2.0

import { Diagnostic, linter } from '@codemirror/lint';
import YAML, { YAMLParseError } from 'yaml';

export const yamlLinter = linter((view) => {
export const yamlLinter = linter(async (view) => {
const diagnostics: Diagnostic[] = [];
const { default: YAML, YAMLParseError } = await import('yaml');

try {
YAML.parse(view.state.doc.toString());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ describe('FileContentPreview', () => {
});

describe('JSON / JSONL dispatch', () => {
it('routes .json through CodeEditor with contentType=json', () => {
it('routes .json through CodeEditor with contentType=json', async () => {
render(
<FileContentPreview
file={{ path: 'data.json' }}
Expand All @@ -88,12 +88,12 @@ describe('FileContentPreview', () => {
content='{"key": "value"}'
/>
);
const editor = screen.getByTestId('code-editor');
const editor = await screen.findByTestId('code-editor');
expect(editor).toHaveAttribute('data-content-type', 'json');
expect(editor).toHaveTextContent('{"key": "value"}');
});

it('routes .jsonl through CodeEditor with contentType=jsonl', () => {
it('routes .jsonl through CodeEditor with contentType=jsonl', async () => {
render(
<FileContentPreview
file={{ path: 'data.jsonl' }}
Expand All @@ -102,12 +102,12 @@ describe('FileContentPreview', () => {
content={'{"line": 1}\n{"line": 2}'}
/>
);
const editor = screen.getByTestId('code-editor');
const editor = await screen.findByTestId('code-editor');
expect(editor).toHaveAttribute('data-content-type', 'jsonl');
expect(editor).toHaveTextContent('{"line": 1}');
});

it('handles nested file paths', () => {
it('handles nested file paths', async () => {
render(
<FileContentPreview
file={{ path: 'folder/subfolder/data.json' }}
Expand All @@ -116,7 +116,7 @@ describe('FileContentPreview', () => {
content='{"nested": true}'
/>
);
expect(screen.getByTestId('code-editor')).toHaveAttribute('data-content-type', 'json');
expect(await screen.findByTestId('code-editor')).toHaveAttribute('data-content-type', 'json');
});
});

Expand Down Expand Up @@ -166,7 +166,7 @@ describe('FileContentPreview', () => {
});

describe('Plain text fallback', () => {
it('routes unknown extensions through CodeEditor with contentType=text', () => {
it('routes unknown extensions through CodeEditor with contentType=text', async () => {
render(
<FileContentPreview
file={{ path: 'readme.txt' }}
Expand All @@ -175,7 +175,7 @@ describe('FileContentPreview', () => {
content="This is plain text content"
/>
);
const editor = screen.getByTestId('code-editor');
const editor = await screen.findByTestId('code-editor');
expect(editor).toHaveAttribute('data-content-type', 'text');
expect(editor).toHaveTextContent('This is plain text content');
});
Expand Down
41 changes: 27 additions & 14 deletions web/packages/common/src/components/FileContentPreview/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { CodeEditor } from '@nemo/common/src/components/CodeEditor';
import { ContentType } from '@nemo/common/src/components/CodeEditor/constants';
import {
getFileExtension,
Expand All @@ -13,10 +12,20 @@ import { MarkdownContent } from '@nemo/common/src/components/MarkdownContent';
import { ScrollTable } from '@nemo/common/src/components/ScrollTable';
import { Flex, Spinner, TableRowDefinition, Text } from '@nvidia/foundations-react-core';
import Papa from 'papaparse';
import { FC, useEffect, useMemo, useState } from 'react';
import { type FC, lazy, Suspense, useEffect, useMemo, useState } from 'react';

const MARKDOWN_EXTENSIONS = new Set(['.md', '.markdown']);

const CodeEditor = lazy(() =>
import('@nemo/common/src/components/CodeEditor').then((m) => ({ default: m.CodeEditor }))
);

const editorFallback = (
<Flex align="center" justify="center" className="h-full">
<Spinner size="medium" aria-label="Loading editor..." />
</Flex>
);

export interface FileContentPreviewProps {
isLoading: boolean;
error: Error | null;
Expand Down Expand Up @@ -116,12 +125,14 @@ export const FileContentPreview: FC<FileContentPreviewProps> = ({
if (isJson && jsonContentType) {
return (
<div className="h-full min-h-0">
<CodeEditor
content={content}
contentType={jsonContentType}
readOnly
className="h-full min-h-0"
/>
<Suspense fallback={editorFallback}>
<CodeEditor
content={content}
contentType={jsonContentType}
readOnly
className="h-full min-h-0"
/>
</Suspense>
</div>
);
}
Expand Down Expand Up @@ -149,12 +160,14 @@ export const FileContentPreview: FC<FileContentPreviewProps> = ({
// Plain text fallback (incl. .txt, .log, anything we don't have a richer view for)
return (
<div className="h-full min-h-0">
<CodeEditor
content={content}
contentType={ContentType.TEXT}
readOnly
className="h-full min-h-0"
/>
<Suspense fallback={editorFallback}>
<CodeEditor
content={content}
contentType={ContentType.TEXT}
readOnly
className="h-full min-h-0"
/>
</Suspense>
</div>
);
};
13 changes: 8 additions & 5 deletions web/packages/studio/src/main.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

// OpenTelemetry patches certain libraries to collect telemetry data, so ensure
// we import this file before the remaining dependencies.
import '@studio/telemetry/telemetry';

import '@studio/index.css';

import { App } from '@studio/App';
import { UI_THEME } from '@studio/util/localStorage';
import { logger } from '@studio/util/logger';
import ReactDOM from 'react-dom/client';

// OpenTelemetry patches fetch/XHR globally, so this must settle before React
// renders and issues the first requests.
const telemetryReady = import('@studio/telemetry/telemetry').catch((error: unknown) => {
logger.error('Telemetry failed to initialize', error);
});

const storedTheme = window.localStorage.getItem(UI_THEME);
const theme = storedTheme ? JSON.parse(storedTheme) : 'dark';

Expand All @@ -34,7 +37,7 @@ function waitForThemeStylesheet(): Promise<void> {

const rootElement = document.getElementById('app')!;
if (!rootElement.innerHTML) {
waitForThemeStylesheet().then(() => {
Promise.all([waitForThemeStylesheet(), telemetryReady]).then(() => {
Comment thread
marcusds marked this conversation as resolved.
rootElement.removeAttribute('aria-busy');
const root = ReactDOM.createRoot(rootElement);
root.render(<App />);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { ChatThreadErrorBoundary } from '@studio/routes/agents/CopilotChatRoute/ChatThreadErrorBoundary';
import { TestProviders } from '@studio/tests/util/TestProviders';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router';

const Boom = ({ shouldThrow }: { shouldThrow: boolean }) => {
if (shouldThrow) throw new Error('Failed to fetch dynamically imported module');
return <div data-testid="chat-thread" />;
};

const renderBoundary = (shouldThrow: boolean, onRetry = vi.fn()) =>
render(
<TestProviders>
<MemoryRouter>
<ChatThreadErrorBoundary onRetry={onRetry}>
<Boom shouldThrow={shouldThrow} />
</ChatThreadErrorBoundary>
</MemoryRouter>
</TestProviders>
);

describe('ChatThreadErrorBoundary', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, 'error').mockImplementation(() => undefined);
});

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

it('renders children when nothing throws', () => {
renderBoundary(false);

expect(screen.getByTestId('chat-thread')).toBeInTheDocument();
});

it('renders the failure message instead of unwinding when the chunk fails to load', () => {
renderBoundary(true);

expect(screen.getByText('Chat failed to load')).toBeInTheDocument();
expect(screen.queryByTestId('chat-thread')).not.toBeInTheDocument();
});

it('clears the error and calls onRetry when Try Again is clicked', async () => {
const user = userEvent.setup();
const onRetry = vi.fn();
const { rerender } = renderBoundary(true, onRetry);

rerender(
<TestProviders>
<MemoryRouter>
<ChatThreadErrorBoundary onRetry={onRetry}>
<Boom shouldThrow={false} />
</ChatThreadErrorBoundary>
</MemoryRouter>
</TestProviders>
);
await user.click(screen.getByRole('button', { name: /try again/i }));

expect(onRetry).toHaveBeenCalledOnce();
expect(screen.getByTestId('chat-thread')).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { ErrorMessage } from '@nemo/common/src/components/ErrorMessage';
import { Button } from '@nvidia/foundations-react-core';
import { logger } from '@studio/util/logger';
import { Component, type ErrorInfo, type ReactNode } from 'react';

interface ChatThreadErrorBoundaryProps {
onRetry: () => void;
children: ReactNode;
}

interface ChatThreadErrorBoundaryState {
error: Error | null;
}

// A failed chunk load throws during render; without this it unwinds to the root
// and blanks all of Studio, not just the pop-out.
export class ChatThreadErrorBoundary extends Component<
ChatThreadErrorBoundaryProps,
ChatThreadErrorBoundaryState
> {
state: ChatThreadErrorBoundaryState = { error: null };

static getDerivedStateFromError(error: Error): ChatThreadErrorBoundaryState {
return { error };
}

componentDidCatch(error: Error, info: ErrorInfo): void {
logger.error(`Copilot chat thread failed to render: ${error.message}`, info.componentStack);
}

private retry = (): void => {
this.setState({ error: null });
this.props.onRetry();
};

render(): ReactNode {
if (!this.state.error) return this.props.children;

return (
<ErrorMessage
header="Chat failed to load"
message="Check your connection and try again."
slotFooter={
<Button kind="secondary" onClick={this.retry}>
Try Again
</Button>
}
/>
);
}
}
Loading
Loading