diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index 0f6fd906f7..b6e6bc5521 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -6,6 +6,7 @@ import { SafeAreaProvider } from "react-native-safe-area-context"; import { StatusBar } from "expo-status-bar"; import { View } from "react-native"; import { ThemeProvider, useTheme } from "../src/theme"; +import { LiveBashOutputProvider } from "../src/contexts/LiveBashOutputContext"; import { WorkspaceChatProvider } from "../src/contexts/WorkspaceChatContext"; import { AppConfigProvider } from "../src/contexts/AppConfigContext"; import { ORPCProvider } from "../src/orpc/react"; @@ -77,7 +78,9 @@ export default function RootLayout(): JSX.Element { - + + + diff --git a/mobile/src/components/AskUserQuestionToolCard.tsx b/mobile/src/components/AskUserQuestionToolCard.tsx new file mode 100644 index 0000000000..27540de382 --- /dev/null +++ b/mobile/src/components/AskUserQuestionToolCard.tsx @@ -0,0 +1,565 @@ +import type { JSX } from "react"; +import { useEffect, useMemo, useState } from "react"; +import { Pressable, Text, TextInput, View } from "react-native"; + +import type { + AskUserQuestionQuestion, + AskUserQuestionToolArgs, + AskUserQuestionToolResult, + AskUserQuestionToolSuccessResult, + ToolErrorResult, +} from "@/common/types/tools"; +import { Surface } from "./Surface"; +import { ThemedText } from "./ThemedText"; +import { useTheme } from "../theme"; +import { useORPC } from "../orpc/react"; + +type ToolStatus = "pending" | "executing" | "completed" | "failed" | "interrupted"; + +const OTHER_VALUE = "__other__"; + +type DraftAnswer = { + selected: string[]; + otherText: string; +}; + +// Cache draft answers by toolCallId so drafts survive list virtualization/workspace switches +const draftStateCache = new Map>(); + +function unwrapJsonContainer(value: unknown): unknown { + if (!value || typeof value !== "object") { + return value; + } + + const record = value as Record; + if (record.type === "json" && "value" in record) { + return record.value; + } + + return value; +} + +function isToolErrorResult(val: unknown): val is ToolErrorResult { + if (!val || typeof val !== "object") { + return false; + } + + const record = val as Record; + return record.success === false && typeof record.error === "string"; +} + +function isAskUserQuestionToolSuccessResult(val: unknown): val is AskUserQuestionToolSuccessResult { + if (!val || typeof val !== "object") { + return false; + } + + const record = val as Record; + if (!Array.isArray(record.questions)) { + return false; + } + + if (!record.answers || typeof record.answers !== "object") { + return false; + } + + for (const [, v] of Object.entries(record.answers as Record)) { + if (typeof v !== "string") { + return false; + } + } + + return true; +} + +function isAskUserQuestionToolArgs(val: unknown): val is AskUserQuestionToolArgs { + if (!val || typeof val !== "object") { + return false; + } + + const record = val as Record; + if (!Array.isArray(record.questions)) { + return false; + } + + return record.questions.every((q) => { + if (!q || typeof q !== "object") { + return false; + } + + const question = q as Record; + if (typeof question.question !== "string") { + return false; + } + + if (typeof question.header !== "string") { + return false; + } + + if (!Array.isArray(question.options)) { + return false; + } + + if (typeof question.multiSelect !== "boolean") { + return false; + } + + return question.options.every((opt) => { + if (!opt || typeof opt !== "object") { + return false; + } + const option = opt as Record; + return typeof option.label === "string" && typeof option.description === "string"; + }); + }); +} + +function parsePrefilledAnswer(question: AskUserQuestionQuestion, answer: string): DraftAnswer { + const trimmed = answer.trim(); + if (trimmed.length === 0) { + return { selected: [], otherText: "" }; + } + + const optionLabels = new Set(question.options.map((o) => o.label)); + + if (!question.multiSelect) { + if (optionLabels.has(trimmed)) { + return { selected: [trimmed], otherText: "" }; + } + + return { selected: [OTHER_VALUE], otherText: trimmed }; + } + + const tokens = trimmed + .split(",") + .map((t) => t.trim()) + .filter((t) => t.length > 0); + + const selected: string[] = []; + const otherParts: string[] = []; + + for (const token of tokens) { + if (optionLabels.has(token)) { + selected.push(token); + } else { + otherParts.push(token); + } + } + + if (otherParts.length > 0) { + selected.push(OTHER_VALUE); + } + + return { selected, otherText: otherParts.join(", ") }; +} + +function isQuestionAnswered(question: AskUserQuestionQuestion, draft: DraftAnswer): boolean { + if (draft.selected.length === 0) { + return false; + } + + if (draft.selected.includes(OTHER_VALUE)) { + return draft.otherText.trim().length > 0; + } + + return true; +} + +function draftToAnswerString(question: AskUserQuestionQuestion, draft: DraftAnswer): string { + const parts: string[] = []; + for (const label of draft.selected) { + if (label === OTHER_VALUE) { + parts.push(draft.otherText.trim()); + } else { + parts.push(label); + } + } + + if (!question.multiSelect) { + return parts[0] ?? ""; + } + + return parts.join(", "); +} + +export function AskUserQuestionToolCard(props: { + args: unknown; + result: unknown; + status: ToolStatus; + toolCallId: string; + workspaceId?: string; +}): JSX.Element { + const theme = useTheme(); + const spacing = theme.spacing; + const client = useORPC(); + + const parsedArgs = useMemo( + () => (isAskUserQuestionToolArgs(props.args) ? props.args : null), + [props.args] + ); + + const resultUnwrapped = useMemo(() => unwrapJsonContainer(props.result), [props.result]); + + const successResult: AskUserQuestionToolSuccessResult | null = + resultUnwrapped && isAskUserQuestionToolSuccessResult(resultUnwrapped) ? resultUnwrapped : null; + + const errorResult: ToolErrorResult | null = + resultUnwrapped && isToolErrorResult(resultUnwrapped) ? resultUnwrapped : null; + + const argsAnswers = parsedArgs?.answers ?? {}; + + const [draftAnswers, setDraftAnswers] = useState>(() => { + const cached = draftStateCache.get(props.toolCallId); + if (cached) { + return cached; + } + + const initial: Record = {}; + if (!parsedArgs) { + return initial; + } + + for (const q of parsedArgs.questions) { + const prefilled = argsAnswers[q.question]; + if (typeof prefilled === "string") { + initial[q.question] = parsePrefilledAnswer(q, prefilled); + } else { + initial[q.question] = { selected: [], otherText: "" }; + } + } + + return initial; + }); + + useEffect(() => { + if (props.status === "executing") { + draftStateCache.set(props.toolCallId, draftAnswers); + } else { + draftStateCache.delete(props.toolCallId); + } + }, [props.status, props.toolCallId, draftAnswers]); + + const isComplete = useMemo(() => { + if (!parsedArgs) { + return false; + } + + return parsedArgs.questions.every((q) => { + const draft = draftAnswers[q.question]; + return draft ? isQuestionAnswered(q, draft) : false; + }); + }, [parsedArgs, draftAnswers]); + + const [isSubmitting, setIsSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); + const [submitted, setSubmitted] = useState(false); + + useEffect(() => { + if (successResult) { + setSubmitted(true); + setSubmitError(null); + } + }, [successResult]); + + const statusLabel = (() => { + switch (props.status) { + case "completed": + return "✓ Completed"; + case "failed": + return "✗ Failed"; + case "interrupted": + return "⚠ Interrupted"; + case "executing": + return "⟳ Executing"; + default: + return "○ Pending"; + } + })(); + + const toggleSelection = (question: AskUserQuestionQuestion, label: string) => { + setDraftAnswers((current) => { + const next = { ...current }; + const draft = next[question.question] ?? { selected: [], otherText: "" }; + + if (!question.multiSelect) { + next[question.question] = { + selected: [label], + otherText: label === OTHER_VALUE ? draft.otherText : "", + }; + return next; + } + + const selectedSet = new Set(draft.selected); + if (selectedSet.has(label)) { + selectedSet.delete(label); + } else { + selectedSet.add(label); + } + + const selected = Array.from(selectedSet); + const otherText = selected.includes(OTHER_VALUE) ? draft.otherText : ""; + next[question.question] = { selected, otherText }; + return next; + }); + }; + + const updateOtherText = (questionText: string, text: string) => { + setDraftAnswers((current) => { + const draft = current[questionText] ?? { selected: [OTHER_VALUE], otherText: "" }; + return { + ...current, + [questionText]: { + ...draft, + selected: draft.selected.includes(OTHER_VALUE) + ? draft.selected + : [...draft.selected, OTHER_VALUE], + otherText: text, + }, + }; + }); + }; + + const handleSubmit = async () => { + if (!parsedArgs) { + return; + } + + if (!props.workspaceId) { + setSubmitError("Missing workspaceId"); + return; + } + + if (!isComplete) { + setSubmitError("Please answer all questions"); + return; + } + + if (isSubmitting || submitted) { + return; + } + + setIsSubmitting(true); + setSubmitError(null); + + try { + const answers: Record = {}; + for (const q of parsedArgs.questions) { + const draft = draftAnswers[q.question]; + if (!draft) { + continue; + } + answers[q.question] = draftToAnswerString(q, draft); + } + + const result = await client.workspace.answerAskUserQuestion({ + workspaceId: props.workspaceId, + toolCallId: props.toolCallId, + answers, + }); + + if (!result.success) { + setSubmitError(result.error ?? "Failed to submit answers"); + return; + } + + setSubmitted(true); + } catch (error) { + setSubmitError(error instanceof Error ? error.message : String(error)); + } finally { + setIsSubmitting(false); + } + }; + + if (!parsedArgs) { + return ( + + ask_user_question + + (Unsupported args) + + + ); + } + + return ( + + + + + ask_user_question ({parsedArgs.questions.length}) + + + {statusLabel} + + + + {errorResult ? ( + + + {errorResult.error} + + + ) : null} + + {successResult ? ( + + {parsedArgs.questions.map((q) => ( + + {q.header} + + {successResult.answers[q.question]} + + + ))} + + ) : ( + + {parsedArgs.questions.map((q) => { + const draft = draftAnswers[q.question] ?? { selected: [], otherText: "" }; + const answered = isQuestionAnswered(q, draft); + + return ( + + + {q.header} + + {q.question} + + + + + {q.options.map((opt) => { + const selected = draft.selected.includes(opt.label); + const indicator = q.multiSelect ? (selected ? "☑" : "☐") : selected ? "◉" : "○"; + + return ( + toggleSelection(q, opt.label)} + disabled={submitted || props.status !== "executing"} + style={({ pressed }) => ({ + flexDirection: "row", + gap: spacing.sm, + paddingVertical: spacing.xs, + paddingHorizontal: spacing.sm, + borderRadius: theme.radii.sm, + backgroundColor: pressed + ? theme.colors.surfaceSecondary + : theme.colors.surfaceSunken, + opacity: submitted || props.status !== "executing" ? 0.6 : 1, + })} + > + {indicator} + + {opt.label} + + {opt.description} + + + + ); + })} + + {/* Implicit Other option */} + {(() => { + const selected = draft.selected.includes(OTHER_VALUE); + const indicator = q.multiSelect ? (selected ? "☑" : "☐") : selected ? "◉" : "○"; + + return ( + + toggleSelection(q, OTHER_VALUE)} + disabled={submitted || props.status !== "executing"} + style={({ pressed }) => ({ + flexDirection: "row", + gap: spacing.sm, + paddingVertical: spacing.xs, + paddingHorizontal: spacing.sm, + borderRadius: theme.radii.sm, + backgroundColor: pressed + ? theme.colors.surfaceSecondary + : theme.colors.surfaceSunken, + opacity: submitted || props.status !== "executing" ? 0.6 : 1, + })} + > + {indicator} + + Other + + Provide your own answer + + + + + {selected ? ( + updateOtherText(q.question, text)} + editable={!submitted && props.status === "executing"} + placeholder="Type your answer" + placeholderTextColor={theme.colors.foregroundMuted} + style={{ + borderWidth: 1, + borderColor: answered ? theme.colors.border : theme.colors.warning, + borderRadius: theme.radii.sm, + backgroundColor: theme.colors.surface, + color: theme.colors.foregroundPrimary, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + fontSize: 14, + }} + /> + ) : null} + + ); + })()} + + + ); + })} + + {submitError ? ( + + {submitError} + + ) : null} + + ({ + marginTop: spacing.sm, + paddingVertical: spacing.sm, + paddingHorizontal: spacing.md, + borderRadius: theme.radii.sm, + alignItems: "center", + backgroundColor: + submitted || props.status !== "executing" || !isComplete + ? theme.colors.inputBorder + : pressed + ? theme.colors.accentHover + : theme.colors.accent, + })} + > + + {submitted ? "Submitted" : isSubmitting ? "Submitting…" : "Submit answers"} + + + + )} + + ); +} diff --git a/mobile/src/components/ProposePlanToolCard.tsx b/mobile/src/components/ProposePlanToolCard.tsx new file mode 100644 index 0000000000..2863243929 --- /dev/null +++ b/mobile/src/components/ProposePlanToolCard.tsx @@ -0,0 +1,254 @@ +import type { JSX } from "react"; +import { useEffect, useMemo, useState } from "react"; +import { View } from "react-native"; + +import type { + LegacyProposePlanToolArgs, + ProposePlanToolResult, + ToolErrorResult, +} from "@/common/types/tools"; +import { ProposePlanCard } from "./ProposePlanCard"; +import { Surface } from "./Surface"; +import { ThemedText } from "./ThemedText"; +import { useORPC } from "../orpc/react"; + +type ToolStatus = "pending" | "executing" | "completed" | "failed" | "interrupted"; + +interface ProposePlanToolCardProps { + args: unknown; + result: unknown; + status: ToolStatus; + toolCallId: string; + workspaceId?: string; + onStartHere?: (content: string) => Promise; +} + +function unwrapJsonContainer(value: unknown): unknown { + if (!value || typeof value !== "object") { + return value; + } + + const record = value as Record; + if (record.type === "json" && "value" in record) { + return record.value; + } + + return value; +} + +function isToolErrorResult(val: unknown): val is ToolErrorResult { + if (!val || typeof val !== "object") { + return false; + } + + const record = val as Record; + return record.success === false && typeof record.error === "string"; +} + +function isLegacyProposePlanToolArgs(val: unknown): val is LegacyProposePlanToolArgs { + if (!val || typeof val !== "object") { + return false; + } + + const record = val as Record; + return typeof record.title === "string" && typeof record.plan === "string"; +} + +function isProposePlanToolResult( + val: unknown +): val is ProposePlanToolResult & { planContent?: string } { + if (!val || typeof val !== "object") { + return false; + } + + const record = val as Record; + if (record.success !== true) { + return false; + } + + return typeof record.planPath === "string"; +} + +function extractTitleFromMarkdown(markdown: string): string | null { + const match = /^#\s+(.+)$/m.exec(markdown); + return match ? match[1] : null; +} + +export function ProposePlanToolCard(props: ProposePlanToolCardProps): JSX.Element { + const client = useORPC(); + + const legacyArgs = useMemo(() => { + return isLegacyProposePlanToolArgs(props.args) ? props.args : null; + }, [props.args]); + + const unwrappedResult = useMemo(() => unwrapJsonContainer(props.result), [props.result]); + + const successResult = useMemo(() => { + return isProposePlanToolResult(unwrappedResult) ? unwrappedResult : null; + }, [unwrappedResult]); + + const errorResult = useMemo(() => { + return isToolErrorResult(unwrappedResult) ? unwrappedResult : null; + }, [unwrappedResult]); + + const [planContent, setPlanContent] = useState(null); + const [planError, setPlanError] = useState(null); + + useEffect(() => { + setPlanContent(null); + setPlanError(null); + }, [props.toolCallId]); + + useEffect(() => { + if (legacyArgs) { + return; + } + + if (props.status !== "completed") { + return; + } + + if (!successResult) { + return; + } + + // Back-compat: some tool calls may include planContent inline + if ( + typeof successResult.planContent === "string" && + successResult.planContent.trim().length > 0 + ) { + setPlanContent(successResult.planContent); + return; + } + + if (!props.workspaceId) { + setPlanError("Plan saved, but workspaceId is missing so content can't be fetched."); + return; + } + + let cancelled = false; + + (async () => { + const result = await client.workspace.getPlanContent({ workspaceId: props.workspaceId! }); + if (cancelled) { + return; + } + + if (!result.success) { + setPlanError(result.error ?? "Failed to load plan content"); + return; + } + + setPlanContent(result.data.content); + })().catch((err) => { + if (!cancelled) { + setPlanError(err instanceof Error ? err.message : String(err)); + } + }); + + return () => { + cancelled = true; + }; + }, [client, legacyArgs, props.status, props.workspaceId, successResult]); + + // Legacy tool calls (old sessions) have title + plan inline + if (legacyArgs) { + const onStartHere = props.onStartHere; + const handleStartHereWithPlan = onStartHere + ? async () => { + const fullContent = `# ${legacyArgs.title}\n\n${legacyArgs.plan}`; + await onStartHere(fullContent); + } + : undefined; + + return ( + + ); + } + + if (errorResult) { + return ( + + propose_plan failed + + {errorResult.error} + + + ); + } + + if (props.status !== "completed") { + return ( + + propose_plan + + Waiting for plan… + + + ); + } + + if (!successResult) { + return ( + + propose_plan + + (No result) + + + ); + } + + if (planError) { + return ( + + propose_plan + + {planError} + + + ); + } + + if (!planContent) { + return ( + + propose_plan + + Loading plan… + + + ); + } + + const title = + extractTitleFromMarkdown(planContent) ?? successResult.planPath.split("/").pop() ?? "Plan"; + + const onStartHere = props.onStartHere; + const handleStartHereWithPlan = onStartHere + ? async () => { + const fullContent = /^#\s+/m.test(planContent) + ? planContent + : `# ${title}\n\n${planContent}`; + await onStartHere(fullContent); + } + : undefined; + + return ( + + + + ); +} diff --git a/mobile/src/components/ReasoningControl.tsx b/mobile/src/components/ReasoningControl.tsx index 6d38234852..1f360e1695 100644 --- a/mobile/src/components/ReasoningControl.tsx +++ b/mobile/src/components/ReasoningControl.tsx @@ -5,7 +5,7 @@ import { useTheme } from "../theme"; import { ThemedText } from "./ThemedText"; import { useThinkingLevel, type ThinkingLevel } from "../contexts/ThinkingContext"; -const LEVELS: ThinkingLevel[] = ["off", "low", "medium", "high"]; +const LEVELS: ThinkingLevel[] = ["off", "low", "medium", "high", "xhigh"]; function thinkingLevelToValue(level: ThinkingLevel): number { const index = LEVELS.indexOf(level); diff --git a/mobile/src/components/RunSettingsSheet.tsx b/mobile/src/components/RunSettingsSheet.tsx index eefbd62e5e..a5d33afac5 100644 --- a/mobile/src/components/RunSettingsSheet.tsx +++ b/mobile/src/components/RunSettingsSheet.tsx @@ -4,6 +4,7 @@ import { Modal, Pressable, ScrollView, StyleSheet, Switch, TextInput, View } fro import { Ionicons } from "@expo/vector-icons"; import { useTheme } from "../theme"; import { ThemedText } from "./ThemedText"; +import { getThinkingPolicyForModel } from "@/common/utils/thinking/policy"; import type { ThinkingLevel, WorkspaceMode } from "../types/settings"; import { formatModelSummary, @@ -13,7 +14,6 @@ import { } from "../utils/modelCatalog"; const ALL_MODELS = listKnownModels(); -const THINKING_LEVELS: ThinkingLevel[] = ["off", "low", "medium", "high"]; interface RunSettingsSheetProps { visible: boolean; @@ -52,6 +52,9 @@ export function RunSettingsSheet(props: RunSettingsSheetProps): JSX.Element { }); }, [query]); + const allowedThinkingLevels = useMemo(() => { + return getThinkingPolicyForModel(props.selectedModel); + }, [props.selectedModel]); const recentModels = useMemo(() => { return props.recentModels.filter(isKnownModelId); }, [props.recentModels]); @@ -293,11 +296,13 @@ export function RunSettingsSheet(props: RunSettingsSheetProps): JSX.Element { Reasoning - {THINKING_LEVELS.map((level) => { + {allowedThinkingLevels.map((level) => { + const locked = allowedThinkingLevels.length <= 1; const active = props.thinkingLevel === level; return ( props.onSelectThinkingLevel(level)} style={({ pressed }) => [ styles.levelChip, @@ -305,7 +310,7 @@ export function RunSettingsSheet(props: RunSettingsSheetProps): JSX.Element { backgroundColor: active ? theme.colors.accent : theme.colors.surfaceSecondary, - opacity: pressed ? 0.85 : 1, + opacity: locked ? 1 : pressed ? 0.85 : 1, }, ]} > diff --git a/mobile/src/contexts/LiveBashOutputContext.tsx b/mobile/src/contexts/LiveBashOutputContext.tsx new file mode 100644 index 0000000000..c2250c3e6f --- /dev/null +++ b/mobile/src/contexts/LiveBashOutputContext.tsx @@ -0,0 +1,114 @@ +import type { JSX, ReactNode } from "react"; +import { createContext, useContext, useEffect, useRef, useState } from "react"; +import { BASH_TRUNCATE_MAX_TOTAL_BYTES } from "@/common/constants/toolLimits"; +import { + appendLiveBashOutputChunk, + toLiveBashOutputView, + type LiveBashOutputInternal, + type LiveBashOutputView, +} from "@/browser/utils/messages/liveBashOutputBuffer"; +import { assert } from "../utils/assert"; + +type Listener = () => void; + +class LiveBashOutputStore { + private readonly outputs = new Map(); + private readonly listeners = new Map>(); + + appendChunk(toolCallId: string, chunk: { text: string; isError: boolean }): void { + assert(toolCallId.length > 0, "appendChunk requires a toolCallId"); + + const prev = this.outputs.get(toolCallId); + const next = appendLiveBashOutputChunk(prev, chunk, BASH_TRUNCATE_MAX_TOTAL_BYTES); + this.outputs.set(toolCallId, next); + this.emit(toolCallId); + } + + clear(toolCallId: string): void { + if (!this.outputs.has(toolCallId)) { + return; + } + this.outputs.delete(toolCallId); + this.emit(toolCallId); + } + + getView(toolCallId: string): LiveBashOutputView | undefined { + const state = this.outputs.get(toolCallId); + return state ? toLiveBashOutputView(state) : undefined; + } + + subscribe(toolCallId: string, listener: Listener): () => void { + const set = this.listeners.get(toolCallId) ?? new Set(); + set.add(listener); + this.listeners.set(toolCallId, set); + + return () => { + const current = this.listeners.get(toolCallId); + if (!current) { + return; + } + current.delete(listener); + if (current.size === 0) { + this.listeners.delete(toolCallId); + } + }; + } + + private emit(toolCallId: string): void { + const set = this.listeners.get(toolCallId); + if (!set) { + return; + } + for (const listener of set) { + try { + listener(); + } catch (error) { + console.error("[LiveBashOutputStore] listener threw", error); + } + } + } +} + +const LiveBashOutputContext = createContext(null); + +export function LiveBashOutputProvider({ children }: { children: ReactNode }): JSX.Element { + const storeRef = useRef(null); + if (!storeRef.current) { + storeRef.current = new LiveBashOutputStore(); + } + + return ( + + {children} + + ); +} + +export function useLiveBashOutputStore(): LiveBashOutputStore { + const store = useContext(LiveBashOutputContext); + if (!store) { + throw new Error("useLiveBashOutputStore must be used within LiveBashOutputProvider"); + } + return store; +} + +export function useLiveBashOutputView(toolCallId: string | undefined): LiveBashOutputView | null { + const store = useLiveBashOutputStore(); + const [, forceRender] = useState(0); + + useEffect(() => { + if (!toolCallId) { + return undefined; + } + + return store.subscribe(toolCallId, () => { + forceRender((v) => v + 1); + }); + }, [store, toolCallId]); + + if (!toolCallId) { + return null; + } + + return store.getView(toolCallId) ?? null; +} diff --git a/mobile/src/contexts/ThinkingContext.tsx b/mobile/src/contexts/ThinkingContext.tsx index c0863f8eba..72f9b7cac1 100644 --- a/mobile/src/contexts/ThinkingContext.tsx +++ b/mobile/src/contexts/ThinkingContext.tsx @@ -2,9 +2,10 @@ import type { JSX } from "react"; import type { PropsWithChildren } from "react"; import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; import * as SecureStore from "expo-secure-store"; +import type { ThinkingLevel } from "@/common/types/thinking"; import { assert } from "../utils/assert"; -export type ThinkingLevel = "off" | "low" | "medium" | "high"; +export type { ThinkingLevel } from "@/common/types/thinking"; interface ThinkingContextValue { thinkingLevel: ThinkingLevel; @@ -27,7 +28,13 @@ function sanitizeWorkspaceId(workspaceId: string): string { async function readThinkingLevel(storageKey: string): Promise { try { const value = await SecureStore.getItemAsync(storageKey); - if (value === "off" || value === "low" || value === "medium" || value === "high") { + if ( + value === "off" || + value === "low" || + value === "medium" || + value === "high" || + value === "xhigh" + ) { return value; } return null; diff --git a/mobile/src/hooks/useWorkspaceDefaults.ts b/mobile/src/hooks/useWorkspaceDefaults.ts index 5195f6d9fa..40512a494d 100644 --- a/mobile/src/hooks/useWorkspaceDefaults.ts +++ b/mobile/src/hooks/useWorkspaceDefaults.ts @@ -49,7 +49,13 @@ async function writeGlobalMode(mode: WorkspaceMode): Promise { async function readGlobalReasoning(): Promise { try { const value = await SecureStore.getItemAsync(STORAGE_KEY_REASONING); - if (value === "off" || value === "low" || value === "medium" || value === "high") { + if ( + value === "off" || + value === "low" || + value === "medium" || + value === "high" || + value === "xhigh" + ) { return value; } return DEFAULT_REASONING; diff --git a/mobile/src/hooks/useWorkspaceSettings.ts b/mobile/src/hooks/useWorkspaceSettings.ts index efd3c1750f..33c0168259 100644 --- a/mobile/src/hooks/useWorkspaceSettings.ts +++ b/mobile/src/hooks/useWorkspaceSettings.ts @@ -131,7 +131,13 @@ function validateMode(value: string): WorkspaceMode | null { } function validateThinkingLevel(value: string): ThinkingLevel | null { - if (value === "off" || value === "low" || value === "medium" || value === "high") { + if ( + value === "off" || + value === "low" || + value === "medium" || + value === "high" || + value === "xhigh" + ) { return value; } return null; diff --git a/mobile/src/messages/MessageRenderer.tsx b/mobile/src/messages/MessageRenderer.tsx index b9b6769475..2d6895e72d 100644 --- a/mobile/src/messages/MessageRenderer.tsx +++ b/mobile/src/messages/MessageRenderer.tsx @@ -20,7 +20,9 @@ import { hasRenderableMarkdown } from "./markdownUtils"; import { Ionicons } from "@expo/vector-icons"; import { Surface } from "../components/Surface"; import { ThemedText } from "../components/ThemedText"; +import { AskUserQuestionToolCard } from "../components/AskUserQuestionToolCard"; import { ProposePlanCard } from "../components/ProposePlanCard"; +import { ProposePlanToolCard } from "../components/ProposePlanToolCard"; import { TodoToolCard } from "../components/TodoToolCard"; import { StatusSetToolCard } from "../components/StatusSetToolCard"; import type { TodoItem } from "../components/TodoItemView"; @@ -105,6 +107,14 @@ export function MessageRenderer({ return ; case "workspace-init": return ; + case "plan-display": + return ( + + ); case "tool": return ( @@ -891,22 +901,43 @@ function WorkspaceInitMessageCard({ } /** - * Type guard for propose_plan tool + * Plan display message card (from /plan) */ -function isProposePlanTool( - message: DisplayedMessage & { type: "tool" } -): message is DisplayedMessage & { - type: "tool"; - args: { title: string; plan: string }; -} { +function PlanDisplayMessageCard({ + message, + workspaceId, + onStartHere, +}: { + message: DisplayedMessage & { type: "plan-display" }; + workspaceId?: string; + onStartHere?: (content: string) => Promise; +}): JSX.Element { + const title = useMemo(() => { + const titleMatch = /^#\s+(.+)$/m.exec(message.content); + if (titleMatch) { + return titleMatch[1]; + } + const filename = message.path.split("/").pop(); + return filename ?? "Plan"; + }, [message.content, message.path]); + + const handleStartHereWithPlan = onStartHere + ? async () => { + const content = /^#\s+/m.test(message.content) + ? message.content + : `# ${title}\n\n${message.content}`; + await onStartHere(content); + } + : undefined; + return ( - message.toolName === "propose_plan" && - message.args !== null && - typeof message.args === "object" && - "title" in message.args && - "plan" in message.args && - typeof message.args.title === "string" && - typeof message.args.plan === "string" + ); } @@ -958,21 +989,28 @@ function ToolMessageCard({ onStartHere?: (content: string) => Promise; }): JSX.Element { // Special handling for propose_plan tool - if (isProposePlanTool(message)) { - const handleStartHereWithPlan = onStartHere - ? async () => { - const fullContent = `# ${message.args.title}\n\n${message.args.plan}`; - await onStartHere(fullContent); - } - : undefined; + if (message.toolName === "propose_plan") { + return ( + + ); + } + // Special handling for ask_user_question tool + if (message.toolName === "ask_user_question") { return ( - ); } diff --git a/mobile/src/messages/normalizeChatEvent.ts b/mobile/src/messages/normalizeChatEvent.ts index 98648c8d5c..861124593c 100644 --- a/mobile/src/messages/normalizeChatEvent.ts +++ b/mobile/src/messages/normalizeChatEvent.ts @@ -33,6 +33,7 @@ export const DISPLAYABLE_MESSAGE_TYPES: ReadonlySet = "stream-error", "history-hidden", "workspace-init", + "plan-display", ]); const DEBUG_TAG = "[ChatEventExpander]"; @@ -151,6 +152,7 @@ function transformMuxToDisplayed(message: MuxMessage): DisplayedMessage[] { isLastPartOfMessage: isLastPart, // Support both new enum ("user"|"idle") and legacy boolean (true) isCompacted: !!message.metadata?.compacted, + isIdleCompacted: message.metadata?.compacted === "idle", model: message.metadata?.model, timestamp: part.timestamp ?? baseTimestamp, }); @@ -226,6 +228,7 @@ export function createChatEventExpander(): ChatEventExpander { lines: [...initState.lines], exitCode: initState.exitCode, timestamp: initState.timestamp, + durationMs: initState.durationMs, }, ]; }; @@ -396,9 +399,18 @@ export function createChatEventExpander(): ChatEventExpander { return []; }, + // UI-only incremental output from the bash tool. + // This is rendered inside the bash tool card (and should not spam the timeline). + "bash-output": () => [], + + // Rolled-up session usage from deleted child workspaces (desktop-only for now). + "session-usage-delta": () => [], // Usage delta: mobile app doesn't display usage, silently ignore "usage-delta": () => [], + // Idle compaction signal: desktop auto-triggers; mobile currently ignores. + "idle-compaction-needed": () => [], + // Pass-through events: return unchanged "caught-up": () => [payload as WorkspaceChatEvent], "stream-error": () => [payload as WorkspaceChatEvent], diff --git a/mobile/src/messages/tools/toolRenderers.tsx b/mobile/src/messages/tools/toolRenderers.tsx index 75ba7e05c9..e127adccd4 100644 --- a/mobile/src/messages/tools/toolRenderers.tsx +++ b/mobile/src/messages/tools/toolRenderers.tsx @@ -1,12 +1,21 @@ import type { ReactNode } from "react"; import React from "react"; -import { View, Text, ScrollView, StyleSheet } from "react-native"; +import { View, Text, ScrollView, StyleSheet, Pressable } from "react-native"; +import { Link } from "expo-router"; import { parsePatch } from "diff"; import type { DisplayedMessage } from "@/common/types/message"; import { FILE_EDIT_TOOL_NAMES, type BashToolArgs, type BashToolResult, + type BashOutputToolArgs, + type BashOutputToolResult, + type BashBackgroundListArgs, + type BashBackgroundListResult, + type BashBackgroundTerminateArgs, + type BashBackgroundTerminateResult, + type WebFetchToolArgs, + type WebFetchToolResult, type FileEditInsertToolArgs, type FileEditInsertToolResult, type FileEditReplaceLinesToolArgs, @@ -16,8 +25,20 @@ import { type FileEditToolName, type FileReadToolArgs, type FileReadToolResult, + type TaskToolArgs, + type TaskToolResult, + type TaskAwaitToolArgs, + type TaskAwaitToolResult, + type TaskListToolArgs, + type TaskListToolResult, + type TaskTerminateToolArgs, + type TaskTerminateToolResult, + type AgentReportToolArgs, + type AgentReportToolResult, } from "@/common/types/tools"; import { useTheme } from "../../theme"; +import { MarkdownMessageBody } from "../../components/MarkdownMessageBody"; +import { useLiveBashOutputView } from "../../contexts/LiveBashOutputContext"; import { ThemedText } from "../../components/ThemedText"; export type ToolDisplayedMessage = DisplayedMessage & { type: "tool" }; @@ -44,6 +65,61 @@ export function renderSpecializedToolCard(message: ToolDisplayedMessage): ToolCa return null; } return buildFileReadViewModel(message as ToolDisplayedMessage & { args: FileReadToolArgs }); + case "web_fetch": + if (!isWebFetchToolArgs(message.args)) { + return null; + } + return buildWebFetchViewModel(message as ToolDisplayedMessage & { args: WebFetchToolArgs }); + case "bash_output": + if (!isBashOutputToolArgs(message.args)) { + return null; + } + return buildBashOutputViewModel( + message as ToolDisplayedMessage & { args: BashOutputToolArgs } + ); + case "bash_background_list": + if (!isBashBackgroundListArgs(message.args)) { + return null; + } + return buildBashBackgroundListViewModel( + message as ToolDisplayedMessage & { args: BashBackgroundListArgs } + ); + case "task": + if (!isTaskToolArgs(message.args)) { + return null; + } + return buildTaskViewModel(message as ToolDisplayedMessage & { args: TaskToolArgs }); + case "task_await": + if (!isTaskAwaitToolArgs(message.args)) { + return null; + } + return buildTaskAwaitViewModel(message as ToolDisplayedMessage & { args: TaskAwaitToolArgs }); + case "task_list": + if (!isTaskListToolArgs(message.args)) { + return null; + } + return buildTaskListViewModel(message as ToolDisplayedMessage & { args: TaskListToolArgs }); + case "task_terminate": + if (!isTaskTerminateToolArgs(message.args)) { + return null; + } + return buildTaskTerminateViewModel( + message as ToolDisplayedMessage & { args: TaskTerminateToolArgs } + ); + case "agent_report": + if (!isAgentReportToolArgs(message.args)) { + return null; + } + return buildAgentReportViewModel( + message as ToolDisplayedMessage & { args: AgentReportToolArgs } + ); + case "bash_background_terminate": + if (!isBashBackgroundTerminateArgs(message.args)) { + return null; + } + return buildBashBackgroundTerminateViewModel( + message as ToolDisplayedMessage & { args: BashBackgroundTerminateArgs } + ); default: if (!FILE_EDIT_TOOL_NAMES.includes(message.toolName as FileEditToolName)) { return null; @@ -75,7 +151,7 @@ function buildBashViewModel( if (result && result.exitCode !== undefined) { metadata.push({ label: "exit code", value: String(result.exitCode) }); } - if (result && result.truncated) { + if (result && "truncated" in result && result.truncated) { metadata.push({ label: "truncated", value: result.truncated.reason, @@ -88,7 +164,14 @@ function buildBashViewModel( caption: "bash", title: preview, summary: metadata.length > 0 ? : undefined, - content: , + content: ( + + ), defaultExpanded: message.status !== "completed" || Boolean(result && result.success === false), }; } @@ -128,6 +211,625 @@ function buildFileReadViewModel( }; } +function buildWebFetchViewModel( + message: ToolDisplayedMessage & { args: WebFetchToolArgs } +): ToolCardViewModel { + const args = message.args; + const result = coerceWebFetchToolResult(message.result); + + const metadata: MetadataItem[] = []; + if (result) { + if (result.success) { + metadata.push({ label: "title", value: truncate(result.title, 80) }); + if (result.byline) { + metadata.push({ label: "byline", value: truncate(result.byline, 80) }); + } + metadata.push({ label: "length", value: `${result.length.toLocaleString()} chars` }); + } else { + metadata.push({ label: "error", value: truncate(result.error, 80), tone: "danger" }); + } + } + + return { + icon: "🌐", + caption: "web_fetch", + title: truncate(args.url, 80), + summary: metadata.length > 0 ? : undefined, + content: , + defaultExpanded: message.status !== "completed" || Boolean(result && result.success === false), + }; +} + +function WebFetchContent({ + args, + result, +}: { + args: WebFetchToolArgs; + result: WebFetchToolResult | null; + status: ToolDisplayedMessage["status"]; +}): JSX.Element { + if (!result) { + return Fetching…; + } + + if (!result.success) { + return ; + } + + return ( + + + + {result.byline ? : null} + + + ); +} + +function buildBashOutputViewModel( + message: ToolDisplayedMessage & { args: BashOutputToolArgs } +): ToolCardViewModel { + const args = message.args; + const result = coerceBashOutputToolResult(message.result); + + const metadata: MetadataItem[] = [{ label: "process", value: truncate(args.process_id, 16) }]; + if (result && result.success) { + metadata.push({ label: "status", value: result.status }); + if (typeof result.exitCode === "number") { + metadata.push({ label: "exit", value: String(result.exitCode) }); + } + } + + return { + icon: "📥", + caption: "bash_output", + title: truncate(args.process_id, 48), + summary: metadata.length > 0 ? : undefined, + content: , + defaultExpanded: message.status !== "completed" || Boolean(result && result.success === false), + }; +} + +function BashOutputContent({ result }: { result: BashOutputToolResult | null }): JSX.Element { + if (!result) { + return Reading output…; + } + + if (!result.success) { + return ; + } + + return ( + + + + + ); +} + +function buildBashBackgroundListViewModel( + message: ToolDisplayedMessage & { args: BashBackgroundListArgs } +): ToolCardViewModel { + const result = coerceBashBackgroundListResult(message.result); + + const count = result && result.success ? result.processes.length : undefined; + + return { + icon: "🧵", + caption: "bash_background_list", + title: "Background processes", + subtitle: typeof count === "number" ? `${count} running/known` : undefined, + content: , + defaultExpanded: message.status !== "completed" || Boolean(result && result.success === false), + }; +} + +function BashBackgroundListContent({ + result, +}: { + result: BashBackgroundListResult | null; +}): JSX.Element { + if (!result) { + return Listing processes…; + } + + if (!result.success) { + return ; + } + + if (result.processes.length === 0) { + return (No background processes); + } + + return ( + + {result.processes.map((proc) => { + const title = proc.display_name ? proc.display_name : truncate(proc.script.trim(), 48); + return ( + + {title} + + + ); + })} + + ); +} + +function buildBashBackgroundTerminateViewModel( + message: ToolDisplayedMessage & { args: BashBackgroundTerminateArgs } +): ToolCardViewModel { + const args = message.args; + const result = coerceBashBackgroundTerminateResult(message.result); + + return { + icon: "🛑", + caption: "bash_background_terminate", + title: truncate(args.process_id, 48), + content: , + defaultExpanded: message.status !== "completed" || Boolean(result && result.success === false), + }; +} + +function BashBackgroundTerminateContent({ + result, +}: { + result: BashBackgroundTerminateResult | null; +}): JSX.Element { + if (!result) { + return Terminating…; + } + + if (!result.success) { + return ; + } + + return {result.message}; +} + +function buildTaskViewModel( + message: ToolDisplayedMessage & { args: TaskToolArgs } +): ToolCardViewModel { + const args = message.args; + const result = coerceTaskToolResult(message.result); + + const taskId = + result && !("success" in result) && typeof (result as { taskId?: unknown }).taskId === "string" + ? ((result as { taskId: string }).taskId ?? null) + : null; + + const metadata: MetadataItem[] = [{ label: "agent", value: args.subagent_type }]; + if (args.run_in_background) { + metadata.push({ label: "background", value: "true" }); + } + if (taskId) { + metadata.push({ label: "task", value: truncate(taskId, 16) }); + } + if (result && !("success" in result)) { + metadata.push({ label: "status", value: result.status }); + } + + return { + icon: "🧵", + caption: "task", + title: args.title, + subtitle: args.subagent_type, + summary: metadata.length > 0 ? : undefined, + content: , + defaultExpanded: message.status !== "completed" || Boolean(result && "success" in result), + }; +} + +function TaskToolContent({ + args, + result, + status, +}: { + args: TaskToolArgs; + result: TaskToolResult | null; + status: ToolDisplayedMessage["status"]; +}): JSX.Element { + const theme = useTheme(); + + if (result && "success" in result) { + return ; + } + + const taskId = + result && !("success" in result) && typeof (result as { taskId?: unknown }).taskId === "string" + ? (result as { taskId: string }).taskId + : null; + + const reportMarkdown = + result && !("success" in result) && result.status === "completed" + ? result.reportMarkdown + : null; + + return ( + + {taskId ? : null} + + {reportMarkdown ? ( + + + report + + + + + + ) : status === "executing" ? ( + Task running… + ) : null} + + ); +} + +function buildTaskAwaitViewModel( + message: ToolDisplayedMessage & { args: TaskAwaitToolArgs } +): ToolCardViewModel { + const args = message.args; + const result = coerceTaskAwaitToolResult(message.result); + + const taskCount = Array.isArray(args.task_ids) ? args.task_ids.length : undefined; + const title = taskCount ? `Awaiting ${taskCount} task(s)` : "Awaiting tasks"; + + const metadata: MetadataItem[] = []; + if (typeof args.timeout_secs === "number") { + metadata.push({ label: "timeout", value: `${args.timeout_secs}s` }); + } + + return { + icon: "⏳", + caption: "task_await", + title, + summary: metadata.length > 0 ? : undefined, + content: , + defaultExpanded: message.status !== "completed" || Boolean(result && "success" in result), + }; +} + +function TaskAwaitContent({ + result, + status, +}: { + result: TaskAwaitToolResult | null; + status: ToolDisplayedMessage["status"]; +}): JSX.Element { + const theme = useTheme(); + + if (!result) { + return Waiting…; + } + + if ("success" in result) { + return ; + } + + const results = result.results; + if (!Array.isArray(results) || results.length === 0) { + return status === "executing" ? ( + Waiting… + ) : ( + No tasks + ); + } + + return ( + + {results.map((entry) => ( + + {entry.status === "completed" ? ( + + + + ) : "error" in entry && typeof entry.error === "string" ? ( + + {entry.error} + + ) : null} + + ))} + + ); +} + +function buildTaskListViewModel( + message: ToolDisplayedMessage & { args: TaskListToolArgs } +): ToolCardViewModel { + const args = message.args; + const result = coerceTaskListToolResult(message.result); + + const statuses = Array.isArray(args.statuses) ? args.statuses.join(", ") : null; + + return { + icon: "📋", + caption: "task_list", + title: "Tasks", + subtitle: statuses ? `filter: ${statuses}` : undefined, + content: , + defaultExpanded: message.status !== "completed" || Boolean(result && "success" in result), + }; +} + +function TaskListContent({ + result, + status, +}: { + result: TaskListToolResult | null; + status: ToolDisplayedMessage["status"]; +}): JSX.Element { + const theme = useTheme(); + + if (!result) { + return Loading…; + } + + if ("success" in result) { + return ; + } + + const tasks = result.tasks; + if (!Array.isArray(tasks) || tasks.length === 0) { + return status === "executing" ? ( + Loading… + ) : ( + No tasks + ); + } + + return ( + + {tasks.map((task) => ( + + {task.title ? ( + + {task.title} + + ) : null} + + ))} + + ); +} + +function buildTaskTerminateViewModel( + message: ToolDisplayedMessage & { args: TaskTerminateToolArgs } +): ToolCardViewModel { + const args = message.args; + const result = coerceTaskTerminateToolResult(message.result); + + return { + icon: "🛑", + caption: "task_terminate", + title: `Terminate ${args.task_ids.length} task(s)`, + content: , + defaultExpanded: message.status !== "completed" || Boolean(result && "success" in result), + }; +} + +function TaskTerminateContent({ + result, + status, +}: { + result: TaskTerminateToolResult | null; + status: ToolDisplayedMessage["status"]; +}): JSX.Element { + const theme = useTheme(); + + if (!result) { + return Terminating…; + } + + if ("success" in result) { + return ; + } + + const results = result.results; + if (!Array.isArray(results) || results.length === 0) { + return status === "executing" ? ( + Terminating… + ) : ( + No results + ); + } + + return ( + + {results.map((entry) => ( + + {"error" in entry && typeof entry.error === "string" ? ( + + {entry.error} + + ) : null} + + ))} + + ); +} + +function buildAgentReportViewModel( + message: ToolDisplayedMessage & { args: AgentReportToolArgs } +): ToolCardViewModel { + const args = message.args; + const result = coerceAgentReportToolResult(message.result); + + return { + icon: "📝", + caption: "agent_report", + title: args.title ?? "Agent report", + content: , + defaultExpanded: true, + }; +} + +function AgentReportContent({ + args, + result, + status, +}: { + args: AgentReportToolArgs; + result: AgentReportToolResult | null; + status: ToolDisplayedMessage["status"]; +}): JSX.Element { + const theme = useTheme(); + + if (result && "success" in result && result.success === false) { + return ; + } + + if (status === "executing") { + return Reporting…; + } + + return ( + + + + ); +} + +function TaskResultRow({ + taskId, + status, + children, +}: { + taskId: string; + status: string; + children?: ReactNode; +}): JSX.Element { + const theme = useTheme(); + + return ( + + + + {taskId} + + + {status} + + + + {children} + + ); +} + +function WorkspaceLinkButton({ + workspaceId, + label, +}: { + workspaceId: string; + label?: string; +}): JSX.Element { + const theme = useTheme(); + + return ( + + ({ + paddingHorizontal: theme.spacing.sm, + paddingVertical: theme.spacing.xs, + borderRadius: theme.radii.pill, + borderWidth: StyleSheet.hairlineWidth, + borderColor: theme.colors.accent, + backgroundColor: theme.colors.accentMuted, + opacity: pressed ? 0.75 : 1, + })} + > + + {label ?? "Open workspace"} + + + + ); +} + +function ScrollableCodeBlock({ + label, + text, + tone, + maxHeight, +}: { + label: string; + text: string; + tone?: "default" | "warning" | "danger"; + maxHeight: number; +}): JSX.Element { + const theme = useTheme(); + const palette = getCodeBlockPalette(theme, tone ?? "default"); + + return ( + + + {label} + + + + {text.length === 0 ? "(empty)" : text} + + + + ); +} + function buildFileEditViewModel( message: ToolDisplayedMessage & { args: FileEditArgsUnion } ): ToolCardViewModel { @@ -231,16 +933,59 @@ function BashToolContent({ args, result, status, + toolCallId, }: { args: BashToolArgs; result: BashToolResult | null; status: ToolDisplayedMessage["status"]; + toolCallId: string; }): JSX.Element { + const theme = useTheme(); + const liveOutput = useLiveBashOutputView(toolCallId); + + const resultHasOutput = typeof (result as { output?: unknown } | null)?.output === "string"; + const showLiveOutput = status === "executing" || (Boolean(liveOutput) && !resultHasOutput); + + if (showLiveOutput) { + const combined = liveOutput?.combined ?? ""; + if (combined.trim().length > 0) { + return ( + + + {liveOutput?.truncated ? ( + + Live output truncated + + ) : null} + {result ? ( + + ) : null} + + ); + } + + if (!result) { + return Command is executing…; + } + } + if (!result) { return Command is executing…; } - const stdout = result.output?.trim() ?? ""; + const stdout = typeof result.output === "string" ? result.output.trim() : ""; const stderr = result.success ? "" : (result.error?.trim() ?? ""); return ( @@ -741,6 +1486,60 @@ function isFileReadToolArgs(value: unknown): value is FileReadToolArgs { return Boolean(value && typeof (value as FileReadToolArgs).filePath === "string"); } +function isWebFetchToolArgs(value: unknown): value is WebFetchToolArgs { + return Boolean(value && typeof (value as WebFetchToolArgs).url === "string"); +} + +function isBashOutputToolArgs(value: unknown): value is BashOutputToolArgs { + return Boolean(value && typeof (value as BashOutputToolArgs).process_id === "string"); +} + +function isBashBackgroundListArgs(value: unknown): value is BashBackgroundListArgs { + return Boolean(value && typeof value === "object"); +} + +function isBashBackgroundTerminateArgs(value: unknown): value is BashBackgroundTerminateArgs { + return Boolean(value && typeof (value as BashBackgroundTerminateArgs).process_id === "string"); +} + +function isTaskToolArgs(value: unknown): value is TaskToolArgs { + return ( + Boolean(value && typeof value === "object") && + typeof (value as TaskToolArgs).prompt === "string" && + typeof (value as TaskToolArgs).title === "string" && + typeof (value as TaskToolArgs).subagent_type === "string" + ); +} + +function isTaskAwaitToolArgs(value: unknown): value is TaskAwaitToolArgs { + if (!value || typeof value !== "object") { + return false; + } + const args = value as TaskAwaitToolArgs; + if (args.task_ids !== undefined && !Array.isArray(args.task_ids)) { + return false; + } + return true; +} + +function isTaskListToolArgs(value: unknown): value is TaskListToolArgs { + if (!value || typeof value !== "object") { + return false; + } + const args = value as TaskListToolArgs; + if (args.statuses !== undefined && !Array.isArray(args.statuses)) { + return false; + } + return true; +} + +function isTaskTerminateToolArgs(value: unknown): value is TaskTerminateToolArgs { + return Boolean(value && Array.isArray((value as TaskTerminateToolArgs).task_ids)); +} + +function isAgentReportToolArgs(value: unknown): value is AgentReportToolArgs { + return Boolean(value && typeof (value as AgentReportToolArgs).reportMarkdown === "string"); +} function isFileEditArgsUnion(value: unknown): value is FileEditArgsUnion { return Boolean(value && typeof (value as FileEditArgsUnion).file_path === "string"); } @@ -757,6 +1556,34 @@ function coerceBashToolResult(value: unknown): BashToolResult | null { return null; } +function coerceWebFetchToolResult(value: unknown): WebFetchToolResult | null { + if (value && typeof value === "object" && "success" in value) { + return value as WebFetchToolResult; + } + return null; +} + +function coerceBashOutputToolResult(value: unknown): BashOutputToolResult | null { + if (value && typeof value === "object" && "success" in value) { + return value as BashOutputToolResult; + } + return null; +} + +function coerceBashBackgroundListResult(value: unknown): BashBackgroundListResult | null { + if (value && typeof value === "object" && "success" in value) { + return value as BashBackgroundListResult; + } + return null; +} + +function coerceBashBackgroundTerminateResult(value: unknown): BashBackgroundTerminateResult | null { + if (value && typeof value === "object" && "success" in value) { + return value as BashBackgroundTerminateResult; + } + return null; +} + function coerceFileReadToolResult(value: unknown): FileReadToolResult | null { if (value && typeof value === "object" && "success" in value) { return value as FileReadToolResult; @@ -770,3 +1597,65 @@ function coerceFileEditResultUnion(value: unknown): FileEditResultUnion | null { } return null; } + +function coerceTaskToolResult(value: unknown): TaskToolResult | null { + if (!value || typeof value !== "object") { + return null; + } + if ("success" in value && typeof (value as { success?: unknown }).success === "boolean") { + return value as TaskToolResult; + } + if ("status" in value && typeof (value as { status?: unknown }).status === "string") { + return value as TaskToolResult; + } + return null; +} + +function coerceTaskAwaitToolResult(value: unknown): TaskAwaitToolResult | null { + if (!value || typeof value !== "object") { + return null; + } + if ("success" in value && typeof (value as { success?: unknown }).success === "boolean") { + return value as TaskAwaitToolResult; + } + if ("results" in value && Array.isArray((value as { results?: unknown }).results)) { + return value as TaskAwaitToolResult; + } + return null; +} + +function coerceTaskListToolResult(value: unknown): TaskListToolResult | null { + if (!value || typeof value !== "object") { + return null; + } + if ("success" in value && typeof (value as { success?: unknown }).success === "boolean") { + return value as TaskListToolResult; + } + if ("tasks" in value && Array.isArray((value as { tasks?: unknown }).tasks)) { + return value as TaskListToolResult; + } + return null; +} + +function coerceTaskTerminateToolResult(value: unknown): TaskTerminateToolResult | null { + if (!value || typeof value !== "object") { + return null; + } + if ("success" in value && typeof (value as { success?: unknown }).success === "boolean") { + return value as TaskTerminateToolResult; + } + if ("results" in value && Array.isArray((value as { results?: unknown }).results)) { + return value as TaskTerminateToolResult; + } + return null; +} + +function coerceAgentReportToolResult(value: unknown): AgentReportToolResult | null { + if (!value || typeof value !== "object") { + return null; + } + if ("success" in value && typeof (value as { success?: unknown }).success === "boolean") { + return value as AgentReportToolResult; + } + return null; +} diff --git a/mobile/src/screens/WorkspaceScreen.tsx b/mobile/src/screens/WorkspaceScreen.tsx index a93c9b11ed..74b6f89a89 100644 --- a/mobile/src/screens/WorkspaceScreen.tsx +++ b/mobile/src/screens/WorkspaceScreen.tsx @@ -31,6 +31,7 @@ import type { ThinkingLevel, WorkspaceMode } from "../types/settings"; import { FloatingTodoCard } from "../components/FloatingTodoCard"; import type { TodoItem } from "../components/TodoItemView"; import type { DisplayedMessage, WorkspaceChatEvent } from "../types"; +import { useLiveBashOutputStore } from "../contexts/LiveBashOutputContext"; import { useWorkspaceChat } from "../contexts/WorkspaceChatContext"; import { applyChatEvent, TimelineEntry } from "./chatTimelineReducer"; import type { SlashSuggestion } from "@/browser/utils/slashCommands/types"; @@ -41,6 +42,8 @@ import { SlashCommandSuggestions } from "../components/SlashCommandSuggestions"; import { executeSlashCommand } from "../utils/slashCommandRunner"; import { createCompactedMessage } from "../utils/messageHelpers"; import type { RuntimeConfig, RuntimeMode } from "@/common/types/runtime"; +import { enforceThinkingPolicy } from "@/common/utils/thinking/policy"; +import { isThinkingLevel } from "@/common/types/thinking"; import { RUNTIME_MODE, parseRuntimeModeAndHost, buildRuntimeString } from "@/common/types/runtime"; import { loadRuntimePreference, saveRuntimePreference } from "../utils/workspacePreferences"; import { FullscreenComposerModal } from "../components/FullscreenComposerModal"; @@ -52,6 +55,7 @@ import { assertKnownModelId, formatModelSummary, getModelDisplayName, + isKnownModelId, sanitizeModelSequence, } from "../utils/modelCatalog"; @@ -178,6 +182,7 @@ function WorkspaceScreenInner({ const theme = useTheme(); const spacing = theme.spacing; const insets = useSafeAreaInsets(); + const liveBashOutputStore = useLiveBashOutputStore(); const { getExpander } = useWorkspaceChat(); const client = useORPC(); const { @@ -194,6 +199,10 @@ function WorkspaceScreenInner({ const { recentModels, addRecentModel } = useModelHistory(); const [isRunSettingsVisible, setRunSettingsVisible] = useState(false); const selectedModelEntry = useMemo(() => assertKnownModelId(model), [model]); + const effectiveThinkingLevel = useMemo( + () => enforceThinkingPolicy(model, thinkingLevel), + [model, thinkingLevel] + ); const supportsExtendedContext = selectedModelEntry.provider === "anthropic"; const modelPickerRecents = useMemo( () => sanitizeModelSequence([model, ...recentModels]), @@ -203,16 +212,25 @@ function WorkspaceScreenInner({ () => ({ model, mode, - thinkingLevel, + thinkingLevel: effectiveThinkingLevel, providerOptions: { anthropic: { use1MContext, }, }, }), - [model, mode, thinkingLevel, use1MContext] + [model, mode, effectiveThinkingLevel, use1MContext] ); const [input, setInput] = useState(""); + + // Keep persisted thinking level compatible with the selected model. + // This avoids invalid combinations when switching models (or when loading legacy settings). + useEffect(() => { + if (effectiveThinkingLevel === thinkingLevel) { + return; + } + void setThinkingLevel(effectiveThinkingLevel); + }, [effectiveThinkingLevel, thinkingLevel, setThinkingLevel]); const [suppressCommandSuggestions, setSuppressCommandSuggestions] = useState(false); const setInputWithSuggestionGuard = useCallback((next: string) => { setInput(next); @@ -460,6 +478,39 @@ function WorkspaceScreenInner({ const metadata = metadataQuery.data ?? null; + // Seed per-workspace settings from backend metadata (desktop parity). + // This keeps model + thinking consistent across devices. + useEffect(() => { + if (!workspaceId || !metadata?.aiSettings) { + return; + } + + const ai = metadata.aiSettings as { model?: unknown; thinkingLevel?: unknown }; + const nextModel = typeof ai.model === "string" && isKnownModelId(ai.model) ? ai.model : null; + const nextThinking = isThinkingLevel(ai.thinkingLevel) ? ai.thinkingLevel : null; + + const modelForThinking = nextModel ?? model; + const effectiveThinking = nextThinking + ? enforceThinkingPolicy(modelForThinking, nextThinking) + : null; + + if (nextModel && nextModel !== model) { + void setModel(nextModel); + } + + if (effectiveThinking && effectiveThinking !== thinkingLevel) { + void setThinkingLevel(effectiveThinking); + } + }, [ + workspaceId, + metadata?.aiSettings?.model, + metadata?.aiSettings?.thinkingLevel, + model, + thinkingLevel, + setModel, + setThinkingLevel, + ]); + useEffect(() => { // Skip SSE subscription in creation mode (no workspace yet) if (isCreationMode) return; @@ -476,6 +527,46 @@ function WorkspaceScreenInner({ const handlePayload = (payload: WorkspaceChatEvent) => { // Track streaming state and tokens (60s trailing window like desktop) if (payload && typeof payload === "object" && "type" in payload) { + if (payload.type === "bash-output") { + const bashOutput = payload as { toolCallId?: unknown; text?: unknown; isError?: unknown }; + if ( + typeof bashOutput.toolCallId === "string" && + typeof bashOutput.text === "string" && + typeof bashOutput.isError === "boolean" + ) { + liveBashOutputStore.appendChunk(bashOutput.toolCallId, { + text: bashOutput.text, + isError: bashOutput.isError, + }); + } else if (__DEV__) { + console.warn("[WorkspaceScreen] Ignoring malformed bash-output event", payload); + } + + return; + } + + // Keep bash live output in sync with tool lifecycle (desktop parity). + // - Clear on tool-call-start (new invocation) + // - Clear on tool-call-end only once the real tool result has output. + // If output is missing (e.g. tmpfile overflow), keep the tail buffer so the UI still shows something. + if (payload.type === "tool-call-start") { + const toolEvent = payload as { toolName?: unknown; toolCallId?: unknown }; + if (toolEvent.toolName === "bash" && typeof toolEvent.toolCallId === "string") { + liveBashOutputStore.clear(toolEvent.toolCallId); + } + } else if (payload.type === "tool-call-end") { + const toolEvent = payload as { + toolName?: unknown; + toolCallId?: unknown; + result?: unknown; + }; + if (toolEvent.toolName === "bash" && typeof toolEvent.toolCallId === "string") { + const output = (toolEvent.result as { output?: unknown } | undefined)?.output; + if (typeof output === "string") { + liveBashOutputStore.clear(toolEvent.toolCallId); + } + } + } if (payload.type === "caught-up") { hasCaughtUpRef.current = true; @@ -620,7 +711,7 @@ function WorkspaceScreenInner({ controller.abort(); wsRef.current = null; }; - }, [client, workspaceId, isCreationMode, recordStreamUsage, getExpander]); + }, [client, workspaceId, isCreationMode, recordStreamUsage, getExpander, liveBashOutputStore]); // Reset timeline, todos, and editing state when workspace changes useEffect(() => { @@ -649,16 +740,34 @@ function WorkspaceScreenInner({ if (modelId === model) { return; } + + const nextThinkingLevel = enforceThinkingPolicy(modelId, thinkingLevel); + try { await setModel(modelId); addRecentModel(modelId); + + if (nextThinkingLevel !== thinkingLevel) { + await setThinkingLevel(nextThinkingLevel); + } + + if (workspaceId) { + client.workspace + .updateAISettings({ + workspaceId, + aiSettings: { model: modelId, thinkingLevel: nextThinkingLevel }, + }) + .catch(() => { + // Best-effort only. + }); + } } catch (error) { if (process.env.NODE_ENV !== "production") { console.error("Failed to update model", error); } } }, - [model, setModel, addRecentModel] + [addRecentModel, client, model, setModel, setThinkingLevel, thinkingLevel, workspaceId] ); const handleSelectMode = useCallback( @@ -673,12 +782,24 @@ function WorkspaceScreenInner({ const handleSelectThinkingLevel = useCallback( (level: ThinkingLevel) => { - if (level === thinkingLevel) { + const effective = enforceThinkingPolicy(model, level); + if (effective === thinkingLevel) { return; } - void setThinkingLevel(level); + + void setThinkingLevel(effective).then(() => { + if (!workspaceId) { + return; + } + + client.workspace + .updateAISettings({ workspaceId, aiSettings: { model, thinkingLevel: effective } }) + .catch(() => { + // Best-effort only. + }); + }); }, - [thinkingLevel, setThinkingLevel] + [client, model, thinkingLevel, setThinkingLevel, workspaceId] ); const handleToggle1MContext = useCallback(() => { @@ -737,50 +858,83 @@ function WorkspaceScreenInner({ setSuppressCommandSuggestions(true); if (isCreationMode) { + if (!creationContext) { + showErrorToast("New workspace", "Missing creation context"); + setInputWithSuggestionGuard(originalContent); + setIsSending(false); + return false; + } + const runtimeConfig: RuntimeConfig | undefined = runtimeMode === RUNTIME_MODE.SSH ? { type: "ssh" as const, host: sshHost, srcBaseDir: "~/mux" } : undefined; - const result = await client.workspace.sendMessage({ - workspaceId: null, + const identity = await client.nameGeneration.generate({ message: trimmed, - options: { - ...sendMessageOptions, - projectPath: creationContext!.projectPath, - trunkBranch, - runtimeConfig, - }, + fallbackModel: sendMessageOptions.model, }); - if (!result.success) { - const err = result.error; + if (!identity.success) { + const err = identity.error; const errorMsg = typeof err === "string" ? err : err?.type === "unknown" ? err.raw : (err?.type ?? "Unknown error"); - console.error("[createWorkspace] Failed:", errorMsg); - setTimeline((current) => - applyChatEvent(current, { type: "error", error: errorMsg } as WorkspaceChatEvent) - ); + console.error("[createWorkspace] Name generation failed:", errorMsg); + showErrorToast("New workspace", errorMsg); setInputWithSuggestionGuard(originalContent); setIsSending(false); return false; } - if (result.data.metadata) { - if (runtimeMode !== RUNTIME_MODE.LOCAL) { - const runtimeString = buildRuntimeString(runtimeMode, sshHost); - if (runtimeString) { - await saveRuntimePreference(creationContext!.projectPath, runtimeString); - } + const createResult = await client.workspace.create({ + projectPath: creationContext.projectPath, + branchName: identity.data.name, + trunkBranch, + title: identity.data.title, + runtimeConfig, + }); + + if (!createResult.success) { + console.error("[createWorkspace] Failed:", createResult.error); + showErrorToast("New workspace", createResult.error ?? "Failed to create workspace"); + setInputWithSuggestionGuard(originalContent); + setIsSending(false); + return false; + } + + if (runtimeMode !== RUNTIME_MODE.LOCAL) { + const runtimeString = buildRuntimeString(runtimeMode, sshHost); + if (runtimeString) { + await saveRuntimePreference(creationContext.projectPath, runtimeString); } + } + + const createdWorkspaceId = createResult.metadata.id; + + const sendResult = await client.workspace.sendMessage({ + workspaceId: createdWorkspaceId, + message: trimmed, + options: sendMessageOptions, + }); - router.replace(`/workspace/${result.data.metadata.id}`); + if (!sendResult.success) { + const err = sendResult.error; + const errorMsg = + typeof err === "string" + ? err + : err?.type === "unknown" + ? err.raw + : (err?.type ?? "Unknown error"); + console.error("[createWorkspace] Initial message failed:", errorMsg); + showErrorToast("Message", errorMsg); } + router.replace(`/workspace/${createdWorkspaceId}`); + setIsSending(false); return true; } diff --git a/mobile/src/screens/chatTimelineReducer.ts b/mobile/src/screens/chatTimelineReducer.ts index 222cebcd93..ce7db7bd2a 100644 --- a/mobile/src/screens/chatTimelineReducer.ts +++ b/mobile/src/screens/chatTimelineReducer.ts @@ -13,6 +13,7 @@ const DISPLAYABLE_MESSAGE_TYPES: ReadonlySet = new Set "stream-error", "history-hidden", "workspace-init", + "plan-display", ]); function isDisplayedMessageEvent(event: WorkspaceChatEvent): event is DisplayedMessage { diff --git a/mobile/src/types/settings.ts b/mobile/src/types/settings.ts index 1f16ae3aba..9b987bf192 100644 --- a/mobile/src/types/settings.ts +++ b/mobile/src/types/settings.ts @@ -2,5 +2,5 @@ * Settings types for workspace and global defaults */ -export type ThinkingLevel = "off" | "low" | "medium" | "high"; +export type { ThinkingLevel } from "@/common/types/thinking"; export type WorkspaceMode = "plan" | "exec"; diff --git a/mobile/src/utils/slashCommandRunner.ts b/mobile/src/utils/slashCommandRunner.ts index da23ef0a20..57c0de8d47 100644 --- a/mobile/src/utils/slashCommandRunner.ts +++ b/mobile/src/utils/slashCommandRunner.ts @@ -71,13 +71,21 @@ export async function executeSlashCommand( return true; case "new": return handleNew(ctx, parsed); - case "unknown-command": - return false; - case "telemetry-set": - case "telemetry-help": + case "truncate": + return handleTruncate(ctx, parsed.percentage); + case "idle-compaction": + return handleIdleCompaction(ctx, parsed.hours); + case "plan-show": + case "plan-open": + case "mcp-add": + case "mcp-edit": + case "mcp-remove": + case "mcp-open": case "vim-toggle": ctx.showInfo("Not supported", "This command is only available on the desktop app."); return true; + case "unknown-command": + return false; default: return false; } @@ -114,6 +122,35 @@ async function handleTruncate( } } +async function handleIdleCompaction( + ctx: SlashCommandRunnerContext, + hours: number | null +): Promise { + const projectPath = ctx.metadata?.projectPath; + if (!projectPath) { + ctx.showError("Idle compaction", "Current workspace project path unknown"); + return true; + } + + try { + const result = await ctx.client.projects.idleCompaction.set({ projectPath, hours }); + if (!result.success) { + ctx.showError("Idle compaction", result.error ?? "Failed to update idle compaction"); + return true; + } + + ctx.showInfo( + "Idle compaction", + hours === null ? "Disabled idle compaction" : `Idle compaction set to ${hours}h` + ); + + return true; + } catch (error) { + ctx.showError("Idle compaction", getErrorMessage(error)); + return true; + } +} + async function handleCompaction( ctx: SlashCommandRunnerContext, parsed: Extract diff --git a/src/browser/components/tools/TaskToolCall.tsx b/src/browser/components/tools/TaskToolCall.tsx index 0519a81713..f6c81415ed 100644 --- a/src/browser/components/tools/TaskToolCall.tsx +++ b/src/browser/components/tools/TaskToolCall.tsx @@ -12,6 +12,8 @@ import { useToolExpansion, getStatusDisplay, type ToolStatus } from "./shared/to import { MarkdownRenderer } from "../Messages/MarkdownRenderer"; import { cn } from "@/common/lib/utils"; import { Tooltip, TooltipTrigger, TooltipContent } from "../ui/tooltip"; +import { useWorkspaceContext, toWorkspaceSelection } from "@/browser/contexts/WorkspaceContext"; +import { useCopyToClipboard } from "@/browser/hooks/useCopyToClipboard"; import type { TaskToolArgs, TaskToolSuccessResult, @@ -120,10 +122,41 @@ const AgentTypeBadge: React.FC<{ ); }; -// Task ID display with monospace styling -const TaskId: React.FC<{ id: string; className?: string }> = ({ id, className }) => ( - {id} -); +// Task ID display with open/copy affordance. +// - If the task workspace exists locally, clicking opens it. +// - Otherwise, clicking copies the ID (so the user can search / share it). +const TaskId: React.FC<{ id: string; className?: string }> = ({ id, className }) => { + const { workspaceMetadata, setSelectedWorkspace } = useWorkspaceContext(); + const { copied, copyToClipboard } = useCopyToClipboard(); + + const workspace = workspaceMetadata.get(id); + + return ( + + + + + + {workspace ? "Open workspace" : copied ? "Copied" : "Copy task ID"} + + + ); +}; // ═══════════════════════════════════════════════════════════════════════════════ // TASK TOOL CALL (spawn sub-agent) diff --git a/src/browser/utils/messages/ChatEventProcessor.ts b/src/browser/utils/messages/ChatEventProcessor.ts index 268da68da8..a803c02046 100644 --- a/src/browser/utils/messages/ChatEventProcessor.ts +++ b/src/browser/utils/messages/ChatEventProcessor.ts @@ -45,7 +45,12 @@ export interface InitState { status: "running" | "success" | "error"; lines: string[]; exitCode: number | null; + + /** Start timestamp from init-start. */ timestamp: number; + + /** Duration in milliseconds (null while running). */ + durationMs: number | null; } export interface ChatEventProcessor { @@ -120,6 +125,7 @@ export function createChatEventProcessor(): ChatEventProcessor { lines: [], exitCode: null, timestamp: event.timestamp, + durationMs: null, }; return; } @@ -145,7 +151,19 @@ export function createChatEventProcessor(): ChatEventProcessor { } initState.status = event.exitCode === 0 ? "success" : "error"; initState.exitCode = event.exitCode; - initState.timestamp = event.timestamp; + + const durationMs = event.timestamp - initState.timestamp; + if (!Number.isFinite(durationMs) || durationMs < 0) { + console.error("Init hook duration was invalid", { + start: initState.timestamp, + end: event.timestamp, + durationMs, + }); + initState.durationMs = null; + } else { + initState.durationMs = durationMs; + } + return; } diff --git a/src/browser/utils/messages/liveBashOutputBuffer.ts b/src/browser/utils/messages/liveBashOutputBuffer.ts index ea3bee6c6f..f64a902dc3 100644 --- a/src/browser/utils/messages/liveBashOutputBuffer.ts +++ b/src/browser/utils/messages/liveBashOutputBuffer.ts @@ -28,8 +28,31 @@ function normalizeNewlines(text: string): string { // In our UI, that reads better as actual line breaks. return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); } +let warnedMissingTextEncoder = false; + function getUtf8ByteLength(text: string): number { - return new TextEncoder().encode(text).length; + if (typeof TextEncoder !== "undefined") { + return new TextEncoder().encode(text).length; + } + + // Defensive fallback for runtimes without TextEncoder (some RN/Hermes builds). + // encodeURIComponent uses UTF-8 percent-encoding; count bytes by scanning '%XX' sequences. + if (!warnedMissingTextEncoder && typeof console !== "undefined") { + warnedMissingTextEncoder = true; + console.warn("[liveBashOutputBuffer] TextEncoder unavailable; using slow UTF-8 fallback"); + } + + const encoded = encodeURIComponent(text); + let bytes = 0; + for (let i = 0; i < encoded.length; i++) { + if (encoded[i] === "%") { + bytes += 1; + i += 2; + } else { + bytes += 1; + } + } + return bytes; } export function appendLiveBashOutputChunk( diff --git a/src/node/services/sessionTimingService.ts b/src/node/services/sessionTimingService.ts index 6ebcdf510a..54cd788baf 100644 --- a/src/node/services/sessionTimingService.ts +++ b/src/node/services/sessionTimingService.ts @@ -166,7 +166,7 @@ export class SessionTimingService { // Serialize disk writes per workspace; useful for tests and crash-safe ordering. private readonly pendingWrites = new Map>(); private readonly writeEpoch = new Map(); - private readonly tickIntervals = new Map(); + private readonly tickIntervals = new Map>(); private statsTabState: StatsTabState = { enabled: false, diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 9dcc6536fe..851bd548f0 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -111,7 +111,7 @@ export class WorkspaceService extends EventEmitter { >(); // Debounce post-compaction metadata refreshes (file_edit_* can fire rapidly) - private readonly postCompactionRefreshTimers = new Map(); + private readonly postCompactionRefreshTimers = new Map>(); // Tracks workspaces currently being renamed to prevent streaming during rename private readonly renamingWorkspaces = new Set(); // Tracks workspaces currently being removed to prevent new sessions/streams during deletion.