Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
1875246
feat(common/AssistantChat): expose broadcast, metrics, hideComposer, …
spombo85 May 29, 2026
0793272
feat(studio/chat): consolidated Chat route with Compare mode + polish
spombo85 May 29, 2026
bbe7f86
feat(studio/chat): Run Prompts — unified dataset picker + per-cell gr…
spombo85 May 29, 2026
873a44c
feat(studio/chat): vibe-check a deployed agent with a new LLM
spombo85 May 29, 2026
37fb7a9
feat(studio/chat): Run Prompts — sticky Average row at table bottom
spombo85 May 29, 2026
cba58c6
chore(studio/chat): sanitation pass before review push
spombo85 Jun 1, 2026
22df871
fix(studio): adversarial review caught some in-flight cancel gaps
nv-odrulea Jun 9, 2026
e90a02a
fix(studio): add metrics in to cell expanded modal in Run Prompts
nv-odrulea Jun 9, 2026
89e0fea
fix(studio): entered text input breaks tie when toggle to broadcast-a…
nv-odrulea Jun 9, 2026
16aba7b
fix(studio): pass workspace and model name when nav from agents list
nv-odrulea Jun 9, 2026
2e0a5c4
fix(studio): address several review comments
nv-odrulea Jun 10, 2026
e3dcd0b
fix(studio): addressing design feedback
nv-odrulea Jun 12, 2026
73e0fba
fix(studio): fix seed chips disappearing bug, remove top_k param
nv-odrulea Jun 12, 2026
85abec5
fix(studio): fix adversarial review
nv-odrulea Jun 12, 2026
4d0fc9f
chore(studio): format
nv-odrulea Jun 12, 2026
50500ad
chore(studio): fix tests
nv-odrulea Jun 12, 2026
728be0e
fix(studio): chat extends to end of last panel, keep right gutter
nv-odrulea Jun 12, 2026
0c28e20
fix(studio): cleanup copy and styles
nv-odrulea Jun 12, 2026
00d78a0
fix(studio): clear composer seed chips when chats have history, resto…
nv-odrulea Jun 15, 2026
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
5 changes: 3 additions & 2 deletions web/packages/common/src/api/models/useModels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ import { useEffect } from 'react';
import { DEFAULT_LARGE_PAGE_SIZE, QUERY_PREFIX_PLATFORM } from '../../constants/api';

