From 1875246a61a9d600c404d1e9625048f6a780e869 Mon Sep 17 00:00:00 2001 From: Santiago Pombo Date: Fri, 29 May 2026 00:12:13 -0700 Subject: [PATCH 01/19] feat(common/AssistantChat): expose broadcast, metrics, hideComposer, slotAboveComposer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds optional props that let a parent route observe and drive an AssistantChat instance externally: - onMessageComplete: per-assistant-message timing (TTFT, tok/s, total) - onRunningChange: surface in-flight state so parents can aggregate - hideComposer: suppress the internal composer (used when the page drives input externally — e.g. a broadcast bar over many chats) - broadcast: nonce-keyed prop that injects a user message + run - cancelNonce: monotonic counter; bump to abort any in-flight stream - slotAboveComposer: ReactNode rendered above the composer card All props are optional and additive; existing callers (ModelPanel, PromptTuningPanel, PromptTuningFormRoute) are unaffected. The AssistantComposer now wraps in flex flex-col gap-2 so the slot has its own row; composer min-height changed from min-h-16 to a single- row baseline that auto-grows with content. Co-Authored-By: Claude Opus 4.7 Signed-off-by: Octavian Drulea --- .../AssistantChat/AssistantChatThread.tsx | 122 +++++++++++------- .../src/components/AssistantChat/index.tsx | 12 ++ .../src/components/AssistantChat/types.ts | 60 +++++++++ .../AssistantChat/useAssistantChatRuntime.ts | 90 ++++++++++++- 4 files changed, 231 insertions(+), 53 deletions(-) diff --git a/web/packages/common/src/components/AssistantChat/AssistantChatThread.tsx b/web/packages/common/src/components/AssistantChat/AssistantChatThread.tsx index 046913521e..88e4d4589b 100644 --- a/web/packages/common/src/components/AssistantChat/AssistantChatThread.tsx +++ b/web/packages/common/src/components/AssistantChat/AssistantChatThread.tsx @@ -39,6 +39,12 @@ interface AssistantChatThreadProps { onReset: () => void; showRunningIndicator?: boolean; attributes?: AssistantChatThreadAttributes; + /** When true, suppresses the bottom composer — message thread still renders. */ + hideComposer?: boolean; + /** Content rendered in a sub-row directly above the composer, INSIDE the + * same outer card. Used for seed-prompt chips so they read as part of the + * composer affordance rather than a separate block. */ + slotAboveComposer?: ReactNode; emptyState?: { slotHeading?: string; slotSubheading?: string; @@ -239,7 +245,7 @@ const UserEditComposer = () => ( type AssistantComposerProps = Pick< AssistantChatThreadProps, - 'disabled' | 'placeholder' | 'onReset' + 'disabled' | 'placeholder' | 'onReset' | 'slotAboveComposer' > & { className?: string; }; @@ -248,50 +254,59 @@ const AssistantComposer = ({ disabled, placeholder, onReset, + slotAboveComposer, className, }: AssistantComposerProps) => ( - - - - - - - - - - - - - - - - + + + + + + + + + + + + + ); export const AssistantChatThread = ({ @@ -300,6 +315,8 @@ export const AssistantChatThread = ({ onReset, showRunningIndicator = true, attributes, + hideComposer, + slotAboveComposer, emptyState, contentClassName, composerContainerClassName, @@ -369,14 +386,21 @@ export const AssistantChatThread = ({ Scroll to bottom - - {composerOverride ?? ( - - )} - + {!hideComposer && ( + + {composerOverride ?? ( + + )} + + )} ); }; diff --git a/web/packages/common/src/components/AssistantChat/index.tsx b/web/packages/common/src/components/AssistantChat/index.tsx index b66b0b7093..bc729af012 100644 --- a/web/packages/common/src/components/AssistantChat/index.tsx +++ b/web/packages/common/src/components/AssistantChat/index.tsx @@ -25,6 +25,12 @@ export const AssistantChat: FC = ({ className, initialMessages = [], onError, + onMessageComplete, + onRunningChange, + hideComposer, + broadcast, + cancelNonce, + slotAboveComposer, emptyState, composerOverride, }) => { @@ -37,6 +43,10 @@ export const AssistantChat: FC = ({ disabled, initialMessages, onError, + onMessageComplete, + onRunningChange, + broadcast, + cancelNonce, }); const composerPlaceholder = useMemo( @@ -53,6 +63,8 @@ export const AssistantChat: FC = ({ onReset={handleReset} showRunningIndicator={showRunningIndicator} attributes={attributes} + hideComposer={hideComposer} + slotAboveComposer={slotAboveComposer} emptyState={emptyState} composerOverride={composerOverride} /> diff --git a/web/packages/common/src/components/AssistantChat/types.ts b/web/packages/common/src/components/AssistantChat/types.ts index daaa562fdb..7d8e1dab64 100644 --- a/web/packages/common/src/components/AssistantChat/types.ts +++ b/web/packages/common/src/components/AssistantChat/types.ts @@ -41,9 +41,69 @@ 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; + /** + * When true, suppresses the internal composer at the bottom of the thread. + * The message thread still renders, including the empty state. Used by the + * Chat route's Compare mode where a single page-level composer drives every + * AssistantChat in parallel. + */ + hideComposer?: boolean; + /** + * External broadcast trigger. Whenever `nonce` 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. + */ + cancelNonce?: 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. + */ + slotAboveComposer?: ReactNode; emptyState?: { slotHeading?: string; slotSubheading?: string; }; composerOverride?: ReactNode; } + +export interface BroadcastSignal { + /** Monotonically increasing; on change, runtime fires a send. */ + nonce: 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; +} diff --git a/web/packages/common/src/components/AssistantChat/useAssistantChatRuntime.ts b/web/packages/common/src/components/AssistantChat/useAssistantChatRuntime.ts index 540c8007d1..0d9d884480 100644 --- a/web/packages/common/src/components/AssistantChat/useAssistantChatRuntime.ts +++ b/web/packages/common/src/components/AssistantChat/useAssistantChatRuntime.ts @@ -7,7 +7,7 @@ import { type ThreadMessageLike, useExternalStoreRuntime, } from '@assistant-ui/react'; -import { useCallback, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { getCompletionText, isAbortError, isChatCompletionStream } from './completionUtils'; import { CANCELLED_STATUS, COMPLETE_STATUS, RUNNING_STATUS } from './constants'; @@ -24,10 +24,14 @@ import { useChatCompletion } from '../../hooks/useChatCompletion'; type UseAssistantChatRuntimeOptions = Pick< AssistantChatProps, | 'baseURL' + | 'broadcast' + | 'cancelNonce' | 'disabled' | 'initialMessages' | 'model' | 'onError' + | 'onMessageComplete' + | 'onRunningChange' | 'promptData' | 'tools' | 'workspace' @@ -42,6 +46,10 @@ export const useAssistantChatRuntime = ({ disabled = false, initialMessages = [], onError, + onMessageComplete, + onRunningChange, + broadcast, + cancelNonce, }: UseAssistantChatRuntimeOptions) => { const [messages, setMessages] = useState(initialMessages); const [isRunning, setIsRunning] = useState(false); @@ -84,6 +92,12 @@ export const useAssistantChatRuntime = ({ setThreadMessages([...conversationMessages, assistantMessage]); setIsRunning(true); let responseText = ''; + // Timing for per-message metrics (TTFT, total, tokens/sec). Emitted via + // onMessageComplete so callers (e.g. Studio's Chat route) can render a + // stats badge without owning the runtime. + const startMs = performance.now(); + let ttftMs = 0; + let chunkCount = 0; try { const result = await createChatCompletion({ @@ -111,16 +125,49 @@ export const useAssistantChatRuntime = ({ if (runController.signal.aborted) streamController.abort(); for await (const chunk of result) { if (runController.signal.aborted || !isCurrentRun()) break; - responseText += chunk.choices[0]?.delta.content ?? ''; - updateAssistantMessage(assistantMessage.id!, responseText, RUNNING_STATUS); + const delta = chunk.choices[0]?.delta.content ?? ''; + if (delta) { + if (ttftMs === 0) ttftMs = Math.round(performance.now() - startMs); + responseText += delta; + chunkCount += 1; + updateAssistantMessage(assistantMessage.id!, responseText, RUNNING_STATUS); + } } updateAssistantMessage( assistantMessage.id!, responseText, runController.signal.aborted ? CANCELLED_STATUS : COMPLETE_STATUS ); + if (!runController.signal.aborted && onMessageComplete) { + const totalMs = Math.round(performance.now() - startMs); + const streamMs = Math.max(1, totalMs - ttftMs); + const completionTokens = Math.max(chunkCount, Math.round(responseText.length / 4)); + onMessageComplete({ + assistantMessageId: assistantMessage.id!, + text: responseText, + ttftMs, + totalMs, + chunkCount, + tokensPerSec: (completionTokens * 1000) / streamMs, + completionTokens, + }); + } } else { - updateAssistantMessage(assistantMessage.id!, getCompletionText(result), COMPLETE_STATUS); + const text = getCompletionText(result); + updateAssistantMessage(assistantMessage.id!, text, COMPLETE_STATUS); + if (onMessageComplete) { + const totalMs = Math.round(performance.now() - startMs); + const completionTokens = Math.round(text.length / 4); + onMessageComplete({ + assistantMessageId: assistantMessage.id!, + text, + ttftMs: totalMs, + totalMs, + chunkCount: 1, + tokensPerSec: (completionTokens * 1000) / Math.max(1, totalMs), + completionTokens, + }); + } } } catch (error: unknown) { if (runController.signal.aborted || isAbortError(error)) { @@ -149,6 +196,7 @@ export const useAssistantChatRuntime = ({ disabled, model, onError, + onMessageComplete, promptData?.inference_params?.max_tokens, promptData?.inference_params?.temperature, promptData?.system_prompt, @@ -227,6 +275,40 @@ export const useAssistantChatRuntime = ({ setThreadMessages([]); }, [setThreadMessages]); + // External broadcast — when the caller bumps `broadcast.nonce`, append the + // payload text as a new user message and run a completion. The ref is seeded + // with whatever nonce is present at mount, so an AssistantChat that mounts + // mid-flight (with a non-null broadcast prop) doesn't re-fire the last + // broadcast it sees on first render. Subsequent changes fire. + const broadcastSeenNonceRef = useRef(broadcast?.nonce); + useEffect(() => { + if (!broadcast) return; + if (broadcast.nonce === broadcastSeenNonceRef.current) return; + broadcastSeenNonceRef.current = broadcast.nonce; + const text = broadcast.text.trim(); + if (!text || disabled) return; + const synthetic: AppendMessage = { + role: 'user', + content: [{ type: 'text', text }], + } as unknown as AppendMessage; + void handleNewMessage(synthetic); + }, [broadcast, disabled, handleNewMessage]); + + // External cancel — same nonce pattern. + const cancelSeenNonceRef = useRef(cancelNonce); + useEffect(() => { + if (cancelNonce === undefined) return; + if (cancelNonce === cancelSeenNonceRef.current) return; + cancelSeenNonceRef.current = cancelNonce; + void handleCancel(); + }, [cancelNonce, handleCancel]); + + // Surface running-state transitions so a parent can aggregate Stop logic + // across multiple AssistantChat instances. + useEffect(() => { + onRunningChange?.(isRunning); + }, [isRunning, onRunningChange]); + const runtime = useExternalStoreRuntime({ messages, setMessages: setThreadMessages, From 0793272a0a065e391dad67329a5d9584fd516b0f Mon Sep 17 00:00:00 2001 From: Santiago Pombo Date: Fri, 29 May 2026 00:12:32 -0700 Subject: [PATCH 02/19] feat(studio/chat): consolidated Chat route with Compare mode + polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ModelCompareRoute becomes the single Chat surface — renamed from "Compare Models", absorbs the v4 Playground capabilities the team asked for: - Tabbed mode picker (Chat | Compare | Run Prompts) with brand-green active underline; Compare tab appears only with >=2 panels - Compare mode: per-panel composers hidden, single page-level CompareComposer broadcasts to every panel with a model selected - Per-panel inline stats badge (TTFT / tok/s / # tokens, brand green) - Per-panel system-prompt collapsible, Params popover with temperature / top_p / top_k / max_tokens - Fine-tuned models surface FIRST in the model picker (mock + heuristic) - Animated "Ready" empty state (particle swirl) when no messages yet - Seed-question chips as floating action buttons above the composer - Agent context overlay via ?agent= URL param: AgentContextBanner + locked Panel 1 baseline + Apply-to-Agent confirmation - Run Evaluation modal pre-populated with current panels (mock submit) - Improved no-models empty state with provider/deployment CTAs - Legacy /workspaces/:workspace/playground URL redirects to /workspaces/:workspace/model-compare via PlaygroundRedirect Bypasses an existing useBaseModels crash via a local useWorkspaceModels shim (track separately; bug is at common/src/api/entity-store/useBaseModels.ts:150). Customizer pre-fill, real Evaluator submission, and real Apply-to-Agent are documented in the modals but remain stubbed pending backend confirmation. Co-Authored-By: Claude Opus 4.7 Signed-off-by: Octavian Drulea --- .../studio/src/components/ModelChat/index.tsx | 124 ++++++- .../ModelChatPanel/ModelChatPanel.spec.tsx | 18 +- .../src/components/ModelChatPanel/index.tsx | 196 +++++++++-- .../src/components/ModelCompareChat/index.tsx | 67 +++- .../components/chat/AgentContextBanner.tsx | 32 ++ .../src/components/chat/ChatEmptyState.tsx | 136 ++++++++ .../src/components/chat/CompareComposer.tsx | 122 +++++++ .../src/components/chat/ParamsPopover.tsx | 87 +++++ .../components/chat/PlaygroundRedirect.tsx | 17 + .../components/chat/RunEvaluationModal.tsx | 123 +++++++ .../src/components/chat/SeedQuestions.tsx | 42 +++ .../studio/src/components/chat/StatsBadge.tsx | 41 +++ .../src/components/chat/useFineTunedGroup.ts | 80 +++++ .../src/components/chat/useWorkspaceModels.ts | 32 ++ web/packages/studio/src/constants/routes.ts | 1 + .../src/routes/ModelCompareRoute/index.tsx | 314 ++++++++++++++++-- .../src/routes/ModelCompareRoute/types.ts | 48 +++ web/packages/studio/src/routes/index.tsx | 13 +- 18 files changed, 1399 insertions(+), 94 deletions(-) create mode 100644 web/packages/studio/src/components/chat/AgentContextBanner.tsx create mode 100644 web/packages/studio/src/components/chat/ChatEmptyState.tsx create mode 100644 web/packages/studio/src/components/chat/CompareComposer.tsx create mode 100644 web/packages/studio/src/components/chat/ParamsPopover.tsx create mode 100644 web/packages/studio/src/components/chat/PlaygroundRedirect.tsx create mode 100644 web/packages/studio/src/components/chat/RunEvaluationModal.tsx create mode 100644 web/packages/studio/src/components/chat/SeedQuestions.tsx create mode 100644 web/packages/studio/src/components/chat/StatsBadge.tsx create mode 100644 web/packages/studio/src/components/chat/useFineTunedGroup.ts create mode 100644 web/packages/studio/src/components/chat/useWorkspaceModels.ts diff --git a/web/packages/studio/src/components/ModelChat/index.tsx b/web/packages/studio/src/components/ModelChat/index.tsx index c8ce489e0d..6286b364c3 100644 --- a/web/packages/studio/src/components/ModelChat/index.tsx +++ b/web/packages/studio/src/components/ModelChat/index.tsx @@ -1,10 +1,17 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { AssistantChat, type AssistantChatProps } from '@nemo/common/src/components/AssistantChat'; +import { + AssistantChat, + type AssistantChatProps, +} from '@nemo/common/src/components/AssistantChat'; +import type { AssistantMessageCompletion } from '@nemo/common/src/components/AssistantChat/types'; import type { ModelChatStatus } from '@nemo/common/src/utils/models'; +import { StatsBadge, type ChatMetrics } from '@studio/components/chat/StatsBadge'; +import type { InferenceParams } from '@studio/components/chat/ParamsPopover'; +import { DEFAULT_SEED_QUESTIONS, SeedQuestions } from '@studio/components/chat/SeedQuestions'; import { handleGenericError } from '@studio/util/logger'; -import type { FC } from 'react'; +import { useMemo, useState, type FC } from 'react'; interface ModelChatProps extends Pick< AssistantChatProps, @@ -20,6 +27,10 @@ interface ModelChatProps extends Pick< | 'initialMessages' | 'emptyState' | 'onError' + | 'hideComposer' + | 'broadcast' + | 'cancelNonce' + | 'onRunningChange' > { /** * When provided, ModelChat derives default `disabled` state and a @@ -28,6 +39,14 @@ interface ModelChatProps extends Pick< * precedence. */ modelChatStatus?: ModelChatStatus; + /** Per-panel system prompt; merged into promptData. */ + systemPrompt?: string; + /** Per-panel inference parameters; merged into promptData.inference_params. */ + params?: InferenceParams; + /** When set, renders the suggestion-chip strip above the composer when there + * are no messages yet. Clicking a chip seeds the composer (using the + * AssistantChat composer set-input API via a small DOM bridge). */ + seedQuestions?: string[]; } const STATUS_EMPTY_STATE: Record< @@ -51,6 +70,10 @@ export const ModelChat: FC = ({ assistantName, emptyState, onError, + systemPrompt, + params, + promptData: promptDataProp, + seedQuestions = DEFAULT_SEED_QUESTIONS, ...rest }) => { const resolvedDisabled = disabled ?? (modelChatStatus ? modelChatStatus !== 'enabled' : false); @@ -58,16 +81,95 @@ export const ModelChat: FC = ({ disabled === undefined && modelChatStatus && modelChatStatus !== 'enabled' ? STATUS_EMPTY_STATE[modelChatStatus] : undefined; - const resolvedEmptyState = emptyState ?? statusDerivedEmptyState; + // In Compare mode (hideComposer = true) the page-level composer is the + // affordance, so the per-panel subhead "Prompt your model to get started." + // is redundant — surface only the headline. + const compareEmptyState = rest.hideComposer + ? { slotHeading: 'Ready', slotSubheading: '' } + : undefined; + const resolvedEmptyState = emptyState ?? statusDerivedEmptyState ?? compareEmptyState; + + // Build the promptData payload AssistantChat understands. Explicit + // `promptData` from the caller wins (existing callers like + // ModelPanel / PromptTuningPanel set their own); otherwise we synthesize + // one from the per-panel systemPrompt + params, falling back to undefined + // so the runtime uses provider defaults. + const promptData = useMemo(() => { + if (promptDataProp) return promptDataProp; + if (!systemPrompt && !params) return undefined; + return { + system_prompt: systemPrompt ?? '', + inference_params: params + ? { + temperature: params.temperature, + max_tokens: params.max_tokens, + } + : undefined, + } as AssistantChatProps['promptData']; + }, [promptDataProp, systemPrompt, params]); + + // Per-message metrics: store the latest completion so a single StatsBadge + // can render under the chat surface. + const [latestMetrics, setLatestMetrics] = useState(null); + + const handleMessageComplete = (info: AssistantMessageCompletion) => { + setLatestMetrics({ + ttftMs: info.ttftMs, + totalMs: info.totalMs, + completionTokens: info.completionTokens, + tokensPerSec: info.tokensPerSec, + }); + }; + + // Seed-question handler: targets the AssistantChat composer's textarea by + // selector and dispatches a native input event so assistant-ui picks it up. + // Kept ugly-and-local on purpose — when the AssistantChat composer exposes a + // proper setInput API we swap this out for a one-liner. + const seedComposer = (text: string) => { + if (typeof document === 'undefined') return; + const composer = document.querySelector( + '.aui-composer-input textarea, [aria-label="Message Composer"] textarea, textarea[placeholder*="Message"]' + ); + if (!composer) return; + const setter = Object.getOwnPropertyDescriptor( + window.HTMLTextAreaElement.prototype, + 'value' + )?.set; + setter?.call(composer, text); + composer.dispatchEvent(new Event('input', { bubbles: true })); + composer.focus(); + }; + + // Seeds render INSIDE the AssistantChat composer card (above the textarea) + // so the chip row + input share one bordered frame. Suppressed once the + // panel has produced a metric (i.e. responded once) and in Compare mode + // (the page-level CompareComposer owns seeds there). + const showChatSeeds = + !!seedQuestions && seedQuestions.length > 0 && !latestMetrics && !rest.hideComposer; + const chatSeedSlot = showChatSeeds ? ( + + ) : undefined; return ( - +
+
+ +
+ {latestMetrics && ( +
+ +
+ )} +
); }; diff --git a/web/packages/studio/src/components/ModelChatPanel/ModelChatPanel.spec.tsx b/web/packages/studio/src/components/ModelChatPanel/ModelChatPanel.spec.tsx index 8fcd645cbc..9a070181ba 100644 --- a/web/packages/studio/src/components/ModelChatPanel/ModelChatPanel.spec.tsx +++ b/web/packages/studio/src/components/ModelChatPanel/ModelChatPanel.spec.tsx @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import type { ModelEntity } from '@nemo/sdk/generated/platform/schema'; +import { DEFAULT_INFERENCE_PARAMS } from '@studio/components/chat/ParamsPopover'; import { ModelChatPanel } from '@studio/components/ModelChatPanel'; import { TestProviders } from '@studio/tests/util/TestProviders'; import { render } from '@testing-library/react'; @@ -31,13 +32,28 @@ const renderPanel = (modelURN: string | null, availableModels: ModelEntity[]) => diff --git a/web/packages/studio/src/components/ModelChatPanel/index.tsx b/web/packages/studio/src/components/ModelChatPanel/index.tsx index 368a458e94..9c9cd113d8 100644 --- a/web/packages/studio/src/components/ModelChatPanel/index.tsx +++ b/web/packages/studio/src/components/ModelChatPanel/index.tsx @@ -5,10 +5,13 @@ import { ModelSelectV2, type ModelSelection } from '@nemo/common/src/components/ import { getPartsFromReference } from '@nemo/common/src/namedEntity'; import { groupModelsByWorkspace } from '@nemo/common/src/utils/models'; import type { ModelEntity } from '@nemo/sdk/generated/platform/schema'; +import { Button, TextArea } from '@nvidia/foundations-react-core'; +import { ParamsPopover, type InferenceParams } from '@studio/components/chat/ParamsPopover'; +import { useFineTunedGroup } from '@studio/components/chat/useFineTunedGroup'; import { ModelChat } from '@studio/components/ModelChat'; -import type { PanelState } from '@studio/routes/ModelCompareRoute/types'; -import { Minimize2, Trash2 } from 'lucide-react'; -import { useCallback, useMemo, type FC } from 'react'; +import { PANEL_ROLE_DOT_CLASS, type PanelState } from '@studio/routes/ModelCompareRoute/types'; +import { ChevronDown, ChevronUp, Minimize2, Sparkles, Target, Trash2 } from 'lucide-react'; +import { useCallback, useMemo, useState, type FC } from 'react'; interface ModelChatPanelProps { panel: PanelState; @@ -20,6 +23,18 @@ interface ModelChatPanelProps { onRemove: (id: number) => void; /** Receives the full URN ("workspace/name"), or null when cleared. */ onModelChange: (id: number, modelURN: string | null) => void; + onSystemPromptChange: (id: number, value: string) => void; + onParamsChange: (id: number, params: InferenceParams) => void; + /** Per-panel CTAs surfaced in single-panel mode. */ + onEvaluate: (id: number) => void; + onFineTune: (id: number) => void; + /** Hide the trash button (locked baseline in agent overlay, or only one panel). */ + hideRemove?: boolean; + /** Compare-mode plumbing — page-level composer drives each panel's chat. */ + hideComposer?: boolean; + broadcast?: { nonce: number; text: string }; + cancelNonce?: number; + onRunningChange?: (id: number, isRunning: boolean) => void; } export const ModelChatPanel: FC = ({ @@ -30,14 +45,29 @@ export const ModelChatPanel: FC = ({ onToggle, onRemove, onModelChange, + onSystemPromptChange, + onParamsChange, + onEvaluate, + onFineTune, + hideRemove, + hideComposer, + broadcast, + cancelNonce, + onRunningChange, }) => { - const modelGroups = useMemo(() => groupModelsByWorkspace(models, { sort: true }), [models]); + const workspaceGroups = useMemo(() => groupModelsByWorkspace(models, { sort: true }), [models]); + const fineTunedGroups = useFineTunedGroup(models); + // Fine-tuned models surface FIRST in the picker — they're the user's own + // artifacts, more relevant than the auto-discovered base catalog below. + const modelGroups = useMemo( + () => [...fineTunedGroups, ...workspaceGroups], + [fineTunedGroups, workspaceGroups] + ); + const selectedModel: ModelSelection | null = panel.modelURN ? { model: panel.modelURN } : null; const handleModelChange = useCallback( (selection: ModelSelection) => { - // ModelSelectV2 emits the full URN — pass it through unchanged so we never - // ambiguously resolve by bare name across workspaces. onModelChange(panel.id, selection.model); }, [panel.id, onModelChange] @@ -49,53 +79,151 @@ export const ModelChatPanel: FC = ({ const modelName = parts?.name ?? null; const modelWorkspace = parts?.workspace || fallbackWorkspace; - const collapsedLabel = modelName ?? `Panel ${panel.id}`; + const [systemOpen, setSystemOpen] = useState(false); if (panel.collapsed) { return ( ); } return ( -
-
-
- +
+ {/* Header — role label (compare mode) OR per-panel CTAs (single mode) */} + {panel.isSinglePanel ? ( +
+
+
+ +
+ onParamsChange(panel.id, p)} /> +
+ +
+ ) : ( + <> +
+ + {panel.roleLabel} +
+ {panel.modelURN && ( + + )} + + {!hideRemove && ( + + )} +
+
+
+
+ +
+ onParamsChange(panel.id, p)} /> +
+ + )} + + {/* System prompt — collapsed by default. Quiet single-line label; + * the panel border above is the only divider — no second border below. */} +
- + {systemOpen && ( +
+