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[]