/**
* A basic filter for a models dropdown that fetches 1000 models sorted alphabetically.
* A basic filter for a models dropdown that fetches models sorted alphabetically.
* page_size is set high so most deployments are covered in a single page.
*/
export const BASIC_ALL_MODELS_DROPDOWN_FILTER: ModelsListModelsParams = {
page_size: DEFAULT_LARGE_PAGE_SIZE,
Expand Down Expand Up @@ -103,5 +104,5 @@ export const buildWorkspaceGroup = (
models: ModelEntity[]
): ModelWorkspaceGroup => ({
workspace: workspaceName,
models,
models: [...models].sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '')),
});
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
ThreadPrimitive,
type ToolCallMessagePartComponent,
} from '@assistant-ui/react';
import { ComposerMode } from '@nemo/common/src/components/AssistantChat/types';
import { ChatEmptyState } from '@nemo/common/src/components/Chat/ChatEmptyState';
import {
MessageContent,
Expand Down Expand Up @@ -39,6 +40,8 @@ interface AssistantChatThreadProps {
onReset: () => void;
showRunningIndicator?: boolean;
attributes?: AssistantChatThreadAttributes;
composerMode?: ComposerMode;
slotComposerStart?: ReactNode;
emptyState?: {
slotHeading?: string;
slotSubheading?: string;
Expand Down Expand Up @@ -239,7 +242,7 @@ const UserEditComposer = () => (

type AssistantComposerProps = Pick<
AssistantChatThreadProps,
'disabled' | 'placeholder' | 'onReset'
'disabled' | 'placeholder' | 'onReset' | 'slotComposerStart'
> & {
className?: string;
};
Expand All @@ -248,50 +251,59 @@ const AssistantComposer = ({
disabled,
placeholder,
onReset,
slotComposerStart,
className,
}: AssistantComposerProps) => (
<ComposerPrimitive.Root
data-testid="assistant-chat-composer"
className={cn(
'flex w-full items-end gap-density-xs rounded-lg border border-base bg-surface-base p-1',
className
)}
>
<ComposerPrimitive.Input
aria-label="Task prompt"
addAttachmentOnPaste={false}
disabled={disabled}
placeholder={placeholder}
submitMode="enter"
className="max-h-64 min-h-20 flex-1 resize-none border-0 bg-transparent px-density-md py-density-md text-sm outline-none disabled:cursor-not-allowed disabled:text-fg-disabled"
/>
<Tooltip slotContent="Clear chat thread">
<Button
aria-label="Reset"
kind="tertiary"
size="small"
onClick={onReset}
type="button"
<div className="flex w-full flex-col gap-2">
{slotComposerStart && <div className="shrink-0">{slotComposerStart}</div>}
<ComposerPrimitive.Root
data-testid="assistant-chat-composer"
className={cn(
'flex w-full items-end gap-density-xs rounded-lg border border-base bg-surface-base p-1',
className
)}
>
<ComposerPrimitive.Input
aria-label="Task prompt"
addAttachmentOnPaste={false}
disabled={disabled}
>
<RotateCcw />
</Button>
</Tooltip>
<ThreadPrimitive.If running>
<ComposerPrimitive.Cancel asChild>
<Button aria-label="Stop" color="danger" size="small" className="size-8 rounded-full p-0">
<Square size={14} />
</Button>
</ComposerPrimitive.Cancel>
</ThreadPrimitive.If>
<ThreadPrimitive.If running={false}>
<ComposerPrimitive.Send asChild>
<Button aria-label="Submit" color="brand" size="small" className="size-8 rounded-full p-0">
<ArrowUp size={16} />
placeholder={placeholder}
submitMode="enter"
className="max-h-32 min-h-[24px] flex-1 resize-none border-0 bg-transparent px-density-md py-density-md text-sm leading-6 outline-none disabled:cursor-not-allowed disabled:text-fg-disabled"
/>
<Tooltip slotContent="Clear chat thread">
<Button
aria-label="Reset"
kind="tertiary"
size="small"
onClick={onReset}
type="button"
disabled={disabled}
>
<RotateCcw />
</Button>
</ComposerPrimitive.Send>
</ThreadPrimitive.If>
</ComposerPrimitive.Root>
</Tooltip>
<ThreadPrimitive.If running>
<ComposerPrimitive.Cancel asChild>
<Button aria-label="Stop" color="danger" size="small" className="size-8 rounded-full p-0">
<Square size={14} />
</Button>
</ComposerPrimitive.Cancel>
</ThreadPrimitive.If>
<ThreadPrimitive.If running={false}>
<ComposerPrimitive.Send asChild>
<Button
aria-label="Submit"
color="brand"
size="small"
className="size-8 rounded-full p-0"
>
<ArrowUp size={16} />
</Button>
</ComposerPrimitive.Send>
</ThreadPrimitive.If>
</ComposerPrimitive.Root>
</div>
);

export const AssistantChatThread = ({
Expand All @@ -300,6 +312,8 @@ export const AssistantChatThread = ({
onReset,
showRunningIndicator = true,
attributes,
composerMode = ComposerMode.PER_PANEL,
slotComposerStart,
emptyState,
contentClassName,
composerContainerClassName,
Expand Down Expand Up @@ -369,14 +383,21 @@ export const AssistantChatThread = ({
Scroll to bottom
</ThreadPrimitive.ScrollToBottom>
</div>
<Flex
className={cn('w-full pt-density-xl', composerContainerClassName)}
data-testid="assistant-chat-composer-container"
>
{composerOverride ?? (
<AssistantComposer disabled={disabled} placeholder={placeholder} onReset={onReset} />
)}
</Flex>
{composerMode !== ComposerMode.BROADCAST_ALL && (
<Flex
className={cn('w-full pt-density-xl', composerContainerClassName)}
data-testid="assistant-chat-composer-container"
>
{composerOverride ?? (
<AssistantComposer
disabled={disabled}
placeholder={placeholder}
onReset={onReset}
slotComposerStart={slotComposerStart}
/>
)}
</Flex>
)}
</ThreadPrimitive.Root>
);
};
32 changes: 0 additions & 32 deletions web/packages/common/src/components/AssistantChat/index.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -220,8 +220,6 @@ describe('AssistantChat', () => {

expect(screen.getByText('Existing prompt')).toBeInTheDocument();
expect(screen.getByText('Existing response')).toBeInTheDocument();
expect(assistantMessage).toHaveClass('whitespace-normal');
expect(assistantMessage).not.toHaveClass('whitespace-pre-wrap');
expect(
within(assistantMessage).getByRole('button', { name: /Copy message/i })
).toBeInTheDocument();
Expand Down Expand Up @@ -266,38 +264,9 @@ describe('AssistantChat', () => {
);

expect(screen.getByText('Approval required')).toBeInTheDocument();
expect(screen.getByTestId('assistant-chat-composer-container')).toHaveClass('pt-density-xl');
expect(screen.queryByRole('textbox', { name: /Task prompt/i })).not.toBeInTheDocument();
});

it('renders the composer with a rounded input shell and circular submit action', () => {
renderAssistantChat(<AssistantChat model="test-model" workspace="default" />);

expect(screen.getByTestId('assistant-chat-viewport')).toHaveClass(
'[scrollbar-width:thin]',
'[scrollbar-color:var(--border-color-interaction-base)_transparent]',
'[&::-webkit-scrollbar]:w-2',
'[&::-webkit-scrollbar-track]:bg-transparent',
'[&::-webkit-scrollbar-thumb]:rounded-full',
'[&::-webkit-scrollbar-thumb]:bg-[var(--border-color-interaction-base)]',
'[&::-webkit-scrollbar-thumb:hover]:bg-[var(--border-color-interaction-strong)]'
);
expect(screen.getByTestId('assistant-chat-composer')).toHaveClass(
'gap-density-xs',
'rounded-lg'
);
expect(screen.getByRole('textbox', { name: /Task prompt/i })).toHaveClass(
'min-h-20',
'px-density-md',
'py-density-md'
);
expect(screen.getByRole('button', { name: /Submit/i })).toHaveClass(
'size-8',
'rounded-full',
'p-0'
);
});

it(
'edits a user message and re-runs inference with the edited prompt',
async () => {
Expand Down Expand Up @@ -357,7 +326,6 @@ describe('AssistantChat', () => {
expect(await screen.findByText('0 this is an example response')).toBeInTheDocument();
const stopButton = screen.getByRole('button', { name: /Stop/i });
expect(stopButton).toBeEnabled();
expect(stopButton).toHaveClass('size-8', 'rounded-full', 'p-0');

await userEvent.click(stopButton);

Expand Down
15 changes: 15 additions & 0 deletions web/packages/common/src/components/AssistantChat/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type { AssistantChatProps } from './types';
import { useAssistantChatRuntime } from './useAssistantChatRuntime';

export type { AssistantChatProps } from './types';
export { ComposerMode } from './types';

export const AssistantChat: FC<AssistantChatProps> = ({
model,
Expand All @@ -25,6 +26,13 @@ export const AssistantChat: FC<AssistantChatProps> = ({
className,
initialMessages = [],
onError,
onMessageComplete,
onRunningChange,
onEmptyChange,
composerMode,
broadcast,
stopCount,
slotComposerStart,
emptyState,
composerOverride,
}) => {
Expand All @@ -37,6 +45,11 @@ export const AssistantChat: FC<AssistantChatProps> = ({
disabled,
initialMessages,
onError,
onMessageComplete,
onRunningChange,
onEmptyChange,
broadcast,
stopCount,
});

const composerPlaceholder = useMemo(
Expand All @@ -53,6 +66,8 @@ export const AssistantChat: FC<AssistantChatProps> = ({
onReset={handleReset}
showRunningIndicator={showRunningIndicator}
attributes={attributes}
composerMode={composerMode}
slotComposerStart={slotComposerStart}
emptyState={emptyState}
composerOverride={composerOverride}
/>
Expand Down
71 changes: 71 additions & 0 deletions web/packages/common/src/components/AssistantChat/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ import type { ReactNode } from 'react';

import type { AssistantChatThreadAttributes } from './AssistantChatThread';

export const ComposerMode = {
PER_PANEL: 'per-panel',
BROADCAST_ALL: 'broadcast-all',
} as const;
export type ComposerMode = (typeof ComposerMode)[keyof typeof ComposerMode];

export interface AssistantChatProps {
/**
* The model name to route through inference gateway.
Expand Down Expand Up @@ -41,9 +47,74 @@ export interface AssistantChatProps {
className?: string;
initialMessages?: readonly ThreadMessageLike[];
onError?: (error: Error) => void;
/**
* Called once per assistant message after the stream completes (or after
* the non-stream completion lands). Surfaces per-message timing so callers
* can render their own latency/throughput UI without owning the runtime.
* Not invoked on cancellation or error.
*/
onMessageComplete?: (info: AssistantMessageCompletion) => void;
/**
* Fires whenever the runtime's "is currently streaming" state changes.
* Lets a parent (e.g. a page that hosts many AssistantChats) aggregate the
* running state across instances — used by the Chat route to drive a global
* Stop button in Compare mode.
*/
onRunningChange?: (isRunning: boolean) => void;
/**
* Fires whenever the thread transitions between empty and non-empty. Lets a
* parent derive seed-chip visibility from whether any messages exist.
*/
onEmptyChange?: (isEmpty: boolean) => void;
/**
* Controls whether the internal composer is shown and how input is driven.
* In `broadcast-all` mode the composer is suppressed; a page-level composer
* drives every AssistantChat in parallel.
* @default ComposerMode.PER_PANEL
*/
composerMode?: ComposerMode;
Comment thread
nv-odrulea marked this conversation as resolved.
/**
* External broadcast trigger. Whenever `seq` changes (excluding initial
* mount), the runtime appends `text` as a new user message and runs a
* completion — same code path as a user typing into the composer.
*/
broadcast?: BroadcastSignal;
/**
* Monotonic counter — when it changes, the runtime aborts any in-flight
* stream. Lets a parent cancel many AssistantChats at once.
*/
stopCount?: number;
/**
* Content rendered immediately above the composer, inside the same outer
* frame. Use for seed-prompt chips or any prefatory hint that should read
* as part of the composer affordance rather than a separate block.
*/
slotComposerStart?: ReactNode;
emptyState?: {
slotHeading?: string;
slotSubheading?: string;
};
composerOverride?: ReactNode;
}

export interface BroadcastSignal {
/** Monotonically increasing sequence — on change, runtime fires a send. */
seq: number;
/** Text to inject as the user's next message. */
text: string;
}

export interface AssistantMessageCompletion {
assistantMessageId: string;
text: string;
/** ms from request start to first delta (0 if non-stream). */
ttftMs: number;
/** ms from request start to final delta. */
totalMs: number;
/** Number of delta chunks (1 for non-stream). */
chunkCount: number;
/** Approximate; chars/4 fallback when the API doesn't return a usage block. */
completionTokens: number;
/** Completion tokens per second of streaming wall-time (excludes TTFT). */
tokensPerSec: number;
}
Loading
Loading