ci/woodpecker/push/publish Pipeline failed
Co-authored-by: shaggy <[email protected]>
182 lines
6.8 KiB
TypeScript
182 lines
6.8 KiB
TypeScript
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<string>();
|
|
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<HTMLTextAreaElement>): 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 (
|
|
<form
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
submit();
|
|
}}
|
|
className="flex flex-col gap-2 border-t p-4"
|
|
>
|
|
<div className="flex flex-wrap gap-2">
|
|
<select
|
|
aria-label="Harness"
|
|
value={harness.harnessId}
|
|
onChange={(event) => harness.selectHarness(event.target.value)}
|
|
className="rounded border px-2 py-1 text-xs"
|
|
>
|
|
<option value="">Select a harness…</option>
|
|
{harness.harnesses.map((item) => (
|
|
<option key={item.id} value={item.id}>
|
|
{item.displayName}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<select
|
|
aria-label="Provider"
|
|
value={harness.providerId}
|
|
onChange={(event) => harness.selectProvider(event.target.value)}
|
|
disabled={harness.catalogUnavailable || providerOptions(harness).length === 0}
|
|
className="rounded border px-2 py-1 text-xs"
|
|
>
|
|
<option value="">Select a provider…</option>
|
|
{providerOptions(harness).map((providerId) => (
|
|
<option key={providerId} value={providerId}>
|
|
{providerId}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<select
|
|
aria-label="Model"
|
|
value={selectedModelValue}
|
|
onChange={(event) => {
|
|
// Resolve the composite option identity back to the exact catalog
|
|
// row and persist that row's own provider+model — never a bare id.
|
|
const selected = models.find((model) => modelOptionValue(model) === event.target.value);
|
|
if (selected) harness.selectModel(selected.providerId, selected.modelId);
|
|
}}
|
|
disabled={harness.catalogUnavailable || models.length === 0}
|
|
className="rounded border px-2 py-1 text-xs"
|
|
>
|
|
<option value="">Select a model…</option>
|
|
{models.map((model) => (
|
|
<option key={modelOptionValue(model)} value={modelOptionValue(model)}>
|
|
{model.displayName}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
{harness.catalogUnavailable ? (
|
|
<p role="status" className="text-xs opacity-70">
|
|
This harness catalog is currently unavailable.
|
|
</p>
|
|
) : null}
|
|
{harness.isStale ? (
|
|
<p role="status" className="text-xs opacity-70">
|
|
The saved model is no longer available — pick another to continue.
|
|
</p>
|
|
) : null}
|
|
{harness.persistError ? (
|
|
<p role="alert" className="text-xs">
|
|
{harness.persistError.message}
|
|
</p>
|
|
) : null}
|
|
<div className="flex items-end gap-2">
|
|
<textarea
|
|
aria-label="Message"
|
|
value={content}
|
|
onChange={(event) => setContent(event.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
rows={2}
|
|
placeholder="Message… (Enter to send, Shift+Enter for a new line)"
|
|
className="flex-1 resize-none rounded border px-3 py-2 text-sm"
|
|
/>
|
|
<button
|
|
type="submit"
|
|
disabled={!content.trim() || busy || !harness.canSend}
|
|
className="rounded px-3 py-2 text-sm font-medium"
|
|
>
|
|
Send
|
|
</button>
|
|
<button
|
|
type="button"
|
|
aria-label="Stop"
|
|
disabled={!hasConversation || !streaming}
|
|
onClick={onStop}
|
|
className="rounded px-3 py-2 text-sm font-medium"
|
|
>
|
|
Stop
|
|
</button>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|