mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 20:22:45 +00:00
Simplify prompt shortcut handling
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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<string, PendingPermission>;
|
||||
isPaneFocused?: boolean;
|
||||
routeBottomAnchorRequest?: BottomAnchorRouteRequest | null;
|
||||
isAuthoritativeHistoryReady?: boolean;
|
||||
onOpenWorkspaceFile?: (input: { filePath: string }) => void;
|
||||
@@ -105,6 +100,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
agent,
|
||||
streamItems,
|
||||
pendingPermissions,
|
||||
isPaneFocused = false,
|
||||
routeBottomAnchorRequest = null,
|
||||
isAuthoritativeHistoryReady = true,
|
||||
onOpenWorkspaceFile,
|
||||
@@ -112,7 +108,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
ref,
|
||||
) {
|
||||
const viewportRef = useRef<StreamViewportHandle | null>(null);
|
||||
const permissionCardRefs = useRef(new Map<string, PermissionRequestCardHandle>());
|
||||
const { theme } = useUnistyles();
|
||||
const router = useRouter();
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
@@ -238,15 +233,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
prepareForViewportChange() {
|
||||
viewportRef.current?.prepareForViewportChange();
|
||||
},
|
||||
selectPendingPermissionOption(index: number) {
|
||||
const topPermission = pendingPermissionItems[0];
|
||||
if (!topPermission) {
|
||||
return false;
|
||||
}
|
||||
return permissionCardRefs.current.get(topPermission.key)?.selectOption(index) ?? false;
|
||||
},
|
||||
}),
|
||||
[pendingPermissionItems],
|
||||
[],
|
||||
);
|
||||
|
||||
function scrollToBottom() {
|
||||
@@ -514,18 +502,12 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
const pendingPermissionsNode =
|
||||
pendingPermissionItems.length > 0 ? (
|
||||
<View style={stylesheet.permissionsContainer}>
|
||||
{pendingPermissionItems.map((permission) => (
|
||||
{pendingPermissionItems.map((permission, index) => (
|
||||
<PermissionRequestCard
|
||||
key={permission.key}
|
||||
ref={(handle) => {
|
||||
if (handle) {
|
||||
permissionCardRefs.current.set(permission.key, handle);
|
||||
return;
|
||||
}
|
||||
permissionCardRefs.current.delete(permission.key);
|
||||
}}
|
||||
permission={permission}
|
||||
client={client}
|
||||
shortcutActive={isPaneFocused && index === 0}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
@@ -550,7 +532,14 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
workingIndicator: workingIndicatorNode,
|
||||
},
|
||||
};
|
||||
}, [baseRenderModel, client, getGapBetween, pendingPermissionItems, showWorkingIndicator]);
|
||||
}, [
|
||||
baseRenderModel,
|
||||
client,
|
||||
getGapBetween,
|
||||
isPaneFocused,
|
||||
pendingPermissionItems,
|
||||
showWorkingIndicator,
|
||||
]);
|
||||
|
||||
const listEmptyComponent = useMemo(() => {
|
||||
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<QuestionFormCardHandle | null>(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 (
|
||||
<QuestionFormCard
|
||||
ref={questionFormRef}
|
||||
permission={permission}
|
||||
onRespond={handleResponse}
|
||||
isResponding={isResponding}
|
||||
shortcutActive={shortcutActive}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1033,9 +1023,6 @@ const PermissionRequestCard = forwardRef<
|
||||
<Text style={[permissionStyles.optionText, { color: theme.colors.foregroundMuted }]}>
|
||||
Deny
|
||||
</Text>
|
||||
{firstOptionShortcut ? (
|
||||
<Shortcut chord={firstOptionShortcut} style={permissionStyles.optionShortcut} />
|
||||
) : null}
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
@@ -1061,16 +1048,13 @@ const PermissionRequestCard = forwardRef<
|
||||
<Text style={[permissionStyles.optionText, { color: theme.colors.foreground }]}>
|
||||
Accept
|
||||
</Text>
|
||||
{secondOptionShortcut ? (
|
||||
<Shortcut chord={secondOptionShortcut} style={permissionStyles.optionShortcut} />
|
||||
) : null}
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -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<number, Set<number>>;
|
||||
type QuestionOtherTexts = Record<number, string>;
|
||||
|
||||
function parseQuestions(input: unknown): Question[] | null {
|
||||
if (
|
||||
typeof input !== "object" ||
|
||||
input === null ||
|
||||
!("questions" in input) ||
|
||||
!Array.isArray((input as Record<string, unknown>).questions)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const raw = (input as Record<string, unknown>).questions as unknown[];
|
||||
const questions: Question[] = [];
|
||||
|
||||
for (const item of raw) {
|
||||
if (typeof item !== "object" || item === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const question = item as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, string> {
|
||||
const answers: Record<string, string> = {};
|
||||
|
||||
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<number>();
|
||||
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<QuestionFormCardHandle, QuestionFormCardProps>(
|
||||
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<QuestionSelections>({});
|
||||
const [otherTexts, setOtherTexts] = useState<QuestionOtherTexts>({});
|
||||
const [respondingAction, setRespondingAction] = useState<"submit" | "dismiss" | null>(null);
|
||||
const [selections, setSelections] = useState<QuestionSelections>({});
|
||||
const [otherTexts, setOtherTexts] = useState<QuestionOtherTexts>({});
|
||||
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<number>() };
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
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 (
|
||||
<View
|
||||
style={[
|
||||
styles.container,
|
||||
{
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{questions.map((q, qIndex) => {
|
||||
const selected = selections[qIndex] ?? new Set<number>();
|
||||
const otherText = otherTexts[qIndex] ?? "";
|
||||
const handlePromptSelection = useCallback(
|
||||
(action: { id: string; index?: number }): boolean => {
|
||||
if (action.id !== "agent.prompt.select" || isResponding || !questions) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
<View key={qIndex} style={styles.questionBlock}>
|
||||
<View style={styles.questionHeader}>
|
||||
<Text style={[styles.questionText, { color: theme.colors.foreground }]}>
|
||||
{q.question}
|
||||
</Text>
|
||||
<CircleHelp size={14} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
<View style={styles.optionsWrap}>
|
||||
{q.options.map((opt, optIndex) => {
|
||||
const isSelected = selected.has(optIndex);
|
||||
const shortcutKeys =
|
||||
activeShortcutQuestionIndex === qIndex
|
||||
? optionShortcuts[optIndex] ?? null
|
||||
: null;
|
||||
return (
|
||||
<Pressable
|
||||
key={optIndex}
|
||||
style={({ pressed, hovered = false }) => [
|
||||
styles.optionItem,
|
||||
(hovered || isSelected) && {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
pressed && styles.optionItemPressed,
|
||||
]}
|
||||
onPress={() => toggleOption(qIndex, optIndex)}
|
||||
disabled={isResponding}
|
||||
>
|
||||
<View style={styles.optionItemContent}>
|
||||
<View style={styles.optionTextBlock}>
|
||||
<Text style={[styles.optionLabel, { color: theme.colors.foreground }]}>
|
||||
{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 (
|
||||
<View
|
||||
style={[
|
||||
styles.container,
|
||||
{
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{questions.map((question, questionIndex) => {
|
||||
const selected = selections[questionIndex] ?? new Set<number>();
|
||||
const otherText = otherTexts[questionIndex] ?? "";
|
||||
|
||||
return (
|
||||
<View key={questionIndex} style={styles.questionBlock}>
|
||||
<View style={styles.questionHeader}>
|
||||
<Text style={[styles.questionText, { color: theme.colors.foreground }]}>
|
||||
{question.question}
|
||||
</Text>
|
||||
<CircleHelp size={14} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
<View style={styles.optionsWrap}>
|
||||
{question.options.map((option, optionIndex) => {
|
||||
const isSelected = selected.has(optionIndex);
|
||||
return (
|
||||
<Pressable
|
||||
key={optionIndex}
|
||||
style={({ pressed, hovered = false }) => [
|
||||
styles.optionItem,
|
||||
(hovered || isSelected) && {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
pressed && styles.optionItemPressed,
|
||||
]}
|
||||
onPress={() =>
|
||||
toggleOption({
|
||||
questionIndex,
|
||||
optionIndex,
|
||||
})
|
||||
}
|
||||
disabled={isResponding}
|
||||
>
|
||||
<View style={styles.optionItemContent}>
|
||||
<View style={styles.optionTextBlock}>
|
||||
<Text style={[styles.optionLabel, { color: theme.colors.foreground }]}>
|
||||
{option.label}
|
||||
</Text>
|
||||
{option.description ? (
|
||||
<Text
|
||||
style={[
|
||||
styles.optionDescription,
|
||||
{ color: theme.colors.foregroundMuted },
|
||||
]}
|
||||
>
|
||||
{option.description}
|
||||
</Text>
|
||||
{opt.description ? (
|
||||
<Text
|
||||
style={[
|
||||
styles.optionDescription,
|
||||
{ color: theme.colors.foregroundMuted },
|
||||
]}
|
||||
>
|
||||
{opt.description}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
{shortcutKeys ? (
|
||||
<Shortcut chord={shortcutKeys} style={styles.optionShortcut} />
|
||||
) : null}
|
||||
{isSelected ? (
|
||||
<View style={styles.optionCheckSlot}>
|
||||
<Check size={16} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.otherInput,
|
||||
{
|
||||
borderColor:
|
||||
otherText.length > 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 ? (
|
||||
<View style={styles.optionCheckSlot}>
|
||||
<Check size={16} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
<TextInput
|
||||
style={[
|
||||
styles.otherInput,
|
||||
{
|
||||
borderColor:
|
||||
otherText.length > 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}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
|
||||
<View style={[styles.actionsContainer, !isMobile && styles.actionsContainerDesktop]}>
|
||||
<Pressable
|
||||
style={({ pressed, hovered = false }) => [
|
||||
<View style={[styles.actionsContainer, !isMobile && styles.actionsContainerDesktop]}>
|
||||
<Pressable
|
||||
style={({ pressed, hovered = false }) => [
|
||||
styles.actionButton,
|
||||
{
|
||||
backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
},
|
||||
pressed && styles.optionItemPressed,
|
||||
]}
|
||||
onPress={handleDeny}
|
||||
disabled={isResponding}
|
||||
>
|
||||
{respondingAction === "dismiss" ? (
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<View style={styles.actionContent}>
|
||||
<X size={14} color={theme.colors.foregroundMuted} />
|
||||
<Text style={[styles.actionText, { color: theme.colors.foregroundMuted }]}>
|
||||
Dismiss
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
style={({ pressed, hovered = false }) => {
|
||||
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" ? (
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<View style={styles.actionContent}>
|
||||
<X size={14} color={theme.colors.foregroundMuted} />
|
||||
<Text style={[styles.actionText, { color: theme.colors.foregroundMuted }]}>
|
||||
Dismiss
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
style={({ pressed, hovered = false }) => {
|
||||
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" ? (
|
||||
<ActivityIndicator size="small" color={theme.colors.foreground} />
|
||||
) : (
|
||||
<View style={styles.actionContent}>
|
||||
<Check
|
||||
size={14}
|
||||
color={allAnswered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.actionText,
|
||||
{
|
||||
color: allAnswered ? theme.colors.foreground : theme.colors.foregroundMuted,
|
||||
},
|
||||
]}
|
||||
>
|
||||
Submit
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
pressed && !disabled ? styles.optionItemPressed : null,
|
||||
];
|
||||
}}
|
||||
onPress={handleSubmit}
|
||||
disabled={!allAnswered || isResponding}
|
||||
>
|
||||
{respondingAction === "submit" ? (
|
||||
<ActivityIndicator size="small" color={theme.colors.foreground} />
|
||||
) : (
|
||||
<View style={styles.actionContent}>
|
||||
<Check
|
||||
size={14}
|
||||
color={allAnswered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.actionText,
|
||||
{
|
||||
color: allAnswered ? theme.colors.foreground : theme.colors.foregroundMuted,
|
||||
},
|
||||
]}
|
||||
>
|
||||
Submit
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
);
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<number, Set<number>>;
|
||||
export type QuestionOtherTexts = Record<number, string>;
|
||||
|
||||
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<string, unknown>).questions)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const raw = (input as Record<string, unknown>).questions as unknown[];
|
||||
const questions: Question[] = [];
|
||||
|
||||
for (const item of raw) {
|
||||
if (typeof item !== "object" || item === null) return null;
|
||||
const question = item as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<number>();
|
||||
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<number>();
|
||||
}
|
||||
|
||||
return { selections, otherTexts };
|
||||
}
|
||||
|
||||
export function buildQuestionAnswers(input: QuestionAnswerState): Record<string, string> {
|
||||
const answers: Record<string, string> = {};
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user