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>
This commit is contained in:
David Kaya
2026-03-26 17:45:27 +01:00
co-authored by Copilot
parent 2ae4bd01b4
commit f15b1aedb1
14 changed files with 356 additions and 12 deletions
+98
View File
@@ -10,6 +10,7 @@ import type {
RunTurnToolingConfig, RunTurnToolingConfig,
SidecarCapabilities, SidecarCapabilities,
TurnDeltaEvent, TurnDeltaEvent,
UserInputRequestedEvent,
} from '@shared/contracts/sidecar'; } from '@shared/contracts/sidecar';
import { import {
buildAvailableModelCatalog, buildAvailableModelCatalog,
@@ -120,6 +121,12 @@ type PendingApprovalHandle = {
resolve: (decision: ApprovalDecision) => void | Promise<void>; resolve: (decision: ApprovalDecision) => void | Promise<void>;
}; };
type PendingUserInputHandle = {
sessionId: string;
requestId: string;
resolve: (answer: string, wasFreeform: boolean) => void | Promise<void>;
};
type DiscoveredToolingResolution = 'accept' | 'dismiss'; type DiscoveredToolingResolution = 'accept' | 'dismiss';
function isBuiltinPattern(patternId: string): boolean { function isBuiltinPattern(patternId: string): boolean {
@@ -147,6 +154,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
private readonly gitService = new GitService(); private readonly gitService = new GitService();
private readonly configScanner = new ConfigScannerRegistry(); private readonly configScanner = new ConfigScannerRegistry();
private readonly pendingApprovalHandles = new Map<string, PendingApprovalHandle>(); private readonly pendingApprovalHandles = new Map<string, PendingApprovalHandle>();
private readonly pendingUserInputHandles = new Map<string, PendingUserInputHandle>();
private workspace?: WorkspaceState; private workspace?: WorkspaceState;
private sidecarCapabilities?: SidecarCapabilities; private sidecarCapabilities?: SidecarCapabilities;
private sidecarCapabilitiesPromise?: Promise<SidecarCapabilities>; private sidecarCapabilitiesPromise?: Promise<SidecarCapabilities>;
@@ -619,6 +627,10 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
await this.handleApprovalRequested(workspace, session.id, requestId, event, (decision) => await this.handleApprovalRequested(workspace, session.id, requestId, event, (decision) =>
this.sidecar.resolveApproval(event.approvalId, decision)); this.sidecar.resolveApproval(event.approvalId, decision));
}, },
async (event) => {
await this.handleUserInputRequested(workspace, session.id, requestId, event, (answer, wasFreeform) =>
this.sidecar.resolveUserInput(event.userInputId, answer, wasFreeform));
},
); );
await this.awaitFinalResponseApproval(workspace, session.id, requestId, effectivePattern, responseMessages); await this.awaitFinalResponseApproval(workspace, session.id, requestId, effectivePattern, responseMessages);
@@ -742,6 +754,59 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
return result; return result;
} }
async resolveSessionUserInput(
sessionId: string,
userInputId: string,
answer: string,
wasFreeform: boolean,
): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace();
const session = this.requireSession(workspace, sessionId);
const pending = session.pendingUserInput;
if (!pending || pending.id !== userInputId) {
throw new Error(`User input "${userInputId}" is not pending for session "${sessionId}".`);
}
const handle = this.pendingUserInputHandles.get(userInputId);
if (!handle || handle.sessionId !== sessionId) {
throw new Error(`User input "${userInputId}" is no longer active. Restart the run and try again.`);
}
const answeredAt = nowIso();
session.pendingUserInput = {
...pending,
status: 'answered',
answer,
answeredAt,
};
session.updatedAt = answeredAt;
const result = await this.persistAndBroadcast(workspace);
this.pendingUserInputHandles.delete(userInputId);
try {
await Promise.resolve(handle.resolve(answer, wasFreeform));
session.pendingUserInput = undefined;
await this.persistAndBroadcast(workspace);
} catch (error) {
session.status = 'error';
session.lastError = error instanceof Error ? error.message : String(error);
session.updatedAt = nowIso();
this.emitSessionEvent({
sessionId,
kind: 'error',
occurredAt: session.updatedAt,
error: session.lastError,
});
await this.persistAndBroadcast(workspace);
throw error;
}
return result;
}
async updateSessionModelConfig( async updateSessionModelConfig(
sessionId: string, sessionId: string,
model: string, model: string,
@@ -1114,6 +1179,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
const completedAt = nowIso(); const completedAt = nowIso();
session.status = 'idle'; session.status = 'idle';
session.lastError = undefined; session.lastError = undefined;
session.pendingUserInput = undefined;
session.updatedAt = completedAt; session.updatedAt = completedAt;
const completedRun = this.updateSessionRun(session, requestId, (run) => const completedRun = this.updateSessionRun(session, requestId, (run) =>
completeSessionRunRecord(run, completedAt)); completeSessionRunRecord(run, completedAt));
@@ -1144,6 +1210,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
const cancelledAt = nowIso(); const cancelledAt = nowIso();
session.status = 'idle'; session.status = 'idle';
session.lastError = undefined; session.lastError = undefined;
session.pendingUserInput = undefined;
session.updatedAt = cancelledAt; session.updatedAt = cancelledAt;
const cancelledRun = this.updateSessionRun(session, requestId, (run) => const cancelledRun = this.updateSessionRun(session, requestId, (run) =>
cancelSessionRunRecord(run, cancelledAt)); cancelSessionRunRecord(run, cancelledAt));
@@ -1187,6 +1254,37 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
} }
} }
private async handleUserInputRequested(
workspace: WorkspaceState,
sessionId: string,
_requestId: string,
event: UserInputRequestedEvent,
resolve: (answer: string, wasFreeform: boolean) => void | Promise<void>,
): Promise<void> {
const session = this.requireSession(workspace, sessionId);
const requestedAt = nowIso();
session.pendingUserInput = {
id: event.userInputId,
status: 'pending',
agentId: event.agentId,
agentName: event.agentName,
question: event.question,
choices: event.choices,
allowFreeform: event.allowFreeform ?? true,
requestedAt,
};
session.updatedAt = requestedAt;
this.pendingUserInputHandles.set(event.userInputId, {
sessionId,
requestId: _requestId,
resolve,
});
await this.persistAndBroadcast(workspace);
}
private createPendingApprovalFromSidecarEvent(event: ApprovalRequestedEvent): PendingApprovalRecord { private createPendingApprovalFromSidecarEvent(event: ApprovalRequestedEvent): PendingApprovalRecord {
return { return {
id: event.approvalId, id: event.approvalId,
+4
View File
@@ -11,6 +11,7 @@ import type {
RenameSessionInput, RenameSessionInput,
RescanProjectConfigsInput, RescanProjectConfigsInput,
ResolveSessionApprovalInput, ResolveSessionApprovalInput,
ResolveSessionUserInputInput,
SaveLspProfileInput, SaveLspProfileInput,
SaveMcpServerInput, SaveMcpServerInput,
SavePatternInput, SavePatternInput,
@@ -110,6 +111,9 @@ export function registerIpcHandlers(window: BrowserWindow, service: AryxAppServi
ipcMain.handle(ipcChannels.resolveSessionApproval, (_event, input: ResolveSessionApprovalInput) => ipcMain.handle(ipcChannels.resolveSessionApproval, (_event, input: ResolveSessionApprovalInput) =>
service.resolveSessionApproval(input.sessionId, input.approvalId, input.decision), service.resolveSessionApproval(input.sessionId, input.approvalId, input.decision),
); );
ipcMain.handle(ipcChannels.resolveSessionUserInput, (_event, input: ResolveSessionUserInputInput) =>
service.resolveSessionUserInput(input.sessionId, input.userInputId, input.answer, input.wasFreeform),
);
ipcMain.handle( ipcMain.handle(
ipcChannels.updateSessionModelConfig, ipcChannels.updateSessionModelConfig,
(_event, input: UpdateSessionModelConfigInput) => (_event, input: UpdateSessionModelConfigInput) =>
+7 -1
View File
@@ -1,4 +1,9 @@
import type { AgentActivityEvent, ApprovalRequestedEvent, TurnDeltaEvent } from '@shared/contracts/sidecar'; import type {
AgentActivityEvent,
ApprovalRequestedEvent,
TurnDeltaEvent,
UserInputRequestedEvent,
} from '@shared/contracts/sidecar';
import type { ChatMessageRecord } from '@shared/domain/session'; import type { ChatMessageRecord } from '@shared/domain/session';
export interface RunTurnPendingCommand { export interface RunTurnPendingCommand {
@@ -8,6 +13,7 @@ export interface RunTurnPendingCommand {
onDelta: (event: TurnDeltaEvent) => void | Promise<void>; onDelta: (event: TurnDeltaEvent) => void | Promise<void>;
onActivity: (event: AgentActivityEvent) => void | Promise<void>; onActivity: (event: AgentActivityEvent) => void | Promise<void>;
onApproval: (event: ApprovalRequestedEvent) => void | Promise<void>; onApproval: (event: ApprovalRequestedEvent) => void | Promise<void>;
onUserInput: (event: UserInputRequestedEvent) => void | Promise<void>;
errored: boolean; errored: boolean;
} }
+34 -2
View File
@@ -9,6 +9,7 @@ import type {
SidecarCapabilities, SidecarCapabilities,
SidecarEvent, SidecarEvent,
TurnDeltaEvent, TurnDeltaEvent,
UserInputRequestedEvent,
ValidatePatternCommand, ValidatePatternCommand,
RunTurnCommand, RunTurnCommand,
} from '@shared/contracts/sidecar'; } from '@shared/contracts/sidecar';
@@ -44,6 +45,12 @@ type PendingCommand =
resolve: () => void; resolve: () => void;
reject: (error: Error) => void; reject: (error: Error) => void;
}) })
| ({
processId: number;
kind: 'resolve-user-input';
resolve: () => void;
reject: (error: Error) => void;
})
| ({ | ({
processId: number; processId: number;
kind: 'cancel-turn'; kind: 'cancel-turn';
@@ -94,8 +101,19 @@ export class SidecarClient {
onDelta: (event: TurnDeltaEvent) => void | Promise<void>, onDelta: (event: TurnDeltaEvent) => void | Promise<void>,
onActivity: (event: AgentActivityEvent) => void | Promise<void>, onActivity: (event: AgentActivityEvent) => void | Promise<void>,
onApproval: (event: ApprovalRequestedEvent) => void | Promise<void>, onApproval: (event: ApprovalRequestedEvent) => void | Promise<void>,
onUserInput: (event: UserInputRequestedEvent) => void | Promise<void>,
): Promise<ChatMessageRecord[]> { ): Promise<ChatMessageRecord[]> {
return this.dispatch<ChatMessageRecord[]>(command, onDelta, onActivity, onApproval); return this.dispatch<ChatMessageRecord[]>(command, onDelta, onActivity, onApproval, onUserInput);
}
async resolveUserInput(userInputId: string, answer: string, wasFreeform: boolean): Promise<void> {
return this.dispatch<void>({
type: 'resolve-user-input',
requestId: `user-input-${Date.now()}`,
userInputId,
answer,
wasFreeform,
});
} }
async resolveApproval(approvalId: string, decision: ApprovalDecision): Promise<void> { async resolveApproval(approvalId: string, decision: ApprovalDecision): Promise<void> {
@@ -199,6 +217,7 @@ export class SidecarClient {
onDelta?: (event: TurnDeltaEvent) => void | Promise<void>, onDelta?: (event: TurnDeltaEvent) => void | Promise<void>,
onActivity?: (event: AgentActivityEvent) => void | Promise<void>, onActivity?: (event: AgentActivityEvent) => void | Promise<void>,
onApproval?: (event: ApprovalRequestedEvent) => void | Promise<void>, onApproval?: (event: ApprovalRequestedEvent) => void | Promise<void>,
onUserInput?: (event: UserInputRequestedEvent) => void | Promise<void>,
): Promise<TResult> { ): Promise<TResult> {
const state = await this.ensureProcess(); const state = await this.ensureProcess();
@@ -212,6 +231,7 @@ export class SidecarClient {
onDelta: onDelta ?? (() => undefined), onDelta: onDelta ?? (() => undefined),
onActivity: onActivity ?? (() => undefined), onActivity: onActivity ?? (() => undefined),
onApproval: onApproval ?? (() => undefined), onApproval: onApproval ?? (() => undefined),
onUserInput: onUserInput ?? (() => undefined),
errored: false, errored: false,
}); });
} else if (command.type === 'validate-pattern') { } else if (command.type === 'validate-pattern') {
@@ -228,6 +248,13 @@ export class SidecarClient {
resolve: resolve as () => void, resolve: resolve as () => void,
reject, reject,
}); });
} else if (command.type === 'resolve-user-input') {
this.pending.set(command.requestId, {
processId: state.id,
kind: 'resolve-user-input',
resolve: resolve as () => void,
reject,
});
} else if (command.type === 'cancel-turn') { } else if (command.type === 'cancel-turn') {
this.pending.set(command.requestId, { this.pending.set(command.requestId, {
processId: state.id, processId: state.id,
@@ -297,6 +324,11 @@ export class SidecarClient {
this.invokeRunTurnHandler(event.requestId, pending, () => pending.onApproval(event)); this.invokeRunTurnHandler(event.requestId, pending, () => pending.onApproval(event));
} }
return; return;
case 'user-input-requested':
if (pending.kind === 'run-turn' && shouldHandleRunTurnEvent(pending)) {
this.invokeRunTurnHandler(event.requestId, pending, () => pending.onUserInput(event));
}
return;
case 'turn-complete': case 'turn-complete':
if (pending.kind === 'run-turn') { if (pending.kind === 'run-turn') {
if (shouldHandleRunTurnEvent(pending)) { if (shouldHandleRunTurnEvent(pending)) {
@@ -318,7 +350,7 @@ export class SidecarClient {
this.pending.delete(event.requestId); this.pending.delete(event.requestId);
return; return;
case 'command-complete': case 'command-complete':
if (pending.kind === 'resolve-approval' || pending.kind === 'cancel-turn') { if (pending.kind === 'resolve-approval' || pending.kind === 'resolve-user-input' || pending.kind === 'cancel-turn') {
pending.resolve(); pending.resolve();
this.pending.delete(event.requestId); this.pending.delete(event.requestId);
} else if (pending.kind !== 'run-turn' || pending.errored) { } else if (pending.kind !== 'run-turn' || pending.errored) {
+1
View File
@@ -36,6 +36,7 @@ const api: ElectronApi = {
sendSessionMessage: (input) => ipcRenderer.invoke(ipcChannels.sendSessionMessage, input), sendSessionMessage: (input) => ipcRenderer.invoke(ipcChannels.sendSessionMessage, input),
cancelSessionTurn: (input) => ipcRenderer.invoke(ipcChannels.cancelSessionTurn, input), cancelSessionTurn: (input) => ipcRenderer.invoke(ipcChannels.cancelSessionTurn, input),
resolveSessionApproval: (input) => ipcRenderer.invoke(ipcChannels.resolveSessionApproval, input), resolveSessionApproval: (input) => ipcRenderer.invoke(ipcChannels.resolveSessionApproval, input),
resolveSessionUserInput: (input) => ipcRenderer.invoke(ipcChannels.resolveSessionUserInput, input),
updateSessionModelConfig: (input) => updateSessionModelConfig: (input) =>
ipcRenderer.invoke(ipcChannels.updateSessionModelConfig, input), ipcRenderer.invoke(ipcChannels.updateSessionModelConfig, input),
querySessions: (input) => ipcRenderer.invoke(ipcChannels.querySessions, input), querySessions: (input) => ipcRenderer.invoke(ipcChannels.querySessions, input),
+3
View File
@@ -250,6 +250,9 @@ export default function App() {
onResolveApproval={(approvalId, decision) => onResolveApproval={(approvalId, decision) =>
api.resolveSessionApproval({ sessionId: selectedSession.id, approvalId, decision }) api.resolveSessionApproval({ sessionId: selectedSession.id, approvalId, decision })
} }
onResolveUserInput={(userInputId, answer, wasFreeform) =>
api.resolveSessionUserInput({ sessionId: selectedSession.id, userInputId, answer, wasFreeform })
}
onUpdateSessionModelConfig={(config) => onUpdateSessionModelConfig={(config) =>
api.updateSessionModelConfig({ api.updateSessionModelConfig({
sessionId: selectedSession.id, sessionId: selectedSession.id,
+46 -8
View File
@@ -1,9 +1,10 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { AlertCircle, ArrowUp, Bot, Circle, GitBranch, Loader2, ShieldAlert, Square, User } from 'lucide-react'; import { AlertCircle, ArrowUp, Bot, Circle, GitBranch, Loader2, MessageCircleQuestion, ShieldAlert, Square, User } from 'lucide-react';
import { MarkdownContent } from '@renderer/components/MarkdownContent'; import { MarkdownContent } from '@renderer/components/MarkdownContent';
import { MarkdownComposer, type MarkdownComposerHandle } from '@renderer/components/MarkdownComposer'; import { MarkdownComposer, type MarkdownComposerHandle } from '@renderer/components/MarkdownComposer';
import { ApprovalBanner, QueuedApprovalsList } from '@renderer/components/chat/ApprovalBanner'; import { ApprovalBanner, QueuedApprovalsList } from '@renderer/components/chat/ApprovalBanner';
import { UserInputBanner } from '@renderer/components/chat/UserInputBanner';
import { InlineApprovalPill, InlineModelPill, InlineThinkingPill, InlineToolsPill } from '@renderer/components/chat/InlinePills'; import { InlineApprovalPill, InlineModelPill, InlineThinkingPill, InlineToolsPill } from '@renderer/components/chat/InlinePills';
import { ThinkingDots } from '@renderer/components/chat/ThinkingDots'; import { ThinkingDots } from '@renderer/components/chat/ThinkingDots';
import { getAssistantMessagePhase } from '@renderer/lib/messagePhase'; import { getAssistantMessagePhase } from '@renderer/lib/messagePhase';
@@ -36,6 +37,7 @@ interface ChatPaneProps {
onSend: (content: string) => Promise<void>; onSend: (content: string) => Promise<void>;
onCancelTurn?: () => void; onCancelTurn?: () => void;
onResolveApproval?: (approvalId: string, decision: ApprovalDecision) => Promise<unknown>; onResolveApproval?: (approvalId: string, decision: ApprovalDecision) => Promise<unknown>;
onResolveUserInput?: (userInputId: string, answer: string, wasFreeform: boolean) => Promise<unknown>;
onUpdateSessionModelConfig?: (config: { onUpdateSessionModelConfig?: (config: {
model: string; model: string;
reasoningEffort?: ReasoningEffort; reasoningEffort?: ReasoningEffort;
@@ -54,6 +56,7 @@ export function ChatPane({
onSend, onSend,
onCancelTurn, onCancelTurn,
onResolveApproval, onResolveApproval,
onResolveUserInput,
onUpdateSessionModelConfig, onUpdateSessionModelConfig,
onUpdateSessionTooling, onUpdateSessionTooling,
onUpdateSessionApprovalSettings, onUpdateSessionApprovalSettings,
@@ -62,6 +65,7 @@ export function ChatPane({
const [configError, setConfigError] = useState<string>(); const [configError, setConfigError] = useState<string>();
const [approvalError, setApprovalError] = useState<string>(); const [approvalError, setApprovalError] = useState<string>();
const [isResolvingApproval, setIsResolvingApproval] = useState(false); const [isResolvingApproval, setIsResolvingApproval] = useState(false);
const [isSubmittingUserInput, setIsSubmittingUserInput] = useState(false);
const [isUpdatingSessionModelConfig, setIsUpdatingSessionModelConfig] = useState(false); const [isUpdatingSessionModelConfig, setIsUpdatingSessionModelConfig] = useState(false);
const transcriptRef = useRef<HTMLDivElement>(null); const transcriptRef = useRef<HTMLDivElement>(null);
const composerRef = useRef<MarkdownComposerHandle>(null); const composerRef = useRef<MarkdownComposerHandle>(null);
@@ -70,6 +74,7 @@ export function ChatPane({
const pendingApproval = session.pendingApproval?.status === 'pending' ? session.pendingApproval : undefined; const pendingApproval = session.pendingApproval?.status === 'pending' ? session.pendingApproval : undefined;
const queuedApprovals = (session.pendingApprovalQueue ?? []).filter((a) => a.status === 'pending'); const queuedApprovals = (session.pendingApprovalQueue ?? []).filter((a) => a.status === 'pending');
const totalPendingCount = (pendingApproval ? 1 : 0) + queuedApprovals.length; const totalPendingCount = (pendingApproval ? 1 : 0) + queuedApprovals.length;
const pendingUserInput = session.pendingUserInput?.status === 'pending' ? session.pendingUserInput : undefined;
const isScratchpad = isScratchpadProject(project); const isScratchpad = isScratchpadProject(project);
const isSingleAgent = pattern.agents.length === 1; const isSingleAgent = pattern.agents.length === 1;
const primaryAgent = pattern.agents[0]; const primaryAgent = pattern.agents[0];
@@ -158,6 +163,20 @@ export function ChatPane({
} }
} }
async function handleResolveUserInput(answer: string, wasFreeform: boolean) {
if (!pendingUserInput || !onResolveUserInput || isSubmittingUserInput) return;
setIsSubmittingUserInput(true);
try {
await onResolveUserInput(pendingUserInput.id, answer, wasFreeform);
} catch {
// User input errors are non-critical; the turn will fail and show the error status
} finally {
setIsSubmittingUserInput(false);
}
}
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
{/* Header — extra top padding clears the title bar overlay zone */} {/* Header — extra top padding clears the title bar overlay zone */}
@@ -194,14 +213,20 @@ export function ChatPane({
)} )}
</div> </div>
)} )}
{isSessionBusy && !pendingApproval && <span className="size-2 animate-pulse rounded-full bg-blue-400" />} {pendingUserInput && !pendingApproval && (
<div className="flex items-center gap-1.5 text-[12px] font-medium text-blue-400">
<MessageCircleQuestion className="size-3.5" />
Awaiting your input
</div>
)}
{isSessionBusy && !pendingApproval && !pendingUserInput && <span className="size-2 animate-pulse rounded-full bg-blue-400" />}
{session.status === 'error' && ( {session.status === 'error' && (
<div className="flex items-center gap-1.5 text-[12px] text-red-400"> <div className="flex items-center gap-1.5 text-[12px] text-red-400">
<AlertCircle className="size-3.5" /> <AlertCircle className="size-3.5" />
Error Error
</div> </div>
)} )}
{session.status === 'idle' && !pendingApproval && session.messages.length > 0 && ( {session.status === 'idle' && !pendingApproval && !pendingUserInput && session.messages.length > 0 && (
<span className="text-[12px] text-zinc-600"> <span className="text-[12px] text-zinc-600">
{session.messages.length} message{session.messages.length === 1 ? '' : 's'} {session.messages.length} message{session.messages.length === 1 ? '' : 's'}
</span> </span>
@@ -339,6 +364,17 @@ export function ChatPane({
</div> </div>
)} )}
{/* Pending user input banner */}
{pendingUserInput && (
<div className="mb-3">
<UserInputBanner
isSubmitting={isSubmittingUserInput}
onSubmit={(answer, wasFreeform) => void handleResolveUserInput(answer, wasFreeform)}
userInput={pendingUserInput}
/>
</div>
)}
{/* Session config pills — tools/approval left, model/reasoning right */} {/* Session config pills — tools/approval left, model/reasoning right */}
{isSingleAgent && ( {isSingleAgent && (
<div className="mb-2 flex items-center gap-2"> <div className="mb-2 flex items-center gap-2">
@@ -426,11 +462,13 @@ export function ChatPane({
placeholder={ placeholder={
pendingApproval pendingApproval
? 'Awaiting approval...' ? 'Awaiting approval...'
: isSessionBusy : pendingUserInput
? 'Waiting for response...' ? 'Awaiting your input above...'
: isUpdatingSessionModelConfig : isSessionBusy
? 'Saving model settings...' ? 'Waiting for response...'
: 'Message...' : isUpdatingSessionModelConfig
? 'Saving model settings...'
: 'Message...'
} }
> >
<button <button
@@ -0,0 +1,113 @@
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>
);
}
+1
View File
@@ -26,6 +26,7 @@ export const ipcChannels = {
sendSessionMessage: 'sessions:send-message', sendSessionMessage: 'sessions:send-message',
cancelSessionTurn: 'sessions:cancel-turn', cancelSessionTurn: 'sessions:cancel-turn',
resolveSessionApproval: 'sessions:resolve-approval', resolveSessionApproval: 'sessions:resolve-approval',
resolveSessionUserInput: 'sessions:resolve-user-input',
querySessions: 'sessions:query', querySessions: 'sessions:query',
updateSessionModelConfig: 'sessions:update-model-config', updateSessionModelConfig: 'sessions:update-model-config',
selectProject: 'selection:project', selectProject: 'selection:project',
+8
View File
@@ -36,6 +36,13 @@ export interface ResolveSessionApprovalInput {
decision: ApprovalDecision; decision: ApprovalDecision;
} }
export interface ResolveSessionUserInputInput {
sessionId: string;
userInputId: string;
answer: string;
wasFreeform: boolean;
}
export interface UpdateSessionModelConfigInput { export interface UpdateSessionModelConfigInput {
sessionId: string; sessionId: string;
model: string; model: string;
@@ -126,6 +133,7 @@ export interface ElectronApi {
sendSessionMessage(input: SendSessionMessageInput): Promise<void>; sendSessionMessage(input: SendSessionMessageInput): Promise<void>;
cancelSessionTurn(input: CancelSessionTurnInput): Promise<void>; cancelSessionTurn(input: CancelSessionTurnInput): Promise<void>;
resolveSessionApproval(input: ResolveSessionApprovalInput): Promise<WorkspaceState>; resolveSessionApproval(input: ResolveSessionApprovalInput): Promise<WorkspaceState>;
resolveSessionUserInput(input: ResolveSessionUserInputInput): Promise<WorkspaceState>;
updateSessionModelConfig(input: UpdateSessionModelConfigInput): Promise<WorkspaceState>; updateSessionModelConfig(input: UpdateSessionModelConfigInput): Promise<WorkspaceState>;
querySessions(input: QuerySessionsInput): Promise<SessionQueryResult[]>; querySessions(input: QuerySessionsInput): Promise<SessionQueryResult[]>;
selectProject(projectId?: string): Promise<WorkspaceState>; selectProject(projectId?: string): Promise<WorkspaceState>;
+23 -1
View File
@@ -92,12 +92,21 @@ export interface ResolveApprovalCommand {
decision: ApprovalDecision; decision: ApprovalDecision;
} }
export interface ResolveUserInputCommand {
type: 'resolve-user-input';
requestId: string;
userInputId: string;
answer: string;
wasFreeform: boolean;
}
export type SidecarCommand = export type SidecarCommand =
| DescribeCapabilitiesCommand | DescribeCapabilitiesCommand
| ValidatePatternCommand | ValidatePatternCommand
| RunTurnCommand | RunTurnCommand
| CancelTurnCommand | CancelTurnCommand
| ResolveApprovalCommand; | ResolveApprovalCommand
| ResolveUserInputCommand;
export interface RunTurnLocalMcpServerConfig { export interface RunTurnLocalMcpServerConfig {
id: string; id: string;
@@ -220,6 +229,18 @@ export interface ApprovalRequestedEvent {
permissionDetail?: PermissionDetail; permissionDetail?: PermissionDetail;
} }
export interface UserInputRequestedEvent {
type: 'user-input-requested';
requestId: string;
sessionId: string;
userInputId: string;
agentId?: string;
agentName?: string;
question: string;
choices?: string[];
allowFreeform?: boolean;
}
export interface CommandErrorEvent { export interface CommandErrorEvent {
type: 'command-error'; type: 'command-error';
requestId: string; requestId: string;
@@ -238,5 +259,6 @@ export type SidecarEvent =
| TurnCompleteEvent | TurnCompleteEvent
| AgentActivityEvent | AgentActivityEvent
| ApprovalRequestedEvent | ApprovalRequestedEvent
| UserInputRequestedEvent
| CommandErrorEvent | CommandErrorEvent
| CommandCompleteEvent; | CommandCompleteEvent;
+2
View File
@@ -11,6 +11,7 @@ import {
type SessionApprovalSettings, type SessionApprovalSettings,
} from '@shared/domain/approval'; } from '@shared/domain/approval';
import type { SessionRunRecord } from '@shared/domain/runTimeline'; import type { SessionRunRecord } from '@shared/domain/runTimeline';
import type { PendingUserInputRecord } from '@shared/domain/userInput';
export type ChatRole = 'system' | 'user' | 'assistant'; export type ChatRole = 'system' | 'user' | 'assistant';
export type SessionStatus = 'idle' | 'running' | 'error'; export type SessionStatus = 'idle' | 'running' | 'error';
@@ -48,6 +49,7 @@ export interface SessionRecord {
approvalSettings?: SessionApprovalSettings; approvalSettings?: SessionApprovalSettings;
pendingApproval?: PendingApprovalRecord; pendingApproval?: PendingApprovalRecord;
pendingApprovalQueue?: PendingApprovalRecord[]; pendingApprovalQueue?: PendingApprovalRecord[];
pendingUserInput?: PendingUserInputRecord;
runs: SessionRunRecord[]; runs: SessionRunRecord[];
} }
+14
View File
@@ -0,0 +1,14 @@
export type UserInputStatus = 'pending' | 'answered';
export interface PendingUserInputRecord {
id: string;
status: UserInputStatus;
agentId?: string;
agentName?: string;
question: string;
choices?: string[];
allowFreeform: boolean;
requestedAt: string;
answer?: string;
answeredAt?: string;
}
+2
View File
@@ -16,6 +16,7 @@ describe('run turn pending helpers', () => {
onDelta: () => undefined, onDelta: () => undefined,
onActivity: () => undefined, onActivity: () => undefined,
onApproval: () => undefined, onApproval: () => undefined,
onUserInput: () => undefined,
errored: false, errored: false,
}; };
@@ -38,6 +39,7 @@ describe('run turn pending helpers', () => {
onDelta: () => undefined, onDelta: () => undefined,
onActivity: () => undefined, onActivity: () => undefined,
onApproval: () => undefined, onApproval: () => undefined,
onUserInput: () => undefined,
errored: false, errored: false,
}; };