diff --git a/web/packages/common/src/api/models/useModels.ts b/web/packages/common/src/api/models/useModels.ts index 22de3d2314..30a5e6b702 100644 --- a/web/packages/common/src/api/models/useModels.ts +++ b/web/packages/common/src/api/models/useModels.ts @@ -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, @@ -103,5 +104,5 @@ export const buildWorkspaceGroup = ( models: ModelEntity[] ): ModelWorkspaceGroup => ({ workspace: workspaceName, - models, + models: [...models].sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '')), }); diff --git a/web/packages/common/src/components/AssistantChat/AssistantChatThread.tsx b/web/packages/common/src/components/AssistantChat/AssistantChatThread.tsx index 046913521e..54e49741fe 100644 --- a/web/packages/common/src/components/AssistantChat/AssistantChatThread.tsx +++ b/web/packages/common/src/components/AssistantChat/AssistantChatThread.tsx @@ -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, @@ -39,6 +40,8 @@ interface AssistantChatThreadProps { onReset: () => void; showRunningIndicator?: boolean; attributes?: AssistantChatThreadAttributes; + composerMode?: ComposerMode; + slotComposerStart?: ReactNode; emptyState?: { slotHeading?: string; slotSubheading?: string; @@ -239,7 +242,7 @@ const UserEditComposer = () => ( type AssistantComposerProps = Pick< AssistantChatThreadProps, - 'disabled' | 'placeholder' | 'onReset' + 'disabled' | 'placeholder' | 'onReset' | 'slotComposerStart' > & { className?: string; }; @@ -248,50 +251,59 @@ const AssistantComposer = ({ disabled, placeholder, onReset, + slotComposerStart, className, }: AssistantComposerProps) => ( - - - - - - - - - - - - - - - - + + + + + + + + + + + + + ); export const AssistantChatThread = ({ @@ -300,6 +312,8 @@ export const AssistantChatThread = ({ onReset, showRunningIndicator = true, attributes, + composerMode = ComposerMode.PER_PANEL, + slotComposerStart, emptyState, contentClassName, composerContainerClassName, @@ -369,14 +383,21 @@ export const AssistantChatThread = ({ Scroll to bottom - - {composerOverride ?? ( - - )} - + {composerMode !== ComposerMode.BROADCAST_ALL && ( + + {composerOverride ?? ( + + )} + + )} ); }; diff --git a/web/packages/common/src/components/AssistantChat/index.spec.tsx b/web/packages/common/src/components/AssistantChat/index.spec.tsx index 8be2afe540..6d1af6d483 100644 --- a/web/packages/common/src/components/AssistantChat/index.spec.tsx +++ b/web/packages/common/src/components/AssistantChat/index.spec.tsx @@ -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(); @@ -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(); - - 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 () => { @@ -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); diff --git a/web/packages/common/src/components/AssistantChat/index.tsx b/web/packages/common/src/components/AssistantChat/index.tsx index b66b0b7093..f80729bea7 100644 --- a/web/packages/common/src/components/AssistantChat/index.tsx +++ b/web/packages/common/src/components/AssistantChat/index.tsx @@ -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 = ({ model, @@ -25,6 +26,13 @@ export const AssistantChat: FC = ({ className, initialMessages = [], onError, + onMessageComplete, + onRunningChange, + onEmptyChange, + composerMode, + broadcast, + stopCount, + slotComposerStart, emptyState, composerOverride, }) => { @@ -37,6 +45,11 @@ export const AssistantChat: FC = ({ disabled, initialMessages, onError, + onMessageComplete, + onRunningChange, + onEmptyChange, + broadcast, + stopCount, }); const composerPlaceholder = useMemo( @@ -53,6 +66,8 @@ export const AssistantChat: FC = ({ onReset={handleReset} showRunningIndicator={showRunningIndicator} attributes={attributes} + composerMode={composerMode} + slotComposerStart={slotComposerStart} 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..62f438f60b 100644 --- a/web/packages/common/src/components/AssistantChat/types.ts +++ b/web/packages/common/src/components/AssistantChat/types.ts @@ -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. @@ -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; + /** + * 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; +} diff --git a/web/packages/common/src/components/AssistantChat/useAssistantChatRuntime.ts b/web/packages/common/src/components/AssistantChat/useAssistantChatRuntime.ts index 540c8007d1..da8836770b 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,15 @@ import { useChatCompletion } from '../../hooks/useChatCompletion'; type UseAssistantChatRuntimeOptions = Pick< AssistantChatProps, | 'baseURL' + | 'broadcast' + | 'stopCount' | 'disabled' | 'initialMessages' | 'model' | 'onError' + | 'onMessageComplete' + | 'onRunningChange' + | 'onEmptyChange' | 'promptData' | 'tools' | 'workspace' @@ -42,6 +47,11 @@ export const useAssistantChatRuntime = ({ disabled = false, initialMessages = [], onError, + onMessageComplete, + onRunningChange, + onEmptyChange, + broadcast, + stopCount, }: UseAssistantChatRuntimeOptions) => { const [messages, setMessages] = useState(initialMessages); const [isRunning, setIsRunning] = useState(false); @@ -84,6 +94,13 @@ 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. + // TODO: provide time metric from backend inference gateway (issue 219) + const startMs = performance.now(); + let ttftMs = 0; + let chunkCount = 0; try { const result = await createChatCompletion({ @@ -111,16 +128,53 @@ 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 completionTokens = Math.max(chunkCount, Math.round(responseText.length / 4)); + // Throughput over total wall-clock. Using the post-first-token + // window instead collapses to a near-zero interval when tokens + // arrive in a single chunk or an end-of-stream burst, inflating the + // rate by orders of magnitude. Total time is stable and matches the + // duration shown alongside it. + onMessageComplete({ + assistantMessageId: assistantMessage.id!, + text: responseText, + ttftMs, + totalMs, + chunkCount, + tokensPerSec: (completionTokens * 1000) / Math.max(1, totalMs), + 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 +203,7 @@ export const useAssistantChatRuntime = ({ disabled, model, onError, + onMessageComplete, promptData?.inference_params?.max_tokens, promptData?.inference_params?.temperature, promptData?.system_prompt, @@ -227,6 +282,54 @@ export const useAssistantChatRuntime = ({ setThreadMessages([]); }, [setThreadMessages]); + // External broadcast — when the caller bumps `broadcast.seq`, append the + // payload text as a new user message and run a completion. The ref is seeded + // with whatever seq 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 broadcastSeenSeqRef = useRef(broadcast?.seq); + useEffect(() => { + if (!broadcast) return; + if (broadcast.seq === broadcastSeenSeqRef.current) return; + broadcastSeenSeqRef.current = broadcast.seq; + 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 sequence pattern. + const stopSeenCountRef = useRef(stopCount); + useEffect(() => { + if (stopCount === undefined) return; + if (stopCount === stopSeenCountRef.current) return; + stopSeenCountRef.current = stopCount; + void handleCancel(); + }, [stopCount, handleCancel]); + + // Abort any in-flight request on unmount (panel removed or remounted). + useEffect(() => { + return () => { + abortControllerRef.current?.abort(); + }; + }, []); + + // Surface running-state transitions so a parent can aggregate Stop logic + // across multiple AssistantChat instances. + useEffect(() => { + onRunningChange?.(isRunning); + }, [isRunning, onRunningChange]); + + // Surface empty/non-empty transitions so callers can derive seed-chip + // visibility from whether the thread has any messages. + const isEmpty = messages.length === 0; + useEffect(() => { + onEmptyChange?.(isEmpty); + }, [isEmpty, onEmptyChange]); + const runtime = useExternalStoreRuntime({ messages, setMessages: setThreadMessages, diff --git a/web/packages/common/src/components/UploadModal/DatasetUploader/Select.spec.tsx b/web/packages/common/src/components/UploadModal/DatasetUploader/Select.spec.tsx index e4f095dd63..d13124900d 100644 --- a/web/packages/common/src/components/UploadModal/DatasetUploader/Select.spec.tsx +++ b/web/packages/common/src/components/UploadModal/DatasetUploader/Select.spec.tsx @@ -168,7 +168,7 @@ describe('DatasetSelect', () => { expect(enabledOptions.length).toBeGreaterThan(0); }); - // Find the first enabled dataset option (not "New Dataset" which is at the end) + // Find an existing dataset option (not "New Dataset", which is now first) const datasetOption = screen.getByRole('option', { name: 'dataset1' }); await user.click(datasetOption); diff --git a/web/packages/common/src/components/UploadModal/DatasetUploader/Select.tsx b/web/packages/common/src/components/UploadModal/DatasetUploader/Select.tsx index 3d5317f8ae..45de88a018 100644 --- a/web/packages/common/src/components/UploadModal/DatasetUploader/Select.tsx +++ b/web/packages/common/src/components/UploadModal/DatasetUploader/Select.tsx @@ -118,11 +118,6 @@ export const DatasetSelect: FC = ({ project, disabled, error }) => { className="motion-safe:[&.nv-input:not(.nv-input--disabled):not(.nv-input--readonly)]:transition-[margin,color,background-color,border-color,outline-color,text-decoration-color,fill,stroke] duration-250 data-[state=open]:mb-24" disabled={disabled} items={[ - { - slotHeading: 'Existing Datasets', - attributes: { MenuHeading: { className: 'hidden', 'aria-hidden': true } }, - items: datasetOptions, - }, ...(allowNewDataset ? [ { @@ -139,10 +134,15 @@ export const DatasetSelect: FC = ({ project, disabled, error }) => { }, ] : []), + { + slotHeading: 'Existing Datasets', + attributes: { MenuHeading: { className: 'hidden', 'aria-hidden': true } }, + items: datasetOptions, + }, ]} value={selectedDatasetOption} onValueChange={handleDatasetSelect} - placeholder="Select a Dataset" + placeholder="Select a dataset" /> )} diff --git a/web/packages/studio/src/components/FileSamplingSnippet/FileSamplingMethodSelect.tsx b/web/packages/studio/src/components/FileSamplingSnippet/FileSamplingMethodSelect.tsx index 42e71d9328..1874e6a80d 100644 --- a/web/packages/studio/src/components/FileSamplingSnippet/FileSamplingMethodSelect.tsx +++ b/web/packages/studio/src/components/FileSamplingSnippet/FileSamplingMethodSelect.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import type { FileSampleMethod } from '@nemo/common/src/utils/sampleTextLines'; -import { Flex, Select, Text } from '@nvidia/foundations-react-core'; +import { Flex, Select } from '@nvidia/foundations-react-core'; import classnames from 'classnames'; import type { FC } from 'react'; @@ -56,6 +56,8 @@ export interface FileSamplingMethodSelectProps { attributes?: FileSamplingMethodSelectAttributes; /** Renders method + "max rows" select in one grouped control. */ rowCountGroup?: FileSamplingRowCountGroup; + /** Size of the underlying selects. Defaults to 'small'. */ + size?: 'small' | 'medium' | 'large'; } export const FileSamplingMethodSelect: FC = ({ @@ -63,13 +65,10 @@ export const FileSamplingMethodSelect: FC = ({ onValueChange, attributes, rowCountGroup, + size = 'small', }) => { const selectAttrs = attributes?.select ?? {}; - const countItems = rowCountGroup - ? buildCountItems(rowCountGroup.maxRows, rowCountGroup.value) - : []; - const methodSelect = ( rowCountGroup.onValueChange(Number(next))} - disabled={selectAttrs.disabled || rowCountGroup.disabled} - className="w-[72px] grow-0" - /> - + {methodSelect} + + {parseError && ( + + {parseError} + )} - - - {models.map((m) => ( - - -
- 0 && ( + + + Prompt Field + + onChange(next === NO_AGENT_VALUE ? null : next)} + disabled={disabled || isLoading} + placeholder={isLoading ? 'Loading agents…' : 'Test for agent…'} + className="w-[260px]" + /> + ); +}; diff --git a/web/packages/studio/src/components/chat/ChatEmptyState.tsx b/web/packages/studio/src/components/chat/ChatEmptyState.tsx new file mode 100644 index 0000000000..77a98c83ac --- /dev/null +++ b/web/packages/studio/src/components/chat/ChatEmptyState.tsx @@ -0,0 +1,134 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Button, Stack, Text } from '@nvidia/foundations-react-core'; +import { ROUTES } from '@studio/constants/routes'; +import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; +import { PlugZap, Server } from 'lucide-react'; +import { type FC } from 'react'; +import { generatePath, useNavigate } from 'react-router-dom'; + +interface ChatEmptyStateProps { + /** When false, paints the headline as "No models available" and surfaces the + * connect/deploy CTAs. When true, just shows the animated "Ready" state. */ + hasModels: boolean; +} + +/** + * Replicates the Figma's "Ready" state: an animated green particle swirl, a + * large headline, a soft subhead, optional seed-question chips above the + * (route-owned) composer. Particle ring is a tuned inline SVG plus a single + * CSS keyframe — Kaizen ships no equivalent (confirmed in component scan). + */ +export const ChatEmptyState: FC = ({ hasModels }) => { + const workspace = useWorkspaceFromPath(); + const navigate = useNavigate(); + + const headline = hasModels ? 'Ready' : 'No models available'; + const subhead = hasModels + ? 'Prompt your model to get started.' + : 'Connect an inference provider or create a deployment to start chatting.'; + + return ( +
+ +
+ +
+ {headline} + + {subhead} + +
+
+ {!hasModels && ( + +
+ + +
+
+ )} +
+
+ ); +}; + +/** + * 96 small dots arranged on a ring with jittered radii and per-dot animation + * delays, so the swirl reads as motion even at low frame budgets. Pure CSS so + * we don't add a dep. + */ +const ParticleSwirl: FC = () => { + const dots = Array.from({ length: 96 }, (_, i) => { + const angle = (i / 96) * Math.PI * 2; + const radiusJitter = 0.85 + ((i * 37) % 100) / 600; + const r = 130 * radiusJitter; + const cx = 144 + Math.cos(angle) * r; + const cy = 144 + Math.sin(angle) * r; + const size = 1.5 + ((i * 13) % 100) / 80; + const delay = (i / 96) * 6; + return { cx, cy, size, delay, key: i }; + }); + + return ( + + ); +}; diff --git a/web/packages/studio/src/components/chat/CompareComposer.tsx b/web/packages/studio/src/components/chat/CompareComposer.tsx new file mode 100644 index 0000000000..a762c317df --- /dev/null +++ b/web/packages/studio/src/components/chat/CompareComposer.tsx @@ -0,0 +1,158 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Button } from '@nvidia/foundations-react-core'; +import { SeedQuestions } from '@studio/components/chat/SeedQuestions'; +import type { ComposerSeed } from '@studio/routes/ModelCompareRoute/types'; +import { ArrowUp, RotateCcw, Square } from 'lucide-react'; +import * as React from 'react'; +import { + type FC, + type MutableRefObject, + type ReactNode, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; + +interface CompareComposerProps { + /** Any panel currently streaming? Switches the Send button into a Stop button. */ + isAnyRunning: boolean; + /** Number of panels with a model selected — used for the placeholder + disable state. */ + readyPanelCount: number; + /** Total panel count — used to phrase the placeholder when models are missing. */ + totalPanelCount: number; + onSubmit: (text: string) => void; + onStop: () => void; + /** Clears all panel histories. */ + onResetAll: () => void; + /** Suggested prompts rendered above the input INSIDE the same composer card. + * Clicking a chip fills the draft but does NOT auto-submit — preserves the + * "send happens on the green button" mental model. */ + seedQuestions?: string[]; + /** Rendered right-aligned at the trailing end of the seed-questions row. */ + slotSeedEnd?: ReactNode; + /** Kept in sync with internal draft so callers can read it imperatively. */ + draftRef?: MutableRefObject; + /** When triggerCount changes, resets the draft to text (panel→broadcast transfer). */ + seed?: ComposerSeed; +} + +/** + * Page-level composer shown only in Compare mode. Mirrors the per-panel + * AssistantComposer's single-card layout: seeds sit in an attached sub-row + * above the input, separated by a thin internal divider, all inside one + * rounded border. + */ +export const CompareComposer: FC = ({ + isAnyRunning, + readyPanelCount, + totalPanelCount, + onSubmit, + onStop, + onResetAll, + seedQuestions, + slotSeedEnd, + draftRef, + seed, +}) => { + const [draft, setDraft] = useState(''); + + // Keep caller's ref in sync so they can read the current draft imperatively. + if (draftRef) draftRef.current = draft; + + // Pre-fill from panel toggle transfer (panel→broadcast). + const seenSeedTriggerRef = useRef(undefined); + useEffect(() => { + if (!seed?.text || seed.triggerCount === seenSeedTriggerRef.current) return; + seenSeedTriggerRef.current = seed.triggerCount; + setDraft(seed.text); + }, [seed?.triggerCount, seed?.text]); + + const canSend = !isAnyRunning && readyPanelCount > 0 && draft.trim().length > 0; + + const handleSubmit = useCallback(() => { + if (!canSend) return; + onSubmit(draft.trim()); + setDraft(''); + }, [canSend, draft, onSubmit]); + + const onKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + handleSubmit(); + } + }, + [handleSubmit] + ); + + const placeholder = + readyPanelCount === 0 + ? totalPanelCount > 0 + ? 'Pick a model in each panel to broadcast a prompt…' + : 'Add panels and pick models to broadcast…' + : `Broadcast to ${readyPanelCount} of ${totalPanelCount} panel${ + totalPanelCount === 1 ? '' : 's' + }…`; + + const showSeeds = !!seedQuestions && seedQuestions.length > 0; + + return ( +
+ {(showSeeds || slotSeedEnd) && ( + setDraft(text)} + slotEnd={slotSeedEnd} + /> + )} +
+