Files
aryx/src/renderer/components/chat/UserInputBanner.tsx
T
David KayaandCopilot f15b1aedb1 feat: add ask_user interactive user input to frontend
Wire the sidecar user-input-requested protocol event through the main
process, IPC layer, and renderer UI so agents can ask the user
interactive questions with choices and freeform input.

- Add UserInputRequestedEvent and ResolveUserInputCommand to sidecar
  protocol types
- Add resolveUserInput method to SidecarClient and onUserInput callback
  to runTurn
- Create PendingUserInputRecord domain type and add pendingUserInput to
  SessionRecord
- Add handleUserInputRequested and resolveSessionUserInput to
  AryxAppService with handle management
- Register sessions:resolve-user-input IPC channel with preload bridge
- Create UserInputBanner component with choice buttons and freeform
  input
- Integrate UserInputBanner into ChatPane with header indicator

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-26 17:45:27 +01:00

114 lines
4.1 KiB
TypeScript

import { useState, useCallback } from 'react';
import { Loader2, MessageCircleQuestion, Send } from 'lucide-react';
import type { PendingUserInputRecord } from '@shared/domain/userInput';
export function UserInputBanner({
userInput,
onSubmit,
isSubmitting,
}: {
userInput: PendingUserInputRecord;
onSubmit: (answer: string, wasFreeform: boolean) => void;
isSubmitting: boolean;
}) {
const [freeformText, setFreeformText] = useState('');
const hasChoices = userInput.choices && userInput.choices.length > 0;
const handleChoiceClick = useCallback(
(choice: string) => {
if (!isSubmitting) {
onSubmit(choice, false);
}
},
[isSubmitting, onSubmit],
);
const handleFreeformSubmit = useCallback(() => {
const trimmed = freeformText.trim();
if (trimmed && !isSubmitting) {
onSubmit(trimmed, true);
}
}, [freeformText, isSubmitting, onSubmit]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleFreeformSubmit();
}
},
[handleFreeformSubmit],
);
return (
<div className="rounded-xl border border-blue-500/30 bg-blue-500/5 px-4 py-3" role="alert">
{/* Header */}
<div className="flex items-start gap-2.5">
<MessageCircleQuestion className="mt-0.5 size-4 shrink-0 text-blue-400" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-[13px] font-semibold text-blue-200">Agent question</span>
<span className="rounded-full bg-blue-500/15 px-2 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-blue-400">
User input
</span>
</div>
{userInput.agentName && (
<div className="mt-1 text-[11px] text-zinc-400">
Agent: <span className="text-zinc-300">{userInput.agentName}</span>
</div>
)}
<p className="mt-2 text-[13px] leading-relaxed text-zinc-200 whitespace-pre-wrap">
{userInput.question}
</p>
</div>
</div>
{/* Choices */}
{hasChoices && (
<div className="mt-3 flex flex-wrap gap-2">
{userInput.choices!.map((choice) => (
<button
className="rounded-lg border border-blue-500/30 bg-blue-500/10 px-3.5 py-1.5 text-[12px] font-medium text-blue-200 transition hover:border-blue-400/50 hover:bg-blue-500/20 hover:text-white disabled:cursor-not-allowed disabled:opacity-50"
disabled={isSubmitting}
key={choice}
onClick={() => handleChoiceClick(choice)}
type="button"
>
{choice}
</button>
))}
</div>
)}
{/* Freeform input */}
{userInput.allowFreeform && (
<div className="mt-3 flex items-center gap-2">
<input
aria-label="Type your answer"
className="min-w-0 flex-1 rounded-lg border border-zinc-700 bg-zinc-900/60 px-3 py-1.5 text-[13px] text-zinc-200 placeholder-zinc-500 outline-none transition focus:border-blue-500/50 focus:ring-1 focus:ring-blue-500/30 disabled:cursor-not-allowed disabled:opacity-50"
disabled={isSubmitting}
onChange={(e) => setFreeformText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={hasChoices ? 'Or type your own answer…' : 'Type your answer…'}
type="text"
value={freeformText}
/>
<button
aria-label="Submit answer"
className="inline-flex items-center gap-1.5 rounded-lg bg-blue-600 px-3.5 py-1.5 text-[12px] font-medium text-white transition hover:bg-blue-500 disabled:cursor-not-allowed disabled:opacity-50"
disabled={isSubmitting || !freeformText.trim()}
onClick={handleFreeformSubmit}
type="button"
>
{isSubmitting ? <Loader2 className="size-3 animate-spin" /> : <Send className="size-3" />}
Send
</button>
</div>
)}
</div>
);
}