Update files

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