mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-29 07:58:47 +02:00
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:
@@ -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,
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user