Update files

This commit is contained in:
Mohamed Boudra
2026-02-07 22:14:10 +07:00
parent 9130ee3134
commit b80f4e0e93
7 changed files with 519 additions and 59 deletions

View File

@@ -49,6 +49,7 @@ import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import type { DaemonClient } from "@server/client/daemon-client";
import { parseToolCallDisplay } from "@/utils/tool-call-parsers";
import { ToolCallDetailsContent } from "./tool-call-details";
import { QuestionFormCard } from "./question-form-card";
import { ToolCallSheetProvider } from "./tool-call-sheet";
import { createMarkdownStyles } from "@/styles/markdown-styles";
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
@@ -1070,6 +1071,16 @@ function PermissionRequestCard({
[permission.agentId, permission.request.id, respondToPermission]
);
if (request.kind === "question") {
return (
<QuestionFormCard
permission={permission}
onRespond={handleResponse}
isResponding={isResponding}
/>
);
}
return (
<View
style={[

View File

@@ -147,8 +147,12 @@ export const UserMessage = memo(function UserMessage({
isLastInGroup = true,
disableOuterSpacing,
}: UserMessageProps) {
const [messageHovered, setMessageHovered] = useState(false);
const [copyButtonHovered, setCopyButtonHovered] = useState(false);
const resolvedDisableOuterSpacing =
useDisableOuterSpacing(disableOuterSpacing);
const showCopyButton =
Platform.OS !== "web" || messageHovered || copyButtonHovered;
return (
<View
@@ -163,29 +167,31 @@ export const UserMessage = memo(function UserMessage({
],
]}
>
<Pressable style={userMessageStylesheet.content}>
{({ hovered }) => {
const showCopyButton = Platform.OS !== "web" || hovered;
return (
<>
<View style={userMessageStylesheet.bubble}>
<Text selectable style={userMessageStylesheet.text}>
{message}
</Text>
</View>
<TurnCopyButton
getContent={() => message}
containerStyle={[
userMessageStylesheet.copyButton,
showCopyButton
? userMessageStylesheet.copyButtonVisible
: userMessageStylesheet.copyButtonHidden,
]}
accessibilityLabel="Copy message"
/>
</>
);
}}
<Pressable
style={userMessageStylesheet.content}
onHoverIn={
Platform.OS === "web" ? () => setMessageHovered(true) : undefined
}
onHoverOut={
Platform.OS === "web" ? () => setMessageHovered(false) : undefined
}
>
<View style={userMessageStylesheet.bubble}>
<Text selectable style={userMessageStylesheet.text}>
{message}
</Text>
</View>
<TurnCopyButton
getContent={() => message}
containerStyle={[
userMessageStylesheet.copyButton,
showCopyButton
? userMessageStylesheet.copyButtonVisible
: userMessageStylesheet.copyButtonHidden,
]}
accessibilityLabel="Copy message"
onHoverChange={setCopyButtonHovered}
/>
</Pressable>
</View>
);
@@ -251,6 +257,7 @@ interface TurnCopyButtonProps {
containerStyle?: StyleProp<ViewStyle>;
accessibilityLabel?: string;
copiedAccessibilityLabel?: string;
onHoverChange?: (hovered: boolean) => void;
}
export const TurnCopyButton = memo(function TurnCopyButton({
@@ -258,6 +265,7 @@ export const TurnCopyButton = memo(function TurnCopyButton({
containerStyle,
accessibilityLabel,
copiedAccessibilityLabel,
onHoverChange,
}: TurnCopyButtonProps) {
const [copied, setCopied] = useState(false);
const copyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -292,6 +300,8 @@ export const TurnCopyButton = memo(function TurnCopyButton({
return (
<Pressable
onPress={handleCopy}
onHoverIn={Platform.OS === "web" ? () => onHoverChange?.(true) : undefined}
onHoverOut={Platform.OS === "web" ? () => onHoverChange?.(false) : undefined}
style={[turnCopyButtonStylesheet.container, containerStyle]}
accessibilityRole="button"
accessibilityLabel={

View File

@@ -0,0 +1,409 @@
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 type { PendingPermission } from "@/types/shared";
import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
interface QuestionOption {
label: string;
description?: string;
}
interface Question {
question: string;
header: string;
options: QuestionOption[];
multiSelect: boolean;
}
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 q = item as Record<string, unknown>;
if (typeof q.question !== "string" || typeof q.header !== "string") return null;
if (!Array.isArray(q.options)) return null;
const options: QuestionOption[] = [];
for (const opt of q.options as unknown[]) {
if (typeof opt !== "object" || opt === null) return null;
const o = opt as Record<string, unknown>;
if (typeof o.label !== "string") return null;
options.push({
label: o.label,
description: typeof o.description === "string" ? o.description : undefined,
});
}
questions.push({
question: q.question,
header: q.header,
options,
multiSelect: q.multiSelect === true,
});
}
return questions.length > 0 ? questions : null;
}
interface QuestionFormCardProps {
permission: PendingPermission;
onRespond: (response: AgentPermissionResponse) => void;
isResponding: boolean;
}
export function QuestionFormCard({
permission,
onRespond,
isResponding,
}: QuestionFormCardProps) {
const { theme } = useUnistyles();
const questions = parseQuestions(permission.request.input);
// selections[questionIndex] = Set of selected option indices
const [selections, setSelections] = useState<Record<number, Set<number>>>({});
// otherTexts[questionIndex] = custom "Other" text
const [otherTexts, setOtherTexts] = useState<Record<number, string>>({});
const toggleOption = useCallback(
(qIndex: number, optIndex: number, multiSelect: boolean) => {
setSelections((prev) => {
const current = prev[qIndex] ?? new Set<number>();
const next = new Set(current);
if (multiSelect) {
if (next.has(optIndex)) {
next.delete(optIndex);
} else {
next.add(optIndex);
}
} else {
if (next.has(optIndex)) {
next.clear();
} else {
next.clear();
next.add(optIndex);
}
}
return { ...prev, [qIndex]: next };
});
// Clear "Other" text when an option is selected
setOtherTexts((prev) => {
if (!prev[qIndex]) return prev;
const next = { ...prev };
delete next[qIndex];
return next;
});
},
[]
);
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;
return { ...prev, [qIndex]: new Set<number>() };
});
}
}, []);
if (!questions) {
return null;
}
const allAnswered = questions.every((_, qIndex) => {
const selected = selections[qIndex];
const otherText = otherTexts[qIndex]?.trim();
return (selected && selected.size > 0) || (otherText && otherText.length > 0);
});
function handleSubmit() {
const answers: Record<string, string> = {};
for (let i = 0; i < questions!.length; i++) {
const q = questions![i];
const selected = selections[i];
const otherText = otherTexts[i]?.trim();
if (otherText && otherText.length > 0) {
answers[q.header] = otherText;
} else if (selected && selected.size > 0) {
const labels = Array.from(selected).map((idx) => q.options[idx].label);
answers[q.header] = labels.join(", ");
}
}
onRespond({
behavior: "allow",
updatedInput: { ...permission.request.input, answers },
});
}
function handleDeny() {
onRespond({
behavior: "deny",
message: "Dismissed by user",
});
}
return (
<View
style={[
styles.container,
{
backgroundColor: theme.colors.surface2,
borderColor: theme.colors.border,
},
]}
>
{questions.map((q, qIndex) => {
const selected = selections[qIndex] ?? new Set<number>();
const otherText = otherTexts[qIndex] ?? "";
return (
<View key={qIndex} style={styles.questionBlock}>
<Text
style={[styles.header, { color: theme.colors.foregroundMuted }]}
>
{q.header}
</Text>
<Text
style={[styles.questionText, { color: theme.colors.foreground }]}
>
{q.question}
</Text>
<View style={styles.optionsWrap}>
{q.options.map((opt, optIndex) => {
const isSelected = selected.has(optIndex);
return (
<Pressable
key={optIndex}
style={(state) => {
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)}
disabled={isResponding}
>
<View style={styles.chipContent}>
{q.multiSelect && isSelected ? (
<Check size={14} color={theme.colors.accent} />
) : null}
<Text
style={[
styles.chipLabel,
{
color: isSelected
? theme.colors.accent
: theme.colors.foreground,
},
]}
>
{opt.label}
</Text>
</View>
{opt.description ? (
<Text
style={[
styles.chipDescription,
{ color: theme.colors.foregroundMuted },
]}
>
{opt.description}
</Text>
) : null}
</Pressable>
);
})}
</View>
<TextInput
style={[
styles.otherInput,
{
borderColor: otherText.length > 0
? theme.colors.accent
: theme.colors.border,
color: theme.colors.foreground,
backgroundColor: theme.colors.surface0,
},
]}
placeholder="Other..."
placeholderTextColor={theme.colors.foregroundMuted}
value={otherText}
onChangeText={(text) => setOtherText(qIndex, text)}
editable={!isResponding}
/>
</View>
);
})}
<View style={styles.actions}>
<Pressable
style={(state) => {
const hovered = Boolean((state as any).hovered);
return [
styles.actionButton,
{
backgroundColor: hovered
? theme.colors.surface1
: theme.colors.surface2,
borderColor: theme.colors.border,
},
];
}}
onPress={handleDeny}
disabled={isResponding}
>
{isResponding ? (
<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={(state) => {
const hovered = Boolean((state as any).hovered);
const disabled = !allAnswered || isResponding;
return [
styles.actionButton,
{
backgroundColor: hovered && !disabled
? theme.colors.surface1
: theme.colors.surface2,
borderColor: disabled
? theme.colors.border
: theme.colors.accent,
opacity: disabled ? 0.5 : 1,
},
];
}}
onPress={handleSubmit}
disabled={!allAnswered || isResponding}
>
{isResponding ? (
<ActivityIndicator size="small" color={theme.colors.accent} />
) : (
<View style={styles.actionContent}>
<Send size={14} color={allAnswered ? theme.colors.accent : theme.colors.foregroundMuted} />
<Text
style={[
styles.actionText,
{ color: allAnswered ? theme.colors.accent : theme.colors.foregroundMuted },
]}
>
Submit
</Text>
</View>
)}
</Pressable>
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
marginVertical: theme.spacing[3],
padding: theme.spacing[3],
borderRadius: theme.spacing[2],
borderWidth: 1,
gap: theme.spacing[3],
},
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: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
chipLabel: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
},
chipDescription: {
fontSize: theme.fontSize.xs,
lineHeight: 16,
},
otherInput: {
borderWidth: theme.borderWidth[1],
borderRadius: theme.borderRadius.md,
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
fontSize: theme.fontSize.sm,
},
actions: {
flexDirection: "row",
gap: theme.spacing[2],
marginTop: theme.spacing[1],
},
actionButton: {
flex: 1,
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
alignItems: "center",
borderWidth: theme.borderWidth[1],
},
actionContent: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
actionText: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
}));

View File

@@ -165,7 +165,7 @@ export type AgentStreamEvent =
timestamp: string;
};
export type AgentPermissionRequestKind = "tool" | "plan" | "mode" | "other";
export type AgentPermissionRequestKind = "tool" | "plan" | "question" | "mode" | "other";
export type AgentPermissionUpdate = AgentMetadata;

View File

@@ -1247,6 +1247,36 @@ describe("convertClaudeHistoryEntry", () => {
expect(result).toEqual([]);
});
test("passes thinking blocks to mapBlocks for assistant entries", () => {
const entry = {
type: "assistant",
message: {
role: "assistant",
content: [
{ type: "thinking", thinking: "Let me reason about this..." },
{ type: "text", text: "Here is my answer." },
],
},
};
const mapBlocks = vi.fn().mockReturnValue([
{ type: "reasoning", text: "Let me reason about this..." },
{ type: "assistant_message", text: "Here is my answer." },
]);
const result = convertClaudeHistoryEntry(entry, mapBlocks);
expect(mapBlocks).toHaveBeenCalledTimes(1);
const arg = mapBlocks.mock.calls[0][0];
expect(arg).toEqual([
{ type: "thinking", thinking: "Let me reason about this..." },
{ type: "text", text: "Here is my answer." },
]);
expect(result).toEqual([
{ type: "reasoning", text: "Let me reason about this..." },
{ type: "assistant_message", text: "Here is my answer." },
]);
});
});
type StreamHydrationUpdate = {

View File

@@ -31,6 +31,7 @@ import type {
AgentMode,
AgentModelDefinition,
AgentPermissionRequest,
AgentPermissionRequestKind,
AgentPermissionResponse,
AgentPermissionUpdate,
AgentPersistenceHandle,
@@ -328,6 +329,17 @@ function isPermissionUpdate(value: AgentPermissionUpdate): value is PermissionUp
return Array.isArray(rules) && typeof behavior === "string" && typeof destination === "string";
}
function resolvePermissionKind(
toolName: string,
input: Record<string, unknown>
): AgentPermissionRequestKind {
if (toolName === "ExitPlanMode") return "plan";
if (toolName === "AskUserQuestion" && Array.isArray(input.questions)) {
return "question";
}
return "tool";
}
export class ClaudeAgentClient implements AgentClient {
readonly provider: "claude" = "claude";
readonly capabilities = CLAUDE_CAPABILITIES;
@@ -483,6 +495,7 @@ class ClaudeAgentSession implements AgentSession {
private lastOptionsModel: string | null = null;
private activeSidechains = new Map<string, string>();
private compacting = false;
private queryRestartNeeded = false;
constructor(
config: ClaudeAgentConfig,
@@ -722,37 +735,16 @@ class ClaudeAgentSession implements AgentSession {
? thinkingOptionId
: null;
const query = await this.ensureQuery();
if (!normalizedThinkingOptionId || normalizedThinkingOptionId === "default") {
// Claude Code TUI exposes only ON/OFF. Default to OFF.
try {
await query.setMaxThinkingTokens(0);
} catch {
await query.setMaxThinkingTokens(1);
}
this.config.thinkingOptionId = undefined;
return;
}
if (normalizedThinkingOptionId === "on") {
await query.setMaxThinkingTokens(null);
this.config.thinkingOptionId = normalizedThinkingOptionId;
return;
}
if (normalizedThinkingOptionId === "off") {
try {
await query.setMaxThinkingTokens(0);
} catch {
// Some runtimes may reject 0; use a tiny cap as "off".
await query.setMaxThinkingTokens(1);
}
} else if (normalizedThinkingOptionId === "on") {
this.config.thinkingOptionId = "on";
} else if (normalizedThinkingOptionId === "off") {
this.config.thinkingOptionId = "off";
return;
} else {
throw new Error(`Unknown thinking option: ${normalizedThinkingOptionId}`);
}
throw new Error(`Unknown thinking option: ${normalizedThinkingOptionId}`);
this.queryRestartNeeded = true;
}
getPendingPermissions(): AgentPermissionRequest[] {
@@ -874,10 +866,18 @@ class ClaudeAgentSession implements AgentSession {
}
private async ensureQuery(): Promise<Query> {
if (this.query) {
if (this.query && !this.queryRestartNeeded) {
return this.query;
}
if (this.queryRestartNeeded && this.query) {
this.input?.end();
try { await this.query.return?.(); } catch { /* ignore */ }
this.query = null;
this.input = null;
this.queryRestartNeeded = false;
}
const input = new Pushable<SDKUserMessage>();
const options = this.buildOptions();
this.logger.debug({ options }, "claude query");
@@ -894,11 +894,10 @@ class ClaudeAgentSession implements AgentSession {
? configuredThinkingOptionId
: "off";
let maxThinkingTokens: number | undefined;
if (typeof thinkingOptionId === "string" && thinkingOptionId.length > 0) {
if (thinkingOptionId === "off") {
maxThinkingTokens = 0;
}
// For "on" we omit maxThinkingTokens (SDK default max).
if (thinkingOptionId === "on") {
maxThinkingTokens = 10000;
} else if (thinkingOptionId === "off") {
maxThinkingTokens = 0;
}
const appendedSystemPrompt = [
@@ -1117,6 +1116,7 @@ class ClaudeAgentSession implements AgentSession {
this.logger.info({ durationMs: Date.now() - t1 }, "interruptActiveTurn: query.return() returned");
this.query = null;
this.input = null;
this.queryRestartNeeded = false;
} catch (error) {
this.logger.warn({ err: error }, "Failed to interrupt active turn");
}
@@ -1361,7 +1361,7 @@ class ClaudeAgentSession implements AgentSession {
id: requestId,
provider: "claude",
name: toolName,
kind: toolName === "ExitPlanMode" ? "plan" : "tool",
kind: resolvePermissionKind(toolName, input),
input,
suggestions: options.suggestions?.map((suggestion) => ({ ...suggestion })),
metadata: Object.keys(metadata).length ? metadata : undefined,

View File

@@ -130,7 +130,7 @@ export const AgentPermissionRequestPayloadSchema: z.ZodType<AgentPermissionReque
id: z.string(),
provider: AgentProviderSchema,
name: z.string(),
kind: z.enum(["tool", "plan", "mode", "other"]),
kind: z.enum(["tool", "plan", "question", "mode", "other"]),
title: z.string().optional(),
description: z.string().optional(),
input: z.record(z.unknown()).optional(),