diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index 0b6b591fe..ced095407 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -1052,8 +1052,11 @@ function PermissionRequestCard({ isPending: isResponding, } = permissionMutation; + const [respondingAction, setRespondingAction] = useState<"accept" | "deny" | null>(null); + useEffect(() => { resetPermissionMutation(); + setRespondingAction(null); }, [permission.request.id, resetPermissionMutation]); const handleResponse = useCallback( (response: AgentPermissionResponse) => { @@ -1086,7 +1089,7 @@ function PermissionRequestCard({ style={[ permissionStyles.container, { - backgroundColor: theme.colors.surface2, + backgroundColor: theme.colors.surface1, borderColor: theme.colors.border, }, ]} @@ -1120,19 +1123,9 @@ function PermissionRequestCard({ Proposed plan ) : null} - - - {planMarkdown} - - + + {planMarkdown} + ) : null} @@ -1144,7 +1137,7 @@ function PermissionRequestCard({ testID="permission-request-question" style={[ permissionStyles.question, - { color: theme.colors.mutedForeground }, + { color: theme.colors.foregroundMuted }, ]} > How would you like to proceed? @@ -1158,29 +1151,26 @@ function PermissionRequestCard({ > { - const hovered = Boolean((state as any).hovered); - const pressed = Boolean(state.pressed); - return [ - permissionStyles.optionButton, - { - backgroundColor: hovered - ? theme.colors.surface1 - : theme.colors.surface2, - borderColor: theme.colors.borderAccent, - }, - pressed ? permissionStyles.optionButtonPressed : null, - ]; - }} - onPress={() => + style={({ pressed, hovered = false }) => [ + permissionStyles.optionButton, + { + backgroundColor: hovered + ? theme.colors.surface2 + : theme.colors.surface1, + borderColor: theme.colors.borderAccent, + }, + pressed ? permissionStyles.optionButtonPressed : null, + ]} + onPress={() => { + setRespondingAction("deny"); handleResponse({ behavior: "deny", message: "Denied by user", - }) - } + }); + }} disabled={isResponding} > - {isResponding ? ( + {respondingAction === "deny" ? ( ) : ( @@ -1199,32 +1189,31 @@ function PermissionRequestCard({ { - const hovered = Boolean((state as any).hovered); - const pressed = Boolean(state.pressed); - return [ - permissionStyles.optionButton, - { - backgroundColor: hovered - ? theme.colors.surface1 - : theme.colors.surface2, - borderColor: theme.colors.primary, - }, - pressed ? permissionStyles.optionButtonPressed : null, - ]; + style={({ pressed, hovered = false }) => [ + permissionStyles.optionButton, + { + backgroundColor: hovered + ? theme.colors.surface2 + : theme.colors.surface1, + borderColor: theme.colors.borderAccent, + }, + pressed ? permissionStyles.optionButtonPressed : null, + ]} + onPress={() => { + setRespondingAction("accept"); + handleResponse({ behavior: "allow" }); }} - onPress={() => handleResponse({ behavior: "allow" })} disabled={isResponding} > - {isResponding ? ( - + {respondingAction === "accept" ? ( + ) : ( - + Accept @@ -1365,8 +1354,8 @@ const permissionStyles = StyleSheet.create((theme) => ({ gap: theme.spacing[2], }, title: { - fontSize: theme.fontSize.lg, - fontWeight: theme.fontWeight.semibold, + fontSize: theme.fontSize.base, + lineHeight: 22, }, description: { fontSize: theme.fontSize.sm, @@ -1378,13 +1367,6 @@ const permissionStyles = StyleSheet.create((theme) => ({ sectionTitle: { fontSize: theme.fontSize.xs, }, - contentCard: { - padding: theme.spacing[3], - borderRadius: theme.borderRadius.lg, - borderWidth: theme.borderWidth[1], - flexShrink: 1, - minWidth: 0, - }, question: { fontSize: theme.fontSize.sm, marginTop: theme.spacing[1], diff --git a/packages/app/src/components/question-form-card.tsx b/packages/app/src/components/question-form-card.tsx index 0253b934a..7a580906a 100644 --- a/packages/app/src/components/question-form-card.tsx +++ b/packages/app/src/components/question-form-card.tsx @@ -1,7 +1,14 @@ import { useState, useCallback } from "react"; -import { View, Text, TextInput, Pressable, ActivityIndicator } from "react-native"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { Check, X, Send } from "lucide-react-native"; +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 type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types"; @@ -59,18 +66,23 @@ interface QuestionFormCardProps { isResponding: boolean; } +const IS_WEB = Platform.OS === "web"; + export function QuestionFormCard({ permission, onRespond, isResponding, }: QuestionFormCardProps) { const { theme } = useUnistyles(); + const isMobile = + UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const questions = parseQuestions(permission.request.input); - // selections[questionIndex] = Set of selected option indices const [selections, setSelections] = useState>>({}); - // otherTexts[questionIndex] = custom "Other" text const [otherTexts, setOtherTexts] = useState>({}); + const [respondingAction, setRespondingAction] = useState< + "submit" | "dismiss" | null + >(null); const toggleOption = useCallback( (qIndex: number, optIndex: number, multiSelect: boolean) => { @@ -93,7 +105,6 @@ export function QuestionFormCard({ } return { ...prev, [qIndex]: next }; }); - // Clear "Other" text when an option is selected setOtherTexts((prev) => { if (!prev[qIndex]) return prev; const next = { ...prev }; @@ -106,7 +117,6 @@ export function QuestionFormCard({ const setOtherText = useCallback((qIndex: number, text: string) => { setOtherTexts((prev) => ({ ...prev, [qIndex]: text })); - // Clear option selections when typing "Other" if (text.length > 0) { setSelections((prev) => { if (!prev[qIndex] || prev[qIndex].size === 0) return prev; @@ -126,6 +136,7 @@ export function QuestionFormCard({ }); function handleSubmit() { + setRespondingAction("submit"); const answers: Record = {}; for (let i = 0; i < questions!.length; i++) { const q = questions![i]; @@ -147,6 +158,7 @@ export function QuestionFormCard({ } function handleDeny() { + setRespondingAction("dismiss"); onRespond({ behavior: "deny", message: "Dismissed by user", @@ -158,7 +170,7 @@ export function QuestionFormCard({ style={[ styles.container, { - backgroundColor: theme.colors.surface2, + backgroundColor: theme.colors.surface1, borderColor: theme.colors.border, }, ]} @@ -169,68 +181,62 @@ export function QuestionFormCard({ return ( - - {q.header} - - - {q.question} - + + + {q.question} + + + {q.options.map((opt, optIndex) => { const isSelected = selected.has(optIndex); return ( { - const hovered = Boolean((state as any).hovered); - return [ - styles.chip, - { - borderColor: isSelected - ? theme.colors.accent - : theme.colors.border, - backgroundColor: isSelected - ? `${theme.colors.accent}18` - : hovered - ? theme.colors.surface1 - : theme.colors.surface2, - }, - ]; - }} - onPress={() => toggleOption(qIndex, optIndex, q.multiSelect)} + style={({ pressed, hovered = false }) => [ + styles.optionItem, + (hovered || isSelected) && { + backgroundColor: theme.colors.surface2, + }, + pressed && styles.optionItemPressed, + ]} + onPress={() => + toggleOption(qIndex, optIndex, q.multiSelect) + } disabled={isResponding} > - - {q.multiSelect && isSelected ? ( - + + + + {opt.label} + + {opt.description ? ( + + {opt.description} + + ) : null} + + {isSelected ? ( + + + ) : null} - - {opt.label} - - {opt.description ? ( - - {opt.description} - - ) : null} ); })} @@ -240,11 +246,13 @@ export function QuestionFormCard({ styles.otherInput, { borderColor: otherText.length > 0 - ? theme.colors.accent + ? theme.colors.borderAccent : theme.colors.border, color: theme.colors.foreground, - backgroundColor: theme.colors.surface0, + 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} @@ -256,25 +264,31 @@ export function QuestionFormCard({ ); })} - + { - const hovered = Boolean((state as any).hovered); - return [ - styles.actionButton, - { - backgroundColor: hovered - ? theme.colors.surface1 - : theme.colors.surface2, - borderColor: theme.colors.border, - }, - ]; - }} + 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} > - {isResponding ? ( - + {respondingAction === "dismiss" ? ( + ) : ( @@ -291,34 +305,49 @@ export function QuestionFormCard({ { - const hovered = Boolean((state as any).hovered); + style={({ pressed, hovered = false }) => { const disabled = !allAnswered || isResponding; return [ styles.actionButton, { - backgroundColor: hovered && !disabled - ? theme.colors.surface1 - : theme.colors.surface2, + backgroundColor: + hovered && !disabled + ? theme.colors.surface2 + : theme.colors.surface1, borderColor: disabled ? theme.colors.border - : theme.colors.accent, + : theme.colors.borderAccent, opacity: disabled ? 0.5 : 1, }, + pressed && !disabled ? styles.optionItemPressed : null, ]; }} onPress={handleSubmit} disabled={!allAnswered || isResponding} > - {isResponding ? ( - + {respondingAction === "submit" ? ( + ) : ( - + Submit @@ -333,7 +362,6 @@ export function QuestionFormCard({ const styles = StyleSheet.create((theme) => ({ container: { - marginVertical: theme.spacing[3], padding: theme.spacing[3], borderRadius: theme.spacing[2], borderWidth: 1, @@ -342,55 +370,70 @@ const styles = StyleSheet.create((theme) => ({ questionBlock: { gap: theme.spacing[2], }, - header: { - fontSize: theme.fontSize.xs, - fontWeight: theme.fontWeight.semibold, - textTransform: "uppercase", - letterSpacing: 0.5, - }, - questionText: { - fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.medium, - }, - optionsWrap: { - flexDirection: "row", - flexWrap: "wrap", - gap: theme.spacing[2], - }, - chip: { - paddingVertical: theme.spacing[2], - paddingHorizontal: theme.spacing[3], - borderRadius: theme.borderRadius.md, - borderWidth: theme.borderWidth[1], - gap: theme.spacing[1], - }, - chipContent: { + questionHeader: { flexDirection: "row", alignItems: "center", + gap: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + paddingBottom: theme.spacing[1], + }, + questionText: { + flex: 1, + fontSize: theme.fontSize.base, + lineHeight: 22, + }, + optionsWrap: { gap: theme.spacing[1], }, - chipLabel: { - fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.medium, + optionItem: { + flexDirection: "row", + alignItems: "center", + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + borderRadius: theme.borderRadius.md, }, - chipDescription: { + optionItemPressed: { + opacity: 0.9, + }, + optionItemContent: { + flex: 1, + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + optionTextBlock: { + flex: 1, + gap: 2, + }, + optionLabel: { + fontSize: theme.fontSize.sm, + }, + optionDescription: { fontSize: theme.fontSize.xs, lineHeight: 16, }, + optionCheckSlot: { + width: 16, + alignItems: "center", + justifyContent: "center", + marginLeft: "auto", + }, otherInput: { - borderWidth: theme.borderWidth[1], - borderRadius: theme.borderRadius.md, - paddingVertical: theme.spacing[2], + borderWidth: 1, + borderRadius: theme.borderRadius.lg, paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[3], fontSize: theme.fontSize.sm, }, - actions: { - flexDirection: "row", + actionsContainer: { gap: theme.spacing[2], - marginTop: theme.spacing[1], + }, + actionsContainerDesktop: { + flexDirection: "row", + justifyContent: "flex-start", + alignItems: "center", }, actionButton: { - flex: 1, paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[3], borderRadius: theme.borderRadius.md, @@ -404,6 +447,5 @@ const styles = StyleSheet.create((theme) => ({ }, actionText: { fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.normal, }, })); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 0c5fe67f7..ef59f355a 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -1171,10 +1171,33 @@ export class AgentManager { break; case "turn_failed": agent.lastError = event.error; + for (const [requestId] of agent.pendingPermissions) { + agent.pendingPermissions.delete(requestId); + if (!options?.fromHistory) { + this.dispatchStream(agent.id, { + type: "permission_resolved", + provider: event.provider, + requestId, + resolution: { behavior: "deny", message: "Turn failed" }, + }); + } + } + this.emitState(agent); break; case "turn_canceled": - // Cancellation is not an error, just clear any previous error agent.lastError = undefined; + for (const [requestId] of agent.pendingPermissions) { + agent.pendingPermissions.delete(requestId); + if (!options?.fromHistory) { + this.dispatchStream(agent.id, { + type: "permission_resolved", + provider: event.provider, + requestId, + resolution: { behavior: "deny", message: "Interrupted" }, + }); + } + } + this.emitState(agent); break; case "permission_requested": agent.pendingPermissions.set(event.request.id, event.request); diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index 1638d55a4..234de76cc 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -625,6 +625,7 @@ class ClaudeAgentSession implements AgentSession { this.pendingInterruptPromise = this.interruptActiveTurn().catch((error) => { this.logger.warn({ err: error }, "Failed to interrupt during cancel"); }); + this.flushPendingToolCalls(); // Push turn_canceled before ending the queue so consumers get proper lifecycle signals queue.push({ type: "turn_canceled", @@ -1084,6 +1085,7 @@ class ClaudeAgentSession implements AgentSession { // Only emit if not already emitted by requestCancel() (indicated by turnCancelRequested). const wasSuperseded = this.currentTurnId !== turnId; if (wasSuperseded && !completedNormally && !this.turnCancelRequested) { + this.flushPendingToolCalls(); queue.push({ type: "turn_canceled", provider: "claude", @@ -1423,6 +1425,21 @@ class ClaudeAgentSession implements AgentSession { this.pushEvent({ type: "timeline", item, provider: "claude" }); } + private flushPendingToolCalls() { + for (const [id, entry] of this.toolUseCache) { + if (entry.started) { + this.pushToolCall({ + name: entry.name, + status: "failed", + callId: id, + input: entry.input, + error: { message: "Interrupted" }, + }); + } + } + this.toolUseCache.clear(); + } + private pushToolCall( data: Omit, target?: AgentTimelineItem[]