From b80f4e0e93252402e2bf9e1db813a13e6788da4e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 7 Feb 2026 22:14:10 +0700 Subject: [PATCH] Update files --- .../app/src/components/agent-stream-view.tsx | 11 + packages/app/src/components/message.tsx | 56 ++- .../app/src/components/question-form-card.tsx | 409 ++++++++++++++++++ .../src/server/agent/agent-sdk-types.ts | 2 +- .../agent/providers/claude-agent.test.ts | 30 ++ .../server/agent/providers/claude-agent.ts | 68 +-- packages/server/src/shared/messages.ts | 2 +- 7 files changed, 519 insertions(+), 59 deletions(-) create mode 100644 packages/app/src/components/question-form-card.tsx diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index f3a8da81f..0b6b591fe 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -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 ( + + ); + } + return ( - - {({ hovered }) => { - const showCopyButton = Platform.OS !== "web" || hovered; - return ( - <> - - - {message} - - - message} - containerStyle={[ - userMessageStylesheet.copyButton, - showCopyButton - ? userMessageStylesheet.copyButtonVisible - : userMessageStylesheet.copyButtonHidden, - ]} - accessibilityLabel="Copy message" - /> - - ); - }} + setMessageHovered(true) : undefined + } + onHoverOut={ + Platform.OS === "web" ? () => setMessageHovered(false) : undefined + } + > + + + {message} + + + message} + containerStyle={[ + userMessageStylesheet.copyButton, + showCopyButton + ? userMessageStylesheet.copyButtonVisible + : userMessageStylesheet.copyButtonHidden, + ]} + accessibilityLabel="Copy message" + onHoverChange={setCopyButtonHovered} + /> ); @@ -251,6 +257,7 @@ interface TurnCopyButtonProps { containerStyle?: StyleProp; 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 | null>(null); @@ -292,6 +300,8 @@ export const TurnCopyButton = memo(function TurnCopyButton({ return ( onHoverChange?.(true) : undefined} + onHoverOut={Platform.OS === "web" ? () => onHoverChange?.(false) : undefined} style={[turnCopyButtonStylesheet.container, containerStyle]} accessibilityRole="button" accessibilityLabel={ diff --git a/packages/app/src/components/question-form-card.tsx b/packages/app/src/components/question-form-card.tsx new file mode 100644 index 000000000..0253b934a --- /dev/null +++ b/packages/app/src/components/question-form-card.tsx @@ -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).questions) + ) { + return null; + } + const raw = (input as Record).questions as unknown[]; + const questions: Question[] = []; + for (const item of raw) { + if (typeof item !== "object" || item === null) return null; + const q = item as Record; + 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; + 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>>({}); + // otherTexts[questionIndex] = custom "Other" text + const [otherTexts, setOtherTexts] = useState>({}); + + const toggleOption = useCallback( + (qIndex: number, optIndex: number, multiSelect: boolean) => { + setSelections((prev) => { + const current = prev[qIndex] ?? new Set(); + 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() }; + }); + } + }, []); + + 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 = {}; + 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 ( + + {questions.map((q, qIndex) => { + const selected = selections[qIndex] ?? new Set(); + const otherText = otherTexts[qIndex] ?? ""; + + return ( + + + {q.header} + + + {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)} + disabled={isResponding} + > + + {q.multiSelect && isSelected ? ( + + ) : null} + + {opt.label} + + + {opt.description ? ( + + {opt.description} + + ) : null} + + ); + })} + + 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} + /> + + ); + })} + + + { + 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 ? ( + + ) : ( + + + + Dismiss + + + )} + + + { + 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 ? ( + + ) : ( + + + + Submit + + + )} + + + + ); +} + +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, + }, +})); diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index e36e155ce..1f7e26b55 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -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; diff --git a/packages/server/src/server/agent/providers/claude-agent.test.ts b/packages/server/src/server/agent/providers/claude-agent.test.ts index 7168c9751..e44127a0c 100644 --- a/packages/server/src/server/agent/providers/claude-agent.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.test.ts @@ -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 = { diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index ac6f62eda..1638d55a4 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -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 +): 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(); 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 { - 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(); 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, diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 02394498d..92e91747b 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -130,7 +130,7 @@ export const AgentPermissionRequestPayloadSchema: z.ZodType