import { useState, type KeyboardEvent, type ReactElement } from 'react'; import type { HarnessSelection } from '@/lib/types'; import type { HarnessSelectionValue } from './use-harness-selection'; interface ComposerProps { onSend: (input: { content: string; selection: HarnessSelection }) => boolean; onStop: () => void; streaming: boolean; /** True from local send time through server turn startup/ack and * throughout streaming — a superset of `streaming` that also covers the * pre-ack window where a second send could otherwise slip through. */ sending: boolean; hasConversation: boolean; /** Structured harness/provider/model selection state. The composer never * accepts free-text provider/model — every sendable tuple is a validated, * persisted catalog entry, and the send projection is derived from it. */ harness: HarnessSelectionValue; } /** The distinct provider ids present in the current catalog, in first-seen * order — the provider select is catalog-derived, never a hardcoded list. */ function providerOptions(harness: HarnessSelectionValue): string[] { const seen = new Set(); const out: string[] = []; for (const model of harness.catalog?.models ?? []) { if (seen.has(model.providerId)) continue; seen.add(model.providerId); out.push(model.providerId); } return out; } export function Composer({ onSend, onStop, streaming, sending, hasConversation, harness, }: ComposerProps): ReactElement { const [content, setContent] = useState(''); const busy = streaming || sending; function submit(): void { if (busy) return; // Send is gated on a validated, persisted catalog tuple — a draft or unset // selection can never emit, so provider/model never travel as free text. if (!harness.canSend || harness.persistedSelection === null) return; const trimmed = content.trim(); if (!trimmed) return; // Pass the validated, persisted selection tuple only. The hook derives the // wire projection (legacy `message` provider/model, or `turn:send`) from the // negotiated `chat:send-capability` protocol — never from flat caller input. const selection = harness.persistedSelection; const ok = onSend({ content: trimmed, selection }); // Clear the input only when the send was accepted — a refused turn (e.g. a // failed idempotency mint) must retain the user's text so it is not lost. if (ok) setContent(''); } function handleKeyDown(event: KeyboardEvent): void { if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); submit(); } } // Scope the model options to the intentionally selected provider. With no // provider chosen (`providerId === ''`) nothing matches, so the model select // offers only the placeholder — never a cross-provider row. const models = (harness.catalog?.models ?? []).filter( (model) => model.providerId === harness.providerId, ); // A collision-safe composite option identity covering the full provider+model // tuple. The controlled select mirrors the same identity so the exact catalog // row highlights (a bare modelId would collide across providers). const modelOptionValue = (model: { providerId: string; modelId: string }): string => `${model.providerId}:${model.modelId}`; const selectedModelValue = harness.modelId ? `${harness.providerId}:${harness.modelId}` : ''; return (
{ event.preventDefault(); submit(); }} className="flex flex-col gap-2 border-t p-4" >
{harness.catalogUnavailable ? (

This harness catalog is currently unavailable.

) : null} {harness.isStale ? (

The saved model is no longer available — pick another to continue.

) : null} {harness.persistError ? (

{harness.persistError.message}

) : null}