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.