From e3b5da267e947a0687ab5f7a7545e9efd4622428 Mon Sep 17 00:00:00 2001 From: mschwab Date: Wed, 5 Aug 2026 11:30:55 -0700 Subject: [PATCH 1/4] perf(studio): code-split the entry chunk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Studio entry chunk had grown to 1,641 kB (gzip 535 kB) because the app shell statically imported subtrees that pulled in heavy libraries on every route. GlobalNav statically imported ClaudeCodeTopBarChat, which reached ClaudeCodeChatThread and through it assistant-ui, the remark/micromark markdown stack, DataView + table-core + dnd-kit + date-fns, and — via AgentBlockingInput -> DatasetFileSelect -> FileContentPreview -> CodeEditor — all of CodeMirror, the lezer grammars, yaml and papaparse. None of it is needed until the copilot pop-out is opened. - ClaudeCodeTopBarChat: lazy() the chat thread, gated on a hasOpened latch so the chunk loads on first open and stays mounted afterwards. The trigger button and its thinking/unread badges stay synchronous. - FileContentPreview: lazy() the CodeEditor behind a Suspense spinner. - CodeEditor/constants: import BasicSetupOptions as a type, so importing ContentType alone no longer drags in @uiw/react-codemirror. - CodeEditor yaml linter: await import('yaml') inside the lint source. - main.tsx: start the telemetry import without awaiting it, then await it alongside the theme stylesheet before rendering. OpenTelemetry still patches fetch/XHR before the first request, but the OTel SDK no longer sits in the entry chunk. Entry chunk is now 326 kB (gzip 76 kB), an 80% reduction. Signed-off-by: mschwab --- .../src/components/CodeEditor/constants.ts | 2 +- .../src/components/CodeEditor/linters/yaml.ts | 4 +- .../FileContentPreview.test.tsx | 16 +++---- .../components/FileContentPreview/index.tsx | 41 +++++++++++------ web/packages/studio/src/main.tsx | 10 ++--- .../CopilotChatRoute/CopilotTopBarChat.tsx | 44 +++++++++++++++---- 6 files changed, 79 insertions(+), 38 deletions(-) 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..821a28805d 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 { 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..8a2c7d052d 100644 --- a/web/packages/studio/src/main.tsx +++ b/web/packages/studio/src/main.tsx @@ -1,16 +1,16 @@ // 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 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'); + const storedTheme = window.localStorage.getItem(UI_THEME); const theme = storedTheme ? JSON.parse(storedTheme) : 'dark'; @@ -34,7 +34,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/CopilotTopBarChat.tsx b/web/packages/studio/src/routes/agents/CopilotChatRoute/CopilotTopBarChat.tsx index 6066514e8b..d49c3e58d3 100644 --- a/web/packages/studio/src/routes/agents/CopilotChatRoute/CopilotTopBarChat.tsx +++ b/web/packages/studio/src/routes/agents/CopilotChatRoute/CopilotTopBarChat.tsx @@ -1,18 +1,34 @@ // 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 { 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 CopilotChatThread = lazy(() => + import('@studio/routes/agents/CopilotChatRoute/CopilotChatThread').then((m) => ({ + default: m.CopilotChatThread, + })) +); + const OPEN_LABEL = 'Open NeMo Copilot chat'; const CLOSE_LABEL = 'Close NeMo Copilot chat'; @@ -30,6 +46,7 @@ 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); // 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 +88,7 @@ const CopilotTopBarChatPopout: FC<{ workspace: string }> = ({ workspace }) => { return; } setIsOpen(true); + setHasOpened(true); setScrollToBottomSignal((signal) => signal + 1); }, [isOpen] @@ -149,11 +167,21 @@ const CopilotTopBarChatPopout: FC<{ workspace: string }> = ({ workspace }) => { - + {hasOpened ? ( + + + + } + > + + + ) : null} } From 77fd37f9ee6f0e9cf1ed53ec996776b680030f00 Mon Sep 17 00:00:00 2001 From: mschwab Date: Wed, 5 Aug 2026 11:32:36 -0700 Subject: [PATCH 2/4] perf(studio): preload the copilot chat chunk on hover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up from a pass with the Vercel React best-practices rules. - bundle-preload: warm the chat chunk on hover of the top-bar trigger so the first open is instant instead of waiting on a ~400 kB fetch. Uses onMouseEnter, not onPointerEnter/onFocus — KUI's PopoverTrigger spreads `...props` after its own handlers, so either of those replaces the trigger's and breaks opening the pop-out. - rendering-hoist-jsx: hoist the Suspense fallback element to module scope instead of rebuilding it on every render. Signed-off-by: mschwab --- .../CopilotChatRoute/CopilotTopBarChat.tsx | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/web/packages/studio/src/routes/agents/CopilotChatRoute/CopilotTopBarChat.tsx b/web/packages/studio/src/routes/agents/CopilotChatRoute/CopilotTopBarChat.tsx index d49c3e58d3..1a83ea63ee 100644 --- a/web/packages/studio/src/routes/agents/CopilotChatRoute/CopilotTopBarChat.tsx +++ b/web/packages/studio/src/routes/agents/CopilotChatRoute/CopilotTopBarChat.tsx @@ -23,17 +23,25 @@ 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'); + const CopilotChatThread = lazy(() => - import('@studio/routes/agents/CopilotChatRoute/CopilotChatThread').then((m) => ({ - default: m.CopilotChatThread, - })) + importChatThread().then((m) => ({ default: m.CopilotChatThread })) ); +const preloadChatThread = () => void importChatThread(); + 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 @@ -168,13 +176,7 @@ const CopilotTopBarChatPopout: FC<{ workspace: string }> = ({ workspace }) => { {hasOpened ? ( - - - - } - > + = ({ workspace }) => { aria-label={isOpen ? CLOSE_LABEL : OPEN_LABEL} className="relative" title={isOpen ? CLOSE_LABEL : OPEN_LABEL} + // Not onPointerEnter: PopoverTrigger spreads `...props` after its own, + // so passing one here replaces it and breaks the pop-out. + onMouseEnter={preloadChatThread} onPointerDown={handleTriggerPointerDown} onClick={handleTriggerClick} > From 52e62346455a0eae800c10394dfc623208db61a8 Mon Sep 17 00:00:00 2001 From: mschwab Date: Wed, 5 Aug 2026 11:37:13 -0700 Subject: [PATCH 3/4] refactor(studio): drop the PopoverTrigger prop-order comment Signed-off-by: mschwab --- .../src/routes/agents/CopilotChatRoute/CopilotTopBarChat.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/web/packages/studio/src/routes/agents/CopilotChatRoute/CopilotTopBarChat.tsx b/web/packages/studio/src/routes/agents/CopilotChatRoute/CopilotTopBarChat.tsx index 1a83ea63ee..a7c1cca504 100644 --- a/web/packages/studio/src/routes/agents/CopilotChatRoute/CopilotTopBarChat.tsx +++ b/web/packages/studio/src/routes/agents/CopilotChatRoute/CopilotTopBarChat.tsx @@ -194,8 +194,6 @@ const CopilotTopBarChatPopout: FC<{ workspace: string }> = ({ workspace }) => { aria-label={isOpen ? CLOSE_LABEL : OPEN_LABEL} className="relative" title={isOpen ? CLOSE_LABEL : OPEN_LABEL} - // Not onPointerEnter: PopoverTrigger spreads `...props` after its own, - // so passing one here replaces it and breaks the pop-out. onMouseEnter={preloadChatThread} onPointerDown={handleTriggerPointerDown} onClick={handleTriggerClick} From 08431ff5a9c6e3d2e1f1ef172e837c1d83645fd8 Mon Sep 17 00:00:00 2001 From: mschwab Date: Wed, 5 Aug 2026 12:41:59 -0700 Subject: [PATCH 4/4] fix(studio): handle lazy-chunk load failures Address review on the entry-chunk code split: - main.tsx: telemetry is optional, so catch its dynamic import. Without a handler a failed telemetry chunk rejects the Promise.all and React never mounts, leaving a blank page. - ClaudeCodeTopBarChat: catch the hover preload rejection, and wrap the lazy chat thread in an error boundary. Nothing above GlobalNav catches, so a failed chunk fetch unwound to the root and blanked all of Studio. Retry builds a fresh lazy component since React caches a rejected import. - FileContentPreview: import FC as a type. Signed-off-by: mschwab --- .../components/FileContentPreview/index.tsx | 2 +- web/packages/studio/src/main.tsx | 5 +- .../ChatThreadErrorBoundary.test.tsx | 68 +++++++++++++++++++ .../ChatThreadErrorBoundary.tsx | 54 +++++++++++++++ .../CopilotChatRoute/CopilotTopBarChat.tsx | 28 +++++--- 5 files changed, 144 insertions(+), 13 deletions(-) create mode 100644 web/packages/studio/src/routes/agents/CopilotChatRoute/ChatThreadErrorBoundary.test.tsx create mode 100644 web/packages/studio/src/routes/agents/CopilotChatRoute/ChatThreadErrorBoundary.tsx diff --git a/web/packages/common/src/components/FileContentPreview/index.tsx b/web/packages/common/src/components/FileContentPreview/index.tsx index 821a28805d..ac3c546f06 100644 --- a/web/packages/common/src/components/FileContentPreview/index.tsx +++ b/web/packages/common/src/components/FileContentPreview/index.tsx @@ -12,7 +12,7 @@ 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, lazy, Suspense, useEffect, useMemo, useState } from 'react'; +import { type FC, lazy, Suspense, useEffect, useMemo, useState } from 'react'; const MARKDOWN_EXTENSIONS = new Set(['.md', '.markdown']); diff --git a/web/packages/studio/src/main.tsx b/web/packages/studio/src/main.tsx index 8a2c7d052d..a5cb3a91b2 100644 --- a/web/packages/studio/src/main.tsx +++ b/web/packages/studio/src/main.tsx @@ -5,11 +5,14 @@ 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'); +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'; 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 a7c1cca504..0ad4c51a58 100644 --- a/web/packages/studio/src/routes/agents/CopilotChatRoute/CopilotTopBarChat.tsx +++ b/web/packages/studio/src/routes/agents/CopilotChatRoute/CopilotTopBarChat.tsx @@ -4,6 +4,7 @@ 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 { getCopilotChatRouteForSession } from '@studio/routes/agents/CopilotChatRoute/util'; import { getCopilotChatRoute } from '@studio/routes/utils'; @@ -25,11 +26,11 @@ import { useNavigate } from 'react-router'; // the trigger renders in the global nav on every route. const importChatThread = () => import('@studio/routes/agents/CopilotChatRoute/CopilotChatThread'); -const CopilotChatThread = lazy(() => - importChatThread().then((m) => ({ default: m.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(); +const preloadChatThread = () => void importChatThread().catch(() => undefined); const OPEN_LABEL = 'Open NeMo Copilot chat'; const CLOSE_LABEL = 'Close NeMo Copilot chat'; @@ -55,6 +56,9 @@ const CopilotTopBarChatPopout: FC<{ workspace: string }> = ({ workspace }) => { 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 @@ -176,13 +180,15 @@ const CopilotTopBarChatPopout: FC<{ workspace: string }> = ({ workspace }) => { {hasOpened ? ( - - - + + + + + ) : null}