From c28d917a4bf4d5c03b1309c00c13cd8a54d23e73 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 24 Mar 2026 11:14:13 +0700 Subject: [PATCH] Simplify prompt shortcut handling --- packages/app/src/app/_layout.tsx | 34 - .../app/src/components/agent-stream-view.tsx | 115 ++- .../app/src/components/question-form-card.tsx | 791 +++++++++++------- .../question-form-card.utils.test.ts | 127 --- .../components/question-form-card.utils.ts | 266 ------ .../app/src/hooks/use-keyboard-shortcuts.ts | 4 - .../src/keyboard/keyboard-shortcuts.test.ts | 12 +- .../app/src/keyboard/keyboard-shortcuts.ts | 10 +- packages/app/src/panels/agent-panel.tsx | 22 +- 9 files changed, 548 insertions(+), 833 deletions(-) delete mode 100644 packages/app/src/components/question-form-card.utils.test.ts delete mode 100644 packages/app/src/components/question-form-card.utils.ts diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index e7e988f3a0..164b9de5e4 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -44,7 +44,6 @@ import { DownloadToast } from "@/components/download-toast"; import { UpdateBanner } from "@/desktop/updates/update-banner"; import { ToastProvider } from "@/contexts/toast-context"; import { usePanelStore } from "@/stores/panel-store"; -import { useSessionStore } from "@/stores/session-store"; import { runOnJS, interpolate, Extrapolation, useSharedValue } from "react-native-reanimated"; import { SidebarAnimationProvider, @@ -256,22 +255,6 @@ interface AppContainerProps { chromeEnabled?: boolean; } -function parseSelectedAgentKey( - selectedAgentKey: string | undefined, -): { serverId: string; agentId: string } | null { - if (!selectedAgentKey) { - return null; - } - const separatorIndex = selectedAgentKey.indexOf(":"); - if (separatorIndex <= 0 || separatorIndex >= selectedAgentKey.length - 1) { - return null; - } - return { - serverId: selectedAgentKey.slice(0, separatorIndex), - agentId: selectedAgentKey.slice(separatorIndex + 1), - }; -} - function AppContainer({ children, selectedAgentId, @@ -284,22 +267,6 @@ function AppContainer({ const toggleBothSidebars = usePanelStore((state) => state.toggleBothSidebars); const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode); const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled); - const selectedAgent = useMemo(() => parseSelectedAgentKey(selectedAgentId), [selectedAgentId]); - const agentAwaitingInput = useSessionStore((state) => { - if (!selectedAgent) { - return false; - } - const pendingPermissions = state.sessions[selectedAgent.serverId]?.pendingPermissions; - if (!pendingPermissions) { - return false; - } - for (const permission of pendingPermissions.values()) { - if (permission.agentId === selectedAgent.agentId) { - return true; - } - } - return false; - }); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0; @@ -309,7 +276,6 @@ function AppContainer({ isMobile, toggleAgentList, selectedAgentId, - agentAwaitingInput, toggleFileExplorer, toggleBothSidebars, toggleFocusMode, diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index ae9ddc1b94..7c45d66cac 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -5,8 +5,8 @@ import { useEffect, useImperativeHandle, useMemo, - useRef, useState, + useRef, } from "react"; import { View, Text, Pressable, Platform, ActivityIndicator } from "react-native"; import Markdown from "react-native-markdown-display"; @@ -36,17 +36,16 @@ import { MessageOuterSpacingProvider, type InlinePathTarget, } from "./message"; -import { Shortcut } from "./ui/shortcut"; import type { StreamItem } from "@/types/stream"; import type { PendingPermission } from "@/types/shared"; import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types"; import type { Agent } from "@/contexts/session-context"; import { useSessionStore } from "@/stores/session-store"; import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions"; -import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; +import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import type { DaemonClient } from "@server/client/daemon-client"; import { ToolCallDetailsContent } from "./tool-call-details"; -import { QuestionFormCard, type QuestionFormCardHandle } from "./question-form-card"; +import { QuestionFormCard } from "./question-form-card"; import { ToolCallSheetProvider } from "./tool-call-sheet"; import { buildAgentStreamRenderModel, @@ -79,11 +78,6 @@ const isToolSequenceItem = (item?: StreamItem) => export interface AgentStreamViewHandle { scrollToBottom(reason?: BottomAnchorLocalRequest["reason"]): void; prepareForViewportChange(): void; - selectPendingPermissionOption(index: number): boolean; -} - -interface PermissionRequestCardHandle { - selectOption(index: number): boolean; } export interface AgentStreamViewProps { @@ -92,6 +86,7 @@ export interface AgentStreamViewProps { agent: Agent; streamItems: StreamItem[]; pendingPermissions: Map; + isPaneFocused?: boolean; routeBottomAnchorRequest?: BottomAnchorRouteRequest | null; isAuthoritativeHistoryReady?: boolean; onOpenWorkspaceFile?: (input: { filePath: string }) => void; @@ -105,6 +100,7 @@ const AgentStreamViewComponent = forwardRef(null); - const permissionCardRefs = useRef(new Map()); const { theme } = useUnistyles(); const router = useRouter(); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; @@ -238,15 +233,8 @@ const AgentStreamViewComponent = forwardRef 0 ? ( - {pendingPermissionItems.map((permission) => ( + {pendingPermissionItems.map((permission, index) => ( { - if (handle) { - permissionCardRefs.current.set(permission.key, handle); - return; - } - permissionCardRefs.current.delete(permission.key); - }} permission={permission} client={client} + shortcutActive={isPaneFocused && index === 0} /> ))} @@ -550,7 +532,14 @@ const AgentStreamViewComponent = forwardRef { if ( @@ -749,18 +738,17 @@ function WorkingIndicator() { } // Permission Request Card Component -const PermissionRequestCard = forwardRef< - PermissionRequestCardHandle, - { - permission: PendingPermission; - client: DaemonClient | null; - } ->(function PermissionRequestCard({ permission, client }, ref) { +function PermissionRequestCard({ + permission, + client, + shortcutActive, +}: { + permission: PendingPermission; + client: DaemonClient | null; + shortcutActive: boolean; +}) { const { theme } = useUnistyles(); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; - const questionFormRef = useRef(null); - const firstOptionShortcut = useShortcutKeys("agent-prompt-select-1"); - const secondOptionShortcut = useShortcutKeys("agent-prompt-select-2"); const { request } = permission; const isPlanRequest = request.kind === "plan"; @@ -920,37 +908,39 @@ const PermissionRequestCard = forwardRef< handleResponse({ behavior: "allow" }); }, [handleResponse]); - useImperativeHandle( - ref, - () => ({ - selectOption(index: number) { - if (isResponding) { - return false; - } - if (request.kind === "question") { - return questionFormRef.current?.selectOption(index) ?? false; - } - if (index === 1) { - handleDeny(); - return true; - } - if (index === 2) { - handleAccept(); - return true; - } + const handlePromptSelection = useCallback( + (action: { id: string; index?: number }): boolean => { + if (action.id !== "agent.prompt.select" || request.kind === "question" || isResponding) { return false; - }, - }), + } + if (action.index === 1) { + handleDeny(); + return true; + } + if (action.index === 2) { + handleAccept(); + return true; + } + return false; + }, [handleAccept, handleDeny, isResponding, request.kind], ); + useKeyboardActionHandler({ + handlerId: `agent-prompt-permission:${permission.key}`, + actions: ["agent.prompt.select"], + enabled: shortcutActive && request.kind !== "question" && !isResponding, + priority: 150, + handle: handlePromptSelection, + }); + if (request.kind === "question") { return ( ); } @@ -1033,9 +1023,6 @@ const PermissionRequestCard = forwardRef< Deny - {firstOptionShortcut ? ( - - ) : null} )} @@ -1061,16 +1048,13 @@ const PermissionRequestCard = forwardRef< Accept - {secondOptionShortcut ? ( - - ) : null} )} ); -}); +} const stylesheet = StyleSheet.create((theme) => ({ container: { @@ -1253,7 +1237,6 @@ const permissionStyles = StyleSheet.create((theme) => ({ alignItems: "center", gap: theme.spacing[2], }, - optionShortcut: {}, optionText: { fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, diff --git a/packages/app/src/components/question-form-card.tsx b/packages/app/src/components/question-form-card.tsx index 3dec296695..84690d6c34 100644 --- a/packages/app/src/components/question-form-card.tsx +++ b/packages/app/src/components/question-form-card.tsx @@ -1,336 +1,530 @@ -import { forwardRef, useCallback, useImperativeHandle, useState } from "react"; +import { useCallback, useState } from "react"; import { View, Text, TextInput, Pressable, ActivityIndicator, Platform } from "react-native"; import { StyleSheet, useUnistyles, UnistylesRuntime } from "react-native-unistyles"; import { Check, CircleHelp, X } from "lucide-react-native"; import type { PendingPermission } from "@/types/shared"; -import { Shortcut } from "@/components/ui/shortcut"; -import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; +import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types"; -import { - applyQuestionShortcutSelection, - areAllQuestionsAnswered, - buildQuestionAnswers, - findFirstUnansweredQuestionIndex, - parseQuestions, - setQuestionOtherText, - toggleQuestionOption, - type QuestionOtherTexts, - type QuestionSelections, -} from "./question-form-card.utils"; + +interface QuestionOption { + label: string; + description?: string; +} + +interface Question { + question: string; + header: string; + options: QuestionOption[]; + multiSelect: boolean; +} + +type QuestionSelections = Record>; +type QuestionOtherTexts = Record; + +function parseQuestions(input: unknown): Question[] | null { + if ( + typeof input !== "object" || + input === null || + !("questions" in input) || + !Array.isArray((input as Record).questions) + ) { + return null; + } + + const raw = (input as Record).questions as unknown[]; + const questions: Question[] = []; + + for (const item of raw) { + if (typeof item !== "object" || item === null) { + return null; + } + + const question = item as Record; + if (typeof question.question !== "string" || typeof question.header !== "string") { + return null; + } + if (!Array.isArray(question.options)) { + return null; + } + + const options: QuestionOption[] = []; + for (const option of question.options as unknown[]) { + if (typeof option !== "object" || option === null) { + return null; + } + + const candidate = option as Record; + if (typeof candidate.label !== "string") { + return null; + } + + options.push({ + label: candidate.label, + description: typeof candidate.description === "string" ? candidate.description : undefined, + }); + } + + questions.push({ + question: question.question, + header: question.header, + options, + multiSelect: question.multiSelect === true, + }); + } + + return questions.length > 0 ? questions : null; +} + +function cloneSelections(input: QuestionSelections): QuestionSelections { + const next: QuestionSelections = {}; + for (const [key, value] of Object.entries(input)) { + next[Number(key)] = new Set(value); + } + return next; +} + +function isQuestionAnswered(input: { + selections: QuestionSelections; + otherTexts: QuestionOtherTexts; + questionIndex: number; +}): boolean { + const selected = input.selections[input.questionIndex]; + const otherText = input.otherTexts[input.questionIndex]?.trim(); + return (selected && selected.size > 0) || Boolean(otherText && otherText.length > 0); +} + +function findFirstUnansweredQuestionIndex(input: { + questions: readonly Question[]; + selections: QuestionSelections; + otherTexts: QuestionOtherTexts; +}): number | null { + for (let questionIndex = 0; questionIndex < input.questions.length; questionIndex += 1) { + if ( + !isQuestionAnswered({ + selections: input.selections, + otherTexts: input.otherTexts, + questionIndex, + }) + ) { + return questionIndex; + } + } + + return null; +} + +function areAllQuestionsAnswered(input: { + questions: readonly Question[]; + selections: QuestionSelections; + otherTexts: QuestionOtherTexts; +}): boolean { + return input.questions.every((_, questionIndex) => + isQuestionAnswered({ + selections: input.selections, + otherTexts: input.otherTexts, + questionIndex, + }), + ); +} + +function buildQuestionAnswers(input: { + questions: readonly Question[]; + selections: QuestionSelections; + otherTexts: QuestionOtherTexts; +}): Record { + const answers: Record = {}; + + for (let questionIndex = 0; questionIndex < input.questions.length; questionIndex += 1) { + const question = input.questions[questionIndex]; + if (!question) { + continue; + } + + const selected = input.selections[questionIndex]; + const otherText = input.otherTexts[questionIndex]?.trim(); + + if (otherText && otherText.length > 0) { + answers[question.header] = otherText; + continue; + } + + if (!selected || selected.size === 0) { + continue; + } + + const labels = Array.from(selected) + .map((optionIndex) => question.options[optionIndex]?.label) + .filter((label): label is string => typeof label === "string"); + if (labels.length > 0) { + answers[question.header] = labels.join(", "); + } + } + + return answers; +} + +function selectQuestionOption(input: { + questions: readonly Question[]; + selections: QuestionSelections; + otherTexts: QuestionOtherTexts; + questionIndex: number; + optionIndex: number; +}): { + selections: QuestionSelections; + otherTexts: QuestionOtherTexts; +} { + const question = input.questions[input.questionIndex]; + if (!question || !question.options[input.optionIndex]) { + return { + selections: input.selections, + otherTexts: input.otherTexts, + }; + } + + const selections = cloneSelections(input.selections); + const otherTexts = { ...input.otherTexts }; + const current = selections[input.questionIndex] ?? new Set(); + const next = new Set(current); + + if (question.multiSelect) { + if (next.has(input.optionIndex)) { + next.delete(input.optionIndex); + } else { + next.add(input.optionIndex); + } + } else if (next.has(input.optionIndex)) { + next.clear(); + } else { + next.clear(); + next.add(input.optionIndex); + } + + selections[input.questionIndex] = next; + delete otherTexts[input.questionIndex]; + + return { selections, otherTexts }; +} interface QuestionFormCardProps { permission: PendingPermission; onRespond: (response: AgentPermissionResponse) => void; isResponding: boolean; -} - -export interface QuestionFormCardHandle { - selectOption(index: number): boolean; + shortcutActive: boolean; } const IS_WEB = Platform.OS === "web"; -export const QuestionFormCard = forwardRef( - function QuestionFormCard({ permission, onRespond, isResponding }, ref) { - const { theme } = useUnistyles(); - const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; - const questions = parseQuestions(permission.request.input); - const firstOptionShortcut = useShortcutKeys("agent-prompt-select-1"); - const secondOptionShortcut = useShortcutKeys("agent-prompt-select-2"); - const thirdOptionShortcut = useShortcutKeys("agent-prompt-select-3"); - const optionShortcuts = [ - firstOptionShortcut, - secondOptionShortcut, - thirdOptionShortcut, - ] as const; +export function QuestionFormCard({ + permission, + onRespond, + isResponding, + shortcutActive, +}: QuestionFormCardProps) { + const { theme } = useUnistyles(); + const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const questions = parseQuestions(permission.request.input); - const [selections, setSelections] = useState({}); - const [otherTexts, setOtherTexts] = useState({}); - const [respondingAction, setRespondingAction] = useState<"submit" | "dismiss" | null>(null); + const [selections, setSelections] = useState({}); + const [otherTexts, setOtherTexts] = useState({}); + const [respondingAction, setRespondingAction] = useState<"submit" | "dismiss" | null>(null); - const applyAnswerState = useCallback( - (next: { selections: QuestionSelections; otherTexts: QuestionOtherTexts }) => { - setSelections(next.selections); - setOtherTexts(next.otherTexts); - }, - [], - ); + const submitResponses = useCallback( + (input: { selections: QuestionSelections; otherTexts: QuestionOtherTexts }) => { + if (!questions) { + return; + } - const submitResponses = useCallback( - (nextSelections: QuestionSelections, nextOtherTexts: QuestionOtherTexts) => { - if (!questions) { - return; - } - setRespondingAction("submit"); - const answers = buildQuestionAnswers({ - questions, - selections: nextSelections, - otherTexts: nextOtherTexts, - }); - - onRespond({ - behavior: "allow", - updatedInput: { ...permission.request.input, answers }, - }); - }, - [onRespond, permission.request.input, questions], - ); - - const toggleOption = useCallback( - (questionIndex: number, optionIndex: number) => { - if (!questions) { - return; - } - applyAnswerState( - toggleQuestionOption({ - questions, - selections, - otherTexts, - questionIndex, - optionIndex, - }), - ); - }, - [applyAnswerState, otherTexts, questions, selections], - ); - - const setOtherText = useCallback( - (questionIndex: number, text: string) => { - applyAnswerState( - setQuestionOtherText({ - selections, - otherTexts, - questionIndex, - text, - }), - ); - }, - [applyAnswerState, otherTexts, selections], - ); - - const handleSubmit = useCallback(() => { - submitResponses(selections, otherTexts); - }, [otherTexts, selections, submitResponses]); - - const handleDeny = useCallback(() => { - setRespondingAction("dismiss"); + setRespondingAction("submit"); onRespond({ - behavior: "deny", - message: "Dismissed by user", - }); - }, [onRespond]); - - useImperativeHandle( - ref, - () => ({ - selectOption(index: number) { - if (isResponding || !questions) { - return false; - } - - const result = applyQuestionShortcutSelection({ + behavior: "allow", + updatedInput: { + ...permission.request.input, + answers: buildQuestionAnswers({ questions, - selections, - otherTexts, - optionIndex: index - 1, - }); - if (!result.applied) { - return false; - } - - applyAnswerState({ - selections: result.selections, - otherTexts: result.otherTexts, - }); - if (result.shouldAutoSubmit) { - submitResponses(result.selections, result.otherTexts); - } - return true; + selections: input.selections, + otherTexts: input.otherTexts, + }), }, - }), - [applyAnswerState, isResponding, otherTexts, questions, selections, submitResponses], - ); + }); + }, + [onRespond, permission.request.input, questions], + ); - if (!questions) { - return null; + const toggleOption = useCallback( + (input: { questionIndex: number; optionIndex: number }) => { + if (!questions) { + return; + } + + const next = selectQuestionOption({ + questions, + selections, + otherTexts, + questionIndex: input.questionIndex, + optionIndex: input.optionIndex, + }); + setSelections(next.selections); + setOtherTexts(next.otherTexts); + }, + [otherTexts, questions, selections], + ); + + const setOtherText = useCallback((input: { questionIndex: number; text: string }) => { + setOtherTexts((previous) => ({ ...previous, [input.questionIndex]: input.text })); + if (input.text.length > 0) { + setSelections((previous) => { + if (!previous[input.questionIndex] || previous[input.questionIndex]?.size === 0) { + return previous; + } + return { ...previous, [input.questionIndex]: new Set() }; + }); } + }, []); - const activeShortcutQuestionIndex = findFirstUnansweredQuestionIndex({ - questions, - selections, - otherTexts, - }); - const allAnswered = areAllQuestionsAnswered({ - questions, - selections, - otherTexts, + const handleSubmit = useCallback(() => { + submitResponses({ selections, otherTexts }); + }, [otherTexts, selections, submitResponses]); + + const handleDeny = useCallback(() => { + setRespondingAction("dismiss"); + onRespond({ + behavior: "deny", + message: "Dismissed by user", }); + }, [onRespond]); - return ( - - {questions.map((q, qIndex) => { - const selected = selections[qIndex] ?? new Set(); - const otherText = otherTexts[qIndex] ?? ""; + const handlePromptSelection = useCallback( + (action: { id: string; index?: number }): boolean => { + if (action.id !== "agent.prompt.select" || isResponding || !questions) { + return false; + } - return ( - - - - {q.question} - - - - - {q.options.map((opt, optIndex) => { - const isSelected = selected.has(optIndex); - const shortcutKeys = - activeShortcutQuestionIndex === qIndex - ? optionShortcuts[optIndex] ?? null - : null; - return ( - [ - styles.optionItem, - (hovered || isSelected) && { - backgroundColor: theme.colors.surface2, - }, - pressed && styles.optionItemPressed, - ]} - onPress={() => toggleOption(qIndex, optIndex)} - disabled={isResponding} - > - - - - {opt.label} + const questionIndex = findFirstUnansweredQuestionIndex({ + questions, + selections, + otherTexts, + }); + if (questionIndex === null) { + return false; + } + + const optionIndex = (action.index ?? 0) - 1; + const question = questions[questionIndex]; + if (!question?.options[optionIndex]) { + return false; + } + + const next = selectQuestionOption({ + questions, + selections, + otherTexts, + questionIndex, + optionIndex, + }); + setSelections(next.selections); + setOtherTexts(next.otherTexts); + + const shouldAutoSubmit = + areAllQuestionsAnswered({ + questions, + selections: next.selections, + otherTexts: next.otherTexts, + }) && questions.every((candidate) => candidate.multiSelect !== true); + + if (shouldAutoSubmit) { + submitResponses(next); + } + + return true; + }, + [isResponding, otherTexts, questions, selections, submitResponses], + ); + + useKeyboardActionHandler({ + handlerId: `agent-prompt-question:${permission.key}`, + actions: ["agent.prompt.select"], + enabled: shortcutActive && !isResponding && questions !== null, + priority: 150, + handle: handlePromptSelection, + }); + + if (!questions) { + return null; + } + + const allAnswered = areAllQuestionsAnswered({ + questions, + selections, + otherTexts, + }); + + return ( + + {questions.map((question, questionIndex) => { + const selected = selections[questionIndex] ?? new Set(); + const otherText = otherTexts[questionIndex] ?? ""; + + return ( + + + + {question.question} + + + + + {question.options.map((option, optionIndex) => { + const isSelected = selected.has(optionIndex); + return ( + [ + styles.optionItem, + (hovered || isSelected) && { + backgroundColor: theme.colors.surface2, + }, + pressed && styles.optionItemPressed, + ]} + onPress={() => + toggleOption({ + questionIndex, + optionIndex, + }) + } + disabled={isResponding} + > + + + + {option.label} + + {option.description ? ( + + {option.description} - {opt.description ? ( - - {opt.description} - - ) : null} - - {shortcutKeys ? ( - - ) : null} - {isSelected ? ( - - - ) : null} - - ); - })} - - 0 ? theme.colors.borderAccent : theme.colors.border, - color: theme.colors.foreground, - backgroundColor: theme.colors.surface2, - }, - // @ts-expect-error - outlineStyle is web-only - IS_WEB && { - outlineStyle: "none", - outlineWidth: 0, - outlineColor: "transparent", - }, - ]} - placeholder="Other..." - placeholderTextColor={theme.colors.foregroundMuted} - value={otherText} - onChangeText={(text) => setOtherText(qIndex, text)} - editable={!isResponding} - /> + {isSelected ? ( + + + + ) : null} + + + ); + })} - ); - })} + 0 ? theme.colors.borderAccent : theme.colors.border, + color: theme.colors.foreground, + backgroundColor: theme.colors.surface2, + }, + // @ts-expect-error - outlineStyle is web-only + IS_WEB && { + outlineStyle: "none", + outlineWidth: 0, + outlineColor: "transparent", + }, + ]} + placeholder="Other..." + placeholderTextColor={theme.colors.foregroundMuted} + value={otherText} + onChangeText={(text) => + setOtherText({ + questionIndex, + text, + }) + } + editable={!isResponding} + /> + + ); + })} - - [ + + [ + styles.actionButton, + { + backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1, + borderColor: theme.colors.borderAccent, + }, + pressed && styles.optionItemPressed, + ]} + onPress={handleDeny} + disabled={isResponding} + > + {respondingAction === "dismiss" ? ( + + ) : ( + + + + Dismiss + + + )} + + + { + const disabled = !allAnswered || isResponding; + return [ styles.actionButton, { - backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1, - borderColor: theme.colors.borderAccent, + backgroundColor: + hovered && !disabled ? theme.colors.surface2 : theme.colors.surface1, + borderColor: disabled ? theme.colors.border : theme.colors.borderAccent, + opacity: disabled ? 0.5 : 1, }, - pressed && styles.optionItemPressed, - ]} - onPress={handleDeny} - disabled={isResponding} - > - {respondingAction === "dismiss" ? ( - - ) : ( - - - - Dismiss - - - )} - - - { - const disabled = !allAnswered || isResponding; - return [ - styles.actionButton, - { - backgroundColor: - hovered && !disabled ? theme.colors.surface2 : theme.colors.surface1, - borderColor: disabled ? theme.colors.border : theme.colors.borderAccent, - opacity: disabled ? 0.5 : 1, - }, - pressed && !disabled ? styles.optionItemPressed : null, - ]; - }} - onPress={handleSubmit} - disabled={!allAnswered || isResponding} - > - {respondingAction === "submit" ? ( - - ) : ( - - - - Submit - - - )} - - + pressed && !disabled ? styles.optionItemPressed : null, + ]; + }} + onPress={handleSubmit} + disabled={!allAnswered || isResponding} + > + {respondingAction === "submit" ? ( + + ) : ( + + + + Submit + + + )} + - ); - }, -); + + ); +} const styles = StyleSheet.create((theme) => ({ container: { @@ -377,7 +571,6 @@ const styles = StyleSheet.create((theme) => ({ flex: 1, gap: 2, }, - optionShortcut: {}, optionLabel: { fontSize: theme.fontSize.sm, }, diff --git a/packages/app/src/components/question-form-card.utils.test.ts b/packages/app/src/components/question-form-card.utils.test.ts deleted file mode 100644 index 1e05fc2002..0000000000 --- a/packages/app/src/components/question-form-card.utils.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - applyQuestionShortcutSelection, - areAllQuestionsAnswered, - buildQuestionAnswers, - findFirstUnansweredQuestionIndex, - parseQuestions, - type Question, - type QuestionOtherTexts, - type QuestionSelections, -} from "./question-form-card.utils"; - -const singleChoiceQuestions: Question[] = [ - { - header: "deploy_target", - question: "Where should this go?", - multiSelect: false, - options: [{ label: "Production" }, { label: "Staging" }, { label: "Preview" }], - }, - { - header: "rollout", - question: "How should I roll this out?", - multiSelect: false, - options: [{ label: "Gradual" }, { label: "Immediate" }], - }, -]; - -function selections( - input: Array<[number, number[]]> = [], -): QuestionSelections { - return Object.fromEntries( - input.map(([questionIndex, optionIndices]) => [questionIndex, new Set(optionIndices)]), - ) as QuestionSelections; -} - -function otherTexts(input: Array<[number, string]> = []): QuestionOtherTexts { - return Object.fromEntries(input) as QuestionOtherTexts; -} - -describe("question-form-card utils", () => { - it("parses structured question input", () => { - expect( - parseQuestions({ - questions: [ - { - header: "approval", - question: "Proceed?", - options: [{ label: "Yes", description: "Ship it" }, { label: "No" }], - }, - ], - }), - ).toEqual([ - { - header: "approval", - question: "Proceed?", - multiSelect: false, - options: [{ label: "Yes", description: "Ship it" }, { label: "No" }], - }, - ]); - }); - - it("selects the first unanswered question when using shortcut options", () => { - const result = applyQuestionShortcutSelection({ - questions: singleChoiceQuestions, - selections: selections([[0, [1]]]), - otherTexts: {}, - optionIndex: 0, - }); - - expect(result.applied).toBe(true); - expect(result.questionIndex).toBe(1); - expect(result.shouldAutoSubmit).toBe(true); - expect(result.selections[1]).toEqual(new Set([0])); - }); - - it("does not auto-submit multi-select questionnaires", () => { - const result = applyQuestionShortcutSelection({ - questions: [ - { - header: "files", - question: "Which files should I include?", - multiSelect: true, - options: [{ label: "src" }, { label: "docs" }], - }, - ], - selections: {}, - otherTexts: {}, - optionIndex: 1, - }); - - expect(result.applied).toBe(true); - expect(result.shouldAutoSubmit).toBe(false); - expect(result.selections[0]).toEqual(new Set([1])); - }); - - it("tracks unanswered questions from selections and freeform answers", () => { - expect( - findFirstUnansweredQuestionIndex({ - questions: singleChoiceQuestions, - selections: selections([[0, [2]]]), - otherTexts: otherTexts([[1, "later"]]), - }), - ).toBeNull(); - - expect( - areAllQuestionsAnswered({ - questions: singleChoiceQuestions, - selections: selections([[0, [2]]]), - otherTexts: otherTexts([[1, "later"]]), - }), - ).toBe(true); - }); - - it("builds answers from selected options and trimmed other text", () => { - expect( - buildQuestionAnswers({ - questions: singleChoiceQuestions, - selections: selections([[0, [0]], [1, [1]]]), - otherTexts: otherTexts([[1, " manual rollout "]]), - }), - ).toEqual({ - deploy_target: "Production", - rollout: "manual rollout", - }); - }); -}); diff --git a/packages/app/src/components/question-form-card.utils.ts b/packages/app/src/components/question-form-card.utils.ts deleted file mode 100644 index 21599dc813..0000000000 --- a/packages/app/src/components/question-form-card.utils.ts +++ /dev/null @@ -1,266 +0,0 @@ -export interface QuestionOption { - label: string; - description?: string; -} - -export interface Question { - question: string; - header: string; - options: QuestionOption[]; - multiSelect: boolean; -} - -export type QuestionSelections = Record>; -export type QuestionOtherTexts = Record; - -type QuestionAnswerState = { - questions: readonly Question[]; - selections: QuestionSelections; - otherTexts: QuestionOtherTexts; -}; - -function cloneSelections(input: QuestionSelections): QuestionSelections { - const next: QuestionSelections = {}; - for (const [key, value] of Object.entries(input)) { - next[Number(key)] = new Set(value); - } - return next; -} - -function cloneOtherTexts(input: QuestionOtherTexts): QuestionOtherTexts { - return { ...input }; -} - -function isQuestionAnswered(input: { - selections: QuestionSelections; - otherTexts: QuestionOtherTexts; - questionIndex: number; -}): boolean { - const selected = input.selections[input.questionIndex]; - const otherText = input.otherTexts[input.questionIndex]?.trim(); - return (selected && selected.size > 0) || Boolean(otherText && otherText.length > 0); -} - -export function parseQuestions(input: unknown): Question[] | null { - if ( - typeof input !== "object" || - input === null || - !("questions" in input) || - !Array.isArray((input as Record).questions) - ) { - return null; - } - - const raw = (input as Record).questions as unknown[]; - const questions: Question[] = []; - - for (const item of raw) { - if (typeof item !== "object" || item === null) return null; - const question = item as Record; - if (typeof question.question !== "string" || typeof question.header !== "string") { - return null; - } - if (!Array.isArray(question.options)) { - return null; - } - - const options: QuestionOption[] = []; - for (const option of question.options as unknown[]) { - if (typeof option !== "object" || option === null) return null; - const candidate = option as Record; - if (typeof candidate.label !== "string") return null; - options.push({ - label: candidate.label, - description: typeof candidate.description === "string" ? candidate.description : undefined, - }); - } - - questions.push({ - question: question.question, - header: question.header, - options, - multiSelect: question.multiSelect === true, - }); - } - - return questions.length > 0 ? questions : null; -} - -export function areAllQuestionsAnswered(input: QuestionAnswerState): boolean { - return input.questions.every((_, questionIndex) => - isQuestionAnswered({ - selections: input.selections, - otherTexts: input.otherTexts, - questionIndex, - }), - ); -} - -export function findFirstUnansweredQuestionIndex(input: QuestionAnswerState): number | null { - for (let questionIndex = 0; questionIndex < input.questions.length; questionIndex += 1) { - if ( - !isQuestionAnswered({ - selections: input.selections, - otherTexts: input.otherTexts, - questionIndex, - }) - ) { - return questionIndex; - } - } - return null; -} - -export function toggleQuestionOption(input: { - questions: readonly Question[]; - selections: QuestionSelections; - otherTexts: QuestionOtherTexts; - questionIndex: number; - optionIndex: number; -}): { - selections: QuestionSelections; - otherTexts: QuestionOtherTexts; -} { - const question = input.questions[input.questionIndex]; - if (!question || !question.options[input.optionIndex]) { - return { - selections: input.selections, - otherTexts: input.otherTexts, - }; - } - - const selections = cloneSelections(input.selections); - const otherTexts = cloneOtherTexts(input.otherTexts); - const current = selections[input.questionIndex] ?? new Set(); - const next = new Set(current); - - if (question.multiSelect) { - if (next.has(input.optionIndex)) { - next.delete(input.optionIndex); - } else { - next.add(input.optionIndex); - } - } else if (next.has(input.optionIndex)) { - next.clear(); - } else { - next.clear(); - next.add(input.optionIndex); - } - - selections[input.questionIndex] = next; - delete otherTexts[input.questionIndex]; - - return { selections, otherTexts }; -} - -export function setQuestionOtherText(input: { - selections: QuestionSelections; - otherTexts: QuestionOtherTexts; - questionIndex: number; - text: string; -}): { - selections: QuestionSelections; - otherTexts: QuestionOtherTexts; -} { - const selections = cloneSelections(input.selections); - const otherTexts = cloneOtherTexts(input.otherTexts); - - otherTexts[input.questionIndex] = input.text; - if (input.text.length > 0) { - selections[input.questionIndex] = new Set(); - } - - return { selections, otherTexts }; -} - -export function buildQuestionAnswers(input: QuestionAnswerState): Record { - const answers: Record = {}; - - for (let questionIndex = 0; questionIndex < input.questions.length; questionIndex += 1) { - const question = input.questions[questionIndex]; - if (!question) { - continue; - } - - const selected = input.selections[questionIndex]; - const otherText = input.otherTexts[questionIndex]?.trim(); - - if (otherText && otherText.length > 0) { - answers[question.header] = otherText; - continue; - } - - if (!selected || selected.size === 0) { - continue; - } - - const labels = Array.from(selected) - .map((optionIndex) => question.options[optionIndex]?.label) - .filter((label): label is string => typeof label === "string"); - if (labels.length > 0) { - answers[question.header] = labels.join(", "); - } - } - - return answers; -} - -export function applyQuestionShortcutSelection(input: { - questions: readonly Question[]; - selections: QuestionSelections; - otherTexts: QuestionOtherTexts; - optionIndex: number; -}): { - applied: boolean; - selections: QuestionSelections; - otherTexts: QuestionOtherTexts; - shouldAutoSubmit: boolean; - questionIndex: number | null; -} { - const questionIndex = findFirstUnansweredQuestionIndex({ - questions: input.questions, - selections: input.selections, - otherTexts: input.otherTexts, - }); - if (questionIndex === null) { - return { - applied: false, - selections: input.selections, - otherTexts: input.otherTexts, - shouldAutoSubmit: false, - questionIndex: null, - }; - } - - const question = input.questions[questionIndex]; - if (!question || !question.options[input.optionIndex]) { - return { - applied: false, - selections: input.selections, - otherTexts: input.otherTexts, - shouldAutoSubmit: false, - questionIndex, - }; - } - - const next = toggleQuestionOption({ - questions: input.questions, - selections: input.selections, - otherTexts: input.otherTexts, - questionIndex, - optionIndex: input.optionIndex, - }); - - return { - applied: true, - selections: next.selections, - otherTexts: next.otherTexts, - shouldAutoSubmit: - areAllQuestionsAnswered({ - questions: input.questions, - selections: next.selections, - otherTexts: next.otherTexts, - }) && input.questions.every((candidate) => candidate.multiSelect !== true), - questionIndex, - }; -} diff --git a/packages/app/src/hooks/use-keyboard-shortcuts.ts b/packages/app/src/hooks/use-keyboard-shortcuts.ts index d79d954204..7cedcad08f 100644 --- a/packages/app/src/hooks/use-keyboard-shortcuts.ts +++ b/packages/app/src/hooks/use-keyboard-shortcuts.ts @@ -32,7 +32,6 @@ export function useKeyboardShortcuts({ isMobile, toggleAgentList, selectedAgentId, - agentAwaitingInput, toggleFileExplorer, toggleBothSidebars, toggleFocusMode, @@ -41,7 +40,6 @@ export function useKeyboardShortcuts({ isMobile: boolean; toggleAgentList: () => void; selectedAgentId?: string; - agentAwaitingInput: boolean; toggleFileExplorer?: () => void; toggleBothSidebars?: () => void; toggleFocusMode?: () => void; @@ -340,7 +338,6 @@ export function useKeyboardShortcuts({ pathname, toggleFileExplorer, }), - agentAwaitingInput, }, chordState: chordStateRef.current, onChordReset: () => { @@ -420,7 +417,6 @@ export function useKeyboardShortcuts({ openProjectPickerAction, pathname, resetModifiers, - agentAwaitingInput, selectedAgentId, toggleAgentList, toggleFileExplorer, diff --git a/packages/app/src/keyboard/keyboard-shortcuts.test.ts b/packages/app/src/keyboard/keyboard-shortcuts.test.ts index d8c534ee65..25fca321ad 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.test.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.test.ts @@ -30,7 +30,6 @@ function shortcutContext( focusScope: "other", commandCenterOpen: false, hasSelectedAgent: true, - agentAwaitingInput: false, ...overrides, }; } @@ -297,9 +296,9 @@ describe("keyboard-shortcuts", () => { stopPropagation: false, }, { - name: "routes number keys to prompt selection when an agent is awaiting input", + name: "routes number keys to prompt selection outside editable scopes", event: { key: "2", code: "Digit2" }, - context: { agentAwaitingInput: true, focusScope: "other" }, + context: { focusScope: "other" }, action: "agent.prompt.select", payload: { index: 2 }, }, @@ -373,15 +372,10 @@ describe("keyboard-shortcuts", () => { event: { key: " ", code: "Space" }, context: { focusScope: "message-input" }, }, - { - name: "does not route prompt selection when no agent is awaiting input", - event: { key: "1", code: "Digit1" }, - context: { focusScope: "other", agentAwaitingInput: false }, - }, { name: "does not route prompt selection while typing in the message input", event: { key: "1", code: "Digit1" }, - context: { focusScope: "message-input", agentAwaitingInput: true }, + context: { focusScope: "message-input" }, }, ]; diff --git a/packages/app/src/keyboard/keyboard-shortcuts.ts b/packages/app/src/keyboard/keyboard-shortcuts.ts index 6ece0ce8ba..dcfe8a5a2c 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.ts @@ -17,7 +17,6 @@ export type KeyboardShortcutContext = { focusScope: KeyboardFocusScope; commandCenterOpen: boolean; hasSelectedAgent: boolean; - agentAwaitingInput: boolean; }; export type KeyboardShortcutMatch = { @@ -66,8 +65,6 @@ interface ShortcutWhen { commandCenter?: false; /** true = requires a selected agent */ hasSelectedAgent?: true; - /** true = requires a visible prompt awaiting agent input */ - agentAwaitingInput?: true; /** Exact focus scope match */ focusScope?: KeyboardFocusScope; } @@ -822,7 +819,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ action: "agent.prompt.select", combo: "1", repeat: false, - when: { agentAwaitingInput: true, focusScope: "other", commandCenter: false }, + when: { focusScope: "other", commandCenter: false }, payload: { type: "index" }, help: { id: "agent-prompt-select-1", @@ -836,7 +833,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ action: "agent.prompt.select", combo: "2", repeat: false, - when: { agentAwaitingInput: true, focusScope: "other", commandCenter: false }, + when: { focusScope: "other", commandCenter: false }, payload: { type: "index" }, help: { id: "agent-prompt-select-2", @@ -850,7 +847,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ action: "agent.prompt.select", combo: "3", repeat: false, - when: { agentAwaitingInput: true, focusScope: "other", commandCenter: false }, + when: { focusScope: "other", commandCenter: false }, payload: { type: "index" }, help: { id: "agent-prompt-select-3", @@ -1009,7 +1006,6 @@ function matchesWhen(when: ShortcutWhen | undefined, context: KeyboardShortcutCo if (when.terminal === false && context.focusScope === "terminal") return false; if (when.commandCenter === false && context.commandCenterOpen) return false; if (when.hasSelectedAgent === true && !context.hasSelectedAgent) return false; - if (when.agentAwaitingInput === true && !context.agentAwaitingInput) return false; if (when.focusScope !== undefined && context.focusScope !== when.focusScope) return false; return true; } diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 4bec100277..fc44380cae 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -22,11 +22,9 @@ import { useArchiveAgent } from "@/hooks/use-archive-agent"; import { useDelayedHistoryRefreshToast } from "@/hooks/use-delayed-history-refresh-toast"; import { useAgentInputDraft } from "@/hooks/use-agent-input-draft"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; -import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import { useStableEvent } from "@/hooks/use-stable-event"; import { usePaneContext } from "@/panels/pane-context"; import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry"; -import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; import { useHostRuntimeClient, useHostRuntimeConnectionStatus, @@ -325,25 +323,6 @@ function AgentPanelBody({ isScreenFocused: isPaneFocused, }); - const handlePromptShortcut = useCallback( - (action: KeyboardActionDefinition): boolean => { - if (!isPaneFocused || action.id !== "agent.prompt.select") { - return false; - } - return streamViewRef.current?.selectPendingPermissionOption(action.index) ?? false; - }, - [isPaneFocused], - ); - - useKeyboardActionHandler({ - handlerId: `agent-prompt-actions:${serverId}:${agentId ?? "__pending__"}`, - actions: ["agent.prompt.select"], - enabled: Boolean(agentId), - priority: 150, - isActive: () => isPaneFocused, - handle: handlePromptShortcut, - }); - useEffect(() => { clearOnAgentBlurRef.current = attentionController.clearOnAgentBlur; }, [attentionController.clearOnAgentBlur]); @@ -818,6 +797,7 @@ function AgentPanelBody({ agent={effectiveAgent} streamItems={shouldUseOptimisticStream ? mergedStreamItems : streamItems} pendingPermissions={pendingPermissions} + isPaneFocused={isPaneFocused} routeBottomAnchorRequest={routeBottomAnchorRequest} isAuthoritativeHistoryReady={hasAppliedAuthoritativeHistory} onOpenWorkspaceFile={onOpenWorkspaceFile}