diff --git a/web/packages/common/src/components/CodeEditor/constants.ts b/web/packages/common/src/components/CodeEditor/constants.ts index 0ae0fd42b8..1c3ac24547 100644 --- a/web/packages/common/src/components/CodeEditor/constants.ts +++ b/web/packages/common/src/components/CodeEditor/constants.ts @@ -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', diff --git a/web/packages/common/src/components/CodeEditor/linters/yaml.ts b/web/packages/common/src/components/CodeEditor/linters/yaml.ts index 78a9780fa3..31d857025b 100644 --- a/web/packages/common/src/components/CodeEditor/linters/yaml.ts +++ b/web/packages/common/src/components/CodeEditor/linters/yaml.ts @@ -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()); diff --git a/web/packages/common/src/components/FileContentPreview/FileContentPreview.test.tsx b/web/packages/common/src/components/FileContentPreview/FileContentPreview.test.tsx index e13a0d7f55..b20b05c550 100644 --- a/web/packages/common/src/components/FileContentPreview/FileContentPreview.test.tsx +++ b/web/packages/common/src/components/FileContentPreview/FileContentPreview.test.tsx @@ -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( { 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( { 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( { content='{"nested": true}' /> ); - expect(screen.getByTestId('code-editor')).toHaveAttribute('data-content-type', 'json'); + expect(await screen.findByTestId('code-editor')).toHaveAttribute('data-content-type', 'json'); }); }); @@ -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( { 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'); }); diff --git a/web/packages/common/src/components/FileContentPreview/index.tsx b/web/packages/common/src/components/FileContentPreview/index.tsx index 1529ce3dd0..ac3c546f06 100644 --- a/web/packages/common/src/components/FileContentPreview/index.tsx +++ b/web/packages/common/src/components/FileContentPreview/index.tsx @@ -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, @@ -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 = ( + + + +); + export interface FileContentPreviewProps { isLoading: boolean; error: Error | null; @@ -116,12 +125,14 @@ export const FileContentPreview: FC = ({ if (isJson && jsonContentType) { return (
- + + +
); } @@ -149,12 +160,14 @@ export const FileContentPreview: FC = ({ // Plain text fallback (incl. .txt, .log, anything we don't have a richer view for) return (
- + + +
); }; diff --git a/web/packages/studio/src/main.tsx b/web/packages/studio/src/main.tsx index 17151f9c51..a5cb3a91b2 100644 --- a/web/packages/studio/src/main.tsx +++ b/web/packages/studio/src/main.tsx @@ -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'; @@ -34,7 +37,7 @@ function waitForThemeStylesheet(): Promise { const rootElement = document.getElementById('app')!; if (!rootElement.innerHTML) { - waitForThemeStylesheet().then(() => { + Promise.all([waitForThemeStylesheet(), telemetryReady]).then(() => { rootElement.removeAttribute('aria-busy'); const root = ReactDOM.createRoot(rootElement); root.render(); diff --git a/web/packages/studio/src/routes/agents/CopilotChatRoute/ChatThreadErrorBoundary.test.tsx b/web/packages/studio/src/routes/agents/CopilotChatRoute/ChatThreadErrorBoundary.test.tsx new file mode 100644 index 0000000000..ff36be9154 --- /dev/null +++ b/web/packages/studio/src/routes/agents/CopilotChatRoute/ChatThreadErrorBoundary.test.tsx @@ -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
; +}; + +const renderBoundary = (shouldThrow: boolean, onRetry = vi.fn()) => + render( + + + + + + + + ); + +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( + + + + + + + + ); + await user.click(screen.getByRole('button', { name: /try again/i })); + + expect(onRetry).toHaveBeenCalledOnce(); + expect(screen.getByTestId('chat-thread')).toBeInTheDocument(); + }); +}); diff --git a/web/packages/studio/src/routes/agents/CopilotChatRoute/ChatThreadErrorBoundary.tsx b/web/packages/studio/src/routes/agents/CopilotChatRoute/ChatThreadErrorBoundary.tsx new file mode 100644 index 0000000000..c627d90525 --- /dev/null +++ b/web/packages/studio/src/routes/agents/CopilotChatRoute/ChatThreadErrorBoundary.tsx @@ -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 ( + + Try Again + + } + /> + ); + } +} diff --git a/web/packages/studio/src/routes/agents/CopilotChatRoute/CopilotTopBarChat.tsx b/web/packages/studio/src/routes/agents/CopilotChatRoute/CopilotTopBarChat.tsx index 6066514e8b..0ad4c51a58 100644 --- a/web/packages/studio/src/routes/agents/CopilotChatRoute/CopilotTopBarChat.tsx +++ b/web/packages/studio/src/routes/agents/CopilotChatRoute/CopilotTopBarChat.tsx @@ -1,23 +1,48 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { Button, Flex, Popover, Stack, Tooltip } from '@nvidia/foundations-react-core'; +import { Button, Flex, Popover, Spinner, Stack, Tooltip } from '@nvidia/foundations-react-core'; import { COPILOT_STUDIO_ENABLED } from '@studio/constants/environment'; import { useWorkspaceFromPathIfExists } from '@studio/hooks/useWorkspaceFromPath'; +import { ChatThreadErrorBoundary } from '@studio/routes/agents/CopilotChatRoute/ChatThreadErrorBoundary'; import { useCopilotChatContext } from '@studio/routes/agents/CopilotChatRoute/context/useCopilotChatContext'; -import { CopilotChatThread } from '@studio/routes/agents/CopilotChatRoute/CopilotChatThread'; import { getCopilotChatRouteForSession } from '@studio/routes/agents/CopilotChatRoute/util'; import { getCopilotChatRoute } from '@studio/routes/utils'; import { Maximize2, Plus, Terminal, X } from 'lucide-react'; -import { type FC, type MouseEvent, useCallback, useEffect, useRef, useState } from 'react'; +import { + lazy, + Suspense, + type FC, + type MouseEvent, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; import { createPortal } from 'react-dom'; import { useNavigate } from 'react-router'; +// Static import would pull the whole chat surface into the entry chunk, since +// the trigger renders in the global nav on every route. +const importChatThread = () => import('@studio/routes/agents/CopilotChatRoute/CopilotChatThread'); + +// lazy() caches a rejected import forever, so a retry needs a fresh component. +const createChatThread = () => + lazy(() => importChatThread().then((m) => ({ default: m.CopilotChatThread }))); + +const preloadChatThread = () => void importChatThread().catch(() => undefined); + const OPEN_LABEL = 'Open NeMo Copilot chat'; const CLOSE_LABEL = 'Close NeMo Copilot chat'; const TopBarChatIcon = () => ; +const chatThreadFallback = ( + + + +); + /** * The top-bar pop-out is a thin view of the shared chat runtime (owned by * CopilotChatProvider). Because the runtime lives above the routes, opening @@ -30,6 +55,10 @@ const CopilotTopBarChatPopout: FC<{ workspace: string }> = ({ workspace }) => { const [isOpen, setIsOpen] = useState(false); const [hasUnreadResponse, setHasUnreadResponse] = useState(false); const [scrollToBottomSignal, setScrollToBottomSignal] = useState(0); + const [hasOpened, setHasOpened] = useState(false); + const [ChatThread, setChatThread] = useState(createChatThread); + + const retryChatThread = useCallback(() => setChatThread(() => createChatThread()), []); // While the agent is blocked on a permission/input request the stream stays // open (isRunning is still true), but it is waiting on the user rather than @@ -71,6 +100,7 @@ const CopilotTopBarChatPopout: FC<{ workspace: string }> = ({ workspace }) => { return; } setIsOpen(true); + setHasOpened(true); setScrollToBottomSignal((signal) => signal + 1); }, [isOpen] @@ -149,11 +179,17 @@ const CopilotTopBarChatPopout: FC<{ workspace: string }> = ({ workspace }) => { - + {hasOpened ? ( + + + + + + ) : null} } @@ -164,6 +200,7 @@ const CopilotTopBarChatPopout: FC<{ workspace: string }> = ({ workspace }) => { aria-label={isOpen ? CLOSE_LABEL : OPEN_LABEL} className="relative" title={isOpen ? CLOSE_LABEL : OPEN_LABEL} + onMouseEnter={preloadChatThread} onPointerDown={handleTriggerPointerDown} onClick={handleTriggerClick} >