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
+34 -2
View File
@@ -9,6 +9,7 @@ import type {
SidecarCapabilities,
SidecarEvent,
TurnDeltaEvent,
UserInputRequestedEvent,
ValidatePatternCommand,
RunTurnCommand,
} from '@shared/contracts/sidecar';
@@ -44,6 +45,12 @@ type PendingCommand =
resolve: () => void;
reject: (error: Error) => void;
})
| ({
processId: number;
kind: 'resolve-user-input';
resolve: () => void;
reject: (error: Error) => void;
})
| ({
processId: number;
kind: 'cancel-turn';
@@ -94,8 +101,19 @@ export class SidecarClient {
onDelta: (event: TurnDeltaEvent) => void | Promise<void>,
onActivity: (event: AgentActivityEvent) => void | Promise<void>,
onApproval: (event: ApprovalRequestedEvent) => void | Promise<void>,
onUserInput: (event: UserInputRequestedEvent) => void | Promise<void>,
): 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> {
@@ -199,6 +217,7 @@ export class SidecarClient {
onDelta?: (event: TurnDeltaEvent) => void | Promise<void>,
onActivity?: (event: AgentActivityEvent) => void | Promise<void>,
onApproval?: (event: ApprovalRequestedEvent) => void | Promise<void>,
onUserInput?: (event: UserInputRequestedEvent) => void | Promise<void>,
): Promise<TResult> {
const state = await this.ensureProcess();
@@ -212,6 +231,7 @@ export class SidecarClient {
onDelta: onDelta ?? (() => undefined),
onActivity: onActivity ?? (() => undefined),
onApproval: onApproval ?? (() => undefined),
onUserInput: onUserInput ?? (() => undefined),
errored: false,
});
} else if (command.type === 'validate-pattern') {
@@ -228,6 +248,13 @@ export class SidecarClient {
resolve: resolve as () => void,
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') {
this.pending.set(command.requestId, {
processId: state.id,
@@ -297,6 +324,11 @@ export class SidecarClient {
this.invokeRunTurnHandler(event.requestId, pending, () => pending.onApproval(event));
}
return;
case 'user-input-requested':
if (pending.kind === 'run-turn' && shouldHandleRunTurnEvent(pending)) {
this.invokeRunTurnHandler(event.requestId, pending, () => pending.onUserInput(event));
}
return;
case 'turn-complete':
if (pending.kind === 'run-turn') {
if (shouldHandleRunTurnEvent(pending)) {
@@ -318,7 +350,7 @@ export class SidecarClient {
this.pending.delete(event.requestId);
return;
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();
this.pending.delete(event.requestId);
} else if (pending.kind !== 'run-turn' || pending.errored) {