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
68 changes: 10 additions & 58 deletions web/packages/common/src/components/LogViewer/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { useStickToBottom } from '@nemo/common/src/hooks/useStickToBottom';
import { triggerDownload } from '@nemo/common/src/utils/file';
import { formatLogs } from '@nemo/common/src/utils/logs';
import type { PlatformJobLog } from '@nemo/sdk/generated/platform/schema';
Expand All @@ -15,7 +16,7 @@ import {
} from '@nvidia/foundations-react-core';
import classNames from 'classnames';
import { ArrowUp, Download } from 'lucide-react';
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { FC, useMemo, useState } from 'react';

const DEFAULT_ROW_COUNT = 30;

Expand All @@ -34,79 +35,30 @@ export const LogViewer: FC<LogViewerProps> = ({
rows = DEFAULT_ROW_COUNT,
emptyMessage = 'No logs available yet',
}) => {
const codeScrollRef = useRef<HTMLDivElement>(null);
const [showAllLogs, setShowAllLogs] = useState(false);
const shouldAutoScrollRef = useRef(true);
const tailLogs = logs?.slice(-rows) || [];
const displayedLogs = showAllLogs ? logs : tailLogs;
const logText = formatLogs(displayedLogs);
const hasMoreLogs = logs && logs.length > rows;

const isShowingLogs = useMemo(() => logs.length > 0 && !isLoading, [logs.length, isLoading]);

const { ref: codeScrollRef, scrollToBottom } = useStickToBottom<HTMLDivElement>({
enabled: isShowingLogs,
resetKey: showAllLogs,
});

const handleDownload = () => {
if (downloadFilename) {
triggerDownload(formatLogs(logs), downloadFilename);
}
};

const scrollToBottomNow = useCallback(() => {
const codeElement = codeScrollRef.current;
if (codeElement) {
const maxScrollTop = codeElement.scrollHeight - codeElement.clientHeight;
codeElement.scrollTop = maxScrollTop;
}
}, []);

const handleLoadMore = () => {
shouldAutoScrollRef.current = true;
scrollToBottom();
setShowAllLogs(true);
};

const isShowingLogs = useMemo(() => logs.length > 0 && !isLoading, [logs.length, isLoading]);

// Watch for actual DOM changes (catches CodeSnippet internal rendering)
useEffect(() => {
if (!isShowingLogs) return;
const codeElement = codeScrollRef.current;
if (!codeElement) return;

const mutationObserver = new MutationObserver(() => {
if (shouldAutoScrollRef.current) {
scrollToBottomNow();
}
});

mutationObserver.observe(codeElement, {
childList: true,
subtree: true,
characterData: true,
});

return () => {
mutationObserver.disconnect();
};
}, [scrollToBottomNow, isShowingLogs, showAllLogs]);

// Track if user scrolls away from bottom
useEffect(() => {
if (!isShowingLogs) return;
const codeElement = codeScrollRef.current;
if (!codeElement) return;

const handleScroll = () => {
const threshold = 50;
const isAtBottom =
Math.abs(codeElement.scrollHeight - codeElement.clientHeight - codeElement.scrollTop) <
threshold;
shouldAutoScrollRef.current = isAtBottom;
};

codeElement.addEventListener('scroll', handleScroll);

return () => {
codeElement.removeEventListener('scroll', handleScroll);
};
}, [isShowingLogs, showAllLogs]);

if (isLoading) {
return <Spinner size="medium" aria-label="Loading..." />;
}
Expand Down
55 changes: 55 additions & 0 deletions web/packages/common/src/hooks/useStickToBottom/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { useStickToBottom } from '@nemo/common/src/hooks/useStickToBottom';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { FC } from 'react';

// jsdom has no layout: scrollHeight/clientHeight report 0 and scrollTop is a no-op.
// Fake the geometry and back scrollTop with a real stored value so assignments stick.
const SCROLL_HEIGHT = 1000;
const CLIENT_HEIGHT = 100;
const MAX_SCROLL_TOP = SCROLL_HEIGHT - CLIENT_HEIGHT;

const mockGeometry = (element: HTMLElement) => {
let scrollTop = 0;
Object.defineProperty(element, 'scrollHeight', { configurable: true, value: SCROLL_HEIGHT });
Object.defineProperty(element, 'clientHeight', { configurable: true, value: CLIENT_HEIGHT });
Object.defineProperty(element, 'scrollTop', {
configurable: true,
get: () => scrollTop,
set: (value: number) => {
scrollTop = value;
},
});
};

const Harness: FC<{ enabled?: boolean; attached?: boolean }> = ({ enabled, attached = true }) => {
const { ref, scrollToBottom } = useStickToBottom<HTMLDivElement>({ enabled });
return (
<>
<button onClick={scrollToBottom}>scroll</button>
{attached && <div ref={ref} data-testid="scroll" />}
</>
);
};

it('scrollToBottom() jumps the container to the bottom', async () => {
const user = userEvent.setup();
render(<Harness enabled />);
const scroll = screen.getByTestId('scroll');
mockGeometry(scroll);
expect(scroll.scrollTop).toBe(0);

await user.click(screen.getByRole('button', { name: 'scroll' }));

expect(scroll.scrollTop).toBe(MAX_SCROLL_TOP);
});

it('scrollToBottom() does not throw before the element is attached', async () => {
const user = userEvent.setup();
render(<Harness enabled attached={false} />);

await expect(user.click(screen.getByRole('button', { name: 'scroll' }))).resolves.not.toThrow();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
86 changes: 86 additions & 0 deletions web/packages/common/src/hooks/useStickToBottom/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { RefObject, useCallback, useEffect, useRef } from 'react';

interface UseStickToBottomOptions {
/** When false, the scroll listener and observer are detached (e.g. while loading). */
enabled?: boolean;
/** Distance (px) from the bottom that still counts as "at the bottom". */
threshold?: number;
/**
* Changing this re-attaches the observers and re-arms auto-scroll — use it when the
* scroll container's content is swapped out (e.g. toggling "show all" vs "tail").
*/
resetKey?: unknown;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

interface UseStickToBottom<T extends HTMLElement> {
/** Attach to the scrollable element (or the element whose content grows). */
ref: RefObject<T | null>;
/** Jump to the bottom now and re-arm auto-scroll so future growth stays pinned. */
scrollToBottom: () => void;
}

/**
* Keeps a scroll container pinned to the bottom as content streams in, but only while
* the user is already at the bottom. If the user scrolls up, auto-scroll pauses until
* they scroll back down (within `threshold`).
*
* Growth is detected with a MutationObserver so it also catches async content (e.g. a
* CodeSnippet that re-renders highlighted text after the value prop changes).
*/
export function useStickToBottom<T extends HTMLElement = HTMLElement>({
enabled = true,
threshold = 50,
resetKey,
}: UseStickToBottomOptions = {}): UseStickToBottom<T> {
const ref = useRef<T>(null);
const shouldAutoScrollRef = useRef(true);

const scrollToBottom = useCallback(() => {
shouldAutoScrollRef.current = true;
const element = ref.current;
if (element) {
element.scrollTop = element.scrollHeight - element.clientHeight;
}
}, []);

// Pin to the bottom whenever content changes and the user is at the bottom.
useEffect(() => {
if (!enabled) return;
const element = ref.current;
if (!element) return;

shouldAutoScrollRef.current = true;
element.scrollTop = element.scrollHeight - element.clientHeight;

const observer = new MutationObserver(() => {
if (shouldAutoScrollRef.current) {
element.scrollTop = element.scrollHeight - element.clientHeight;
}
});

observer.observe(element, { childList: true, subtree: true, characterData: true });

return () => observer.disconnect();
}, [enabled, resetKey]);

// Track whether the user is at the bottom so we can pause/resume auto-scroll.
useEffect(() => {
if (!enabled) return;
const element = ref.current;
if (!element) return;

const handleScroll = () => {
const distanceFromBottom = element.scrollHeight - element.clientHeight - element.scrollTop;
shouldAutoScrollRef.current = Math.abs(distanceFromBottom) < threshold;
};

element.addEventListener('scroll', handleScroll);

return () => element.removeEventListener('scroll', handleScroll);
}, [enabled, threshold, resetKey]);

return { ref, scrollToBottom };
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,18 +28,20 @@ const meta = {
export default meta;
type Story = StoryObj<typeof meta>;

const NEMOTRON_MODEL = 'nvidia/llama-3.3-nemotron-super-49b-v1';

const models: BuilderModel[] = [
{
id: 'model-0',
alias: 'default',
model: 'openai/gpt-4o-mini',
provider: 'openai',
model: NEMOTRON_MODEL,
provider: 'nvidia',
inferenceParams: { temperature: 0.7 },
},
{
id: 'model-1',
alias: 'judge',
model: 'meta/llama-3.1-70b-instruct',
model: 'nvidia/llama-3.1-nemotron-70b-instruct',
provider: 'nvidia',
inferenceParams: { temperature: 0, max_tokens: 1024 },
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

import { SamplerType } from '@nemo/sdk/generated/data-designer/schema';
import type { FilesetTemplate } from '@studio/components/CreateFilesetStart/types';
import { DEFAULT_BUILD_MODEL_NAME } from '@studio/constants/constants';
import { DEFAULT_BUILD_MODEL_NAME, DEFAULT_EMBEDDER_MODEL_NAME } from '@studio/constants/constants';
import {
Braces,
Code2,
Expand Down Expand Up @@ -284,6 +284,8 @@ export const FILESET_TEMPLATES: FilesetTemplate[] = [
prompt:
'On a scale of 1–5 (1 = very poor, 5 = excellent), rate the quality of the following answer.\n\nQuestion: {{ instruction }}\nAnswer: {{ chosen }}\n\nReturn only the integer score.',
model_alias: 'default',
scores:
'[{ "name": "Quality", "description": "Overall answer quality.", "options": { "1": "Very poor", "5": "Excellent" } }]',
},
},
],
Expand Down Expand Up @@ -334,7 +336,15 @@ export const FILESET_TEMPLATES: FilesetTemplate[] = [
],
models: [
{ alias: 'default', model: DEFAULT_BUILD_MODEL_NAME },
{ alias: 'embedder', model: 'nvidia/nv-embedqa-e5-v5' },
{
alias: 'embedder',
model: DEFAULT_EMBEDDER_MODEL_NAME,
inferenceParams: {
generation_type: 'embedding',
encoding_format: 'float',
extra_body: { input_type: 'passage', truncate: 'NONE' },
},
},
],
},
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import type {
import { Banner, Button, Text } from '@nvidia/foundations-react-core';
import { BulkDeleteModal } from '@studio/components/BulkDeleteModal';
import { DataDesignerJobActionsMenu } from '@studio/components/DataDesignerJobActionsMenu';
import { DataDesignerIconFc } from '@studio/constants/constants';
import { STATUS_FILTER_OPTIONS } from '@studio/constants/platformJobs';
import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath';
import { getDataDesignerJobDetailsRoute, getNewDataDesignerJobRoute } from '@studio/routes/utils';
Expand Down Expand Up @@ -230,6 +231,7 @@ export const DataDesignerJobsDataView: FC = () => {
/>
) : (
<TableEmptyState
icon={<DataDesignerIconFc className="h-[64px] w-[64px]" />}
header="Data Designer Jobs"
emptyMessage="Create and manage data designer jobs to generate or transform datasets."
actions={
Expand Down
5 changes: 5 additions & 0 deletions web/packages/studio/src/constants/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
* its affiliates is strictly prohibited.
*/

import { Palette } from 'lucide-react';

export const CHAT_DEFAULT_MAX_TOKENS = 4096;
export const DEFAULT_LARGE_PAGE_SIZE = 1000;
export const DATASET_NAME_REGEX = /^[a-zA-Z0-9._-]+$/;
Expand All @@ -20,6 +22,7 @@ export const DEFAULT_TOOLS_FILE_NAME = 'tools.json';
export const EMPTY_FIELD_VALUE = '-';
export const EMPTY_FIELD_EMDASH_VALUE = '—';
export const DEFAULT_BUILD_MODEL_NAME = 'nvidia-llama-3-3-nemotron-super-49b-v1';
export const DEFAULT_EMBEDDER_MODEL_NAME = 'nvidia-nv-embedqa-e5-v5';

export const KNOWN_TEXT_EXTENSIONS = new Set([
// Data
Expand Down Expand Up @@ -76,3 +79,5 @@ export const KNOWN_TEXT_EXTENSIONS = new Set([
'dockerfile',
'makefile',
]);

export const DataDesignerIconFc = Palette;
2 changes: 1 addition & 1 deletion web/packages/studio/src/mocks/evaluation/configs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,7 @@ export const mockEvalConfigRag = {
version_id: '',
api_endpoint: {
url: 'http://nemo-embedding-ms.nemo-retrieval.svc.cluster.local:8080/v1/embeddings',
model_id: 'nvidia/nv-embedqa-e5-v5',
model_id: 'nvidia-nv-embedqa-e5-v5',
format: 'nim',
},
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { useStickToBottom } from '@nemo/common/src/hooks/useStickToBottom';
import { Banner, Button, CodeSnippet, Flex, Stack } from '@nvidia/foundations-react-core';
import { formatPreviewLogsForDisplay } from '@studio/components/NewDataDesignerJobForm/previewApi';
import { ChevronDown, ChevronRight } from 'lucide-react';
Expand All @@ -20,6 +21,10 @@ export const BuilderDetailsPanel: FC<BuilderDetailsPanelProps> = ({
isOpen,
onToggle,
}) => {
const { ref: logsScrollRef } = useStickToBottom<HTMLDivElement>({
enabled: isOpen && !!previewLogs,
});

const hasDetails = validationErrors.length > 0 || !!submitError || !!previewLogs;
if (!hasDetails) return null;

Expand Down Expand Up @@ -68,7 +73,7 @@ export const BuilderDetailsPanel: FC<BuilderDetailsPanelProps> = ({
value={formatPreviewLogsForDisplay(previewLogs)}
language="json"
kind="block"
attributes={{ CodeSnippetCode: { className: 'max-h-[240px]' } }}
attributes={{ CodeSnippetCode: { ref: logsScrollRef, className: 'max-h-[240px]' } }}
/>
)}
</Stack>
Expand Down
Loading