import { useEffect, useLayoutEffect, useRef, useState, useMemo, useCallback } from 'react';
import {
  View,
  Text,
  TextInput,
  StyleSheet,
  ScrollView,
  TouchableOpacity,
  Platform,
  KeyboardAvoidingView,
  ActivityIndicator,
  Alert,
  Pressable,
  Image,
  AppState,
  AppStateStatus,
  NativeScrollEvent,
  NativeSyntheticEvent,
  TextInputKeyPressEventData,
  useWindowDimensions,
  Modal,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import * as Clipboard from 'expo-clipboard';

const darkSpinner = require('../../assets/loading-spinner.gif');
const lightSpinner = require('../../assets/light-spinner.gif');
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import {
	DEFAULT_HANDS_FREE_MESSAGE_DEBOUNCE_MS,
	useConfigContext,
	saveConfig,
} from '../store/config';
import { useSessionContext } from '../store/sessions';
import { useMessageQueueContext } from '../store/message-queue';
import { MessageQueuePanel } from '../ui/MessageQueuePanel';
import { ResponseHistoryPanel } from '../ui/ResponseHistoryPanel';
import { speakRemoteTts, stopRemoteTts } from '../lib/remoteTts';
import { useConnectionManager } from '../store/connectionManager';
import { useTunnelConnection } from '../store/tunnelConnection';
import { useProfile } from '../store/profile';
import { ConnectionStatusIndicator } from '../ui/ConnectionStatusIndicator';
import { ChatMessage, AgentProgressUpdate } from '../lib/openaiClient';
import { ExtendedSettingsApiClient } from '../lib/settingsApi';
import { RecoveryState, formatConnectionStatus } from '../lib/connectionRecovery';
import * as Speech from 'expo-speech';
import * as ImagePicker from 'expo-image-picker';
import {
  extractRespondToUserResponseEvents,
  preprocessTextForTTS,
  shouldCollapseMessage,
  formatToolArguments,
  getCompactToolExecutionPreview,
  getToolResultsSummary,
  getAgentConversationStateLabel,
  extractRespondToUserContentFromArgs,
  RESPOND_TO_USER_TOOL,
  isToolOnlyMessage,
  normalizeAgentConversationState,
  groupToolActivity,
  type AgentConversationState,
  type AgentUserResponseEvent,
  type HandsFreePhase,
  type Loop,
  type PredefinedPromptSummary,
  type Skill,
  type ToolActivityGroup,
} from '@dotagents/shared';
import { useHeaderHeight } from '@react-navigation/elements';
import { useIsFocused } from '@react-navigation/native';
import { useTheme } from '../ui/ThemeProvider';
import { spacing, radius, Theme, hexToRgba } from '../ui/theme';
import { MarkdownRenderer } from '../ui/MarkdownRenderer';
import { AgentSelectorSheet } from '../ui/AgentSelectorSheet';
import { HandsFreeStatusChip } from '../ui/HandsFreeStatusChip';
import {
  createButtonAccessibilityLabel,
  createChatComposerAccessibilityHint,
  createExpandCollapseAccessibilityLabel,
  createMicControlAccessibilityHint,
  createMicControlAccessibilityLabel,
  createMinimumTouchTargetStyle,
  createSwitchAccessibilityLabel,
  createTextInputAccessibilityLabel,
  createVoiceInputLiveRegionAnnouncement,
} from '../lib/accessibility';
import { formatVoiceDebugEntry, useVoiceDebug } from '../lib/voice/voiceDebug';
import { useSpeechRecognizer } from '../lib/voice/useSpeechRecognizer';
import { useHandsFreeController } from '../lib/voice/useHandsFreeController';
import { createDelegationProgressMessages } from '../lib/delegationProgress';

interface PendingImageAttachment {
  id: string;
  name: string;
  previewUri: string;
  dataUrl: string;
}

const MAX_PENDING_IMAGES = 4;
const MAX_PENDING_IMAGE_FILE_SIZE_BYTES = 4 * 1024 * 1024;
const MAX_TOTAL_PENDING_IMAGE_EMBEDDED_BYTES = 900 * 1024;
const INITIAL_VISIBLE_CHAT_MESSAGES = 80;
const VISIBLE_CHAT_MESSAGES_INCREMENT = 60;
const CHAT_COMPOSER_HINT_NATIVE_ID = 'chat-composer-hint';
const CHAT_VOICE_STATUS_LIVE_REGION_NATIVE_ID = 'chat-voice-status-live-region';
const AUTO_TTS_DUPLICATE_SUPPRESSION_MS = 5_000;
const MESSAGE_COPY_FEEDBACK_RESET_MS = 2_000;

const IMAGE_MIME_BY_EXTENSION: Record<string, string> = {
  '.png': 'image/png',
  '.jpg': 'image/jpeg',
  '.jpeg': 'image/jpeg',
  '.gif': 'image/gif',
  '.webp': 'image/webp',
  '.bmp': 'image/bmp',
  '.svg': 'image/svg+xml',
  '.heic': 'image/heic',
  '.heif': 'image/heif',
};

const escapeMarkdownImageAlt = (value: string) => value.replace(/[\[\]\\]/g, '').trim();

const normalizeAutoTtsTextKey = (value: string) => value.replace(/\s+/g, ' ').trim().toLowerCase();

const getApproxBase64Bytes = (base64: string) => {
  const normalized = base64.replace(/\s+/g, '');
  if (!normalized) return 0;
  const padding = normalized.endsWith('==') ? 2 : normalized.endsWith('=') ? 1 : 0;
  return Math.max(0, Math.floor((normalized.length * 3) / 4) - padding);
};

const getApproxDataUrlBytes = (dataUrl: string) => {
  const [, base64 = ''] = dataUrl.split(',', 2);
  return getApproxBase64Bytes(base64);
};

const formatMb = (bytes: number) => `${(bytes / (1024 * 1024)).toFixed(2)}MB`;

const inferImageMimeType = (asset: {
  mimeType?: string | null;
  fileName?: string | null;
  uri?: string | null;
}) => {
  const mimeType = asset.mimeType?.trim().toLowerCase();
  if (mimeType?.startsWith('image/')) {
    return mimeType;
  }

  const pathLike = (asset.fileName || asset.uri || '').split('?')[0].split('#')[0];
  const extensionMatch = pathLike.match(/\.([a-z0-9]+)$/i);
  if (!extensionMatch) {
    return null;
  }
  return IMAGE_MIME_BY_EXTENSION[`.${extensionMatch[1].toLowerCase()}`] || null;
};

const buildMessageWithPendingImages = (text: string, images: PendingImageAttachment[]) => {
  const trimmed = text.trim();
  const imageMarkdown = images
    .map((image, index) => {
      const fallbackName = `Image ${index + 1}`;
      const safeName = escapeMarkdownImageAlt(image.name || fallbackName) || fallbackName;
      return `![${safeName}](${image.dataUrl})`;
    })
    .join('\n\n');

  return [trimmed, imageMarkdown].filter(Boolean).join('\n\n');
};

type QuickStartShortcut = {
  id: string;
  title: string;
  content: string;
  description?: string;
  source: 'command' | 'saved-prompt' | 'skill' | 'task' | 'action';
  action?: 'add-prompt';
  prompt?: PredefinedPromptSummary;
  task?: Loop;
};

const isSlashCommandPrompt = (prompt: PredefinedPromptSummary) => /^\/[\S]+/.test(prompt.name.trim());

const getSkillPromptContent = (skill: Skill): string => {
  const instructions = skill.instructions?.trim();
  if (instructions) return instructions;
  return `Use the "${skill.name}" skill for this request.${skill.description ? `\n\n${skill.description}` : ''}`;
};

const INLINE_DATA_IMAGE_MARKDOWN_REGEX = /!\[([^\]]*)\]\((data:image\/[^)]+)\)/gi;

/** Meta-tools whose results are already shown as visible message content or are purely internal */
const HIDDEN_META_TOOLS = new Set([RESPOND_TO_USER_TOOL, 'mark_work_complete']);

const sanitizeMessageContentForModel = (content: string) =>
  content.replace(INLINE_DATA_IMAGE_MARKDOWN_REGEX, (_match, altText: string) => {
    const cleanedAlt = altText?.trim();
    return cleanedAlt ? `[Image: ${cleanedAlt}]` : '[Image]';
  });

const sanitizeMessagesForModel = (messages: ChatMessage[]): ChatMessage[] =>
  messages.map((message) => {
    const rawContent = typeof message.content === 'string' ? message.content : '';
    const sanitizedContent = sanitizeMessageContentForModel(rawContent);
    if (sanitizedContent === rawContent) {
      return message;
    }
    return {
      ...message,
      content: sanitizedContent,
    };
  });

const resolveConversationStateFromProgress = (
  update: AgentProgressUpdate,
  lifecycleState: 'running' | 'complete' = update.isComplete ? 'complete' : 'running'
): AgentConversationState => {
  if (update.conversationState) {
    return normalizeAgentConversationState(update.conversationState, lifecycleState);
  }
  const hasPendingApproval = update.steps.some((step) => step.type === 'pending_approval');
  if (hasPendingApproval) {
    return 'needs_input';
  }
  return lifecycleState;
};

type RespondToUserHistorySourceMessage = {
  role: 'user' | 'assistant' | 'tool';
  timestamp?: number;
  toolCalls?: Array<{ name: string; arguments: unknown }>;
};

const extractRespondToUserHistory = (
  messages: RespondToUserHistorySourceMessage[]
): AgentUserResponseEvent[] =>
  extractRespondToUserResponseEvents(messages, { idPrefix: 'mobile-history' });

const sortResponseEvents = (events: AgentUserResponseEvent[]): AgentUserResponseEvent[] =>
  [...events].sort((a, b) => {
    if ((a.runId ?? 0) !== (b.runId ?? 0)) return (a.runId ?? 0) - (b.runId ?? 0);
    if (a.ordinal !== b.ordinal) return a.ordinal - b.ordinal;
    return a.timestamp - b.timestamp;
  });

const getNextResponseEventOrdinal = (events: AgentUserResponseEvent[]): number =>
  events.reduce((maxOrdinal, event) => Math.max(maxOrdinal, event.ordinal), 0) + 1;

const getMessageLogMeta = (content: string) => ({
  length: content.length,
  inlineImageCount: (content.match(/!\[[^\]]*\]\((?:data:image\/[^)]+)\)/gi) || []).length,
});

const normalizeVoiceText = (text?: string) => (text || '').replace(/\s+/g, ' ').trim();

const mergeVoiceText = (base?: string, live?: string) => {
	const a = normalizeVoiceText(base);
	const b = normalizeVoiceText(live);
	if (!a) return b;
	if (!b) return a;
	if (a === b) return a;
	if (b.startsWith(a)) return b;
	if (a.startsWith(b)) return a;
	const aWords = a.split(' ');
	const bWords = b.split(' ');
	const maxOverlap = Math.min(aWords.length, bWords.length);
	for (let overlap = maxOverlap; overlap > 0; overlap -= 1) {
		const aSuffix = aWords.slice(-overlap).join(' ');
		const bPrefix = bWords.slice(0, overlap).join(' ');
		if (aSuffix === bPrefix) {
			return normalizeVoiceText(`${a} ${bWords.slice(overlap).join(' ')}`);
		}
	}
	return normalizeVoiceText(`${a} ${b}`);
};

const getCollapsedMessagePreview = (content: string) =>
  content
    .replace(/!\[[^\]]*\]\((?:data:image\/[^)]+|[^)]+)\)/gi, '[Image]')
    .replace(/(^|[^!])\[[^\]]*\]\((?:assets:\/\/conversation-video\/[^)]+|https?:\/\/[^)]+\.(?:mp4|m4v|webm|mov|ogv)(?:[?#][^)]*)?)\)/gi, '$1[Video]')
    .replace(/^#{1,6}\s+/gm, '')
    .replace(/\s+/g, ' ')
    .trim();

const TOOL_PAYLOAD_PREFIX_REGEX = /^(?:using tool:|tool result:)/i;

const getRespondToUserContentFromMessage = (message: ChatMessage): string | null => {
  if (message.role !== 'assistant' || !message.toolCalls?.length) {
    return null;
  }

  for (const call of message.toolCalls) {
    if (call.name !== RESPOND_TO_USER_TOOL) {
      continue;
    }

    const extractedContent = extractRespondToUserContentFromArgs(call.arguments);
    if (extractedContent) {
      return extractedContent;
    }
  }

  return null;
};

const TOOL_RESULT_BRACKET_REGEX = /^\[[\w_.-]+\]\s*[{\[#]/;

// Match [tool_name] {json...} or [tool_name] [...] patterns anywhere in text
// Used to strip raw tool call / tool result text that leaks into visible content
const INLINE_TOOL_BRACKET_REGEX = /\[[\w_.-]+\]\s*(?:\{[\s\S]*?\}|\[[\s\S]*?\])/g;

// Match garbled tool-call-as-text patterns (model hallucinating tool call syntax)
const GARBLED_TOOL_CALL_REGEX = /(?:multi_tool_use[.\s]|to=(?:multi_tool_use|functions)\.|recipient_name.*functions\.)/i;

const looksLikeToolPayloadContent = (content?: string): boolean => {
  const trimmedContent = content?.trim();
  if (!trimmedContent) {
    return false;
  }

  if (/<\|tool_calls_section_begin\|>|<\|tool_call_begin\|>/i.test(trimmedContent)) {
    return true;
  }

  if (TOOL_PAYLOAD_PREFIX_REGEX.test(trimmedContent)) {
    return true;
  }

  if (/^tool_call$/i.test(trimmedContent)) {
    return true;
  }

  // Catch raw tool result content like "[tool_name] { json }" or "[tool_name] # markdown"
  if (TOOL_RESULT_BRACKET_REGEX.test(trimmedContent)) {
    return true;
  }

  // Catch garbled tool call text
  if (GARBLED_TOOL_CALL_REGEX.test(trimmedContent)) {
    return true;
  }

  return false;
};

/**
 * Strip raw tool call / tool result text that leaks into visible message content.
 * Removes patterns like "[execute_command] {"command":"ls"}" or
 * "[tool_name] [{"key":"value"}]" that backends sometimes include as text.
 */
const stripRawToolTextFromContent = (content: string): string => {
  if (!content) return content;

  // Remove [tool_name] {json} or [tool_name] [array] patterns
  let cleaned = content.replace(INLINE_TOOL_BRACKET_REGEX, '');

  // Remove tool marker tags like <|tool_call_begin|> etc.
  cleaned = cleaned.replace(/<\|[^|]*\|>/g, '');

  // Remove garbled multi_tool_use patterns
  cleaned = cleaned.replace(/(?:multi_tool_use[.\s]|to=(?:multi_tool_use|functions)\.)[\s\S]*$/i, '');

  return cleaned.trim();
};

const getRenderableMessageContent = (message: ChatMessage): string =>
  message.displayContent ?? message.content ?? '';

const getVisibleMessageContent = (message: ChatMessage): string => {
  // Tool role messages are raw tool results — always hide their content
  // (they should have been merged into the preceding assistant message)
  if (message.role === 'tool') {
    return '';
  }

  if (message.role !== 'assistant') {
    return getRenderableMessageContent(message);
  }

  const respondToUserContent = getRespondToUserContentFromMessage(message);
  if (respondToUserContent) {
    return respondToUserContent;
  }

  const hasToolMetadata =
    (message.toolCalls?.length ?? 0) > 0 ||
    (message.toolResults?.length ?? 0) > 0;

  const renderContent = getRenderableMessageContent(message);
  const displayMessage = { ...message, content: renderContent };

  if (isToolOnlyMessage(displayMessage)) {
    return '';
  }

  if (hasToolMetadata && looksLikeToolPayloadContent(renderContent)) {
    return '';
  }

  // Hide standalone messages that are raw tool results (no structured metadata
  // but content looks like "[tool_name] { json }" from unmerged tool responses)
  if (looksLikeToolPayloadContent(renderContent)) {
    return '';
  }

  // Strip any remaining inline tool call text (e.g. "[execute_command] {json}")
  // that might be embedded within otherwise valid content
  const rawContent = renderContent;
  const stripped = stripRawToolTextFromContent(rawContent);
  if (stripped.length > 0) {
    return stripped;
  }

  return stripped === rawContent ? rawContent : '';
};

const shouldTreatMessageAsToolOnly = (message: ChatMessage): boolean => {
  const hasToolMetadata =
    (message.toolCalls?.length ?? 0) > 0 ||
    (message.toolResults?.length ?? 0) > 0;

  // Also treat standalone raw tool result messages as tool-only
  if (looksLikeToolPayloadContent(getRenderableMessageContent(message))) {
    return true;
  }

  return hasToolMetadata && getVisibleMessageContent(message).trim().length === 0;
};

const preserveDisplayContentFromProgress = (
  finalMessages: ChatMessage[],
  progressMessages: ChatMessage[]
): ChatMessage[] => {
  if (progressMessages.length === 0) return finalMessages;

  return finalMessages.map((message, index) => {
    if (message.displayContent) return message;
    const progressMessage = progressMessages[index];
    if (message.role !== 'assistant' || !progressMessage?.displayContent) {
      return message;
    }
    return { ...message, displayContent: progressMessage.displayContent };
  });
};

const applyUserResponseToMessages = (
  messages: ChatMessage[],
  userResponse?: string
): ChatMessage[] => {
  const trimmedResponse = userResponse?.trim();
  if (!trimmedResponse) {
    return messages;
  }

  const updatedMessages = [...messages];
  for (let i = updatedMessages.length - 1; i >= 0; i--) {
    const msg = updatedMessages[i];
    if (msg.role !== 'assistant') {
      continue;
    }

    const hasToolMetadata =
      (msg.toolCalls && msg.toolCalls.length > 0) ||
      (msg.toolResults && msg.toolResults.length > 0);
    const shouldReplaceToolContent =
      hasToolMetadata && (
        isToolOnlyMessage(msg) ||
        looksLikeToolPayloadContent(msg.content) ||
        !!getRespondToUserContentFromMessage(msg)
      );

    if (hasToolMetadata && !shouldReplaceToolContent) {
      continue;
    }

    updatedMessages[i] = { ...msg, content: trimmedResponse, displayContent: undefined };
    return updatedMessages;
  }

  updatedMessages.push({ role: 'assistant', content: trimmedResponse });
  return updatedMessages;
};

type TurnDurationEntry = {
  durationMs: number;
  isLive: boolean;
};

type TurnDurationSummary = {
  byUserTimestamp: Map<number, TurnDurationEntry>;
  totalMs: number;
  hasLive: boolean;
};

type ToolExecutionStats = {
  durationMs?: number;
  totalTokens?: number;
  toolUseCount?: number;
  inputTokens?: number;
  outputTokens?: number;
  cacheHitTokens?: number;
  model?: string;
  subagentId?: string;
};

const TURN_DURATION_TICK_MS = 1000;

const isThinkingOnlyMessage = (message: ChatMessage): boolean =>
  message.role === 'assistant' &&
  (!message.content || message.content.length === 0) &&
  !message.toolCalls?.length &&
  !message.toolResults?.length;

const computeTurnDurations = (
  messages: ChatMessage[],
  isComplete: boolean,
  nowMs: number
): TurnDurationSummary => {
  const byUserTimestamp = new Map<number, TurnDurationEntry>();
  let totalMs = 0;
  let hasLive = false;

  for (let i = 0; i < messages.length; i += 1) {
    const message = messages[i];
    if (message.role !== 'user' || typeof message.timestamp !== 'number') continue;

    let endTimestamp: number | null = null;
    let hasNextUserMessage = false;
    for (let j = i + 1; j < messages.length; j += 1) {
      const nextMessage = messages[j];
      if (nextMessage.role === 'user') {
        hasNextUserMessage = true;
        break;
      }
      if (typeof nextMessage.timestamp === 'number' && !isThinkingOnlyMessage(nextMessage)) {
        endTimestamp = nextMessage.timestamp;
      }
    }

    const isLive = !hasNextUserMessage && !isComplete;
    const effectiveEndTimestamp = isLive ? nowMs : (endTimestamp ?? message.timestamp);
    const durationMs = Math.max(0, effectiveEndTimestamp - message.timestamp);
    const entry = { durationMs, isLive };

    byUserTimestamp.set(message.timestamp, entry);
    totalMs += durationMs;
    if (isLive) hasLive = true;
  }

  return { byUserTimestamp, totalMs, hasLive };
};

const formatTurnDuration = (durationMs: number): string => {
  if (!Number.isFinite(durationMs) || durationMs <= 0) return '0s';
  const seconds = Math.max(1, Math.round(durationMs / 1000));
  if (seconds < 60) return `${seconds}s`;
  const minutes = Math.floor(seconds / 60);
  const remainingSeconds = seconds % 60;
  if (minutes < 60) return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`;
  const hours = Math.floor(minutes / 60);
  const remainingMinutes = minutes % 60;
  return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
};

const getFiniteToolExecutionStat = (value: number | null | undefined): number | undefined =>
  typeof value === 'number' && Number.isFinite(value) ? value : undefined;

const formatToolExecutionDuration = (durationMs: number): string => {
  if (durationMs < 1000) return `${Math.round(durationMs)}ms`;
  if (durationMs < 60000) return `${(durationMs / 1000).toFixed(1)}s`;
  const minutes = Math.floor(durationMs / 60000);
  const seconds = Math.round((durationMs % 60000) / 1000);
  return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
};

const formatToolExecutionTokens = (tokens: number): string => {
  if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`;
  if (tokens >= 1000) return `${(tokens / 1000).toFixed(tokens >= 10000 ? 0 : 1)}k`;
  return `${tokens}`;
};

const truncateToolExecutionSubagentId = (id: string): string => {
  if (id.length > 12 && id.includes('-')) return `agent:${id.split('-')[0].slice(0, 7)}`;
  if (id.length <= 12) return id;
  return `${id.slice(0, 10)}...`;
};

const getAgentProgressStepToolExecutionStats = (
  step?: { executionStats?: ToolExecutionStats | null; subagentId?: string | null } | null
): ToolExecutionStats | undefined => {
  if (!step?.executionStats) return undefined;
  return {
    ...step.executionStats,
    ...(step.subagentId ? { subagentId: step.subagentId } : {}),
  };
};

const getToolPayloadCopyAccessibilityLabel = (
  kind: 'input' | 'output' | 'error',
  toolName: string,
) => {
  const label = kind === 'input' ? 'Copy input' : kind === 'output' ? 'Copy output' : 'Copy error details';
  return `${label} for ${toolName}`;
};

const formatToolExecutionStatsLabel = (stats?: ToolExecutionStats | null): string | null => {
  const durationMs = getFiniteToolExecutionStat(stats?.durationMs);
  const totalTokens = getFiniteToolExecutionStat(stats?.totalTokens);
  const model = stats?.model?.trim();
  const subagentId = stats?.subagentId?.trim();
  const parts = [
    subagentId ? truncateToolExecutionSubagentId(subagentId) : null,
    model || null,
    durationMs !== undefined ? formatToolExecutionDuration(durationMs) : null,
    totalTokens !== undefined ? `${formatToolExecutionTokens(totalTokens)} tokens` : null,
  ].filter((part): part is string => Boolean(part));

  return parts.length > 0 ? parts.join(' • ') : null;
};

export default function ChatScreen({ route, navigation }: any) {
  const insets = useSafeAreaInsets();
  const headerHeight = useHeaderHeight();
  const { theme, isDark } = useTheme();
  const isFocused = useIsFocused();
  const { height: screenHeight } = useWindowDimensions();
  const styles = useMemo(() => createStyles(theme, screenHeight), [theme, screenHeight]);
  const { config, setConfig } = useConfigContext();
  const sessionStore = useSessionContext();
  const messageQueue = useMessageQueueContext();
  const connectionManager = useConnectionManager();
  const { connectionInfo } = useTunnelConnection();
  const { currentProfile } = useProfile();
  const currentAgentLabel = currentProfile?.name || 'Default Agent';
  const currentSession = sessionStore.getCurrentSession();
  const isCurrentSessionPinned = !!currentSession?.isPinned;
  const handsFree = !!config.handsFree;
  const settingsClient = useMemo(() => {
    if (!config.baseUrl || !config.apiKey) {
      return null;
    }
    return new ExtendedSettingsApiClient(config.baseUrl, config.apiKey);
  }, [config.apiKey, config.baseUrl]);
  const [predefinedPrompts, setPredefinedPrompts] = useState<PredefinedPromptSummary[]>([]);
  const [availableSkills, setAvailableSkills] = useState<Skill[]>([]);
  const [availableTasks, setAvailableTasks] = useState<Loop[]>([]);
  const [isLoadingQuickStartPrompts, setIsLoadingQuickStartPrompts] = useState(false);
  const [runningPromptTaskId, setRunningPromptTaskId] = useState<string | null>(null);
  const [remoteTtsProvider, setRemoteTtsProvider] = useState<'native' | 'edge'>('native');
  const [remoteEdgeTtsVoice, setRemoteEdgeTtsVoice] = useState('en-US-AriaNeural');
  const [remoteEdgeTtsRate, setRemoteEdgeTtsRate] = useState(1.0);
  const [pendingToolApprovalResponseId, setPendingToolApprovalResponseId] = useState<string | null>(null);
  const [branchingMessageIndex, setBranchingMessageIndex] = useState<number | null>(null);
  // Effective TTS provider/voice/rate — local mobile config takes precedence over
  // any value pulled from the connected desktop's settings. Edge TTS only plays on web.
  const effectiveTtsProvider: 'native' | 'edge' =
    config.ttsProvider === 'edge' ? 'edge' : remoteTtsProvider;
  const effectiveEdgeTtsVoice =
    config.ttsProvider === 'edge' && config.edgeTtsVoice
      ? config.edgeTtsVoice
      : remoteEdgeTtsVoice;
  const effectiveEdgeTtsRate =
    config.ttsProvider === 'edge' ? config.ttsRate ?? 1.0 : remoteEdgeTtsRate;
  const [addPromptModalVisible, setAddPromptModalVisible] = useState(false);
  const [editingPrompt, setEditingPrompt] = useState<PredefinedPromptSummary | null>(null);
  const [newPromptName, setNewPromptName] = useState('');
  const [newPromptContent, setNewPromptContent] = useState('');
  const [isSavingPrompt, setIsSavingPrompt] = useState(false);
  const handsFreeMessageDebounceMs = config.handsFreeMessageDebounceMs ?? DEFAULT_HANDS_FREE_MESSAGE_DEBOUNCE_MS;
  const handsFreeWakePhrase = config.handsFreeWakePhrase || 'hey dot agents';
  const handsFreeSleepPhrase = config.handsFreeSleepPhrase || 'go to sleep';
  const handsFreeDebugEnabled = config.handsFreeDebug === true;
  const handsFreeForegroundOnly = config.handsFreeForegroundOnly !== false;
  const messageQueueEnabled = config.messageQueueEnabled !== false; // default true
  const handsFreeRef = useRef<boolean>(handsFree);
  useEffect(() => { handsFreeRef.current = !!config.handsFree; }, [config.handsFree]);
  const handsFreePhaseRef = useRef<HandsFreePhase>('sleeping');
  // Track ttsEnabled in a ref so speech callbacks resolved before a mute-toggle
  // (e.g. in-flight send() progress callbacks) still see the latest setting and
  // bail before queueing or playing audio.
  const ttsEnabledRef = useRef<boolean>(config.ttsEnabled !== false);
  useEffect(() => { ttsEnabledRef.current = config.ttsEnabled !== false; }, [config.ttsEnabled]);
  const [appState, setAppState] = useState<AppStateStatus>(AppState.currentState);
  const isAppActive = appState === 'active';
  const handsFreeRuntimeActive = handsFree && isFocused && isAppActive;

  const toggleHandsFree = async () => {
    const next = !handsFreeRef.current;
	    handsFreeRef.current = next;
    const nextCfg = { ...config, handsFree: next } as any;
    setConfig(nextCfg);
    try { await saveConfig(nextCfg); } catch {}
    if (!next) {
	      handsFreeController.reset();
      void stopRecognitionOnly?.();
      Speech.stop();
      stopRemoteTts();
      setDebugInfo('Handsfree mode turned off.');
    } else {
      setDebugInfo('Handsfree mode turned on. Say the wake phrase to begin.');
    }
  };

  // TTS toggle
  const ttsEnabled = config.ttsEnabled !== false; // default true
  const toggleTts = async () => {
    const next = !ttsEnabled;
    // Stop any currently playing TTS when disabling. The speaker icon doubles
    // as a "mute" control, so it must silence both native and remote (Edge)
    // playback and clear the auto-speech queue/state so nothing resumes.
    if (!next) {
      intendedSpeakingIndexRef.current = null;
      Speech.stop();
      stopRemoteTts();
      queuedResponseEventsRef.current = [];
      activeAutoSpeechEventIdRef.current = null;
      setSpeakingMessageIndex(null);
      // Only transition the hands-free controller when it was actually speaking;
      // calling onSpeechFinished mid-`processing` would prematurely return to
      // listening while a request is still in-flight.
      if (handsFreeRef.current && handsFreePhaseRef.current === 'speaking') {
        handsFreeController.onSpeechFinished();
        voiceLog('tts-stopped', 'Assistant speech stopped from speaker toggle.');
      }
    }
    const nextCfg = { ...config, ttsEnabled: next } as any;
    setConfig(nextCfg);
    try { await saveConfig(nextCfg); } catch {}
  };

  const [responding, setResponding] = useState(false);
  const [conversationState, setConversationState] = useState<AgentConversationState | null>(null);
  const [connectionState, setConnectionState] = useState<RecoveryState | null>(null);
  const [agentSelectorVisible, setAgentSelectorVisible] = useState(false);
  const [messages, setMessages] = useState<ChatMessage[]>([]);
  const [turnDurationNow, setTurnDurationNow] = useState(() => Date.now());
  const hasLiveAgentTurn = responding || conversationState === 'running';
  useEffect(() => {
    if (!hasLiveAgentTurn) return;
    setTurnDurationNow(Date.now());
    const interval = setInterval(() => setTurnDurationNow(Date.now()), TURN_DURATION_TICK_MS);
    return () => clearInterval(interval);
  }, [hasLiveAgentTurn]);
  const turnDurations = useMemo(
    () => computeTurnDurations(messages, !hasLiveAgentTurn, turnDurationNow),
    [hasLiveAgentTurn, messages, turnDurationNow]
  );
  const headerTotalTurnDuration = turnDurations.totalMs > 0
    ? formatTurnDuration(turnDurations.totalMs)
    : null;

  // Track the current active request to prevent cross-request state clobbering
  // Each request gets a unique ID; only the currently active request can reset UI states
  const activeRequestIdRef = useRef<number>(0);

  // Stable ref for current session ID to avoid stale closures in callbacks
  // This fixes the issue where useSessions() returns a new object each render
  const currentSessionIdRef = useRef<string | null>(sessionStore.currentSessionId);
  useEffect(() => {
    currentSessionIdRef.current = sessionStore.currentSessionId;
  }, [sessionStore.currentSessionId]);

  // Get or create a connection for the current session using the connection manager
  // This preserves connections when switching between sessions (fixes #608)
  const getSessionClient = useCallback(() => {
    const currentSessionId = sessionStore.currentSessionId;
    if (!currentSessionId) {
      console.warn('[ChatScreen] No current session ID, cannot get client');
      return null;
    }
    const connection = connectionManager.getOrCreateConnection(currentSessionId);
    // Note: Connection status callback is set up via subscribeToConnectionStatus in useEffect below
    // This avoids overwriting the SessionConnectionManager's internal callback (PR review fix)
    return connection.client;
  }, [connectionManager, sessionStore.currentSessionId]);

  // Subscribe to connection status changes for the current session
  // Uses subscribeToConnectionStatus to avoid overwriting the internal callback in SessionConnectionManager
  useEffect(() => {
    const currentSessionId = sessionStore.currentSessionId;
    if (!currentSessionId) {
      // Reset both connection state and responding state when there's no session
      // This prevents the UI from being stuck in "responding" state if the session
      // is deleted/cleared while ChatScreen remains mounted (PR review fix #15)
      setConnectionState(null);
      setResponding(false);
      setConversationState(null);
      return;
    }

    // Restore existing connection state when switching sessions
    const existingState = connectionManager.getConnectionState(currentSessionId);
    if (existingState) {
      setConnectionState(existingState);
    } else {
      setConnectionState(null);
    }

    // Check if there's an active request for this session
    const isActive = connectionManager.isConnectionActive(currentSessionId);
    setResponding(isActive);
    setConversationState(isActive ? 'running' : null);

    // Ensure connection exists for subscription
    connectionManager.getOrCreateConnection(currentSessionId);

    // Subscribe to connection status changes for this session
    // The callback uses currentSessionIdRef to always check against the latest session ID
    const unsubscribe = connectionManager.subscribeToConnectionStatus(
      currentSessionId,
      (state) => {
        // Only update UI if this is still the current session (using ref for latest value)
        if (currentSessionIdRef.current === currentSessionId) {
          setConnectionState(state);
          console.log('[ChatScreen] Connection status:', formatConnectionStatus(state));
        }
      }
    );

    return unsubscribe;
  }, [sessionStore.currentSessionId, connectionManager]);

  const handleKillSwitch = async () => {
    console.log('[ChatScreen] Kill switch button pressed');
    const client = getSessionClient();
    if (!client) {
      console.error('[ChatScreen] No client available for kill switch');
      return;
    }

    if (Platform.OS === 'web') {
      const confirmed = window.confirm(
        '⚠️ Emergency Stop\n\nAre you sure you want to stop all agent sessions on the remote server? This will immediately terminate any running tasks.'
      );
      if (confirmed) {
        try {
          const result = await client.killSwitch();
          if (result.success) {
            window.alert(result.message || 'All sessions stopped');
          } else {
            window.alert('Error: ' + (result.error || 'Failed to stop sessions'));
          }
        } catch (e: any) {
          console.error('[ChatScreen] Kill switch error:', e);
          window.alert('Error: ' + (e.message || 'Failed to connect to server'));
        }
      }
      return;
    }

    Alert.alert(
      '⚠️ Emergency Stop',
      'Are you sure you want to stop all agent sessions on the remote server? This will immediately terminate any running tasks.',
      [
        { text: 'Cancel', style: 'cancel' },
        {
          text: 'Stop All',
          style: 'destructive',
          onPress: async () => {
            try {
              const result = await client.killSwitch();
              if (result.success) {
                Alert.alert('Success', result.message || 'All sessions stopped');
              } else {
                Alert.alert('Error', result.error || 'Failed to stop sessions');
              }
            } catch (e: any) {
              console.error('[ChatScreen] Kill switch error:', e);
              Alert.alert('Error', e.message || 'Failed to connect to server');
            }
          },
        },
      ],
    );
  };

  const handleNewChat = useCallback(() => {
    // Reset all UI states unconditionally when creating a new chat
    // This ensures the new session starts with a clean slate, even if
    // an old request is still in-flight (its callbacks will be ignored
    // via the session/request guards)
    setResponding(false);
    setConversationState(null);
    setConnectionState(null);
    setDebugInfo('');
    sessionStore.createNewSession();
  }, [sessionStore]);

  const handleInsertQuickStartPrompt = useCallback((content: string) => {
    const trimmed = content.trim();
    if (!trimmed) return;

    setInput((currentValue) => {
      const existing = currentValue.trim();
      return existing.length > 0 ? `${existing}\n\n${trimmed}` : trimmed;
    });
    inputRef.current?.focus?.();
  }, []);

  const handleRunPromptTask = useCallback(async (task: Loop) => {
    if (!settingsClient || runningPromptTaskId) return;
    setRunningPromptTaskId(task.id);
    try {
      await settingsClient.runLoop(task.id);
      Alert.alert('Task started', `Running "${task.name}" on desktop.`);
    } catch (error: any) {
      Alert.alert('Error', error?.message || 'Failed to run task.');
    } finally {
      setRunningPromptTaskId(null);
    }
  }, [runningPromptTaskId, settingsClient]);

  const openAddPromptModal = useCallback(() => {
    setEditingPrompt(null);
    setNewPromptName('');
    setNewPromptContent('');
    setAddPromptModalVisible(true);
  }, []);

  const openEditPromptModal = useCallback((prompt: PredefinedPromptSummary) => {
    setEditingPrompt(prompt);
    setNewPromptName(prompt.name);
    setNewPromptContent(prompt.content);
    setAddPromptModalVisible(true);
  }, []);

  const closePromptModal = useCallback(() => {
    if (isSavingPrompt) return;
    setAddPromptModalVisible(false);
    setEditingPrompt(null);
    setNewPromptName('');
    setNewPromptContent('');
  }, [isSavingPrompt]);

  const handleQuickStartPress = useCallback((item: QuickStartShortcut) => {
    if (item.action === 'add-prompt') {
      openAddPromptModal();
      return;
    }
    if (item.source === 'task' && item.task) {
      void handleRunPromptTask(item.task);
      return;
    }
    handleInsertQuickStartPrompt(item.content);
  }, [handleInsertQuickStartPrompt, handleRunPromptTask, openAddPromptModal]);

  const handleToggleCurrentSessionPinned = useCallback(() => {
    const currentSessionId = sessionStore.currentSessionId;
    if (!currentSessionId) return;
    void sessionStore.toggleSessionPinned(currentSessionId);
  }, [sessionStore]);

  const handleBranchFromMessage = useCallback(async (messageIndex: number) => {
    const serverConversationId = currentSession?.serverConversationId;
    if (!settingsClient || !serverConversationId) {
      Alert.alert('Branch Unavailable', 'This chat is not linked to a desktop conversation yet.');
      return;
    }

    setBranchingMessageIndex(messageIndex);
    try {
      const branchedConversation = await settingsClient.branchConversation(serverConversationId, { messageIndex });
      await sessionStore.syncWithServer(settingsClient);
      const branchedSession = sessionStore.findSessionByServerConversationId(branchedConversation.id);
      if (branchedSession) {
        sessionStore.setCurrentSession(branchedSession.id);
        navigation.navigate('Chat');
        return;
      }

      Alert.alert('Branch Created', 'The branched chat will appear in the chat list after sync.');
    } catch (error: any) {
      Alert.alert('Branch Failed', error?.message || 'Failed to branch this conversation.');
    } finally {
      setBranchingMessageIndex(null);
    }
  }, [currentSession?.serverConversationId, navigation, sessionStore, settingsClient]);

  const headerConversationState = conversationState ?? (responding ? 'running' : null);
  const headerConversationLabel = headerConversationState
    ? getAgentConversationStateLabel(headerConversationState)
    : null;
  const headerConversationChipStyle = useMemo(() => {
    if (!headerConversationState) return null;
    if (headerConversationState === 'complete') {
      return {
        backgroundColor: hexToRgba(theme.colors.success, 0.12),
        borderColor: hexToRgba(theme.colors.success, 0.35),
        textColor: theme.colors.success,
      };
    }
    if (headerConversationState === 'needs_input') {
      return {
        backgroundColor: 'rgba(245, 158, 11, 0.12)',
        borderColor: 'rgba(245, 158, 11, 0.35)',
        textColor: '#d97706',
      };
    }
    if (headerConversationState === 'blocked') {
      return {
        backgroundColor: hexToRgba(theme.colors.danger, 0.12),
        borderColor: hexToRgba(theme.colors.danger, 0.35),
        textColor: theme.colors.danger,
      };
    }
    return {
      backgroundColor: hexToRgba(theme.colors.primary, 0.12),
      borderColor: hexToRgba(theme.colors.primary, 0.35),
      textColor: theme.colors.primary,
    };
  }, [headerConversationState, theme.colors.danger, theme.colors.primary, theme.colors.success]);

  useLayoutEffect(() => {
    navigation?.setOptions?.({
      headerTitle: () => (
        <TouchableOpacity
          style={{ alignItems: 'center', justifyContent: 'center', height: '100%', minHeight: 44 }}
          onPress={() => setAgentSelectorVisible(true)}
          accessibilityRole="button"
          accessibilityLabel={`Current agent: ${currentAgentLabel}. Tap to change.`}
          accessibilityHint="Opens agent selection menu"
        >
          <View style={{
            flexDirection: 'row',
            alignItems: 'center',
            gap: 3,
            backgroundColor: theme.colors.primary + '33',
            paddingHorizontal: 8,
            paddingVertical: 2,
            borderRadius: 10,
          }}>
            <Text style={{
              fontSize: 11,
              color: theme.colors.primary,
              fontWeight: '500',
            }}>
              {currentAgentLabel}
            </Text>
            <Ionicons name="chevron-down" size={10} color={theme.colors.primary} />
          </View>
          {headerTotalTurnDuration && (
            <View style={[
              styles.headerDurationChip,
              turnDurations.hasLive && styles.headerDurationChipLive,
            ]}>
              <Ionicons
                name="time-outline"
                size={10}
                color={turnDurations.hasLive ? theme.colors.primary : theme.colors.mutedForeground}
              />
              <Text style={[
                styles.headerDurationChipText,
                turnDurations.hasLive && { color: theme.colors.primary },
              ]}>
                {headerTotalTurnDuration}{turnDurations.hasLive ? ' live' : ''}
              </Text>
            </View>
          )}
        </TouchableOpacity>
      ),
      headerLeft: () => (
        <View style={styles.headerActionsRow}>
          <TouchableOpacity
            onPress={() => navigation.navigate('Sessions')}
            accessibilityRole="button"
            accessibilityLabel="Back to chat history"
            accessibilityHint="Returns to the chat history list"
            style={styles.headerEdgeActionButton}
          >
            <Text style={{ fontSize: 20, color: theme.colors.foreground }}>←</Text>
          </TouchableOpacity>
          <TouchableOpacity
            onPress={handleToggleCurrentSessionPinned}
            accessibilityRole="button"
            accessibilityLabel={isCurrentSessionPinned ? 'Unpin current chat' : 'Pin current chat'}
            accessibilityHint={isCurrentSessionPinned
              ? 'Removes this chat from the pinned chats list.'
              : 'Keeps this chat at the top of the chats list.'}
            style={[styles.headerPinButton, isCurrentSessionPinned && styles.headerPinButtonActive]}
          >
            <Text style={[styles.headerPinButtonText, isCurrentSessionPinned && styles.headerPinButtonTextActive]}>
              {isCurrentSessionPinned ? 'Pinned' : 'Pin'}
            </Text>
          </TouchableOpacity>
        </View>
      ),
      headerRight: () => (
        <View style={styles.headerActionsRow}>
          {headerConversationLabel && headerConversationChipStyle && (
            <View
              style={[
                styles.headerConversationChip,
                {
                  backgroundColor: headerConversationChipStyle.backgroundColor,
                  borderColor: headerConversationChipStyle.borderColor,
                },
              ]}
            >
              {headerConversationState === 'running' && (
                <Image
                  source={isDark ? darkSpinner : lightSpinner}
                  style={{ width: 14, height: 14 }}
                  resizeMode="contain"
                />
              )}
              <Text
                style={[
                  styles.headerConversationChipText,
                  { color: headerConversationChipStyle.textColor },
                ]}
              >
                {headerConversationLabel}
              </Text>
            </View>
          )}
          {headerConversationState === 'running' && (
            <TouchableOpacity
              onPress={handleKillSwitch}
              accessibilityRole="button"
              accessibilityLabel="Emergency stop - kill all agent sessions"
              accessibilityHint="Shows a confirmation before stopping all running sessions"
              style={styles.headerActionButton}
            >
              <View style={{
                width: 28,
                height: 28,
                borderRadius: 14,
                backgroundColor: theme.colors.danger,
                alignItems: 'center',
                justifyContent: 'center',
              }}>
                <Text style={{ fontSize: 14, color: '#FFFFFF' }}>⏹</Text>
              </View>
            </TouchableOpacity>
          )}
          <TouchableOpacity
            onPress={toggleHandsFree}
            accessibilityRole="switch"
            accessibilityLabel={createSwitchAccessibilityLabel('Hands-free voice mode')}
            accessibilityHint="When enabled, speech is sent automatically after each phrase"
            accessibilityState={{ checked: handsFree }}
            aria-checked={handsFree}
            style={styles.headerActionButton}
          >
            <View style={{ width: 24, height: 24, alignItems: 'center', justifyContent: 'center' }}>
              <Text style={{ fontSize: 18 }}>🎙️</Text>
              {!handsFree && (
                <View
                  style={{
                    position: 'absolute',
                    width: 20,
                    height: 2,
                    backgroundColor: theme.colors.danger,
                    transform: [{ rotate: '45deg' }],
                    borderRadius: 1,
                  }}
                />
              )}
            </View>
          </TouchableOpacity>
        </View>
      ),
    });
  }, [navigation, handsFree, handleKillSwitch, handleNewChat, handleToggleCurrentSessionPinned, isCurrentSessionPinned, responding, headerConversationLabel, headerConversationState, headerConversationChipStyle, headerTotalTurnDuration, turnDurations.hasLive, theme, isDark, sessionStore, currentProfile, styles]);


  const respondToToolApproval = useCallback(async (approvalId: string, approved: boolean) => {
    if (!settingsClient) {
      Alert.alert('Connection Required', 'Configure your desktop server connection before responding to tool approvals.');
      return;
    }

    setPendingToolApprovalResponseId(approvalId);
    try {
      const response = await settingsClient.respondToToolApproval(approvalId, approved);
      setMessages((current) => current.filter((message) => message.toolApproval?.approvalId !== approvalId));
      if (!response.success) {
        Alert.alert('Approval Unavailable', 'The approval request is no longer pending.');
      }
    } catch (error: any) {
      Alert.alert('Approval Failed', error?.message || 'Failed to respond to the tool approval request.');
    } finally {
      setPendingToolApprovalResponseId(null);
    }
  }, [settingsClient]);
  const [visibleMessageCount, setVisibleMessageCount] = useState(INITIAL_VISIBLE_CHAT_MESSAGES);
  // Keep a ref to messages to avoid stale closures in setTimeout callbacks (PR review fix)
  const messagesRef = useRef<ChatMessage[]>(messages);
  // Track progress messages so we can merge them with final conversationHistory
  // instead of replacing, preventing intermediate messages from disappearing (#1083)
  const progressMessagesRef = useRef<ChatMessage[]>([]);
  // Track respond_to_user history for the current session (Issue #26)
  const [respondToUserHistory, setRespondToUserHistory] = useState<AgentUserResponseEvent[]>([]);
  const respondToUserHistoryRef = useRef<AgentUserResponseEvent[]>([]);
  const nextResponseEventOrdinalRef = useRef(1);
  const playedResponseEventIdsRef = useRef<Set<string>>(new Set());
  const queuedResponseEventsRef = useRef<AgentUserResponseEvent[]>([]);
  const activeAutoSpeechEventIdRef = useRef<string | null>(null);
  const recentAutoSpeechByTextRef = useRef<Map<string, number>>(new Map());
  useEffect(() => { messagesRef.current = messages; }, [messages]);
	// Stable ref to the latest send() to avoid stale closures in speech callbacks
	const sendRef = useRef<(text: string) => Promise<void>>(async () => {});
	  const [input, setInput] = useState('');
	  const [pendingImages, setPendingImages] = useState<PendingImageAttachment[]>([]);
	  const inputRef = useRef<TextInput>(null);
  const [debugInfo, setDebugInfo] = useState<string>('');
  const [expandedMessages, setExpandedMessages] = useState<Record<number, boolean>>({});
  // Track which individual tool calls are fully expanded to show all input/output details
  // Key format: "messageId-toolCallIndex" (messageId falls back to message array index if undefined)
  const [expandedToolCalls, setExpandedToolCalls] = useState<Record<string, boolean>>({});
  // Track which tool-activity groups are expanded (keyed by startIndex so the
  // state survives when new tool/skill messages append to the same group)
  const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({});
  const [copiedMessageIndex, setCopiedMessageIndex] = useState<number | null>(null);
  const copiedMessageTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  const clearCopiedMessageFeedback = useCallback(() => {
    if (copiedMessageTimeoutRef.current) {
      clearTimeout(copiedMessageTimeoutRef.current);
      copiedMessageTimeoutRef.current = null;
    }
    setCopiedMessageIndex(null);
  }, []);
  const showCopiedMessageFeedback = useCallback((messageIndex: number) => {
    setCopiedMessageIndex(messageIndex);
    if (copiedMessageTimeoutRef.current) {
      clearTimeout(copiedMessageTimeoutRef.current);
    }
    copiedMessageTimeoutRef.current = setTimeout(() => {
      setCopiedMessageIndex((currentIndex) => (
        currentIndex === messageIndex ? null : currentIndex
      ));
      copiedMessageTimeoutRef.current = null;
    }, MESSAGE_COPY_FEEDBACK_RESET_MS);
  }, []);
  useEffect(() => () => {
    if (copiedMessageTimeoutRef.current) {
      clearTimeout(copiedMessageTimeoutRef.current);
      copiedMessageTimeoutRef.current = null;
    }
  }, []);
  const handleCopyMessage = useCallback(async (messageIndex: number, content: string) => {
    const copyContent = content.trim();
    if (!copyContent) return;

    try {
      await Clipboard.setStringAsync(copyContent);
      showCopiedMessageFeedback(messageIndex);
    } catch (error: any) {
      Alert.alert('Copy Failed', error?.message || 'Could not copy this message.');
    }
  }, [showCopiedMessageFeedback]);

  const handleCopyToolPayload = useCallback(async (content: string) => {
    const copyContent = content.trim();
    if (!copyContent) return;

    try {
      await Clipboard.setStringAsync(copyContent);
    } catch (error: any) {
      Alert.alert('Copy Failed', error?.message || 'Could not copy this tool payload.');
    }
  }, []);
  // Track the last failed message for retry functionality
  const [lastFailedMessage, setLastFailedMessage] = useState<string | null>(null);
	  const [willCancel, setWillCancel] = useState(false);

	  const { events: voiceEvents, log: voiceLog, clear: clearVoiceDebug } = useVoiceDebug(handsFreeDebugEnabled);
	  useEffect(() => {
		if (!handsFreeDebugEnabled) {
			clearVoiceDebug();
		}
	  }, [clearVoiceDebug, handsFreeDebugEnabled]);

	  const handsFreeController = useHandsFreeController({
		enabled: handsFree,
		runtimeActive: handsFreeRuntimeActive,
		wakePhrase: handsFreeWakePhrase,
		sleepPhrase: handsFreeSleepPhrase,
		log: voiceLog,
	  });
  useEffect(() => {
    handsFreePhaseRef.current = handsFreeController.state.phase;
  }, [handsFreeController.state.phase]);

	  const {
		listening,
		liveTranscript,
		sttPreview,
		micButtonRef,
		startRecording,
		stopRecognitionOnly,
		handlePushToTalkPressIn,
		handlePushToTalkPressOut,
	  } = useSpeechRecognizer({
		handsFree,
			handsFreeDebounceMs: handsFreeMessageDebounceMs,
		willCancel,
		audioInputDeviceId: config.audioInputDeviceId,
		onVoiceFinalized: ({ text, mode }) => {
			const finalText = text.trim();
			if (!finalText) return;

			if (mode === 'edit') {
				setInput((current) => mergeVoiceText(current, finalText));
				setDebugInfo('Voice transcript added to the composer.');
				setTimeout(() => inputRef.current?.focus(), 0);
				return;
			}

				if (mode === 'handsfree') {
					if (handsFreeRef.current) {
						const action = handsFreeController.handleFinalTranscript(finalText);
						if (action.type === 'send') {
							void sendRef.current(action.text);
						}
						return;
					}
				}

			void sendRef.current(finalText);
		},
		onRecognizerError: (message) => {
			handsFreeController.onRecognizerError(message);
			setDebugInfo(`Voice error: ${message}`);
		},
		onPermissionDenied: () => {
			setDebugInfo('Speech recognition permission denied.');
		},
		log: voiceLog,
	  });

	  useEffect(() => {
		const subscription = AppState.addEventListener('change', (nextState) => {
			setAppState(nextState);
		});
		return () => subscription.remove();
	  }, []);

	  useEffect(() => {
		if (!handsFree) {
			return;
		}
		if (!handsFreeRuntimeActive && listening) {
			void stopRecognitionOnly();
		}
	  }, [handsFree, handsFreeRuntimeActive, listening, stopRecognitionOnly]);

	  useEffect(() => {
		if (!handsFree) {
			return;
		}

		if (handsFreeController.state.phase === 'error') {
			const timer = setTimeout(() => {
				handsFreeController.resetError();
			}, 2500);
			return () => clearTimeout(timer);
		}

		if (handsFreeController.shouldKeepRecognizerActive && !listening) {
			void startRecording();
			return;
		}

		if (!handsFreeController.shouldKeepRecognizerActive && listening) {
			void stopRecognitionOnly();
		}
	  }, [
		handsFree,
		handsFreeController.resetError,
		handsFreeController.shouldKeepRecognizerActive,
		handsFreeController.state.phase,
		listening,
		startRecording,
		stopRecognitionOnly,
	  ]);

	  const speakAssistantResponse = useCallback((content: string, reason: string, onSettled?: () => void) => {
		// Honor a mute that may have happened after this callback was scheduled but
		// before it ran (stale closures inside in-flight send() progress handlers).
		if (!ttsEnabledRef.current) {
			onSettled?.();
			return false;
		}
		const processedText = preprocessTextForTTS(content);
		if (!processedText) {
				onSettled?.();
			return false;
		}

      const ttsTextKey = normalizeAutoTtsTextKey(processedText);
      const now = Date.now();
      const lastSpokenAt = recentAutoSpeechByTextRef.current.get(ttsTextKey) ?? 0;
      if (now - lastSpokenAt < AUTO_TTS_DUPLICATE_SUPPRESSION_MS) {
        onSettled?.();
        return false;
      }
      recentAutoSpeechByTextRef.current.set(ttsTextKey, now);
      for (const [key, spokenAt] of recentAutoSpeechByTextRef.current) {
        if (now - spokenAt > AUTO_TTS_DUPLICATE_SUPPRESSION_MS) {
          recentAutoSpeechByTextRef.current.delete(key);
        }
      }

		let settled = false;
		const settle = () => {
			if (settled) return;
			settled = true;
				onSettled?.();
			if (handsFree) {
				handsFreeController.onSpeechFinished();
				voiceLog('tts-stopped', `Assistant speech stopped (${reason}).`);
			}
		};

		if (handsFree) {
			handsFreeController.onSpeechStarted();
			voiceLog('tts-started', `Assistant speech started (${reason}).`);
		}

		if (effectiveTtsProvider === 'edge' && config.baseUrl && config.apiKey) {
			// Edge TTS routes through the paired desktop's /v1/tts/speak.
			void speakRemoteTts(processedText, {
				baseUrl: config.baseUrl,
				apiKey: config.apiKey,
				providerId: 'edge',
				voice: effectiveEdgeTtsVoice,
				rate: effectiveEdgeTtsRate,
				onDone: settle,
				onError: settle,
				onStopped: settle,
			});
			return true;
		}

		const speechOptions: Speech.SpeechOptions = {
			language: 'en-US',
			rate: config.ttsRate ?? 1.0,
			pitch: config.ttsPitch ?? 1.0,
			onDone: settle,
			onError: settle,
			onStopped: settle,
		};
		if (config.ttsVoiceId) {
			speechOptions.voice = config.ttsVoiceId;
		}
		Speech.speak(processedText, speechOptions);
		return true;
		  }, [config.apiKey, config.baseUrl, config.ttsPitch, config.ttsRate, config.ttsVoiceId, effectiveEdgeTtsRate, effectiveEdgeTtsVoice, effectiveTtsProvider, handsFree, handsFreeController, voiceLog]);

	  const syncResponseHistoryRefs = useCallback((events: AgentUserResponseEvent[]) => {
	    respondToUserHistoryRef.current = events;
	    nextResponseEventOrdinalRef.current = getNextResponseEventOrdinal(events);
	  }, []);

	  const replaceResponseHistory = useCallback((events: AgentUserResponseEvent[]) => {
	    const sortedEvents = sortResponseEvents(events);
	    syncResponseHistoryRefs(sortedEvents);
	    setRespondToUserHistory(sortedEvents);
	  }, [syncResponseHistoryRefs]);

	  const createFallbackResponseEvent = useCallback((
	    sessionId: string | null | undefined,
	    runId: number | undefined,
	    text: string,
	  ): AgentUserResponseEvent => {
	    const ordinal = nextResponseEventOrdinalRef.current;
	    nextResponseEventOrdinalRef.current = ordinal + 1;
	    const timestamp = Date.now();

	    return {
	      id: `legacy-progress-${sessionId ?? 'session'}-${runId ?? 'run'}-${ordinal}-${timestamp}`,
	      sessionId: sessionId ?? 'session',
	      runId,
	      ordinal,
	      text,
	      timestamp,
	    };
	  }, []);

	  const mergeResponseEvents = useCallback((incomingEvents: AgentUserResponseEvent[]) => {
	    if (!incomingEvents.length) return;
	    const merged = new Map(respondToUserHistoryRef.current.map((event) => [event.id, event]));
	    for (const event of incomingEvents) {
	      merged.set(event.id, event);
	    }

	    const mergedEvents = sortResponseEvents(Array.from(merged.values()));
	    syncResponseHistoryRefs(mergedEvents);
	    setRespondToUserHistory(mergedEvents);
	  }, [syncResponseHistoryRefs]);

  const processAutoSpeechQueue = useCallback(() => {
    if (activeAutoSpeechEventIdRef.current || queuedResponseEventsRef.current.length === 0) {
      return;
    }

    const nextEvent = queuedResponseEventsRef.current.shift();
    if (!nextEvent) return;
    activeAutoSpeechEventIdRef.current = nextEvent.id;

    const spoken = speakAssistantResponse(nextEvent.text, `response event ${nextEvent.ordinal}`, () => {
      activeAutoSpeechEventIdRef.current = null;
      processAutoSpeechQueue();
    });

    if (!spoken) {
      activeAutoSpeechEventIdRef.current = null;
      processAutoSpeechQueue();
      return;
    }

    playedResponseEventIdsRef.current.add(nextEvent.id);
  }, [speakAssistantResponse]);

  const enqueueResponseEventsForSpeech = useCallback((events: AgentUserResponseEvent[]) => {
    // Use the ref alongside the captured config so a mute that landed after this
    // callback was scheduled still suppresses queueing.
    if (config.ttsEnabled === false || !ttsEnabledRef.current || !events.length) return;

    const queuedIds = new Set(queuedResponseEventsRef.current.map((event) => event.id));
    const activeId = activeAutoSpeechEventIdRef.current;
    const unseenEvents = events.filter((event) => (
      !playedResponseEventIdsRef.current.has(event.id)
      && !queuedIds.has(event.id)
      && event.id !== activeId
    ));

    if (!unseenEvents.length) return;

    queuedResponseEventsRef.current = [
      ...queuedResponseEventsRef.current,
      ...unseenEvents,
    ].sort((a, b) => {
      if ((a.runId ?? 0) !== (b.runId ?? 0)) return (a.runId ?? 0) - (b.runId ?? 0);
      if (a.ordinal !== b.ordinal) return a.ordinal - b.ordinal;
      return a.timestamp - b.timestamp;
    });

    processAutoSpeechQueue();
  }, [config.ttsEnabled, processAutoSpeechQueue]);

  // Per-message TTS: track which message index is currently being spoken (#1078)
  const [speakingMessageIndex, setSpeakingMessageIndex] = useState<number | null>(null);
  // Ref to track the intended speaking index, preventing race conditions
  // when Speech.stop()'s onStopped fires after a new Speech.speak() starts
  const intendedSpeakingIndexRef = useRef<number | null>(null);

  const speakMessage = useCallback((index: number, content: string) => {
    if (speakingMessageIndex === index) {
      // Toggle off - stop speaking
      intendedSpeakingIndexRef.current = null;
      Speech.stop();
	      if (handsFree) {
	        handsFreeController.onSpeechFinished();
	        voiceLog('tts-stopped', 'Assistant speech stopped from message playback.');
	      }
      setSpeakingMessageIndex(null);
      return;
    }
    // Stop any current speech first
    intendedSpeakingIndexRef.current = index;
    Speech.stop();
    stopRemoteTts();
    const processedText = preprocessTextForTTS(content);
    if (!processedText) {
      intendedSpeakingIndexRef.current = null;
      return;
    }
	    if (handsFree) {
	      handsFreeController.onSpeechStarted();
	      voiceLog('tts-started', 'Assistant speech started from message playback.');
	    }
    setSpeakingMessageIndex(index);
    if (effectiveTtsProvider === 'edge' && config.baseUrl && config.apiKey) {
      // Edge TTS routes through the paired desktop's /v1/tts/speak.
      void speakRemoteTts(processedText, {
        baseUrl: config.baseUrl,
        apiKey: config.apiKey,
        providerId: 'edge',
        voice: effectiveEdgeTtsVoice,
        rate: effectiveEdgeTtsRate,
        onDone: () => {
          intendedSpeakingIndexRef.current = null;
          if (handsFree) {
            handsFreeController.onSpeechFinished();
            voiceLog('tts-stopped', 'Assistant speech finished from message playback.');
          }
          setSpeakingMessageIndex(null);
        },
        onError: () => {
          intendedSpeakingIndexRef.current = null;
          if (handsFree) {
            handsFreeController.onSpeechFinished();
            voiceLog('tts-stopped', 'Assistant speech errored during message playback.');
          }
          setSpeakingMessageIndex(null);
        },
        onStopped: () => {
          if (intendedSpeakingIndexRef.current === null) {
            if (handsFree) {
              handsFreeController.onSpeechFinished();
              voiceLog('tts-stopped', 'Assistant speech stopped during message playback.');
            }
            setSpeakingMessageIndex(null);
          }
        },
      });
      return;
    }

    const speechOptions: Speech.SpeechOptions = {
      language: 'en-US',
      rate: config.ttsRate ?? 1.0,
      pitch: config.ttsPitch ?? 1.0,
      onDone: () => {
        intendedSpeakingIndexRef.current = null;
	        if (handsFree) {
	          handsFreeController.onSpeechFinished();
	          voiceLog('tts-stopped', 'Assistant speech finished from message playback.');
	        }
        setSpeakingMessageIndex(null);
      },
      onError: () => {
        intendedSpeakingIndexRef.current = null;
	        if (handsFree) {
	          handsFreeController.onSpeechFinished();
	          voiceLog('tts-stopped', 'Assistant speech errored during message playback.');
	        }
        setSpeakingMessageIndex(null);
      },
      onStopped: () => {
        // Only clear if this callback is for the current intended message,
        // not a stale callback from a previously stopped utterance
        if (intendedSpeakingIndexRef.current === null) {
	          if (handsFree) {
	            handsFreeController.onSpeechFinished();
	            voiceLog('tts-stopped', 'Assistant speech stopped during message playback.');
	          }
          setSpeakingMessageIndex(null);
        }
      },
    };
    if (config.ttsVoiceId) {
      speechOptions.voice = config.ttsVoiceId;
    }
    Speech.speak(processedText, speechOptions);
	  }, [
		speakingMessageIndex,
		config.apiKey,
		config.baseUrl,
		config.ttsRate,
		config.ttsPitch,
		config.ttsVoiceId,
		effectiveEdgeTtsRate,
		effectiveEdgeTtsVoice,
		effectiveTtsProvider,
		handsFree,
		handsFreeController,
		voiceLog,
	  ]);

  // Auto-scroll state and ref for mobile chat
  const scrollViewRef = useRef<ScrollView>(null);
  const [shouldAutoScroll, setShouldAutoScroll] = useState(true);
  // Track scroll timeout for debouncing rapid message updates
  const scrollTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  // Ref to track current auto-scroll state for use in timeout callbacks
  const shouldAutoScrollRef = useRef(true);
  // Track if user is actively dragging to distinguish from programmatic scrolls
  const isUserDraggingRef = useRef(false);
  // Track drag end timeout to prevent flaky behavior with rapid re-drags
  const dragEndTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);

  // Cleanup: stop speech on unmount (#1078)
  useEffect(() => {
    return () => {
      Speech.stop();
      stopRemoteTts();
    };
  }, []);

  // Keep ref in sync with state
  useEffect(() => {
    shouldAutoScrollRef.current = shouldAutoScroll;
    // Cancel any pending scroll when user disables auto-scroll
    if (!shouldAutoScroll && scrollTimeoutRef.current) {
      clearTimeout(scrollTimeoutRef.current);
      scrollTimeoutRef.current = null;
    }
  }, [shouldAutoScroll]);

  // Handle user starting to drag the scroll view
  const handleScrollBeginDrag = useCallback(() => {
    // Clear any pending drag end timeout from previous drag
    if (dragEndTimeoutRef.current) {
      clearTimeout(dragEndTimeoutRef.current);
      dragEndTimeoutRef.current = null;
    }
    isUserDraggingRef.current = true;
  }, []);

  // Handle user ending drag - keep flag active briefly for momentum scroll
  const handleScrollEndDrag = useCallback(() => {
    // Clear any existing drag end timeout before scheduling a new one
    if (dragEndTimeoutRef.current) {
      clearTimeout(dragEndTimeoutRef.current);
    }
    // Clear the flag after a short delay to account for momentum scrolling
    dragEndTimeoutRef.current = setTimeout(() => {
      isUserDraggingRef.current = false;
      dragEndTimeoutRef.current = null;
    }, 150);
  }, []);

  // Handle scroll events to detect when user scrolls away from bottom
  const handleScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
    const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent;
    // Consider "at bottom" if within 50 pixels of the bottom
    const isAtBottom = layoutMeasurement.height + contentOffset.y >= contentSize.height - 50;
    const isNearTop = contentOffset.y <= 120;

    if (isAtBottom && !shouldAutoScroll) {
      // User scrolled back to bottom, resume auto-scroll
      setShouldAutoScroll(true);
    } else if (!isAtBottom && shouldAutoScroll && isUserDraggingRef.current) {
      // Only pause auto-scroll when user is actively dragging (not programmatic scroll)
      setShouldAutoScroll(false);
    }
    if (isNearTop && visibleMessageCount < messages.length) {
      setVisibleMessageCount((current) => Math.min(messages.length, current + VISIBLE_CHAT_MESSAGES_INCREMENT));
    }
  }, [messages.length, shouldAutoScroll, visibleMessageCount]);

  useEffect(() => {
    setVisibleMessageCount(INITIAL_VISIBLE_CHAT_MESSAGES);
  }, [sessionStore.currentSessionId]);

  useEffect(() => {
    setVisibleMessageCount((current) => {
      if (messages.length === 0) return INITIAL_VISIBLE_CHAT_MESSAGES;
      const next = Math.max(INITIAL_VISIBLE_CHAT_MESSAGES, current);
      return Math.min(messages.length, next);
    });
  }, [messages.length]);

  // Scroll to bottom when messages change and auto-scroll is enabled
  // Uses debouncing to handle rapid streaming updates efficiently
  useEffect(() => {
    if (shouldAutoScroll && scrollViewRef.current) {
      // Clear any pending scroll timeout to debounce rapid updates
      if (scrollTimeoutRef.current) {
        clearTimeout(scrollTimeoutRef.current);
      }

      // Schedule a new scroll with a short delay to batch rapid updates
      scrollTimeoutRef.current = setTimeout(() => {
        // Double-check auto-scroll is still enabled before scrolling
        if (shouldAutoScrollRef.current && scrollViewRef.current) {
          scrollViewRef.current.scrollToEnd({ animated: true });
        }
      }, 50);
    }
  }, [messages, shouldAutoScroll]);

  // Cleanup timeouts on unmount
  useEffect(() => {
    return () => {
      if (scrollTimeoutRef.current) {
        clearTimeout(scrollTimeoutRef.current);
      }
      if (dragEndTimeoutRef.current) {
        clearTimeout(dragEndTimeoutRef.current);
      }
    };
  }, []);

  useEffect(() => {
    if (!settingsClient || !isFocused) {
      if (!settingsClient) {
        setPredefinedPrompts([]);
        setAvailableSkills([]);
        setAvailableTasks([]);
        setIsLoadingQuickStartPrompts(false);
      }
      return;
    }

    let cancelled = false;
    setIsLoadingQuickStartPrompts(true);

    Promise.allSettled([
      settingsClient.getSettings(),
      settingsClient.getSkills(),
      settingsClient.getLoops(),
    ] as const)
      .then(([settingsResult, skillsResult, loopsResult]) => {
        if (cancelled) return;

        if (settingsResult.status === 'fulfilled') {
          const settings = settingsResult.value;
          const nextPrompts = [...(settings.predefinedPrompts || [])].sort((a, b) => b.updatedAt - a.updatedAt);
          setPredefinedPrompts(nextPrompts);
          setRemoteTtsProvider(settings.ttsProviderId === 'edge' ? 'edge' : 'native');
          setRemoteEdgeTtsVoice(settings.edgeTtsVoice || 'en-US-AriaNeural');
          setRemoteEdgeTtsRate(settings.edgeTtsRate ?? 1.0);
        } else {
          setPredefinedPrompts([]);
        }

        setAvailableSkills(skillsResult.status === 'fulfilled' ? skillsResult.value.skills : []);
        setAvailableTasks(loopsResult.status === 'fulfilled' ? loopsResult.value.loops : []);
      })
      .finally(() => {
        if (cancelled) return;
        setIsLoadingQuickStartPrompts(false);
      });

    return () => {
      cancelled = true;
    };
  }, [isFocused, settingsClient]);

  const handleSavePrompt = async () => {
    const name = newPromptName.trim();
    const content = newPromptContent.trim();
    if (!settingsClient || !name || !content || isSavingPrompt) return;
    setIsSavingPrompt(true);
    const wasEditingPrompt = Boolean(editingPrompt);
    try {
      const now = Date.now();
      const updatedPrompts = editingPrompt
        ? predefinedPrompts.map((prompt) => (
            prompt.id === editingPrompt.id
              ? {
                  ...prompt,
                  name,
                  content,
                  updatedAt: now,
                }
              : prompt
          ))
        : [
            {
              id: `prompt-${now}-${Math.random().toString(36).substr(2, 9)}`,
              name,
              content,
              createdAt: now,
              updatedAt: now,
            },
            ...predefinedPrompts,
          ];

      await settingsClient.updateSettings({ predefinedPrompts: updatedPrompts });
      setPredefinedPrompts(updatedPrompts);
      setAddPromptModalVisible(false);
      setEditingPrompt(null);
      setNewPromptName('');
      setNewPromptContent('');
      Alert.alert(
        'Success',
        wasEditingPrompt
          ? 'Prompt updated in your desktop prompt library.'
          : 'Prompt saved to your desktop prompt library.'
      );
    } catch (error: any) {
      console.error('[ChatScreen] Error saving prompt:', error);
      Alert.alert('Error', error.message || 'Failed to save prompt.');
    } finally {
      setIsSavingPrompt(false);
    }
  };

  const handleDeletePromptConfirmed = useCallback(async (prompt: PredefinedPromptSummary) => {
    if (!settingsClient || isSavingPrompt) return;
    setIsSavingPrompt(true);
    try {
      const updatedPrompts = predefinedPrompts.filter((existingPrompt) => existingPrompt.id !== prompt.id);
      await settingsClient.updateSettings({ predefinedPrompts: updatedPrompts });
      setPredefinedPrompts(updatedPrompts);
      if (editingPrompt?.id === prompt.id) {
        setAddPromptModalVisible(false);
        setEditingPrompt(null);
        setNewPromptName('');
        setNewPromptContent('');
      }
      Alert.alert('Deleted', 'Prompt removed from your desktop prompt library.');
    } catch (error: any) {
      console.error('[ChatScreen] Error deleting prompt:', error);
      Alert.alert('Error', error.message || 'Failed to delete prompt.');
    } finally {
      setIsSavingPrompt(false);
    }
  }, [editingPrompt?.id, isSavingPrompt, predefinedPrompts, settingsClient]);

  const handleDeletePrompt = useCallback((prompt: PredefinedPromptSummary) => {
    if (!settingsClient || isSavingPrompt) return;
    const message = `Delete "${prompt.name}" from your desktop prompt library?`;

    if (Platform.OS === 'web') {
      if (window.confirm(message)) {
        void handleDeletePromptConfirmed(prompt);
      }
      return;
    }

    Alert.alert('Delete Prompt', message, [
      { text: 'Cancel', style: 'cancel' },
      {
        text: 'Delete',
        style: 'destructive',
        onPress: () => {
          void handleDeletePromptConfirmed(prompt);
        },
      },
    ]);
  }, [handleDeletePromptConfirmed, isSavingPrompt, settingsClient]);

  // Reset auto-scroll when session changes
  useEffect(() => {
    setShouldAutoScroll(true);
    // Scroll to bottom when switching sessions
    const timeoutId = setTimeout(() => {
      scrollViewRef.current?.scrollToEnd({ animated: false });
    }, 100);
    return () => clearTimeout(timeoutId);
  }, [sessionStore.currentSessionId]);

  const lastLoadedSessionIdRef = useRef<string | null>(null);
  const pendingLazyLoadSessionIdRef = useRef<string | null>(null);
  // Set to true before hydrating local state from a server lazy-load so the
  // persistence effect doesn't re-save the just-fetched messages (which would
  // regenerate IDs/timestamps and cause unnecessary updatedAt drift).
  const skipNextPersistRef = useRef(false);

  // Load messages when currentSession changes (fixes #470)
  useEffect(() => {
    const currentSessionId = sessionStore.currentSessionId;
    const hasServerAuth = !!config.baseUrl && !!config.apiKey;
    let currentSession = sessionStore.getCurrentSession();
    const shouldAttemptStubLoad = !!(
      currentSession &&
      currentSession.messages.length === 0 &&
      currentSession.serverConversationId &&
      hasServerAuth
    );

    // Avoid repeated work on stable sessions unless we still need to lazy-load stub messages.
    if (lastLoadedSessionIdRef.current === currentSessionId && !shouldAttemptStubLoad) {
      return;
    }

    const isSessionSwitch = lastLoadedSessionIdRef.current !== currentSessionId;
    if (isSessionSwitch) {
      // Reset expandedMessages and expandedToolCalls on session switch to ensure consistent
      // "final response expanded" behavior per chat and prevent stale UI state from leaking.
      setExpandedMessages({});
      setExpandedToolCalls({});
      setExpandedGroups({});
      clearCopiedMessageFeedback();
      // Clear respond_to_user history for the new session
	      replaceResponseHistory([]);
      playedResponseEventIdsRef.current = new Set();
      queuedResponseEventsRef.current = [];
      activeAutoSpeechEventIdRef.current = null;
      // Clear stale in-flight marker when switching sessions.
      pendingLazyLoadSessionIdRef.current = null;
      // Clear skipNextPersistRef to prevent the first real message in the new session
      // from being skipped if a lazy-load from the previous session had set it.
      skipNextPersistRef.current = false;
    }

    // If we have an existing session, always load its messages regardless of deletions
    if (currentSession) {
      lastLoadedSessionIdRef.current = currentSession.id;

      if (currentSession.messages.length > 0) {
        const chatMessages: ChatMessage[] = currentSession.messages.map(m => ({
          role: m.role,
          content: m.content,
          displayContent: (m as ChatMessage).displayContent,
          timestamp: m.timestamp,
          toolCalls: m.toolCalls,
          toolResults: m.toolResults,
        }));
        setMessages(chatMessages);

        // Extract respond_to_user content from saved messages for display (#32, #33)
        const savedResponses = extractRespondToUserHistory(chatMessages as RespondToUserHistorySourceMessage[]);
	        replaceResponseHistory(savedResponses);
        playedResponseEventIdsRef.current = new Set(savedResponses.map((event) => event.id));
        queuedResponseEventsRef.current = [];
        activeAutoSpeechEventIdRef.current = null;
      } else if (currentSession.serverConversationId && hasServerAuth) {
        // Stub session — lazy-load messages from server
        setMessages([]);
	        replaceResponseHistory([]);
        const stubSessionId = currentSession.id;
        if (pendingLazyLoadSessionIdRef.current === stubSessionId) {
          return;
        }
        pendingLazyLoadSessionIdRef.current = stubSessionId;
        const client = settingsClient || new ExtendedSettingsApiClient(config.baseUrl, config.apiKey);
        sessionStore.loadSessionMessages(stubSessionId, client)
          .then((result) => {
            if (!result) return;
            // Ignore late results if the user switched sessions while loading.
            if (currentSessionIdRef.current !== stubSessionId) return;
            // Skip persistence whenever loadSessionMessages returned messages that are
            // already in the store (both freshly fetched and in-flight bail-out cases)
            // to avoid ID/updatedAt regeneration. The flag is always cleared by the
            // persistence effect on the next render (or immediately if length is unchanged).
            if (result.messages.length > 0) {
              skipNextPersistRef.current = true;
            }
            const loadedMessages = result.messages.map(m => ({
              id: m.id,
              role: m.role,
              content: m.content,
              displayContent: (m as ChatMessage).displayContent,
              timestamp: m.timestamp,
              toolCalls: m.toolCalls,
              toolResults: m.toolResults,
            }));
            setMessages(loadedMessages);

            // Extract respond_to_user content from lazy-loaded messages (#32, #33)
            const lazyResponses = extractRespondToUserHistory(
              loadedMessages as RespondToUserHistorySourceMessage[]
            );
	            replaceResponseHistory(lazyResponses);
            playedResponseEventIdsRef.current = new Set(lazyResponses.map((event) => event.id));
            queuedResponseEventsRef.current = [];
            activeAutoSpeechEventIdRef.current = null;
          })
          .catch((err) => {
            console.warn('[ChatScreen] Failed to lazy-load session messages:', err);
          })
          .finally(() => {
            if (pendingLazyLoadSessionIdRef.current === stubSessionId) {
              pendingLazyLoadSessionIdRef.current = null;
            }
          });
      } else {
        setMessages([]);
	        replaceResponseHistory([]);
      }
      return;
    }

    // No current session - only auto-create if no deletions are in progress (fixes #571)
    // This prevents race conditions where a new session is created before the deletion completes
    if (sessionStore.deletingSessionIds.size > 0) {
      return;
    }

    currentSession = sessionStore.createNewSession();
    lastLoadedSessionIdRef.current = currentSession.id;

    if (currentSession.messages.length > 0) {
      const chatMessages: ChatMessage[] = currentSession.messages.map(m => ({
        role: m.role,
        content: m.content,
        displayContent: (m as ChatMessage).displayContent,
        timestamp: m.timestamp,
        toolCalls: m.toolCalls,
        toolResults: m.toolResults,
      }));
      setMessages(chatMessages);

      // Extract respond_to_user content from new session messages (#32, #33)
      const newResponses = extractRespondToUserHistory(chatMessages as RespondToUserHistorySourceMessage[]);
	      replaceResponseHistory(newResponses);
      playedResponseEventIdsRef.current = new Set(newResponses.map((event) => event.id));
      queuedResponseEventsRef.current = [];
      activeAutoSpeechEventIdRef.current = null;
    } else {
      setMessages([]);
	      replaceResponseHistory([]);
    }
	  }, [sessionStore.currentSessionId, sessionStore, sessionStore.deletingSessionIds.size, config.baseUrl, config.apiKey, settingsClient, clearCopiedMessageFeedback, replaceResponseHistory]);

  // Auto-send initialMessage from route params (e.g. from rapid fire mode in SessionListScreen)
  const initialMessageRef = useRef<string | null>(route?.params?.initialMessage ?? null);
  const initialMessageSentRef = useRef(false);
	useEffect(() => {
		const nextInitial = route?.params?.initialMessage;
		if (!nextInitial || typeof nextInitial !== 'string') return;
		initialMessageRef.current = nextInitial;
		initialMessageSentRef.current = false;
		voiceLog('state-transition', 'Route initial message received.', { initialMessage: nextInitial });
	}, [route?.params?.initialMessage, voiceLog]);
  useEffect(() => {
    if (!initialMessageRef.current || initialMessageSentRef.current) return;
    if (!sessionStore.currentSessionId) return;
    initialMessageSentRef.current = true;
    const msg = initialMessageRef.current;
    initialMessageRef.current = null;
		try { navigation?.setParams?.({ initialMessage: undefined }); } catch {}
    // Small delay to ensure the session is fully loaded and the component is rendered
    const timer = setTimeout(() => {
      void sendRef.current(msg);
    }, 300);
    return () => clearTimeout(timer);
	}, [navigation, sessionStore.currentSessionId]);

  const prevMessagesLengthRef = useRef(0);
  const prevSessionIdRef = useRef<string | null>(null);
  useEffect(() => {
    const currentSessionId = sessionStore.currentSessionId;

    // Don't save messages if the current session is being deleted (fixes #571)
    // Only skip if the current session is in the deleting set, not for any deletion
    if (currentSessionId && sessionStore.deletingSessionIds.has(currentSessionId)) {
      return;
    }

    const isSessionSwitch = prevSessionIdRef.current !== null && prevSessionIdRef.current !== currentSessionId;
    prevSessionIdRef.current = currentSessionId;

    if (isSessionSwitch) {
      prevMessagesLengthRef.current = messages.length;
      return;
    }

    if (messages.length > 0 && messages.length !== prevMessagesLengthRef.current) {
      if (skipNextPersistRef.current) {
        // Messages were just hydrated from a server lazy-load and are already
        // saved by loadSessionMessages; skip to avoid ID/updatedAt regeneration.
        skipNextPersistRef.current = false;
      } else {
        sessionStore.setMessages(messages);
      }
    } else if (skipNextPersistRef.current) {
      // Length didn't change (or is 0), so the effect above won't fire — clear
      // the flag now to prevent it from accidentally skipping the next real
      // message persistence (e.g., lazy-load returned same count as before).
      skipNextPersistRef.current = false;
    }
    prevMessagesLengthRef.current = messages.length;
  }, [messages, sessionStore, sessionStore.currentSessionId, sessionStore.deletingSessionIds]);

  const toggleMessageExpansion = useCallback((index: number) => {
    setExpandedMessages(prev => ({ ...prev, [index]: !prev[index] }));
  }, []);

  // Toggle expansion of individual tool call details (input params and results)
  const toggleToolCallExpansion = useCallback((messageId: string, toolCallIndex: number) => {
    const key = `${messageId}-${toolCallIndex}`;
    setExpandedToolCalls(prev => ({ ...prev, [key]: !prev[key] }));
  }, []);

  // Compute tool-activity groups for consecutive connected tool-call messages
  const toolActivityGroups = useMemo(() => groupToolActivity(messages), [messages]);

  const getToolActivityGroupKey = useCallback((group: ToolActivityGroup) => `${group.startIndex}`, []);

  // Toggle expansion of a tool-activity group using a stable key for the run.
  const toggleGroupExpansion = useCallback((group: ToolActivityGroup) => {
    const key = getToolActivityGroupKey(group);
    setExpandedGroups(prev => ({ ...prev, [key]: !prev[key] }));
  }, [getToolActivityGroupKey]);

  // Auto-expand logic matching desktop behavior (#32, #33):
  // - Tool-only messages (toolCalls/toolResults with no visible user-facing content) collapse by default
  // - Messages with tool metadata and visible user-facing content can still expand normally
  // - Only the final assistant message auto-expands, and only when not streaming (agent complete)
  // - Tool-only messages stay collapsed during streaming to avoid showing raw payload text
  // - Users can still manually expand any collapsed message
  useEffect(() => {
    const lastAssistantIndex = messages.reduce((lastIdx, m, i) =>
      m.role === 'assistant' ? i : lastIdx, -1);

    if (lastAssistantIndex >= 0) {
      setExpandedMessages(prev => {
        const updated = { ...prev };
        const lastMsg = messages[lastAssistantIndex];

        // Collapse tool-only assistant messages by default when they have no visible user-facing content
        messages.forEach((m, i) => {
          if (m.role === 'assistant' && shouldTreatMessageAsToolOnly(m)) {
            // Only set to false if not explicitly toggled by user
            // (We check if the index exists in prev - if so, preserve user choice)
            if (!(i in prev)) {
              updated[i] = false;
            }
          }
        });

        // Expand final assistant message ONLY if:
        // 1. Agent is not currently streaming/responding
        // 2. The message is NOT a tool-only message (has real content for user)
        const isComplete = !responding;
        const isFinalToolOnly = shouldTreatMessageAsToolOnly(lastMsg);

        if (isComplete && !isFinalToolOnly) {
          // Expand final message only if it has user-facing content
          updated[lastAssistantIndex] = true;
        } else if (!(lastAssistantIndex in prev)) {
          // During streaming or for tool-only messages, collapse by default
          updated[lastAssistantIndex] = false;
        }

        return updated;
      });
    }
  }, [messages, responding]);

  const convoRef = useRef<string | undefined>(undefined);

  const convertProgressToMessages = useCallback((update: AgentProgressUpdate): ChatMessage[] => {
    const messages: ChatMessage[] = [];
    const delegationMessages = createDelegationProgressMessages(update.steps);
    console.log('[convertProgressToMessages] Processing update, steps:', update.steps?.length || 0, 'history:', update.conversationHistory?.length || 0, 'isComplete:', update.isComplete);

    if (update.steps && update.steps.length > 0) {
      let currentToolCalls: any[] = [];
      let currentToolResults: any[] = [];
      const currentToolExecutionStats: Array<ToolExecutionStats | undefined> = [];
      let thinkingContent = '';

      for (const step of update.steps) {
        const stepContent = step.content || step.llmContent;
        if (step.type === 'thinking' && stepContent) {
          thinkingContent = stepContent;
        } else if (step.type === 'tool_call') {
          if (step.toolCall) {
            currentToolCalls.push(step.toolCall);
            currentToolExecutionStats.push(getAgentProgressStepToolExecutionStats(step));
          }
          if (step.toolResult) {
            currentToolResults.push(step.toolResult);
          }
        } else if (step.type === 'tool_result' && step.toolResult) {
          currentToolResults.push(step.toolResult);
        } else if (step.type === 'completion' && stepContent) {
          thinkingContent = stepContent;
        }
      }

      const activeStep = [...update.steps].reverse().find((step) => step.status === 'in_progress');
      const isVerificationStep = activeStep?.title?.toLowerCase().includes('verifying');
      const hasCurrentToolActivity = currentToolCalls.length > 0 || currentToolResults.length > 0;
      const hasCurrentAssistantFeedback = hasCurrentToolActivity || thinkingContent.trim().length > 0;
      const hasCurrentStateFeedback =
        hasCurrentAssistantFeedback ||
        !!update.pendingToolApproval ||
        delegationMessages.length > 0 ||
        !!update.streamingContent?.text;

      if (hasCurrentAssistantFeedback) {
        const toolExecutions = currentToolExecutionStats.some(Boolean)
          ? currentToolCalls.map((toolCall, index) => ({
              toolCall,
              result: currentToolResults[index],
              executionStats: currentToolExecutionStats[index],
            }))
          : undefined;
        messages.push({
          role: 'assistant',
          content: thinkingContent || (hasCurrentToolActivity ? 'Executing tools...' : ''),
          toolCalls: currentToolCalls.length > 0 ? currentToolCalls : undefined,
          toolResults: currentToolResults.length > 0 ? currentToolResults : undefined,
          toolExecutionStats: currentToolExecutionStats.some(Boolean) ? currentToolExecutionStats : undefined,
          toolExecutions,
        });
      } else if (
        !update.isComplete &&
        !hasCurrentStateFeedback &&
        !isVerificationStep
      ) {
        messages.push({
          role: 'assistant',
          content: activeStep?.type === 'tool_call'
            ? activeStep.title || 'Running tool...'
            : activeStep?.description || 'Thinking...',
        });
      }
    }

    if (update.conversationHistory && update.conversationHistory.length > 0) {
      let currentTurnStartIndex = 0;
      for (let i = 0; i < update.conversationHistory.length; i++) {
        if (update.conversationHistory[i].role === 'user') {
          currentTurnStartIndex = i;
        }
      }

      const hasAssistantMessages = currentTurnStartIndex + 1 < update.conversationHistory.length;
      if (hasAssistantMessages) {
        messages.length = 0;

        for (let i = currentTurnStartIndex + 1; i < update.conversationHistory.length; i++) {
          const historyMsg = update.conversationHistory[i];

          // Merge tool results into the preceding assistant message to avoid duplication
          // The server sends: assistant (with toolCalls) -> tool (with toolResults)
          // We want to display them as a single message with both toolCalls and toolResults
          if (historyMsg.role === 'tool' && messages.length > 0) {
            const lastMessage = messages[messages.length - 1];
            if (lastMessage.role === 'assistant' && lastMessage.toolCalls && lastMessage.toolCalls.length > 0) {
              const hasToolResults = historyMsg.toolResults && historyMsg.toolResults.length > 0;

              if (hasToolResults) {
                // Merge toolResults into the existing assistant message
                lastMessage.toolResults = [
                  ...(lastMessage.toolResults || []),
                  ...(historyMsg.toolResults || []),
                ];
                // Skip adding this as a separate message only when we merged results
                continue;
              }
            }
          }

          // Drop synthetic tool-role summaries (e.g. "TOOL FAILED: ...") that
          // carry no toolResults/toolCalls — the underlying failures are
          // already visible inside the tool call stack via toolResults on the
          // preceding tool message.
          if (
            historyMsg.role === 'tool' &&
            !(historyMsg.toolResults && historyMsg.toolResults.length > 0) &&
            !(historyMsg.toolCalls && historyMsg.toolCalls.length > 0)
          ) {
            continue;
          }

          messages.push({
            role: historyMsg.role === 'tool' ? 'assistant' : historyMsg.role,
            content: historyMsg.content || '',
            displayContent: historyMsg.displayContent,
            toolCalls: historyMsg.toolCalls,
            toolResults: historyMsg.toolResults,
          });
        }
      }
    }

    if (update.streamingContent?.text) {
      if (
        messages.length > 0
        && messages[messages.length - 1].role === 'assistant'
        && messages[messages.length - 1].variant !== 'delegation'
        && messages[messages.length - 1].variant !== 'approval'
      ) {
        messages[messages.length - 1].content = update.streamingContent.text;
      } else {
        messages.push({
          role: 'assistant',
          content: update.streamingContent.text,
        });
      }
    }

    if (update.pendingToolApproval) {
      messages.push({
        role: 'assistant',
        content: `Tool approval required: ${update.pendingToolApproval.toolName}`,
        variant: 'approval',
        toolApproval: update.pendingToolApproval,
      });
    }

    const messagesWithUserResponse = applyUserResponseToMessages(
      messages,
      update.userResponse || update.spokenContent,
    );
    return [...messagesWithUserResponse, ...delegationMessages];
  }, []);

  // Get the current conversation ID for queue operations
  const currentConversationId = sessionStore.currentSessionId || 'default';

  // Get queued messages for the current conversation
  const queuedMessages = messageQueue.getQueue(currentConversationId);
  const nextQueuedMessage = !responding ? messageQueue.peek(currentConversationId) : null;

  const handlePickImages = useCallback(async () => {
    if (pendingImages.length >= MAX_PENDING_IMAGES) {
      Alert.alert('Image limit reached', `You can attach up to ${MAX_PENDING_IMAGES} images per message.`);
      return;
    }

    const existingEmbeddedBytes = pendingImages.reduce(
      (sum, image) => sum + getApproxDataUrlBytes(image.dataUrl),
      0
    );
    if (existingEmbeddedBytes >= MAX_TOTAL_PENDING_IMAGE_EMBEDDED_BYTES) {
      Alert.alert(
        'Image budget reached',
        `This message already reached the image budget (${formatMb(MAX_TOTAL_PENDING_IMAGE_EMBEDDED_BYTES)}).`
      );
      return;
    }

    try {
      const result = await ImagePicker.launchImageLibraryAsync({
        mediaTypes: ImagePicker.MediaTypeOptions.Images,
        allowsMultipleSelection: true,
        selectionLimit: MAX_PENDING_IMAGES - pendingImages.length,
        quality: 0.8,
        base64: true,
      });

      if (result.canceled || !result.assets || result.assets.length === 0) return;

      const slotsRemaining = MAX_PENDING_IMAGES - pendingImages.length;
      const selectedAssets = result.assets.slice(0, slotsRemaining);
      const nextImages: PendingImageAttachment[] = [];
      const missingBase64Names: string[] = [];
      const oversizedImageNames: string[] = [];
      const unknownMimeNames: string[] = [];
      const budgetExceededNames: string[] = [];
      let runningEmbeddedBytes = existingEmbeddedBytes;

      selectedAssets.forEach((asset, index) => {
        const displayName = asset.fileName || `Image ${index + 1}`;
        if (!asset.base64) {
          missingBase64Names.push(displayName);
          return;
        }

        const inferredBytes = getApproxBase64Bytes(asset.base64);
        const fileSizeBytes = typeof asset.fileSize === 'number' && asset.fileSize > 0
          ? asset.fileSize
          : inferredBytes;
        if (fileSizeBytes > MAX_PENDING_IMAGE_FILE_SIZE_BYTES) {
          oversizedImageNames.push(displayName);
          return;
        }

        const mimeType = inferImageMimeType(asset);
        if (!mimeType) {
          unknownMimeNames.push(displayName);
          return;
        }

        const dataUrl = `data:${mimeType};base64,${asset.base64}`;
        const embeddedBytes = getApproxDataUrlBytes(dataUrl) || inferredBytes;
        if (runningEmbeddedBytes + embeddedBytes > MAX_TOTAL_PENDING_IMAGE_EMBEDDED_BYTES) {
          budgetExceededNames.push(displayName);
          return;
        }
        runningEmbeddedBytes += embeddedBytes;

        const fileName = asset.fileName || `image-${Date.now()}-${index + 1}`;
        nextImages.push({
          id: `${Date.now()}-${index}-${asset.uri}`,
          name: fileName,
          previewUri: asset.uri,
          dataUrl,
        });
      });

      if (nextImages.length > 0) {
        setPendingImages((prev) => [...prev, ...nextImages]);
      }

      if (missingBase64Names.length > 0) {
        Alert.alert(
          'Some images were skipped',
          `${missingBase64Names.join(', ')} could not be attached. Please try again.`
        );
      }

      if (oversizedImageNames.length > 0) {
        Alert.alert(
          'Image too large',
          `${oversizedImageNames.join(', ')} exceed the 4MB limit.`
        );
      }

      if (unknownMimeNames.length > 0) {
        Alert.alert(
          'Unsupported image format',
          `${unknownMimeNames.join(', ')} could not be attached because the image type could not be determined.`
        );
      }

      if (budgetExceededNames.length > 0) {
        Alert.alert(
          'Image budget reached',
          `${budgetExceededNames.join(', ')} exceed the per-message image budget (${formatMb(MAX_TOTAL_PENDING_IMAGE_EMBEDDED_BYTES)}).`
        );
      }
    } catch (error: any) {
      Alert.alert('Image picker error', error?.message || 'Unable to select images right now.');
    }
  }, [pendingImages]);

  const removePendingImage = useCallback((attachmentId: string) => {
    setPendingImages((prev) => prev.filter((image) => image.id !== attachmentId));
  }, []);

  const send = async (text: string, options?: { fromComposer?: boolean }) => {
    if (!text.trim()) return;

    // If message queue is enabled and we're already responding, queue the message
    if (messageQueueEnabled && responding) {
      console.log('[ChatScreen] Agent busy, queuing message:', getMessageLogMeta(text));
      messageQueue.enqueue(currentConversationId, text, currentConversationId);
      setInput('');
      if (options?.fromComposer) {
        setPendingImages([]);
      }
      return;
    }

    console.log('[ChatScreen] Sending message:', getMessageLogMeta(text));

    // Get client from connection manager (preserves connections across session switches)
    const client = getSessionClient();
    if (!client) {
      console.error('[ChatScreen] No client available for send');
      setDebugInfo('Error: No session available');
      return;
    }

    setDebugInfo(`Starting request to ${config.baseUrl}...`);
    // Clear any previous failed message when starting a new send
    setLastFailedMessage(null);

    const userMsg: ChatMessage = { role: 'user', content: text };
	    // Use ref to avoid stale closures (notably auto-send after rapid-fire session switch).
	    const currentMessages = messagesRef.current;
	    const messageCountBeforeTurn = currentMessages.length;
    // Clear progress messages ref for this new request (#1083)
    progressMessagesRef.current = [];
    setMessages((m) => [...m, userMsg, { role: 'assistant', content: '' }]);
    setResponding(true);
    setConversationState('running');
	    if (handsFree) {
	      handsFreeController.onRequestStarted();
	    }

    // Generate a unique request ID for this request
    // This prevents cross-request race conditions on view-level state
    const thisRequestId = Date.now();
    // Note: We keep activeRequestIdRef for backward compatibility and view-level state,
    // but the primary "superseded" check now uses per-session tracking (PR review fix #13)
    activeRequestIdRef.current = thisRequestId;

    const currentSession = sessionStore.getCurrentSession();
    const startingServerConversationId = currentSession?.serverConversationId;

    console.log('[ChatScreen] Session info:', {
      sessionId: currentSession?.id,
      serverConversationId: startingServerConversationId || 'new',
      requestId: thisRequestId
    });

    setInput('');
	    if (options?.fromComposer) {
	      setPendingImages([]);
	    }

    // Capture the session ID at request start to guard against session changes
    const requestSessionId = sessionStore.currentSessionId;
    let resolvedConversationId: string | undefined;

    if (requestSessionId && !startingServerConversationId) {
      sessionStore.markPendingServerConversation(requestSessionId, true);
    }

    // Mark this request as the latest for this session in the connection manager
    // and increment active request count
    // This enables per-session request tracking to prevent cross-session superseding (PR review fix #13)
    if (requestSessionId) {
      connectionManager.setLatestRequestId(requestSessionId, thisRequestId);
      connectionManager.incrementActiveRequests(requestSessionId);
    }

    try {
      let streamingText = '';
      let latestConversationState: AgentConversationState = 'running';
      // Track userResponse from progress updates for TTS
      // This is set via the respond_to_user tool and takes priority over finalText
      let lastUserResponse: string | undefined;
	      let lastResponseEvents: AgentUserResponseEvent[] = [];
	      let midTurnLegacyResponseText: string | undefined;

      const serverConversationId = sessionStore.getServerConversationId();
	      console.log('[ChatScreen] Starting chat request with', currentMessages.length + 1, 'messages, conversationId:', serverConversationId || 'new');
      setDebugInfo('Request sent, waiting for response...');

      const onProgress = (update: AgentProgressUpdate) => {
        // Guard: skip update if session has changed since request started
        // Use currentSessionIdRef.current to avoid stale closure issue (useSessions returns new object each render)
        if (currentSessionIdRef.current !== requestSessionId) {
          console.log('[ChatScreen] Session changed, skipping onProgress update');
          return;
        }
        // Guard: skip update if this request is no longer the latest one for this session
        // Uses per-session tracking to prevent cross-session sends from incorrectly superseding (PR review fix #13)
        if (requestSessionId && connectionManager.getLatestRequestId(requestSessionId) !== thisRequestId) {
          console.log('[ChatScreen] Request superseded within same session, skipping onProgress update');
          return;
        }
        latestConversationState = resolveConversationStateFromProgress(update, 'running');
        setConversationState(latestConversationState);
        if (update.responseEvents?.length) {
	          lastResponseEvents = [...update.responseEvents].sort((a, b) => a.ordinal - b.ordinal);
	          mergeResponseEvents(lastResponseEvents);
	          enqueueResponseEventsForSpeech(lastResponseEvents);
	          lastUserResponse = lastResponseEvents[lastResponseEvents.length - 1]?.text;
	        } else if (update.userResponse || update.spokenContent) {
	          const responseText = update.userResponse || update.spokenContent;
	          if (responseText && responseText !== lastUserResponse) {
		            const fallbackEvent = createFallbackResponseEvent(
		              requestSessionId,
		              update.runId,
		              responseText,
		            );
	            mergeResponseEvents([fallbackEvent]);
	          }
	          lastUserResponse = responseText;
	          if (responseText && responseText !== midTurnLegacyResponseText && config.ttsEnabled !== false) {
	            midTurnLegacyResponseText = responseText;
		            speakAssistantResponse(responseText, 'mid-turn progress');
	          }
	        }
        const progressMessages = convertProgressToMessages(update);
        if (progressMessages.length > 0) {
          // Store progress messages so we can merge with final history (#1083)
          progressMessagesRef.current = progressMessages;
          setMessages((m) => {
            const beforePlaceholder = m.slice(0, messageCountBeforeTurn + 1);
            const newMessages = [...beforePlaceholder, ...progressMessages];
            return newMessages;
          });
        }
      };

      const onToken = (tok: string) => {
        // Guard: skip update if session has changed since request started
        // Use currentSessionIdRef.current to avoid stale closure issue (useSessions returns new object each render)
        if (currentSessionIdRef.current !== requestSessionId) {
          console.log('[ChatScreen] Session changed, skipping onToken update');
          return;
        }
        // Guard: skip update if this request is no longer the latest one for this session
        // Uses per-session tracking to prevent cross-session sends from incorrectly superseding (PR review fix #13)
        if (requestSessionId && connectionManager.getLatestRequestId(requestSessionId) !== thisRequestId) {
          console.log('[ChatScreen] Request superseded within same session, skipping onToken update');
          return;
        }
        // Handle both delta tokens and full-text updates.
        // Progress events with streamingContent.text send the full accumulated text,
        // while SSE delta events send just the new token.
        // Detect full-text updates to prevent double-words from compounding tokens.
        if (tok.startsWith(streamingText) && tok.length >= streamingText.length) {
          streamingText = tok;
        } else {
          streamingText += tok;
        }

        setMessages((m) => {
          const copy = [...m];
          for (let i = copy.length - 1; i >= 0; i--) {
            if (copy[i].role === 'assistant') {
              copy[i] = { ...copy[i], content: streamingText };
              break;
            }
          }
          return copy;
        });
      };

      const modelMessages = sanitizeMessagesForModel([...currentMessages, userMsg]);
	      const response = await client.chat(modelMessages, onToken, onProgress, serverConversationId);
      const finalText = response.content || streamingText;
		      const finalResponseEvent = lastResponseEvents[lastResponseEvents.length - 1];
		      const finalDisplayText = finalResponseEvent?.text || lastUserResponse || finalText;
      console.log('[ChatScreen] Chat completed, conversationId:', response.conversationId);

      // Guard: skip UI updates if session has changed, BUT still persist to the original session
      // Use currentSessionIdRef.current to avoid stale closure issue (useSessions returns new object each render)
      const sessionChanged = currentSessionIdRef.current !== requestSessionId;
	      if (!sessionChanged) {
	        setConversationState(latestConversationState === 'running' ? 'complete' : latestConversationState);
	      }
      if (sessionChanged) {
        console.log('[ChatScreen] Session changed during request, persisting to original session without UI update');
      } else {
        setDebugInfo(`Completed!`);
      }

      // Guard: skip final updates if this request is no longer the latest one for this session
      // This prevents older, superseded requests from clobbering messages when multiple sends occur within the same session
      // Uses per-session tracking to prevent cross-session sends from incorrectly superseding (PR review fix #13)
      // Note: This guard only applies when session hasn't changed - if session changed, we still want to persist
      const isLatestForSession = requestSessionId
        ? connectionManager.getLatestRequestId(requestSessionId) === thisRequestId
        : true;
      if (!sessionChanged && !isLatestForSession) {
        console.log('[ChatScreen] Request superseded within same session, skipping final message updates', {
          thisRequestId,
          latestRequestId: requestSessionId ? connectionManager.getLatestRequestId(requestSessionId) : 'no-session'
        });
        return;
      }

      // Save conversation ID to the appropriate session
      if (response.conversationId) {
        if (sessionChanged && requestSessionId) {
          await sessionStore.setServerConversationIdForSession(requestSessionId, response.conversationId);
        } else {
          await sessionStore.setServerConversationId(response.conversationId);
        }
        resolvedConversationId = response.conversationId;
      }

      if (response.conversationHistory && response.conversationHistory.length > 0) {
        console.log('[ChatScreen] Processing final conversationHistory:', response.conversationHistory.length, 'messages');
        console.log('[ChatScreen] ConversationHistory roles:', response.conversationHistory.map(m => m.role).join(', '));

        let currentTurnStartIndex = 0;
        for (let i = 0; i < response.conversationHistory.length; i++) {
          if (response.conversationHistory[i].role === 'user') {
            currentTurnStartIndex = i;
          }
        }
        console.log('[ChatScreen] currentTurnStartIndex:', currentTurnStartIndex);

        const newMessages: ChatMessage[] = [];
        for (let i = currentTurnStartIndex; i < response.conversationHistory.length; i++) {
          const historyMsg = response.conversationHistory[i];
          if (historyMsg.role === 'user') continue;

          // Merge tool results into the preceding assistant message to avoid duplication
          // The server sends: assistant (with toolCalls) -> tool (with toolResults)
          // We want to display them as a single message with both toolCalls and toolResults
          if (historyMsg.role === 'tool' && newMessages.length > 0) {
            const lastMessage = newMessages[newMessages.length - 1];
            if (lastMessage.role === 'assistant' && lastMessage.toolCalls && lastMessage.toolCalls.length > 0) {
              const hasToolResults = historyMsg.toolResults && historyMsg.toolResults.length > 0;

              if (hasToolResults) {
                // Merge toolResults into the existing assistant message
                lastMessage.toolResults = [
                  ...(lastMessage.toolResults || []),
                  ...(historyMsg.toolResults || []),
                ];
                // Skip adding this as a separate message only when we merged results
                continue;
              }
            }
          }

          // Drop synthetic tool-role summaries (e.g. "TOOL FAILED: ...") that
          // carry no toolResults/toolCalls — the underlying failures are
          // already visible inside the tool call stack via toolResults on the
          // preceding tool message.
          if (
            historyMsg.role === 'tool' &&
            !(historyMsg.toolResults && historyMsg.toolResults.length > 0) &&
            !(historyMsg.toolCalls && historyMsg.toolCalls.length > 0)
          ) {
            continue;
          }

          newMessages.push({
            role: historyMsg.role === 'tool' ? 'assistant' : historyMsg.role,
            content: historyMsg.content || '',
            displayContent: historyMsg.displayContent,
            toolCalls: historyMsg.toolCalls,
            toolResults: historyMsg.toolResults,
          });
        }
		        const finalTurnMessages = applyUserResponseToMessages(newMessages, finalResponseEvent?.text || lastUserResponse);
	        console.log('[ChatScreen] newMessages count:', finalTurnMessages.length);
	        console.log('[ChatScreen] newMessages roles:', finalTurnMessages.map(m => `${m.role}(toolCalls:${m.toolCalls?.length || 0},toolResults:${m.toolResults?.length || 0})`).join(', '));
        console.log('[ChatScreen] messageCountBeforeTurn:', messageCountBeforeTurn);

        if (sessionChanged && requestSessionId) {
          // Only persist to background session if this is still the latest request for that session
          // This prevents an older request from overwriting newer history (PR review fix #14)
          if (isLatestForSession) {
            console.log('[ChatScreen] Persisting completed response to background session:', requestSessionId);
            // Build the final messages array: messages before this turn + user message + new assistant messages
	            const messagesBeforeTurn = currentMessages.slice(0, messageCountBeforeTurn);
	            const finalMessages = [...messagesBeforeTurn, userMsg, ...finalTurnMessages];
            await sessionStore.setMessagesForSession(requestSessionId, finalMessages);
          } else {
            console.log('[ChatScreen] Skipping background persistence - request superseded within session:', {
              thisRequestId,
              latestRequestId: connectionManager.getLatestRequestId(requestSessionId)
            });
          }
        } else {
          // Normal case: update UI state (persistence happens via useEffect)
          // Merge progress messages with final history to prevent intermediate messages
          // from disappearing when the server's history has fewer messages (#1083)
          const progressMsgs = progressMessagesRef.current;
          setMessages((m) => {
            console.log('[ChatScreen] Current messages before update:', m.length);
            const beforePlaceholder = m.slice(0, messageCountBeforeTurn + 1);
            console.log('[ChatScreen] beforePlaceholder count:', beforePlaceholder.length);
            // If progress had more messages than conversationHistory, keep progress messages
            // and only update/append the final message from history
            let mergedMessages: ChatMessage[];
            if (progressMsgs.length > 0 && finalTurnMessages.length === 0) {
              // Edge case: server returned empty history but we have progress messages
              // Keep progress messages to prevent intermediate messages from disappearing (#1083)
              console.log('[ChatScreen] Merging: newMessages empty, keeping progress messages');
              mergedMessages = [...progressMsgs];
            } else if (progressMsgs.length > finalTurnMessages.length && finalTurnMessages.length > 0) {
              console.log('[ChatScreen] Merging: progress had more messages, preserving intermediate');
              mergedMessages = [...progressMsgs];
              // Replace/update the last message with the final one from history
              mergedMessages[mergedMessages.length - 1] = preserveDisplayContentFromProgress(
                [finalTurnMessages[finalTurnMessages.length - 1]],
                [mergedMessages[mergedMessages.length - 1]],
              )[0];
            } else {
              // History is authoritative when it has >= messages
              mergedMessages = preserveDisplayContentFromProgress(finalTurnMessages, progressMsgs);
            }
            const result = [...beforePlaceholder, ...mergedMessages];
            console.log('[ChatScreen] Final messages count:', result.length);
            return result;
          });
        }
	      } else if (finalDisplayText) {
        console.log('[ChatScreen] FALLBACK: No conversationHistory, using finalText only. response.conversationHistory:', response.conversationHistory);
        if (sessionChanged && requestSessionId) {
          // Only persist to background session if this is still the latest request for that session
          // This prevents an older request from overwriting newer history (PR review fix #14)
          if (isLatestForSession) {
            console.log('[ChatScreen] Persisting fallback response to background session:', requestSessionId);
	            const messagesBeforeTurn = currentMessages.slice(0, messageCountBeforeTurn);
	            const finalMessages = [...messagesBeforeTurn, userMsg, { role: 'assistant' as const, content: finalDisplayText }];
            await sessionStore.setMessagesForSession(requestSessionId, finalMessages);
          } else {
            console.log('[ChatScreen] Skipping fallback background persistence - request superseded within session:', {
              thisRequestId,
              latestRequestId: connectionManager.getLatestRequestId(requestSessionId)
            });
          }
        } else {
          // Normal case: update UI state
          setMessages((m) => {
            const copy = [...m];
            for (let i = copy.length - 1; i >= 0; i--) {
              if (copy[i].role === 'assistant') {
	                copy[i] = { ...copy[i], content: finalDisplayText };
                break;
              }
            }
            return copy;
          });
        }
      } else {
        console.log('[ChatScreen] WARNING: No conversationHistory and no finalText!');
      }

      // Note: Removed duplicate setServerConversationId call that was after the message handling
      // The conversation ID is now saved once at the beginning of this block

      // TTS: prefer userResponse (from respond_to_user tool) over finalText
      // userResponse is explicitly set by the agent for user communication
      // Skip TTS if we already played the same text mid-turn
	      const ttsText = finalResponseEvent?.text || lastUserResponse || finalText;
	      const alreadySpokenMidTurn = !!(finalResponseEvent
	        ? playedResponseEventIdsRef.current.has(finalResponseEvent.id)
	        : midTurnLegacyResponseText && ttsText === midTurnLegacyResponseText);
	      if (!alreadySpokenMidTurn && !sessionChanged && ttsText && config.ttsEnabled !== false) {
	        if (handsFree) {
	          handsFreeController.onRequestCompleted();
	        }
	        speakAssistantResponse(ttsText, 'final response');
	      } else if (handsFree) {
	        handsFreeController.onRequestCompleted();
      }
    } catch (e: any) {
      console.error('[ChatScreen] Chat error:', e);
      console.error('[ChatScreen] Error details:', {
        message: e.message,
        stack: e.stack,
        name: e.name
      });

      // Guard: skip error message if session has changed since request started
      // Use currentSessionIdRef.current to avoid stale closure issue (useSessions returns new object each render)
      if (currentSessionIdRef.current !== requestSessionId) {
        console.log('[ChatScreen] Session changed during request, skipping error message');
        return;
      }

      // Guard: skip error handling if this request is no longer the active one
      // This prevents a superseded request from surfacing a retry banner for an older send
      if (activeRequestIdRef.current !== thisRequestId) {
        console.log('[ChatScreen] Request superseded, skipping error handling', {
          thisRequestId,
          activeRequestId: activeRequestIdRef.current
        });
        return;
      }
	      setConversationState('blocked');

      const recoveryState = connectionState;
      let errorMessage = e.message;

      if (recoveryState?.status === 'failed') {
        errorMessage = `Connection failed after ${recoveryState.retryCount} retries. ${recoveryState.lastError || ''}`;
      } else if (recoveryState?.status === 'reconnecting') {
        errorMessage = `Connection lost. Attempted ${recoveryState.retryCount} reconnections. ${e.message}`;
      }

      // Save the failed message for retry
      setLastFailedMessage(text);

      // Check if there's partial content we can show
      const partialContent = client.getPartialContent();
      const hasPartialContent = partialContent && partialContent.length > 0;

      setDebugInfo(`Error: ${errorMessage}`);
      // Update the in-flight assistant message instead of appending a new one
      // This avoids duplicating the assistant loading placeholder and ensures
      // the retry pop logic removes the correct items
      setMessages((m) => {
        const errorContent = hasPartialContent
          ? `${partialContent}\n\n---\n⚠️ Connection lost. Partial response shown above.\n\nError: ${errorMessage}`
          : `Error: ${errorMessage}\n\nTip: Check your internet connection and tap "Retry" to try again.`;
        // Find and update the last assistant message instead of appending
        const copy = [...m];
        for (let i = copy.length - 1; i >= 0; i--) {
          if (copy[i].role === 'assistant') {
            copy[i] = { ...copy[i], content: errorContent };
            break;
          }
        }
        return copy;
      });
	      if (handsFree) {
	        handsFreeController.onRequestCompleted();
	      }
    } finally {
      if (requestSessionId && !startingServerConversationId && !resolvedConversationId) {
        sessionStore.markPendingServerConversation(requestSessionId, false);
      }

      console.log('[ChatScreen] Chat request finished, requestId:', thisRequestId);

      // Decrement active request count in the connection manager
      if (requestSessionId) {
        connectionManager.decrementActiveRequests(requestSessionId);
      }

      // Only reset UI states if:
      // 1. This request is still the latest one for its session (per-session tracking, PR review fix #13)
      // 2. We're still on the same session (prevents background completions from affecting other sessions)
      // This addresses PR review comments #10 and #13
      const isLatestForThisSession = requestSessionId
        ? connectionManager.getLatestRequestId(requestSessionId) === thisRequestId
        : true;
      const isCurrentSession = currentSessionIdRef.current === requestSessionId;

      if (isLatestForThisSession && isCurrentSession) {
        setResponding(false);
        setConnectionState(null);
        // Guard the setTimeout callback: only clear debugInfo if this request
        // is still the latest one when the timeout fires. This prevents an
        // old request's delayed clear from wiping debug info for a newer request.
        const capturedRequestId = thisRequestId;
        const capturedSessionId = requestSessionId;
        setTimeout(() => {
          const stillLatest = capturedSessionId
            ? connectionManager.getLatestRequestId(capturedSessionId) === capturedRequestId
            : true;
          if (stillLatest && currentSessionIdRef.current === capturedSessionId) {
            setDebugInfo('');
          }
        }, 5000);

        // Process next queued message if any
        if (messageQueueEnabled && (!handsFree || handsFreePhaseRef.current !== 'paused')) {
          const nextMessage = messageQueue.peek(currentConversationId);
          if (nextMessage) {
            console.log('[ChatScreen] Processing next queued message:', nextMessage.id);
            // Use setTimeout to avoid recursive call stack issues
            setTimeout(() => {
              if (handsFreeRef.current && handsFreePhaseRef.current === 'paused') {
                return;
              }
              messageQueue.markProcessing(currentConversationId, nextMessage.id);
              processQueuedMessage(nextMessage);
            }, 100);
          }
        }
      } else {
        console.log('[ChatScreen] Skipping finally state resets:', {
          thisRequestId,
          latestRequestId: requestSessionId ? connectionManager.getLatestRequestId(requestSessionId) : 'no-session',
          requestSessionId,
          currentSessionId: currentSessionIdRef.current,
          reason: !isLatestForThisSession ? 'newer request is active for this session' : 'session changed'
        });
      }
    }
  };

  // Process a queued message (similar to send but handles queue state)
  const processQueuedMessage = async (queuedMsg: { id: string; text: string }) => {
    const text = queuedMsg.text;
    if (!text.trim()) {
      messageQueue.markProcessed(currentConversationId, queuedMsg.id);
      return;
    }

    console.log('[ChatScreen] Processing queued message:', queuedMsg.id, getMessageLogMeta(text));

    // Get client from connection manager (preserves connections across session switches)
    const client = getSessionClient();
    if (!client) {
      console.error('[ChatScreen] No client available for processing queued message');
      messageQueue.markFailed(currentConversationId, queuedMsg.id, 'No session available');
      setDebugInfo('Error: No session available');
      return;
    }

    setDebugInfo(`Processing queued message...`);

    const userMsg: ChatMessage = { role: 'user', content: text };
    // Use ref to get latest messages to avoid stale closure when called via setTimeout (PR review fix)
    const currentMessages = messagesRef.current;
    const messageCountBeforeTurn = currentMessages.length;
    setMessages((m) => [...m, userMsg, { role: 'assistant', content: '' }]);
    setResponding(true);
    setConversationState('running');
	    if (handsFree) {
	      handsFreeController.onRequestStarted();
	    }

    const thisRequestId = Date.now();
    activeRequestIdRef.current = thisRequestId;

    const currentSession = sessionStore.getCurrentSession();
    const startingServerConversationId = currentSession?.serverConversationId;

    const requestSessionId = sessionStore.currentSessionId;
    let resolvedConversationId: string | undefined;

    if (requestSessionId && !startingServerConversationId) {
      sessionStore.markPendingServerConversation(requestSessionId, true);
    }

    try {
      let streamingText = '';
      let latestConversationState: AgentConversationState = 'running';
      // Track userResponse from progress updates for TTS
      let lastUserResponse: string | undefined;
	      let lastResponseEvents: AgentUserResponseEvent[] = [];
	      let midTurnLegacyResponseText: string | undefined;

      const onProgress = (update: AgentProgressUpdate) => {
        if (sessionStore.currentSessionId !== requestSessionId) return;
        if (activeRequestIdRef.current !== thisRequestId) return;
        latestConversationState = resolveConversationStateFromProgress(update, 'running');
        setConversationState(latestConversationState);
        if (update.responseEvents?.length) {
	          lastResponseEvents = [...update.responseEvents].sort((a, b) => a.ordinal - b.ordinal);
	          mergeResponseEvents(lastResponseEvents);
	          enqueueResponseEventsForSpeech(lastResponseEvents);
	          lastUserResponse = lastResponseEvents[lastResponseEvents.length - 1]?.text;
	        } else if (update.userResponse || update.spokenContent) {
	          const responseText = update.userResponse || update.spokenContent;
	          if (responseText && responseText !== lastUserResponse) {
		            const fallbackEvent = createFallbackResponseEvent(
		              requestSessionId,
		              update.runId,
		              responseText,
		            );
	            mergeResponseEvents([fallbackEvent]);
	          }
	          lastUserResponse = responseText;
	          if (responseText && responseText !== midTurnLegacyResponseText && config.ttsEnabled !== false) {
	            midTurnLegacyResponseText = responseText;
		            speakAssistantResponse(responseText, 'queued mid-turn progress');
	          }
	        }
        const progressMessages = convertProgressToMessages(update);
        if (progressMessages.length > 0) {
          setMessages((m) => {
            const beforePlaceholder = m.slice(0, messageCountBeforeTurn + 1);
            return [...beforePlaceholder, ...progressMessages];
          });
        }
      };

      const onToken = (tok: string) => {
        if (sessionStore.currentSessionId !== requestSessionId) return;
        if (activeRequestIdRef.current !== thisRequestId) return;
        // Handle both delta tokens and full-text updates.
        // Progress events with streamingContent.text send the full accumulated text,
        // while SSE delta events send just the new token.
        // Detect full-text updates to prevent double-words from compounding tokens.
        if (tok.startsWith(streamingText) && tok.length >= streamingText.length) {
          streamingText = tok;
        } else {
          streamingText += tok;
        }
        setMessages((m) => {
          const copy = [...m];
          for (let i = copy.length - 1; i >= 0; i--) {
            if (copy[i].role === 'assistant') {
              copy[i] = { ...copy[i], content: streamingText };
              break;
            }
          }
          return copy;
        });
      };

      const modelMessages = sanitizeMessagesForModel([...currentMessages, userMsg]);
      const response = await client.chat(modelMessages, onToken, onProgress, startingServerConversationId);
      const finalText = response.content || streamingText;
	      const finalResponseEvent = lastResponseEvents[lastResponseEvents.length - 1];
	      const finalDisplayText = finalResponseEvent?.text || lastUserResponse || finalText;

      // Early exit guards - finalize queue status before returning to prevent stuck 'processing' items
      if (sessionStore.currentSessionId !== requestSessionId) {
        // Session changed - mark as failed so user can retry in correct session
        messageQueue.markFailed(currentConversationId, queuedMsg.id, 'Session changed during processing');
        return;
      }
      if (activeRequestIdRef.current !== thisRequestId) {
        // Request superseded - mark as failed so user can retry
        messageQueue.markFailed(currentConversationId, queuedMsg.id, 'Request superseded');
        return;
      }
      setConversationState(latestConversationState === 'running' ? 'complete' : latestConversationState);

      if (response.conversationId) {
        await sessionStore.setServerConversationId(response.conversationId);
        resolvedConversationId = response.conversationId;
      }

      if (response.conversationHistory && response.conversationHistory.length > 0) {
        let currentTurnStartIndex = 0;
        for (let i = 0; i < response.conversationHistory.length; i++) {
          if (response.conversationHistory[i].role === 'user') {
            currentTurnStartIndex = i;
          }
        }

        const newMessages: ChatMessage[] = [];
        for (let i = currentTurnStartIndex; i < response.conversationHistory.length; i++) {
          const historyMsg = response.conversationHistory[i];
          if (historyMsg.role === 'user') continue;
          // Drop synthetic tool-role summaries (e.g. "TOOL FAILED: ...") that
          // carry no toolResults/toolCalls — the underlying failures are
          // already visible inside the tool call stack via toolResults on the
          // preceding tool message.
          if (
            historyMsg.role === 'tool' &&
            !(historyMsg.toolResults && historyMsg.toolResults.length > 0) &&
            !(historyMsg.toolCalls && historyMsg.toolCalls.length > 0)
          ) {
            continue;
          }
          newMessages.push({
            role: historyMsg.role === 'tool' ? 'assistant' : historyMsg.role,
            content: historyMsg.content || '',
            displayContent: historyMsg.displayContent,
            toolCalls: historyMsg.toolCalls,
            toolResults: historyMsg.toolResults,
          });
        }
	        const finalTurnMessages = applyUserResponseToMessages(newMessages, finalResponseEvent?.text || lastUserResponse);

        setMessages((m) => {
          const beforePlaceholder = m.slice(0, messageCountBeforeTurn + 1);
          return [...beforePlaceholder, ...finalTurnMessages];
        });
      } else if (finalDisplayText) {
        setMessages((m) => {
          const copy = [...m];
          for (let i = copy.length - 1; i >= 0; i--) {
            if (copy[i].role === 'assistant') {
              copy[i] = { ...copy[i], content: finalDisplayText };
              break;
            }
          }
          return copy;
        });
      }

      // TTS: prefer userResponse (from respond_to_user tool) over finalText
      // Skip TTS if we already played the same text mid-turn
	      const ttsText = finalResponseEvent?.text || lastUserResponse || finalText;
	      const alreadySpokenMidTurn = !!(finalResponseEvent
	        ? playedResponseEventIdsRef.current.has(finalResponseEvent.id)
	        : midTurnLegacyResponseText && ttsText === midTurnLegacyResponseText);
      if (!alreadySpokenMidTurn && ttsText && config.ttsEnabled !== false) {
	        if (handsFree) {
	          handsFreeController.onRequestCompleted();
	        }
	        speakAssistantResponse(ttsText, 'queued final response');
	      } else if (handsFree) {
	        handsFreeController.onRequestCompleted();
      }

      // Mark as processed on success
      messageQueue.markProcessed(currentConversationId, queuedMsg.id);
    } catch (e: any) {
      console.error('[ChatScreen] Queued message error:', e);
      messageQueue.markFailed(currentConversationId, queuedMsg.id, e.message || 'Unknown error');
      setConversationState('blocked');
      setMessages((m) => [...m, { role: 'assistant', content: `Error: ${e.message}` }]);
	      if (handsFree) {
	        handsFreeController.onRequestCompleted();
	      }
    } finally {
      if (requestSessionId && !startingServerConversationId && !resolvedConversationId) {
        sessionStore.markPendingServerConversation(requestSessionId, false);
      }

      if (activeRequestIdRef.current === thisRequestId) {
        setResponding(false);
        setConnectionState(null);
        setTimeout(() => {
          if (activeRequestIdRef.current === thisRequestId) {
            setDebugInfo('');
          }
        }, 5000);

        // Process next queued message if any
        const nextMessage = messageQueue.peek(currentConversationId);
        if (nextMessage && (!handsFree || handsFreePhaseRef.current !== 'paused')) {
          console.log('[ChatScreen] Processing next queued message:', nextMessage.id);
          setTimeout(() => {
            if (handsFreeRef.current && handsFreePhaseRef.current === 'paused') {
              return;
            }
            messageQueue.markProcessing(currentConversationId, nextMessage.id);
            processQueuedMessage(nextMessage);
          }, 100);
        }
      }
    }
  };

  const handleProcessNextQueuedMessage = useCallback(() => {
    if (responding) return;

    const nextMessage = messageQueue.peek(currentConversationId);
    if (!nextMessage) return;

    if (handsFree && handsFreeController.state.phase === 'paused') return;

    console.log('[ChatScreen] Processing queue while idle, next message:', nextMessage.id);
    setTimeout(() => {
      if (handsFreeRef.current && handsFreePhaseRef.current === 'paused') {
        return;
      }
      messageQueue.markProcessing(currentConversationId, nextMessage.id);
      processQueuedMessage(nextMessage);
    }, 100);
  }, [currentConversationId, handsFree, handsFreeController.state.phase, messageQueue, processQueuedMessage, responding]);

	// Keep sendRef in sync with the latest send() implementation for speech callbacks.
	// IMPORTANT: This must live outside send() so voice callbacks can send even before any manual send() occurs.
	// We intentionally assign during render (not useEffect) so it is available immediately.
	sendRef.current = send;

	const isWebPlatform = Platform.OS === 'web';
	const composerAccessibilityHint = createChatComposerAccessibilityHint({
	  handsFree,
	  listening,
	  isWeb: isWebPlatform,
	});
	const micControlAccessibilityHint = createMicControlAccessibilityHint({
	  handsFree,
	  listening,
	  willCancel,
	});
	const voiceInputLiveRegionAnnouncement = createVoiceInputLiveRegionAnnouncement({
	  listening,
	  handsFree,
	  willCancel,
	  liveTranscript,
		  sttPreview,
		});

  const promptQuickStarts = useMemo<QuickStartShortcut[]>(
    () => {
      const promptItems = predefinedPrompts
        .map((prompt) => ({
          id: prompt.id,
          title: prompt.name,
          content: prompt.content,
          description: prompt.content,
          source: isSlashCommandPrompt(prompt) ? 'command' as const : 'saved-prompt' as const,
          prompt,
        }));

      const skillItems = availableSkills.map((skill) => ({
        id: `skill-${skill.id}`,
        title: skill.name,
        content: getSkillPromptContent(skill),
        description: skill.description || skill.instructions || 'Use this skill as a reusable prompt.',
        source: 'skill' as const,
      }));

      const taskItems = availableTasks.map((task) => ({
        id: `task-${task.id}`,
        title: task.name,
        content: task.prompt || '',
        description: task.prompt || 'Run this desktop task now.',
        source: 'task' as const,
        task,
      }));

      const addPromptItem: QuickStartShortcut[] = settingsClient ? [{
        id: 'action-add-prompt',
        title: '+ Add Prompt',
        content: '',
        description: 'Create a predefined prompt and save it back to desktop.',
        source: 'action' as const,
        action: 'add-prompt' as const,
      }] : [];

      return [
        ...promptItems,
        ...skillItems,
        ...taskItems,
        ...addPromptItem,
      ];
    },
    [availableSkills, availableTasks, predefinedPrompts, settingsClient]
  );

  const composerHasContent = input.trim().length > 0 || pendingImages.length > 0;

  const sendComposerInput = useCallback(() => {
    const composedMessage = buildMessageWithPendingImages(input, pendingImages);
    if (!composedMessage.trim()) return;
    void send(composedMessage, { fromComposer: true });
  }, [input, pendingImages, send]);

  const queueComposerInput = useCallback(() => {
    const composedMessage = buildMessageWithPendingImages(input, pendingImages);
    if (!composedMessage.trim()) return;

    messageQueue.enqueue(currentConversationId, composedMessage, currentConversationId);
    setInput('');
    setPendingImages([]);
    setDebugInfo('Message queued. Use Send Next when you are ready.');
  }, [currentConversationId, input, messageQueue, pendingImages]);

  // Track modifier keys for keyboard shortcut handling
  const modifierKeysRef = useRef<{ shift: boolean; ctrl: boolean; meta: boolean }>({
    shift: false,
    ctrl: false,
    meta: false,
  });

  // Timeout ref for auto-resetting modifier state
  // This prevents "sticky" modifier state when a modifier is pressed then released before Enter
  const modifierTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);

  // Flag to suppress the next onChangeText update after native keyboard shortcut submission
  // This prevents stray newlines from being added when Enter is pressed with a modifier
  const suppressNextChangeRef = useRef(false);

  // Handle keyboard shortcuts for text submission
  // Shift+Enter or Ctrl/Cmd+Enter to submit
  const handleInputKeyPress = useCallback(
    (e: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
      const key = e.nativeEvent.key;

      // On web platform, we have access to modifier keys via nativeEvent
      if (Platform.OS === 'web') {
        const webEvent = e.nativeEvent as unknown as KeyboardEvent;
        const isEnter = key === 'Enter';
        const hasModifier = webEvent.shiftKey || webEvent.ctrlKey || webEvent.metaKey;

        if (isEnter && hasModifier) {
          // Prevent default on both the synthetic event and the underlying keyboard event
          // to ensure the newline is not inserted after send() clears the input
          e.preventDefault?.();
          webEvent.preventDefault?.();
          if (composerHasContent) {
            sendComposerInput();
          }
        }
      } else {
        // On native platforms, track modifier key state
        // Note: onKeyPress doesn't provide key-up events, so we use a timeout to auto-reset
        // modifier state. This prevents "sticky" modifiers where pressing Shift then releasing
        // it (without pressing another key) could cause a subsequent plain Enter to submit.
        const setModifierWithTimeout = (modifier: 'shift' | 'ctrl' | 'meta') => {
          modifierKeysRef.current[modifier] = true;
          // Clear any existing timeout
          if (modifierTimeoutRef.current) {
            clearTimeout(modifierTimeoutRef.current);
          }
          // Auto-reset modifier state after 500ms if no Enter is pressed
          // This matches the typical key repeat delay and prevents stickiness
          modifierTimeoutRef.current = setTimeout(() => {
            modifierKeysRef.current = { shift: false, ctrl: false, meta: false };
          }, 500);
        };

        if (key === 'Shift') {
          setModifierWithTimeout('shift');
        } else if (key === 'Control') {
          setModifierWithTimeout('ctrl');
        } else if (key === 'Meta') {
          setModifierWithTimeout('meta');
        } else if (key === 'Enter') {
          // Clear the timeout since we're processing the Enter now
          if (modifierTimeoutRef.current) {
            clearTimeout(modifierTimeoutRef.current);
            modifierTimeoutRef.current = null;
          }
          const hasModifier =
            modifierKeysRef.current.shift ||
            modifierKeysRef.current.ctrl ||
            modifierKeysRef.current.meta;

          if (hasModifier) {
            // Always suppress the newline that will be inserted by the native TextInput
            // when modifier+Enter is pressed, even if input is empty (matches web behavior)
            suppressNextChangeRef.current = true;
            if (composerHasContent) {
              sendComposerInput();
            }
          }
          // Reset modifier state after Enter is processed
          modifierKeysRef.current = { shift: false, ctrl: false, meta: false };
        } else {
          // Reset modifier state on any other key
          if (modifierTimeoutRef.current) {
            clearTimeout(modifierTimeoutRef.current);
            modifierTimeoutRef.current = null;
          }
          modifierKeysRef.current = { shift: false, ctrl: false, meta: false };
        }
      }
    },
    [composerHasContent, sendComposerInput]
  );

  // Wrapper for onChangeText that suppresses stray newlines after native keyboard shortcut submission
  const handleInputChange = useCallback((text: string) => {
    if (suppressNextChangeRef.current) {
      // Reset the flag and ignore this update (it's likely a stray newline from Enter)
      suppressNextChangeRef.current = false;
      return;
    }
    setInput(text);
  }, []);

		const wakeHandsFreeByUser = useCallback(() => {
			handsFreeController.wakeByUser();
			if (!listening) {
				void startRecording();
			}
			setDebugInfo('Handsfree awake. Listening for your request.');
		}, [handsFreeController.wakeByUser, listening, startRecording]);

		const sleepHandsFreeByUser = useCallback(() => {
			handsFreeController.sleepByUser();
			setDebugInfo(`Handsfree sleeping. Say “${handsFreeWakePhrase}” or tap Wake to begin.`);
		}, [handsFreeController.sleepByUser, handsFreeWakePhrase]);

		const resumeHandsFreeByUser = useCallback(() => {
			handsFreeController.resumeByUser();
			if (!listening) {
				void startRecording();
			}
			setDebugInfo('Handsfree resumed.');
		}, [handsFreeController.resumeByUser, listening, startRecording]);

		const pauseHandsFreeByUser = useCallback(() => {
			handsFreeController.pauseByUser();
			Speech.stop();
			void stopRecognitionOnly();
			setDebugInfo('Handsfree paused.');
		}, [handsFreeController.pauseByUser, stopRecognitionOnly]);

		const handleHandsFreePrimaryControl = useCallback(() => {
			if (handsFreeController.state.phase === 'sleeping') {
				wakeHandsFreeByUser();
				return;
			}
			if (handsFreeController.state.phase === 'paused') {
				resumeHandsFreeByUser();
				return;
			}
			pauseHandsFreeByUser();
		}, [handsFreeController.state.phase, pauseHandsFreeByUser, resumeHandsFreeByUser, wakeHandsFreeByUser]);

		const handsFreePauseResumeLabel = handsFreeController.state.phase === 'paused' ? 'Resume' : 'Pause';

	const handsFreeStatusSubtitle = useMemo(() => {
		if (!handsFree) return undefined;
		switch (handsFreeController.state.phase) {
			case 'sleeping':
					return `Say “${handsFreeWakePhrase}” or tap Wake to wake the assistant.`;
			case 'waking':
				return 'Listening for your next request.';
			case 'listening':
				return `Say “${handsFreeSleepPhrase}” to return to sleep.`;
			case 'processing':
				return 'Working on your request.';
			case 'speaking':
				return 'Speech recognition pauses while the assistant speaks.';
			case 'paused':
				return 'Tap the mic to resume handsfree listening.';
			case 'error':
				return handsFreeController.state.lastError || 'Voice recognition is recovering.';
			default:
				return handsFreeForegroundOnly
					? 'Handsfree works while Chat stays open in the foreground.'
					: undefined;
		}
	}, [
		handsFree,
		handsFreeController.state.lastError,
		handsFreeController.state.phase,
		handsFreeForegroundOnly,
		handsFreeSleepPhrase,
		handsFreeWakePhrase,
	]);

	const composerPlaceholder = handsFree
		? (handsFreeController.state.phase === 'paused'
			? 'Handsfree paused — tap mic to resume or type a message'
			: `Say “${handsFreeWakePhrase}” or type a message`)
		: (listening ? 'Listening…' : 'Type or hold mic');

	const micButtonLabel = handsFree
			? (handsFreeController.state.phase === 'sleeping' ? 'Wake' : handsFreePauseResumeLabel)
		: (listening ? '...' : 'Hold');
  const isMessageQueuePaused = handsFree && handsFreeController.state.phase === 'paused';

  const firstVisibleMessageIndex = Math.max(0, messages.length - visibleMessageCount);
  const visibleMessages = messages.slice(firstVisibleMessageIndex);
  const canLoadOlderMessages = firstVisibleMessageIndex > 0;



  return (
    <KeyboardAvoidingView
      style={{ flex: 1, backgroundColor: theme.colors.background }}
      behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
      keyboardVerticalOffset={headerHeight}
    >
      <View style={{ flex: 1 }}>
        <ScrollView
          ref={scrollViewRef}
          style={{ flex: 1, paddingHorizontal: spacing.sm, paddingVertical: spacing.xs, backgroundColor: theme.colors.background }}
          contentContainerStyle={{ paddingBottom: insets.bottom, gap: spacing.xs }}
          keyboardShouldPersistTaps="handled"
          contentInsetAdjustmentBehavior="automatic"
          onScroll={handleScroll}
          onScrollBeginDrag={handleScrollBeginDrag}
          onScrollEndDrag={handleScrollEndDrag}
          scrollEventThrottle={16}
        >
          {sessionStore.isLoadingMessages && messages.length === 0 && (
            <View
              accessible
              accessibilityRole="progressbar"
              accessibilityLabel="Loading messages from desktop"
              accessibilityState={{ busy: true }}
              style={{ flex: 1, justifyContent: 'center', alignItems: 'center', paddingVertical: 40 }}
            >
              <Image
                source={isDark ? darkSpinner : lightSpinner}
                style={{ width: 32, height: 32 }}
                resizeMode="contain"
              />
            </View>
          )}
          {!sessionStore.isLoadingMessages && messages.length === 0 && (
            <View style={styles.chatHomeCard}>
	              {promptQuickStarts.length > 0 ? (
	                <View style={styles.chatHomeShortcutGrid}>
	                  {promptQuickStarts.map((item) => (
	                    <Pressable
	                      key={item.id}
	                      style={({ pressed }) => [
	                        styles.chatHomeShortcutCard,
		                        item.action === 'add-prompt' && styles.chatHomeShortcutCardAdd,
		                        item.source === 'task' && runningPromptTaskId === item.task?.id && styles.chatHomeShortcutCardDisabled,
	                        pressed && styles.chatHomeShortcutCardPressed,
	                      ]}
		                      onPress={() => handleQuickStartPress(item)}
		                      disabled={item.source === 'task' && runningPromptTaskId === item.task?.id}
	                      accessibilityRole="button"
		                      accessibilityLabel={createButtonAccessibilityLabel(item.action === 'add-prompt' ? 'Add new prompt' : item.source === 'task' ? `Run task ${item.title}` : `Insert ${item.source} ${item.title}`)}
		                      accessibilityHint={item.action === 'add-prompt' ? 'Create a predefined prompt and save it to desktop.' : item.source === 'task' ? 'Runs this desktop task now.' : 'Inserts this desktop library item into the composer.'}
	                    >
		                      <Text style={[
		                        styles.chatHomeShortcutTitle,
		                        item.action === 'add-prompt' && styles.chatHomeShortcutTitleAdd,
		                      ]} numberOfLines={2}>{item.title}</Text>
		                      {item.description ? (
		                        <Text style={styles.chatHomeShortcutDescription} numberOfLines={2}>{item.description}</Text>
		                      ) : null}
		                      {item.prompt ? (
		                        <View style={styles.chatHomeShortcutActions}>
		                          <TouchableOpacity
		                            style={styles.chatHomeShortcutActionButton}
		                            onPress={(event) => {
		                              event.stopPropagation();
		                              if (item.prompt) openEditPromptModal(item.prompt);
		                            }}
		                            accessibilityRole="button"
		                            accessibilityLabel={createButtonAccessibilityLabel(`Edit prompt ${item.title}`)}
		                          >
		                            <Text style={styles.chatHomeShortcutActionButtonText}>Edit</Text>
		                          </TouchableOpacity>
		                          <TouchableOpacity
		                            style={[
		                              styles.chatHomeShortcutActionButton,
		                              styles.chatHomeShortcutActionButtonDanger,
		                            ]}
		                            onPress={(event) => {
		                              event.stopPropagation();
		                              if (item.prompt) handleDeletePrompt(item.prompt);
		                            }}
		                            accessibilityRole="button"
		                            accessibilityLabel={createButtonAccessibilityLabel(`Delete prompt ${item.title}`)}
		                          >
		                            <Text style={[
		                              styles.chatHomeShortcutActionButtonText,
		                              styles.chatHomeShortcutActionButtonDangerText,
		                            ]}>Delete</Text>
		                          </TouchableOpacity>
		                        </View>
		                      ) : null}
	                    </Pressable>
	                  ))}
	                </View>
	              ) : (
	                <Text style={styles.chatHomeEmptyText}>
		                  {isLoadingQuickStartPrompts ? 'Loading desktop library…' : 'No prompts, skills, or tasks available from your connected desktop app.'}
	                </Text>
	              )}
            </View>
          )}
          {canLoadOlderMessages && (
            <View style={styles.loadOlderContainer}>
              <Text style={styles.loadOlderText}>
                Showing latest {visibleMessages.length} of {messages.length} messages. Scroll up to load older messages.
              </Text>
            </View>
          )}
          {visibleMessages.map((m, visibleIndex) => {
            const i = firstVisibleMessageIndex + visibleIndex;
            // --- Tool-activity group handling ---
            const group = toolActivityGroups.groupByIndex.get(i);
            if (group) {
              const groupKey = getToolActivityGroupKey(group);
              const isGroupExpanded = expandedGroups[groupKey] ?? expandedMessages[group.startIndex] ?? false;

              // Non-first message in a collapsed group: skip rendering
              if (i !== group.startIndex && !isGroupExpanded) {
                return null;
              }

              // First message in a collapsed group: render the group header
              if (i === group.startIndex && !isGroupExpanded) {
                return (
                  <Pressable
                    key={`group-${groupKey}`}
                    onPress={() => toggleGroupExpansion(group)}
                    accessibilityRole="button"
                    accessibilityLabel={`${group.count} tool activities, collapsed. Tap to expand.`}
                    accessibilityState={{ expanded: false }}
                    aria-expanded={false}
                    style={({ pressed }) => [
                      styles.toolActivityGroupCollapsed,
                      pressed && styles.toolActivityGroupPressed,
                    ]}
                  >
                    <View style={styles.toolActivityGroupHeaderRow}>
                      <Text style={styles.toolActivityGroupHeader}>
                        ▶ {group.count} tool {group.count === 1 ? 'activity' : 'activities'}
                      </Text>
                      {group.previewLines.length > 0 && (
                        <Text
                          style={styles.toolActivityGroupPreviewLine}
                          numberOfLines={1}
                          ellipsizeMode="tail"
                        >
                          {group.previewLines.join(', ')}
                        </Text>
                      )}
                    </View>
                  </Pressable>
                );
              }

              // First message in an expanded group: render a collapse header before the message
              // (non-first messages in expanded groups fall through to normal rendering below)
            }

            const visibleMessageContent = getVisibleMessageContent(m);
            // Messages whose visible content comes from respond_to_user should
            // never be collapsed — they ARE the assistant's response to the user
            const hasRespondToUserContent = !!getRespondToUserContentFromMessage(m);
            const shouldCollapse = m.role === 'assistant'
              ? shouldCollapseMessage(visibleMessageContent)
              : shouldCollapseMessage(m.content, m.toolCalls, m.toolResults);
            const effectiveShouldCollapse = hasRespondToUserContent ? false : shouldCollapse;
            // expandedMessages is auto-updated via useEffect to expand the last assistant message
            // and persist the expansion state so it doesn't collapse when new messages arrive
            const isExpanded = expandedMessages[i] ?? false;
            const hasToolMetadata =
              (m.toolCalls?.length ?? 0) > 0 ||
              (m.toolResults?.length ?? 0) > 0;
            const shouldShowExpandedContent = visibleMessageContent.length > 0 && (isExpanded || !effectiveShouldCollapse);
            const shouldShowCollapsedTextPreview =
              visibleMessageContent.length > 0 &&
              !isExpanded &&
              effectiveShouldCollapse;
            const canSpeakVisibleContent =
              m.role === 'assistant' &&
              visibleMessageContent.trim().length > 0 &&
              config.ttsEnabled !== false &&
              (shouldShowExpandedContent || shouldShowCollapsedTextPreview);
            const canBranchFromMessage =
              !!currentSession?.serverConversationId &&
              (m.role === 'user' || m.role === 'assistant') &&
              m.variant !== 'approval';
            const canCopyMessage =
              (m.role === 'user' || m.role === 'assistant') &&
              visibleMessageContent.trim().length > 0 &&
              (shouldShowExpandedContent || shouldShowCollapsedTextPreview);
            const turnDuration = m.role === 'user' && typeof m.timestamp === 'number'
              ? turnDurations.byUserTimestamp.get(m.timestamp)
              : undefined;
            const turnDurationLabel = turnDuration
              ? `Agent time ${formatTurnDuration(turnDuration.durationMs)}${turnDuration.isLive ? ' live' : ''}`
              : null;

            const toolCalls = m.toolCalls ?? [];
            const toolResults = m.toolResults ?? [];
            // Filter out meta-tools from display (respond_to_user, mark_work_complete)
            // since their content is already shown as visible message text
            const displayToolEntries = (m.toolExecutions?.length
              ? m.toolExecutions.map((execution, origIdx) => ({
                  toolCall: execution.toolCall,
                  label: undefined as string | undefined,
                  origIdx,
                  result: execution.result,
                  executionStats: execution.executionStats,
                }))
              : toolCalls.map((toolCall, origIdx) => ({
                  toolCall,
                  label: undefined as string | undefined,
                  origIdx,
                  result: toolResults[origIdx],
                  executionStats: m.toolExecutionStats?.[origIdx],
                })))
              .filter((entry) => !HIDDEN_META_TOOLS.has(entry.toolCall.name));
            const fallbackToolEntries =
              displayToolEntries.length === 0 && toolResults.length > 0
                ? toolResults.map((result, idx) => ({
                    toolCall: {
                      name: 'tool_call',
                      arguments: {},
                    },
                    label: 'Tool result',
                    origIdx: idx,
                    result,
                    executionStats: m.toolExecutionStats?.[idx],
                  }))
                : [];
            const renderedToolEntries = fallbackToolEntries.length > 0
              ? fallbackToolEntries
              : displayToolEntries;
            const displayToolCallCount = renderedToolEntries.length;
            const hasToolResults = renderedToolEntries.some(entry => !!entry.result);
            const allSuccess =
              hasToolResults && renderedToolEntries.every(entry => entry.result?.success === true);
            const hasErrors = renderedToolEntries.some(entry => entry.result?.success === false);
            // isPending is true when any displayed tool call has not received its result yet.
            const isPending =
              renderedToolEntries.some(entry => !entry.result);

            // Skip empty messages: no visible content AND no tool calls to display
            // Also skip messages that only have toolResults but no toolCalls (raw result blobs)
            if (visibleMessageContent.trim().length === 0 && displayToolCallCount === 0) {
              return null;
            }

            // Determine if this message needs group expand/collapse chrome
            const groupKey = group ? getToolActivityGroupKey(group) : '';
            const isExpandedGroup = group ? (expandedGroups[groupKey] ?? expandedMessages[group.startIndex] ?? false) : false;
            const isFirstInExpandedGroup = group && i === group.startIndex && isExpandedGroup;
            const isLastInExpandedGroup = group && i === group.endIndex && isExpandedGroup;

            return (
              <View key={i}>
                {/* Expanded group collapse header */}
                {isFirstInExpandedGroup && (
                  <Pressable
                    onPress={() => toggleGroupExpansion(group!)}
                    accessibilityRole="button"
                    accessibilityLabel={`Collapse ${group!.count} tool activities`}
                    accessibilityState={{ expanded: true }}
                    aria-expanded={true}
                    style={({ pressed }) => [
                      styles.toolActivityGroupCollapsed,
                      pressed && styles.toolActivityGroupPressed,
                    ]}
                  >
                    <Text style={styles.toolActivityGroupHeader}>
                      ▼ {group!.count} tool {group!.count === 1 ? 'activity' : 'activities'}
                    </Text>
                  </Pressable>
                )}
              <View
                style={[
                  styles.msg,
                  m.role === 'user' ? styles.user : styles.assistant,
                ]}
              >
                {/* Compact message header - only for non-tool-only messages (tool-only uses the tool compact row) */}
                {shouldCollapse && !shouldTreatMessageAsToolOnly(m) && (
                  <Pressable
                    onPress={() => toggleMessageExpansion(i)}
                    accessibilityRole="button"
                    accessibilityLabel={createExpandCollapseAccessibilityLabel('message', isExpanded)}
                    accessibilityHint={isExpanded ? 'Collapse message' : 'Expand message'}
                    accessibilityState={{ expanded: isExpanded }}
                    aria-expanded={isExpanded}
                    style={({ pressed }) => [
                      styles.messageHeader,
                      styles.messageHeaderClickable,
                      pressed && styles.messageHeaderPressed,
                    ]}
                  >
                    <View style={styles.expandButton}>
                      <Text style={styles.expandButtonText}>
                        {isExpanded ? '▲' : '▼'}
                      </Text>
                    </View>
                  </Pressable>
                )}

                {m.variant === 'approval' && m.toolApproval ? (
                  <View style={styles.toolApprovalCard}>
                    <Text style={styles.toolApprovalTitle}>Tool Approval Required</Text>
                    <Text style={styles.toolApprovalTool} numberOfLines={2}>
                      {m.toolApproval.toolName}
                    </Text>
                    <Text style={styles.toolApprovalArguments} numberOfLines={4}>
                      {formatToolArguments(m.toolApproval.arguments)}
                    </Text>
                    <View style={styles.toolApprovalActions}>
                      <TouchableOpacity
                        style={[
                          styles.toolApprovalButton,
                          styles.toolApprovalDenyButton,
                          pendingToolApprovalResponseId === m.toolApproval.approvalId && styles.toolApprovalButtonDisabled,
                        ]}
                        onPress={() => respondToToolApproval(m.toolApproval!.approvalId, false)}
                        disabled={pendingToolApprovalResponseId === m.toolApproval.approvalId}
                        accessibilityRole="button"
                        accessibilityLabel={`Deny tool call ${m.toolApproval.toolName}`}
                      >
                        <Text style={styles.toolApprovalDenyButtonText}>Deny</Text>
                      </TouchableOpacity>
                      <TouchableOpacity
                        style={[
                          styles.toolApprovalButton,
                          styles.toolApprovalApproveButton,
                          pendingToolApprovalResponseId === m.toolApproval.approvalId && styles.toolApprovalButtonDisabled,
                        ]}
                        onPress={() => respondToToolApproval(m.toolApproval!.approvalId, true)}
                        disabled={pendingToolApprovalResponseId === m.toolApproval.approvalId}
                        accessibilityRole="button"
                        accessibilityLabel={`Approve tool call ${m.toolApproval.toolName}`}
                      >
                        <Text style={styles.toolApprovalApproveButtonText}>
                          {pendingToolApprovalResponseId === m.toolApproval.approvalId ? 'Responding...' : 'Approve'}
                        </Text>
                      </TouchableOpacity>
                    </View>
                  </View>
                ) : m.role === 'assistant' && (!m.content || m.content.length === 0) && !m.toolCalls && !m.toolResults ? (
                  <View
                    accessible
                    accessibilityRole="progressbar"
                    accessibilityLabel="Assistant is thinking"
                    accessibilityState={{ busy: true }}
                    style={{ flexDirection: 'row', alignItems: 'center' }}
                  >
                    <Image
                      source={isDark ? darkSpinner : lightSpinner}
                      style={{ width: 14, height: 14 }}
                      resizeMode="contain"
                    />
                  </View>
                ) : (
                  <>
                    {shouldShowExpandedContent ? (
                      <View style={m.role === 'assistant' ? styles.assistantMessageRow : undefined}>
                        <View style={m.role === 'assistant' ? styles.assistantMessageBody : undefined}>
                          <MarkdownRenderer
                            content={visibleMessageContent}
                            assetBaseUrl={config.baseUrl}
                            assetAuthToken={config.apiKey}
                          />
                        </View>
                        {canSpeakVisibleContent && (
                          <TouchableOpacity
                            onPress={() => speakMessage(i, visibleMessageContent)}
                            style={[
                              styles.speakButton,
                              speakingMessageIndex === i && styles.speakButtonActive,
                            ]}
                            accessibilityRole="button"
                            accessibilityLabel={speakingMessageIndex === i ? 'Stop reading' : 'Read aloud'}
                          >
                            <Text style={[
                              styles.speakButtonText,
                              speakingMessageIndex === i && styles.speakButtonTextActive,
                            ]}>
                              {speakingMessageIndex === i ? '⏹' : '🔊'}
                            </Text>
                          </TouchableOpacity>
                        )}
                      </View>
                    ) : shouldShowCollapsedTextPreview ? (
                      <View style={m.role === 'assistant' ? styles.assistantMessageRow : undefined}>
                        <Text
                          style={styles.collapsedMessagePreview}
                          numberOfLines={1}
                        >
                          {getCollapsedMessagePreview(visibleMessageContent)}
                        </Text>
                        {canSpeakVisibleContent && (
                          <TouchableOpacity
                            onPress={() => speakMessage(i, visibleMessageContent)}
                            style={[
                              styles.speakButton,
                              speakingMessageIndex === i && styles.speakButtonActive,
                            ]}
                            accessibilityRole="button"
                            accessibilityLabel={speakingMessageIndex === i ? 'Stop reading' : 'Read aloud'}
                          >
                            <Text style={[
                              styles.speakButtonText,
                              speakingMessageIndex === i && styles.speakButtonTextActive,
                            ]}>
                              {speakingMessageIndex === i ? '⏹' : '🔊'}
                            </Text>
                          </TouchableOpacity>
                        )}
                      </View>
                    ) : null}

                    {turnDurationLabel && (
                      <View
                        style={[
                          styles.turnDurationBadge,
                          turnDuration?.isLive && styles.turnDurationBadgeLive,
                        ]}
                        accessibilityRole="text"
                        accessibilityLabel={turnDurationLabel}
                      >
                        <Ionicons
                          name="time-outline"
                          size={12}
                          color={turnDuration?.isLive ? theme.colors.primary : theme.colors.mutedForeground}
                        />
                        <Text style={[
                          styles.turnDurationBadgeText,
                          turnDuration?.isLive && { color: theme.colors.primary },
                        ]}>
                          {turnDurationLabel}
                        </Text>
                      </View>
                    )}

                    {/* Unified Tool Execution Display - only show when there are displayable tool calls */}
                    {displayToolCallCount > 0 && (
                      <>
                        {/* Collapsed view - one line per tool call with useful info */}
                        {!isExpanded && (
                          <Pressable
                            onPress={() => toggleMessageExpansion(i)}
                            accessibilityRole="button"
                            accessibilityLabel={createExpandCollapseAccessibilityLabel('tool execution details', false)}
                            accessibilityHint="Expands this tool execution summary"
                            accessibilityState={{ expanded: false }}
                            aria-expanded={false}
                            style={({ pressed }) => [
                              styles.toolCallCompactContainer,
                              pressed && styles.toolCallCompactPressed,
                            ]}
                          >
                            {renderedToolEntries.map(({ toolCall, label, origIdx, result: tcResult }, tcIdx) => {
                              const tcPending = !tcResult;
                              const tcSuccess = tcResult?.success === true;
                              const tcError = tcResult?.success === false;
                              const toolPreview = label ?? getCompactToolExecutionPreview(toolCall, tcResult ?? null);
                              return (
                                <View key={tcIdx} style={styles.toolCallCompactLine}>
                                  <Text style={[
                                    styles.toolCallCompactStatus,
                                    tcPending && styles.toolCallCompactStatusPending,
                                    tcSuccess && styles.toolCallCompactStatusSuccess,
                                    tcError && styles.toolCallCompactStatusError,
                                  ]}>
                                    {tcPending ? '⏳' : tcSuccess ? '✓' : '✗'}
                                  </Text>
                                  <Text
                                    style={[
                                      styles.toolCallCompactName,
                                      tcPending && styles.toolCallCompactNamePending,
                                      tcSuccess && styles.toolCallCompactNameSuccess,
                                      tcError && styles.toolCallCompactNameError,
                                    ]}
                                    numberOfLines={1}
                                    ellipsizeMode="tail"
                                  >
                                    {toolPreview}
                                  </Text>
                                </View>
                              );
                            })}
                          </Pressable>
                        )}

                        {/* Expanded view - each tool call with its own params + result */}
                        {isExpanded && (
                          <View style={[
                            { position: 'relative' as const },
                          ]}>
                          {/* Collapse button at top of expanded view */}
                          <Pressable
                            onPress={() => toggleMessageExpansion(i)}
                            accessibilityRole="button"
                            accessibilityLabel="Collapse tool execution details"
                            accessibilityHint="Collapse back to compact view"
                            style={({ pressed }) => [
                              styles.toolCallCompactContainer,
                              pressed && styles.toolCallCompactPressed,
                              { marginBottom: 4 },
                            ]}
                          >
                            <Text style={styles.toolCallCompactStatus}>▲</Text>
                            <Text style={styles.toolCallCompactName}>
                              Collapse {displayToolCallCount} tool {displayToolCallCount === 1 ? 'call' : 'calls'}
                            </Text>
                          </Pressable>
                          <View style={[
                            styles.toolExecutionCard,
                            isPending && styles.toolExecutionPending,
                            allSuccess && styles.toolExecutionSuccess,
                            hasErrors && styles.toolExecutionError,
                          ]}>
                            {renderedToolEntries.map(({ toolCall, label, origIdx, result, executionStats }, idx) => {
                              const isResultPending = !result;
                              const executionStatsLabel = formatToolExecutionStatsLabel(executionStats);
                              // Use message id or fallback to array index to ensure stable, unique keys
                              // that won't collide when m.id is undefined (which is common)
                              const stableMessageKey = m.id ?? String(i);
                              const toolCallKey = `${stableMessageKey}-${origIdx}`;
                              const isToolCallFullyExpanded = expandedToolCalls[toolCallKey] ?? false;
                              const toolNameLabel = label ?? toolCall.name;
                              const toolInputPayload = toolCall.arguments ? formatToolArguments(toolCall.arguments) : '';
                              const toolResultContent = result?.content || 'No content returned';
                              return (
                                <View key={idx} style={styles.toolCallSection}>
                                  {/* Tool name heading - tappable to toggle full expansion */}
                                  <Pressable
                                    onPress={() => toggleToolCallExpansion(stableMessageKey, origIdx)}
                                    style={({ pressed }) => [
                                      styles.toolCallHeader,
                                      pressed && styles.toolCallHeaderPressed,
                                    ]}
                                    accessibilityRole="button"
                                    accessibilityLabel={createExpandCollapseAccessibilityLabel(`${toolNameLabel} tool details`, isToolCallFullyExpanded)}
                                    accessibilityState={{ expanded: isToolCallFullyExpanded }}
                                    aria-expanded={isToolCallFullyExpanded}
                                    accessibilityHint={isToolCallFullyExpanded ? 'Collapse tool details' : 'Expand to show full input/output'}
                                  >
                                    <Text style={styles.toolName}>{toolNameLabel}</Text>
                                    <Text style={styles.toolCallExpandHint}>
                                      {isToolCallFullyExpanded ? '▼ Collapse' : '▶ Full Details'}
                                    </Text>
                                  </Pressable>

                                  {/* Parameters */}
                                  {toolInputPayload && (
                                    <View style={styles.toolParamsSection}>
                                      <View style={styles.toolSectionHeaderRow}>
                                        <Text style={styles.toolSectionLabel}>Input:</Text>
                                        <Pressable
                                          style={({ pressed }) => [
                                            styles.toolDetailCopyButton,
                                            pressed && styles.toolDetailCopyButtonPressed,
                                          ]}
                                          onPress={() => { void handleCopyToolPayload(toolInputPayload); }}
                                          accessibilityRole="button"
                                          accessibilityLabel={getToolPayloadCopyAccessibilityLabel('input', toolNameLabel)}
                                        >
                                          <Ionicons name="copy-outline" size={10} color={theme.colors.mutedForeground} />
                                          <Text style={styles.toolDetailCopyButtonText}>Copy</Text>
                                        </Pressable>
                                      </View>
                                      <ScrollView
                                        style={isToolCallFullyExpanded ? styles.toolParamsScrollExpanded : styles.toolParamsScroll}
                                        nestedScrollEnabled
                                      >
                                        <Text style={styles.toolParamsCode}>
                                          {toolInputPayload}
                                        </Text>
                                      </ScrollView>
                                    </View>
                                  )}

                                  {executionStatsLabel && (
                                    <Text
                                      style={styles.toolExecutionStatsText}
                                      accessibilityRole="text"
                                      accessibilityLabel={`Tool execution stats: ${executionStatsLabel}`}
                                    >
                                      {executionStatsLabel}
                                    </Text>
                                  )}

                                  {/* Result for this specific tool call */}
                                  {result ? (
                                    <View style={styles.toolResultItem}>
                                      <View style={styles.toolResultHeader}>
                                        <Text style={styles.toolSectionLabel}>Output:</Text>
                                        <Text style={[
                                          styles.toolResultBadge,
                                          result.success ? styles.toolResultBadgeSuccess : styles.toolResultBadgeError
                                        ]}>
                                          {result.success ? '✅ OK' : '❌ Error'}
                                        </Text>
                                        <Text style={styles.toolResultCharCount}>
                                          {(result.content?.length || 0).toLocaleString()} chars
                                        </Text>
                                        <Pressable
                                          style={({ pressed }) => [
                                            styles.toolDetailCopyButton,
                                            pressed && styles.toolDetailCopyButtonPressed,
                                          ]}
                                          onPress={() => { void handleCopyToolPayload(toolResultContent); }}
                                          accessibilityRole="button"
                                          accessibilityLabel={getToolPayloadCopyAccessibilityLabel('output', toolNameLabel)}
                                        >
                                          <Ionicons name="copy-outline" size={10} color={theme.colors.mutedForeground} />
                                          <Text style={styles.toolDetailCopyButtonText}>Copy</Text>
                                        </Pressable>
                                      </View>
                                      <ScrollView
                                        style={isToolCallFullyExpanded ? styles.toolResultScrollExpanded : styles.toolResultScroll}
                                        nestedScrollEnabled
                                      >
                                        <Text style={styles.toolResultCode}>
                                          {toolResultContent}
                                        </Text>
                                      </ScrollView>
                                      {result.error && (
                                        <View style={styles.toolResultErrorSection}>
                                          <View style={styles.toolSectionHeaderRow}>
                                            <Text style={styles.toolResultErrorLabel}>Error:</Text>
                                            <Pressable
                                              style={({ pressed }) => [
                                                styles.toolDetailCopyButton,
                                                pressed && styles.toolDetailCopyButtonPressed,
                                              ]}
                                              onPress={() => { void handleCopyToolPayload(result.error || ''); }}
                                              accessibilityRole="button"
                                              accessibilityLabel={getToolPayloadCopyAccessibilityLabel('error', toolNameLabel)}
                                            >
                                              <Ionicons name="copy-outline" size={10} color={theme.colors.mutedForeground} />
                                              <Text style={styles.toolDetailCopyButtonText}>Copy</Text>
                                            </Pressable>
                                          </View>
                                          <Text style={styles.toolResultErrorText}>{result.error}</Text>
                                        </View>
                                      )}
                                    </View>
                                  ) : isResultPending ? (
                                    <Text style={styles.toolResponsePendingText}>⏳ Waiting...</Text>
                                  ) : null}
                                </View>
                              );
                            })}
                            {/* Show message if no displayable tool calls */}
                            {displayToolCallCount === 0 && (
                              <Text style={styles.toolResponsePendingText}>No tool calls</Text>
                            )}
                          </View>
                          {/* Collapse button at bottom of expanded view for easy access */}
                          <Pressable
                            onPress={() => toggleMessageExpansion(i)}
                            accessibilityRole="button"
                            accessibilityLabel="Collapse tool execution details"
                            accessibilityHint="Collapse back to compact view"
                            style={({ pressed }) => [
                              styles.toolCallCompactContainer,
                              pressed && styles.toolCallCompactPressed,
                              { marginTop: 4 },
                            ]}
                          >
                            <Text style={styles.toolCallCompactStatus}>▲</Text>
                            <Text style={styles.toolCallCompactName}>
                              Collapse
                            </Text>
                          </Pressable>
                          </View>
                        )}
	                      </>
	                    )}
                    {(canCopyMessage || canBranchFromMessage) && (
                      <View style={styles.messageActionsRow}>
                        {canCopyMessage && (
                          <Pressable
                            style={[
                              styles.messageActionButton,
                              copiedMessageIndex === i && styles.messageActionButtonActive,
                            ]}
                            onPress={() => { void handleCopyMessage(i, visibleMessageContent); }}
                            accessibilityRole="button"
                            accessibilityLabel={copiedMessageIndex === i ? `Copied ${m.role} message ${i + 1}` : `Copy ${m.role} message ${i + 1}`}
                          >
                            <Ionicons
                              name={copiedMessageIndex === i ? 'checkmark' : 'copy-outline'}
                              size={12}
                              color={copiedMessageIndex === i ? theme.colors.success : theme.colors.primary}
                            />
                            <Text style={[
                              styles.messageActionButtonText,
                              copiedMessageIndex === i && styles.messageActionButtonTextActive,
                            ]}>
                              {copiedMessageIndex === i ? 'Copied' : 'Copy'}
                            </Text>
                          </Pressable>
                        )}
                        {canBranchFromMessage && (
                        <Pressable
                          style={[
                            styles.messageActionButton,
                            branchingMessageIndex !== null && styles.messageActionButtonDisabled,
                          ]}
                          onPress={() => { void handleBranchFromMessage(i); }}
                          disabled={branchingMessageIndex !== null}
                          accessibilityRole="button"
                          accessibilityLabel={`Branch conversation from ${m.role} message ${i + 1}`}
                        >
                          <Text style={styles.messageActionButtonText}>
                            {branchingMessageIndex === i ? 'Branching...' : 'Branch'}
                          </Text>
                        </Pressable>
                        )}
                      </View>
                    )}
	                  </>
	                )}
              </View>
                {/* Expanded group collapse footer */}
                {isLastInExpandedGroup && (
                  <Pressable
                    onPress={() => toggleGroupExpansion(group!)}
                    accessibilityRole="button"
                    accessibilityLabel={`Collapse ${group!.count} tool activities`}
                    style={({ pressed }) => [
                      styles.toolActivityGroupCollapsed,
                      pressed && styles.toolActivityGroupPressed,
                    ]}
                  >
                    <Text style={styles.toolActivityGroupHeader}>
                      ▲ Collapse {group!.count} tool {group!.count === 1 ? 'activity' : 'activities'}
                    </Text>
                  </Pressable>
                )}
              </View>
            );
          })}
          {connectionState && connectionState.status === 'reconnecting' && (
            <View style={styles.connectionBanner}>
              <ActivityIndicator size="small" color="#f59e0b" style={{ marginRight: spacing.sm }} />
              <Text style={styles.connectionBannerText}>
                {formatConnectionStatus(connectionState)}
              </Text>
            </View>
          )}
          {debugInfo && (
            <View style={styles.debugInfo}>
              <Text style={styles.debugText}>{debugInfo}</Text>
            </View>
          )}
	          {handsFreeDebugEnabled && voiceEvents.length > 0 && (
	            <View style={styles.debugInfo}>
	              <Text style={styles.debugText}>Voice debug</Text>
	              {voiceEvents.slice(0, 6).map((entry) => (
	                <Text key={entry.id} style={styles.debugText}>
	                  {formatVoiceDebugEntry(entry)}
	                </Text>
	              ))}
	            </View>
	          )}
        </ScrollView>
        {/* Respond-to-user history panel - sticky at bottom of conversation */}
        {respondToUserHistory.length > 0 && (
          <ResponseHistoryPanel
            responses={respondToUserHistory}
            ttsProvider={effectiveTtsProvider}
            edgeTtsVoice={effectiveEdgeTtsVoice}
            ttsRate={config.ttsRate ?? 1.0}
            ttsPitch={config.ttsPitch ?? 1.0}
            ttsVoiceId={config.ttsVoiceId}
            remoteBaseUrl={config.baseUrl}
            remoteApiKey={config.apiKey}
          />
        )}
        {/* Scroll to bottom button - appears when user scrolls up */}
        {!shouldAutoScroll && (
          <TouchableOpacity
            style={[styles.scrollToBottomButton, { bottom: 80 + insets.bottom }]}
            onPress={() => {
              setShouldAutoScroll(true);
              scrollViewRef.current?.scrollToEnd({ animated: true });
            }}
            activeOpacity={0.8}
            accessibilityRole="button"
            accessibilityLabel="Scroll to bottom"
            accessibilityHint="Scrolls to the latest messages"
          >
            <Text style={styles.scrollToBottomText}>↓</Text>
          </TouchableOpacity>
        )}
		        {listening && (
		          <View style={[styles.overlay, { bottom: 72 + insets.bottom }]} pointerEvents="none">
		            <View style={styles.overlayCard}>
		              <Text style={styles.overlayText}>
		                {handsFree ? 'Listening...' : (willCancel ? 'Release to edit' : 'Release to send')}
		              </Text>
		              {!!liveTranscript && (
		                <Text style={styles.overlayTranscript} numberOfLines={3}>
		                  {liveTranscript}
		                </Text>
		              )}
		            </View>
		          </View>
		        )}
        {/* Message Queue Panel */}
        {messageQueueEnabled && queuedMessages.length > 0 && (
          <View style={{ paddingHorizontal: spacing.md, paddingTop: spacing.sm }}>
            <MessageQueuePanel
              conversationId={currentConversationId}
              messages={queuedMessages}
              onRemove={(messageId) => messageQueue.removeFromQueue(currentConversationId, messageId)}
              onUpdate={(messageId, text) => messageQueue.updateText(currentConversationId, messageId, text)}
              onRetry={(messageId) => {
                messageQueue.resetToPending(currentConversationId, messageId);
                // If not already processing, trigger queue processing
                if (!responding) {
                  handleProcessNextQueuedMessage();
                }
              }}
              onProcessNext={handleProcessNextQueuedMessage}
              canProcessNext={!!nextQueuedMessage}
              onClear={() => messageQueue.clearQueue(currentConversationId)}
              isPaused={isMessageQueuePaused}
              onPause={pauseHandsFreeByUser}
              onResume={resumeHandsFreeByUser}
            />
          </View>
        )}
        {/* Connection status banner - shows when reconnecting */}
        {connectionState && connectionState.status === 'reconnecting' && (
          <View style={[styles.connectionBanner, styles.connectionBannerReconnecting]}>
            <View style={styles.connectionBannerContent}>
              <Text style={styles.connectionBannerIcon}>🔄</Text>
              <View style={styles.connectionBannerTextContainer}>
                <Text style={styles.connectionBannerText}>
                  Reconnecting... (attempt {connectionState.retryCount})
                </Text>
                {connectionState.lastError && (
                  <Text style={styles.connectionBannerSubtext} numberOfLines={1}>
                    {connectionState.lastError}
                  </Text>
                )}
              </View>
            </View>
          </View>
        )}
        {/* Retry banner - shows when there's a failed message that can be retried */}
        {lastFailedMessage && !responding && (
          <View style={[styles.connectionBanner, styles.connectionBannerFailed]}>
            <View style={styles.connectionBannerContent}>
              <Text style={styles.connectionBannerIcon}>⚠️</Text>
              <View style={styles.connectionBannerTextContainer}>
                <Text style={styles.connectionBannerText}>Message failed to send</Text>
                <Text style={styles.connectionBannerSubtext} numberOfLines={1}>
                  Tap retry to try again
                </Text>
              </View>
              <TouchableOpacity
                style={styles.retryButton}
                onPress={async () => {
                  const messageToRetry = lastFailedMessage;
                  setLastFailedMessage(null);

                  // Use the recovery conversation ID if available, so the retry resumes
                  // the same server-created conversation when the first attempt failed mid-stream
                  const retryClient = getSessionClient();
                  const recoveryConversationId = retryClient?.getRecoveryConversationId();

                  // Try to recover conversation state from server first (fixes #815)
                  // If the server already processed the message, we should sync the state
                  // instead of re-sending the message
                  if (recoveryConversationId && retryClient) {
                    console.log('[ChatScreen] Retry: Checking server conversation state:', recoveryConversationId);
                    try {
                      const serverConversation = await retryClient.getConversation(recoveryConversationId);
                      if (serverConversation && serverConversation.messages.length > 0) {
                        // Check if the server has the user's message and a response
                        const serverMessages = serverConversation.messages;

                        // Find the index of the last user message
                        let lastUserMsgIndex = -1;
                        for (let i = serverMessages.length - 1; i >= 0; i--) {
                          if (serverMessages[i].role === 'user') {
                            lastUserMsgIndex = i;
                            break;
                          }
                        }

                        // Check if there's ANY assistant message with content after the last user message
                        // This handles cases where tool messages follow the assistant response
                        let hasAssistantResponse = false;
                        if (lastUserMsgIndex >= 0) {
                          for (let i = lastUserMsgIndex + 1; i < serverMessages.length; i++) {
                            if (serverMessages[i].role === 'assistant' && serverMessages[i].content) {
                              hasAssistantResponse = true;
                              break;
                            }
                          }
                        }

                        // If there's an assistant response after the last user message, server already processed the request
                        if (hasAssistantResponse) {
                          console.log('[ChatScreen] Retry: Server already has response, syncing state');

                          // Update the server conversation ID
                          await sessionStore.setServerConversationId(recoveryConversationId);

                          // Convert server messages to ChatMessage format, filtering out tool messages
                          // and merging their toolResults into the preceding assistant message
                          const recoveredMessages: ChatMessage[] = [];
                          for (const msg of serverMessages) {
                            // Only include 'user' and 'assistant' roles
                            if (msg.role === 'user' || msg.role === 'assistant') {
                              recoveredMessages.push({
                                id: msg.id,
                                role: msg.role,
                                content: msg.content,
                                displayContent: (msg as ChatMessage).displayContent,
                                toolCalls: msg.toolCalls,
                                toolResults: msg.toolResults,
                              });
                            } else if (msg.role === 'tool' && recoveredMessages.length > 0) {
                              // Merge tool message toolResults into the preceding assistant message
                              const lastMessage = recoveredMessages[recoveredMessages.length - 1];
                              if (lastMessage.role === 'assistant' && lastMessage.toolCalls && lastMessage.toolCalls.length > 0) {
                                const hasToolResults = msg.toolResults && msg.toolResults.length > 0;

                                if (hasToolResults) {
                                  // Merge toolResults into the existing assistant message
                                  lastMessage.toolResults = [
                                    ...(lastMessage.toolResults || []),
                                    ...(msg.toolResults || []),
                                  ];
                                }
                              }
                            }
                          }

                          // Replace local messages with server state
                          setMessages(recoveredMessages);

                          // Also persist to session store
                          await sessionStore.setMessages(recoveredMessages);

                          console.log('[ChatScreen] Retry: Successfully recovered', recoveredMessages.length, 'messages from server');
                          return; // Don't retry, we've recovered the state
                        }
                      }
                    } catch (error) {
                      console.log('[ChatScreen] Retry: Could not fetch server state, will retry message:', error);
                    }

                    // If we couldn't recover, set the conversation ID for the retry
                    console.log('[ChatScreen] Retry: Using recovery conversationId:', recoveryConversationId);
                    await sessionStore.setServerConversationId(recoveryConversationId);
                  }

                  // Remove the last error message before retrying
                  setMessages((m) => {
                    // Remove the last assistant message (error) and user message
                    const newMessages = [...m];
                    if (newMessages.length >= 2) {
                      newMessages.pop(); // Remove error message
                      newMessages.pop(); // Remove user message
                    }
                    return newMessages;
                  });
                  // Use setTimeout to ensure setMessages completes before send() reads the updated state.
                  // React batches state updates, so send() would otherwise read stale messages.
                  setTimeout(() => send(messageToRetry), 0);
                }}
                activeOpacity={0.7}
              >
                <Text style={styles.retryButtonText}>Retry</Text>
              </TouchableOpacity>
            </View>
          </View>
        )}
		        <View style={[styles.inputArea, { paddingBottom: 12 + insets.bottom }]}>
	          {!!sttPreview && (
	            <View style={styles.sttPreviewBox}>
	              <Text style={styles.sttPreviewLabel}>STT preview</Text>
		              <Text style={styles.sttPreviewText}>{sttPreview}</Text>
	            </View>
	          )}
	          {pendingImages.length > 0 && (
	            <ScrollView
	              horizontal
	              showsHorizontalScrollIndicator={false}
	              contentContainerStyle={styles.pendingImagesRow}
	            >
	              {pendingImages.map((image) => (
	                <View key={image.id} style={styles.pendingImageCard}>
	                  <Image source={{ uri: image.previewUri }} style={styles.pendingImagePreview} />
	                  <TouchableOpacity
	                    style={styles.pendingImageRemoveButton}
	                    onPress={() => removePendingImage(image.id)}
	                    activeOpacity={0.8}
	                  >
	                    <Text style={styles.pendingImageRemoveButtonText}>✕</Text>
	                  </TouchableOpacity>
	                </View>
	              ))}
	            </ScrollView>
	          )}
          {handsFree && (
            <>
              <View style={styles.handsFreeStatusRow}>
                <HandsFreeStatusChip
                  phase={handsFreeController.state.phase}
                  label={handsFreeController.statusLabel}
                  subtitle={handsFreeStatusSubtitle}
                />
              </View>
              <View style={styles.handsFreeControlsRow}>
                {handsFreeController.state.phase === 'sleeping' ? (
                  <TouchableOpacity
                    style={styles.handsFreeControlButton}
	                    onPress={wakeHandsFreeByUser}
                    activeOpacity={0.7}
                  >
                    <Text style={styles.handsFreeControlButtonText}>Wake</Text>
                  </TouchableOpacity>
                ) : (
                  <TouchableOpacity
                    style={styles.handsFreeControlButton}
	                    onPress={sleepHandsFreeByUser}
                    activeOpacity={0.7}
                  >
                    <Text style={styles.handsFreeControlButtonText}>Sleep</Text>
                  </TouchableOpacity>
                )}
                <TouchableOpacity
                  style={styles.handsFreeControlButton}
	                  onPress={handsFreeController.state.phase === 'paused' ? resumeHandsFreeByUser : pauseHandsFreeByUser}
                  activeOpacity={0.7}
                >
                  <Text style={styles.handsFreeControlButtonText}>
	                    {handsFreePauseResumeLabel}
                  </Text>
                </TouchableOpacity>
              </View>
            </>
          )}
	          {/* Top row: TTS toggle, text input, send button */}
		          <View style={styles.inputRow}>
	            <TouchableOpacity
	              style={[styles.ttsToggle, pendingImages.length > 0 && styles.ttsToggleOn]}
	              onPress={handlePickImages}
	              activeOpacity={0.7}
	              accessibilityRole="button"
	              accessibilityLabel="Attach images"
	              accessibilityHint="Select one or more images to include with your next message."
	            >
	              <Ionicons
                  name="image-outline"
                  size={18}
                  color={pendingImages.length > 0 ? theme.colors.primary : theme.colors.mutedForeground}
                />
	            </TouchableOpacity>
            <TouchableOpacity
              style={[styles.ttsToggle, ttsEnabled && styles.ttsToggleOn]}
              onPress={toggleTts}
              activeOpacity={0.7}
              accessibilityRole="switch"
              accessibilityState={{ checked: ttsEnabled }}
              accessibilityLabel={createSwitchAccessibilityLabel('Text-to-Speech')}
              accessibilityHint="Toggles spoken playback for assistant responses."
              aria-checked={ttsEnabled}
            >
              <Ionicons
                name={ttsEnabled ? 'volume-high-outline' : 'volume-mute-outline'}
                size={18}
                color={ttsEnabled ? theme.colors.primary : theme.colors.mutedForeground}
              />
            </TouchableOpacity>
	            {!handsFree && (
	              <TouchableOpacity
	                style={[styles.ttsToggle, willCancel && styles.ttsToggleOn]}
		                onPress={() => setWillCancel((current) => !current)}
	                activeOpacity={0.7}
	                accessibilityRole="switch"
		                aria-checked={willCancel}
	                accessibilityState={{ checked: willCancel }}
	                accessibilityLabel="Edit before send"
	                accessibilityHint="When enabled, releasing the mic inserts the transcript into the input so you can edit before sending."
	              >
	                <Ionicons
                    name="create-outline"
                    size={18}
                    color={willCancel ? theme.colors.primary : theme.colors.mutedForeground}
                  />
	              </TouchableOpacity>
	            )}
            <TextInput
	              ref={inputRef}
              style={styles.input}
              value={input}
              onChangeText={handleInputChange}
              onKeyPress={handleInputKeyPress}
              accessibilityLabel={createTextInputAccessibilityLabel('Message composer')}
              accessibilityHint={composerAccessibilityHint}
              aria-describedby={isWebPlatform ? CHAT_COMPOSER_HINT_NATIVE_ID : undefined}
	              placeholder={composerPlaceholder}
              placeholderTextColor={theme.colors.mutedForeground}
              multiline
            />
            {isWebPlatform && (
              <Text nativeID={CHAT_COMPOSER_HINT_NATIVE_ID} style={styles.visuallyHiddenComposerHint}>
                {composerAccessibilityHint}
              </Text>
            )}
	            {isWebPlatform && (
	              <Text
	                nativeID={CHAT_VOICE_STATUS_LIVE_REGION_NATIVE_ID}
	                style={styles.visuallyHiddenComposerHint}
	                accessibilityLiveRegion="polite"
	                aria-live="polite"
	              >
	                {voiceInputLiveRegionAnnouncement}
	              </Text>
	            )}
            {handsFree && messageQueueEnabled && (
              <TouchableOpacity
                style={[styles.queueButton, !composerHasContent && styles.sendButtonDisabled]}
                onPress={queueComposerInput}
                activeOpacity={0.7}
                disabled={!composerHasContent}
                accessibilityRole="button"
                accessibilityLabel={createButtonAccessibilityLabel('Queue message')}
                accessibilityHint="Adds your typed text and attached images to the queued-messages list without sending immediately."
                accessibilityState={{ disabled: !composerHasContent }}
              >
                <Ionicons name="time-outline" size={18} color={theme.colors.primary} />
              </TouchableOpacity>
            )}
	            <TouchableOpacity
	              style={[styles.sendButton, !composerHasContent && styles.sendButtonDisabled]}
	              onPress={sendComposerInput}
	              activeOpacity={0.7}
	              disabled={!composerHasContent}
              accessibilityRole="button"
              accessibilityLabel={createButtonAccessibilityLabel('Send message')}
	              accessibilityHint="Sends your typed text and any attached images to the selected agent."
              accessibilityState={{ disabled: !composerHasContent }}
	            >
              <Ionicons name="send-outline" size={18} color={theme.colors.primaryForeground} />
            </TouchableOpacity>
          </View>
          {/* Large mic button - ~20% of screen height */}
          <View
            ref={micButtonRef}
            style={styles.micWrapper}
          >
            <Pressable
              style={[
                styles.mic,
                listening && styles.micOn,
                // @ts-ignore - Web-only CSS to disable long-press selection/callouts
                Platform.OS === 'web' && { userSelect: 'none', WebkitUserSelect: 'none', WebkitTouchCallout: 'none', touchAction: 'manipulation' },
              ]}
              accessibilityRole="button"
              accessibilityLabel={createMicControlAccessibilityLabel()}
              accessibilityHint={micControlAccessibilityHint}
              accessibilityState={{ busy: listening }}
              aria-busy={listening}
	              onPressIn={!handsFree ? handlePushToTalkPressIn : undefined}
	              onPressOut={!handsFree ? handlePushToTalkPressOut : undefined}
	              onPress={handsFree ? handleHandsFreePrimaryControl : undefined}
            >
              <Ionicons
                name={listening ? 'mic' : 'mic-outline'}
                size={20}
                color={listening ? theme.colors.primaryForeground : theme.colors.mutedForeground}
              />
              <Text style={[styles.micLabel, listening && styles.micLabelOn]} selectable={false}>
	                {micButtonLabel}
              </Text>
            </Pressable>
          </View>
        </View>
      </View>
      <AgentSelectorSheet
        visible={agentSelectorVisible}
        onClose={() => setAgentSelectorVisible(false)}
      />

      <Modal
        visible={addPromptModalVisible}
        transparent={true}
        animationType="slide"
        onRequestClose={closePromptModal}
      >
        <KeyboardAvoidingView
          style={{ flex: 1 }}
          behavior={Platform.OS === 'ios' ? 'padding' : undefined}
        >
          <View style={styles.modalOverlay}>
            <View style={styles.modalContent}>
              <Text style={styles.modalTitle}>{editingPrompt ? 'Edit Prompt' : 'Add New Prompt'}</Text>

              <Text style={styles.modalLabel}>Name</Text>
              <TextInput
                style={styles.modalInput}
                value={newPromptName}
                onChangeText={setNewPromptName}
                placeholder="e.g., Code Review Request"
                placeholderTextColor={theme.colors.mutedForeground}
              />

              <Text style={styles.modalLabel}>Prompt Content</Text>
              <TextInput
                style={[styles.modalInput, styles.modalInputMultiline]}
                value={newPromptContent}
                onChangeText={setNewPromptContent}
                placeholder="Enter your prompt text..."
                placeholderTextColor={theme.colors.mutedForeground}
                multiline
                textAlignVertical="top"
              />

              <View style={styles.modalActions}>
                <TouchableOpacity
                  style={styles.modalCancelButton}
                  onPress={closePromptModal}
                  disabled={isSavingPrompt}
                >
                  <Text style={styles.modalCancelButtonText}>Cancel</Text>
                </TouchableOpacity>
                <TouchableOpacity
                  style={[
                    styles.modalSaveButton,
                    (!newPromptName.trim() || !newPromptContent.trim() || isSavingPrompt) && styles.modalSaveButtonDisabled
                  ]}
                  onPress={handleSavePrompt}
                  disabled={!newPromptName.trim() || !newPromptContent.trim() || isSavingPrompt}
                >
                  <Text style={styles.modalSaveButtonText}>
                    {isSavingPrompt ? 'Saving...' : editingPrompt ? 'Save Prompt' : 'Add Prompt'}
                  </Text>
                </TouchableOpacity>
              </View>
            </View>
          </View>
        </KeyboardAvoidingView>
      </Modal>
    </KeyboardAvoidingView>
  );
}

function createStyles(theme: Theme, screenHeight: number) {
  const headerActionButton = createMinimumTouchTargetStyle();
  const headerEdgeActionButton = createMinimumTouchTargetStyle({ horizontalPadding: 16 });
  return StyleSheet.create({
    headerActionsRow: {
      flexDirection: 'row',
      alignItems: 'center',
      gap: 2,
    },
    headerConversationChip: {
      flexDirection: 'row',
      alignItems: 'center',
      gap: 6,
      borderWidth: 1,
      borderRadius: 999,
      paddingHorizontal: 8,
      paddingVertical: 4,
      marginHorizontal: 4,
    },
    headerConversationChipText: {
      ...theme.typography.caption,
      fontWeight: '700',
    },
    headerDurationChip: {
      flexDirection: 'row',
      alignItems: 'center',
      gap: 3,
      marginTop: 2,
      paddingHorizontal: 6,
      paddingVertical: 1,
      borderRadius: 999,
      backgroundColor: theme.colors.muted,
    },
    headerDurationChipLive: {
      backgroundColor: hexToRgba(theme.colors.primary, 0.12),
    },
    headerDurationChipText: {
      ...theme.typography.caption,
      color: theme.colors.mutedForeground,
      fontSize: 10,
      fontWeight: '700',
    },
    headerActionButton,
    headerEdgeActionButton,
    headerPinButton: {
      ...createMinimumTouchTargetStyle({ horizontalPadding: 10, verticalPadding: 8 }),
      borderRadius: radius.lg,
      borderWidth: 1,
      borderColor: theme.colors.border,
      backgroundColor: theme.colors.background,
    },
    headerPinButtonActive: {
      borderColor: theme.colors.primary,
      backgroundColor: theme.colors.primary + '18',
    },
    headerPinButtonText: {
      ...theme.typography.caption,
      color: theme.colors.mutedForeground,
      fontWeight: '600',
    },
    headerPinButtonTextActive: {
      color: theme.colors.primary,
    },
    loadOlderContainer: {
      alignItems: 'center',
      paddingVertical: spacing.xs,
    },
    loadOlderText: {
      ...theme.typography.caption,
      color: theme.colors.mutedForeground,
      textAlign: 'center',
    },
    // Compact desktop-style messages: left-border accent, full width, no bubbles
    msg: {
      paddingLeft: spacing.xs,
      paddingVertical: 2,
      marginBottom: 0,
      width: '100%',
    },
    user: {
      // User messages: subtle left border accent
      borderLeftWidth: 2,
      borderLeftColor: hexToRgba(theme.colors.info, 0.4),
      paddingLeft: spacing.xs,
    },
    assistant: {
      // Assistant messages: subtle left-border accent like desktop
      borderLeftWidth: 2,
      borderLeftColor: hexToRgba(theme.colors.mutedForeground, 0.3),
      paddingLeft: spacing.xs,
    },
    turnDurationBadge: {
      alignSelf: 'flex-end',
      flexDirection: 'row',
      alignItems: 'center',
      gap: 4,
      marginTop: 3,
      paddingHorizontal: 7,
      paddingVertical: 3,
      borderRadius: 999,
      backgroundColor: theme.colors.muted,
    },
    turnDurationBadgeLive: {
      backgroundColor: hexToRgba(theme.colors.primary, 0.12),
    },
    turnDurationBadgeText: {
      ...theme.typography.caption,
      color: theme.colors.mutedForeground,
      fontSize: 11,
      fontWeight: '700',
    },
    messageHeader: {
      flexDirection: 'row',
      alignItems: 'center',
      flexWrap: 'wrap',
      gap: 2,
      marginBottom: 1,
      paddingVertical: 1,
      marginHorizontal: -1,
      paddingHorizontal: 1,
      borderRadius: radius.sm,
    },
    messageHeaderClickable: {
      // Visual hint that header is clickable
      minHeight: 44,
      justifyContent: 'center',
    },
    messageHeaderPressed: {
      backgroundColor: theme.colors.muted,
    },
    expandButton: {
      marginLeft: 'auto',
      paddingHorizontal: 2,
      paddingVertical: 1,
    },
    expandButtonText: {
      fontSize: 8,
      color: theme.colors.primary,
      fontWeight: '500',
    },

    inputArea: {
      borderTopWidth: theme.hairline,
      borderColor: theme.colors.border,
      backgroundColor: theme.colors.card,
    },
    pendingImagesRow: {
      paddingHorizontal: spacing.sm,
      paddingTop: spacing.xs,
      paddingBottom: 2,
      gap: spacing.xs,
    },
    pendingImageCard: {
      width: 64,
      height: 64,
      borderRadius: radius.md,
      borderWidth: 1,
      borderColor: theme.colors.border,
      overflow: 'hidden',
      backgroundColor: theme.colors.muted,
      position: 'relative',
    },
    pendingImagePreview: {
      width: '100%',
      height: '100%',
    },
    pendingImageRemoveButton: {
      position: 'absolute',
      top: 4,
      right: 4,
      width: 18,
      height: 18,
      borderRadius: 9,
      backgroundColor: 'rgba(0,0,0,0.7)',
      alignItems: 'center',
      justifyContent: 'center',
    },
    pendingImageRemoveButtonText: {
      color: '#FFFFFF',
      fontSize: 11,
      fontWeight: '700',
      lineHeight: 12,
    },
    sttPreviewBox: {
      marginHorizontal: spacing.sm,
      marginTop: spacing.xs,
      borderWidth: 1,
      borderColor: theme.colors.border,
      backgroundColor: theme.colors.background,
      borderRadius: radius.md,
      paddingHorizontal: spacing.sm,
      paddingVertical: spacing.xs,
    },
    sttPreviewLabel: {
      ...theme.typography.caption,
      color: theme.colors.mutedForeground,
      marginBottom: 2,
      fontWeight: '600',
    },
    sttPreviewText: {
      ...theme.typography.body,
      color: theme.colors.foreground,
    },
    inputRow: {
      flexDirection: 'row',
      alignItems: 'center',
      gap: spacing.xs,
      paddingHorizontal: spacing.sm,
      paddingVertical: spacing.xs,
    },
    handsFreeStatusRow: {
      paddingHorizontal: spacing.sm,
      paddingTop: spacing.xs,
    },
    handsFreeControlsRow: {
      flexDirection: 'row',
      alignItems: 'center',
      gap: spacing.xs,
      paddingHorizontal: spacing.sm,
      paddingTop: spacing.xs,
    },
    handsFreeControlButton: {
      flex: 1,
      borderWidth: 1,
      borderColor: theme.colors.border,
      backgroundColor: theme.colors.background,
      minHeight: 36,
      paddingHorizontal: spacing.sm,
      borderRadius: radius.md,
      alignItems: 'center',
      justifyContent: 'center',
    },
    handsFreeControlButtonText: {
      color: theme.colors.foreground,
      fontWeight: '600',
      fontSize: 12,
    },
    chatHomeCard: {
      marginHorizontal: spacing.sm,
      marginTop: spacing.md,
      padding: spacing.md,
      borderRadius: radius.lg,
      borderWidth: 1,
      borderColor: theme.colors.border,
      backgroundColor: theme.colors.card,
      gap: spacing.sm,
    },
	    chatHomeEmptyText: {
      ...theme.typography.caption,
      color: theme.colors.mutedForeground,
	      textAlign: 'center',
	      paddingVertical: spacing.md,
    },
    chatHomeShortcutGrid: {
      flexDirection: 'row',
      flexWrap: 'wrap',
      gap: spacing.sm,
    },
    chatHomeShortcutCard: {
      minHeight: 72,
      minWidth: '47%',
      flexGrow: 1,
      flexBasis: '47%',
      paddingHorizontal: spacing.sm,
      paddingVertical: spacing.sm,
      borderRadius: radius.md,
      borderWidth: 1,
      borderColor: theme.colors.border,
      backgroundColor: theme.colors.background,
      justifyContent: 'center',
    },
    chatHomeShortcutCardAdd: {
      borderStyle: 'dashed',
      borderColor: theme.colors.primary,
      backgroundColor: 'transparent',
      alignItems: 'center',
    },
    chatHomeShortcutCardDisabled: {
      opacity: 0.5,
    },
    chatHomeShortcutCardPressed: {
      opacity: 0.88,
      transform: [{ scale: 0.99 }],
    },
    chatHomeShortcutTitle: {
      ...theme.typography.body,
      color: theme.colors.foreground,
      fontWeight: '600',
    },
    chatHomeShortcutTitleAdd: {
      color: theme.colors.primary,
      textAlign: 'center',
    },
    chatHomeShortcutDescription: {
      ...theme.typography.caption,
      color: theme.colors.mutedForeground,
      marginTop: 3,
      lineHeight: 15,
    },
    chatHomeShortcutActions: {
      flexDirection: 'row',
      alignItems: 'center',
      gap: spacing.xs,
      marginTop: spacing.sm,
    },
    chatHomeShortcutActionButton: {
      minHeight: 28,
      paddingHorizontal: spacing.sm,
      paddingVertical: 5,
      borderRadius: radius.sm,
      borderWidth: 1,
      borderColor: theme.colors.border,
      backgroundColor: theme.colors.card,
      justifyContent: 'center',
      alignItems: 'center',
    },
    chatHomeShortcutActionButtonDanger: {
      borderColor: hexToRgba(theme.colors.destructive, 0.28),
      backgroundColor: hexToRgba(theme.colors.destructive, 0.08),
    },
    chatHomeShortcutActionButtonText: {
      ...theme.typography.caption,
      color: theme.colors.foreground,
      fontWeight: '600',
    },
    chatHomeShortcutActionButtonDangerText: {
      color: theme.colors.destructive,
    },
    modalOverlay: {
      flex: 1,
      backgroundColor: 'rgba(0, 0, 0, 0.5)',
      justifyContent: 'center',
      padding: spacing.lg,
    },
    modalContent: {
      backgroundColor: theme.colors.background,
      borderRadius: radius.xl,
      padding: spacing.lg,
      borderWidth: 1,
      borderColor: theme.colors.border,
    },
    modalTitle: {
      ...theme.typography.h2,
      marginBottom: spacing.md,
      color: theme.colors.foreground,
    },
    modalLabel: {
      ...theme.typography.caption,
      fontWeight: '600',
      color: theme.colors.foreground,
      marginBottom: spacing.xs,
    },
    modalInput: {
      ...theme.input,
      marginBottom: spacing.md,
      color: theme.colors.foreground,
    },
    modalInputMultiline: {
      height: 120,
      paddingTop: spacing.sm,
      paddingBottom: spacing.sm,
    },
    modalActions: {
      flexDirection: 'row',
      justifyContent: 'flex-end',
      gap: spacing.sm,
      marginTop: spacing.sm,
    },
    modalCancelButton: {
      paddingHorizontal: spacing.md,
      paddingVertical: spacing.sm,
      borderRadius: radius.md,
    },
    modalCancelButtonText: {
      color: theme.colors.mutedForeground,
      fontWeight: '600',
    },
    modalSaveButton: {
      paddingHorizontal: spacing.lg,
      paddingVertical: spacing.sm,
      borderRadius: radius.md,
      backgroundColor: theme.colors.primary,
      minWidth: 100,
      alignItems: 'center',
    },
    modalSaveButtonDisabled: {
      opacity: 0.5,
    },
    modalSaveButtonText: {
      color: theme.colors.primaryForeground,
      fontWeight: '600',
    },
    input: {
      ...theme.input,
      flex: 1,
      maxHeight: 120,
    },
    visuallyHiddenComposerHint: {
      position: 'absolute',
      left: -10000,
      width: 1,
      height: 1,
    },
    micWrapper: {
      paddingHorizontal: spacing.sm,
      paddingBottom: spacing.sm,
    },
    mic: {
      width: '100%' as any,
      height: 56,
      flexDirection: 'row',
      borderRadius: radius.lg,
      borderWidth: 1.5,
      borderColor: theme.colors.border,
      backgroundColor: theme.colors.card,
      alignItems: 'center',
      justifyContent: 'center',
      gap: spacing.sm,
    },
    micOn: {
      backgroundColor: theme.colors.primary,
      borderColor: theme.colors.primary,
    },
    micText: {
      fontSize: 20,
    },
    micLabel: {
      fontSize: 15,
      color: theme.colors.mutedForeground,
      fontWeight: '600',
    },
    micLabelOn: {
      color: theme.colors.primaryForeground,
    },
    ttsToggle: {
	      width: 44,
	      height: 44,
	      borderRadius: 22,
      borderWidth: 1,
      borderColor: theme.colors.border,
      backgroundColor: theme.colors.muted,
      alignItems: 'center',
      justifyContent: 'center',
    },
    ttsToggleOn: {
      backgroundColor: theme.colors.card,
      borderColor: theme.colors.primary,
    },
    ttsToggleText: {
      fontSize: 14,
    },
    sendButton: {
      backgroundColor: theme.colors.primary,
      minHeight: 44,
      minWidth: 44,
      paddingHorizontal: spacing.sm,
      paddingVertical: spacing.sm,
      borderRadius: radius.md,
      flexDirection: 'row',
      alignItems: 'center',
      justifyContent: 'center',
      gap: 4,
    },
    queueButton: {
      borderWidth: 1,
      borderColor: theme.colors.border,
      backgroundColor: theme.colors.background,
      minHeight: 44,
      minWidth: 44,
      paddingHorizontal: spacing.sm,
      paddingVertical: spacing.sm,
      borderRadius: radius.md,
      flexDirection: 'row',
      alignItems: 'center',
      justifyContent: 'center',
      gap: 4,
    },
    sendButtonDisabled: {
      opacity: 0.5,
    },
    queueButtonText: {
      color: theme.colors.foreground,
      fontWeight: '600',
      fontSize: 13,
    },
    sendButtonText: {
      color: theme.colors.primaryForeground,
      fontWeight: '600',
      fontSize: 13,
    },
    debugInfo: {
      backgroundColor: theme.colors.muted,
      padding: spacing.sm,
      margin: spacing.sm,
      borderRadius: radius.lg,
      borderLeftWidth: 4,
      borderLeftColor: theme.colors.primary,
    },
    debugText: {
      fontSize: 12,
      color: theme.colors.mutedForeground,
      fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
    },
    connectionBanner: {
      paddingHorizontal: spacing.md,
      paddingVertical: spacing.sm,
      marginHorizontal: spacing.md,
      marginBottom: spacing.sm,
      borderRadius: radius.md,
      borderWidth: 1,
    },
    connectionBannerReconnecting: {
      backgroundColor: hexToRgba(theme.colors.info, 0.1),
      borderColor: hexToRgba(theme.colors.info, 0.3),
    },
    connectionBannerFailed: {
      backgroundColor: hexToRgba(theme.colors.destructive, 0.1),
      borderColor: hexToRgba(theme.colors.destructive, 0.3),
    },
    connectionBannerContent: {
      flexDirection: 'row',
      alignItems: 'center',
    },
    connectionBannerIcon: {
      fontSize: 16,
      marginRight: spacing.sm,
    },
    connectionBannerTextContainer: {
      flex: 1,
    },
    connectionBannerText: {
      fontSize: 13,
      fontWeight: '500',
      color: theme.colors.foreground,
    },
    connectionBannerSubtext: {
      fontSize: 11,
      color: theme.colors.mutedForeground,
      marginTop: 2,
    },
    retryButton: {
      backgroundColor: theme.colors.primary,
      paddingHorizontal: spacing.md,
      paddingVertical: spacing.sm,
      borderRadius: radius.md,
      marginLeft: spacing.sm,
    },
    retryButtonText: {
      color: theme.colors.primaryForeground,
      fontSize: 13,
      fontWeight: '600',
    },
    scrollToBottomButton: {
      position: 'absolute',
      right: spacing.lg,
      width: 44,
      height: 44,
      borderRadius: 22,
      backgroundColor: theme.colors.primary,
      alignItems: 'center',
      justifyContent: 'center',
      shadowColor: '#000',
      shadowOffset: { width: 0, height: 2 },
      shadowOpacity: 0.25,
      shadowRadius: 4,
      elevation: 5,
    },
    scrollToBottomText: {
      fontSize: 20,
      color: theme.colors.primaryForeground,
      fontWeight: '600',
    },
	    overlay: {
	      position: 'absolute',
	      left: 0,
	      right: 0,
	      bottom: 72,
	      // Ensure the live transcription overlay renders above the input area.
	      zIndex: 1000,
	      elevation: 10,
	      alignItems: 'center',
	      paddingHorizontal: spacing.md,
	      paddingBottom: spacing.sm,
	    },
	    overlayCard: {
	      maxWidth: '88%',
	      borderRadius: radius.xl,
	      backgroundColor: hexToRgba(theme.colors.foreground, 0.72),
	      paddingHorizontal: 12,
	      paddingVertical: 8,
	    },
	    overlayText: {
	      ...theme.typography.caption,
	      color: theme.colors.background,
	      textAlign: 'center',
	    },
	    overlayTranscript: {
	      color: theme.colors.background,
	      marginTop: 4,
	      fontSize: 12,
	      lineHeight: 16,
	      opacity: 0.92,
	    },
    toolApprovalCard: {
      gap: spacing.xs,
      padding: spacing.sm,
      borderRadius: radius.sm,
      borderWidth: 1,
      borderColor: 'rgba(245, 158, 11, 0.35)',
      backgroundColor: 'rgba(245, 158, 11, 0.1)',
    },
    toolApprovalTitle: {
      fontSize: 13,
      fontWeight: '700',
      color: '#b45309',
    },
    toolApprovalTool: {
      fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
      fontSize: 12,
      color: theme.colors.foreground,
    },
    toolApprovalArguments: {
      fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
      fontSize: 11,
      lineHeight: 15,
      color: theme.colors.mutedForeground,
    },
    toolApprovalActions: {
      flexDirection: 'row',
      justifyContent: 'flex-end',
      flexWrap: 'wrap',
      gap: spacing.sm,
      marginTop: spacing.xs,
    },
    toolApprovalButton: {
      minHeight: 36,
      minWidth: 84,
      borderRadius: radius.sm,
      paddingHorizontal: spacing.md,
      paddingVertical: spacing.sm,
      alignItems: 'center',
      justifyContent: 'center',
    },
    toolApprovalButtonDisabled: {
      opacity: 0.6,
    },
    toolApprovalApproveButton: {
      backgroundColor: theme.colors.primary,
    },
    toolApprovalApproveButtonText: {
      color: theme.colors.primaryForeground,
      fontSize: 13,
      fontWeight: '700',
    },
    toolApprovalDenyButton: {
      borderWidth: 1,
      borderColor: theme.colors.destructive,
      backgroundColor: theme.colors.background,
    },
    toolApprovalDenyButtonText: {
      color: theme.colors.destructive,
      fontSize: 13,
      fontWeight: '700',
    },
    // Unified Tool Execution Card styles - compact left-accent design matching desktop
    toolExecutionCard: {
      marginTop: 2,
      borderRadius: radius.sm,
      borderLeftWidth: 1.5,
      borderLeftColor: hexToRgba(theme.colors.mutedForeground, 0.5),
      backgroundColor: hexToRgba(theme.colors.mutedForeground, 0.02),
      overflow: 'hidden',
    },
    toolExecutionPending: {
      borderLeftColor: hexToRgba(theme.colors.info, 0.5),
      backgroundColor: hexToRgba(theme.colors.info, 0.02),
    },
    toolExecutionSuccess: {
      borderLeftColor: hexToRgba(theme.colors.success, 0.5),
      backgroundColor: hexToRgba(theme.colors.success, 0.02),
    },
    toolExecutionError: {
      borderLeftColor: hexToRgba(theme.colors.destructive, 0.5),
      backgroundColor: hexToRgba(theme.colors.destructive, 0.02),
    },
    toolCallCompactContainer: {
      paddingVertical: 1,
      paddingHorizontal: 2,
      borderRadius: radius.sm,
      gap: 1,
    },
    toolCallCompactLine: {
      flexDirection: 'row',
      alignItems: 'center',
      gap: 4,
      paddingVertical: 1,
      overflow: 'hidden',
    },
    toolCallCompactPressed: {
      opacity: 0.7,
    },
    toolCallCompactName: {
      fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
      fontSize: 10,
      fontWeight: '500',
      flexShrink: 1,
      minWidth: 0,
      color: theme.colors.mutedForeground,
    },
    toolCallCompactNamePending: {
      color: theme.colors.info,
    },
    toolCallCompactNameSuccess: {
      color: theme.colors.success,
    },
    toolCallCompactNameError: {
      color: theme.colors.destructive,
    },
    toolCallCompactStatus: {
      fontSize: 9,
    },
    toolCallCompactStatusPending: {
      color: theme.colors.info,
    },
    toolCallCompactStatusSuccess: {
      color: theme.colors.success,
    },
    toolCallCompactStatusError: {
      color: theme.colors.destructive,
    },
    // Tool-activity group styles (collapsed-by-default grouping of consecutive tool calls)
    toolActivityGroupCollapsed: {
      paddingVertical: 4,
      paddingHorizontal: spacing.xs,
      borderRadius: radius.sm,
      borderLeftWidth: 2,
      borderLeftColor: hexToRgba(theme.colors.mutedForeground, 0.3),
      marginBottom: 2,
    },
    toolActivityGroupPressed: {
      opacity: 0.7,
    },
    toolActivityGroupHeaderRow: {
      flexDirection: 'row',
      alignItems: 'center',
      gap: 6,
      overflow: 'hidden',
    },
    toolActivityGroupHeader: {
      fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
      fontSize: 10,
      fontWeight: '600',
      color: theme.colors.mutedForeground,
      flexShrink: 0,
    },
    toolActivityGroupPreviewLine: {
      fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
      fontSize: 10,
      color: theme.colors.mutedForeground,
      flexShrink: 1,
      minWidth: 0,
    },
    toolParamsSection: {
      paddingHorizontal: spacing.xs,
      paddingVertical: 2,
    },
    toolParamsSectionTitle: {
      fontSize: 9,
      fontWeight: '600',
      color: theme.colors.mutedForeground,
      marginBottom: 2,
      opacity: 0.7,
    },
    toolCallCard: {
      backgroundColor: hexToRgba(theme.colors.foreground, 0.02),
      borderRadius: radius.sm,
      padding: 3,
      marginBottom: 2,
    },
    toolCallSection: {
      marginBottom: spacing.xs,
      paddingBottom: spacing.xs,
      borderBottomWidth: 0.5,
      borderBottomColor: hexToRgba(theme.colors.mutedForeground, 0.15),
    },
    toolName: {
      fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
      fontWeight: '600',
      color: theme.colors.primary,
      fontSize: 10,
      flex: 1,
    },
    toolCallHeader: {
      flexDirection: 'row',
      alignItems: 'center',
      justifyContent: 'space-between',
      paddingVertical: spacing.xs,
      marginBottom: spacing.xs,
      minHeight: 44,
    },
    toolCallHeaderPressed: {
      opacity: 0.7,
    },
    toolCallExpandHint: {
      fontSize: 9,
      color: theme.colors.mutedForeground,
      fontWeight: '500',
    },
    toolSectionLabel: {
      fontSize: 8,
      fontWeight: '600',
      color: theme.colors.mutedForeground,
      marginBottom: 2,
      textTransform: 'uppercase',
      letterSpacing: 0.5,
    },
    toolSectionHeaderRow: {
      flexDirection: 'row',
      alignItems: 'center',
      justifyContent: 'space-between',
      gap: spacing.xs,
      marginBottom: 2,
    },
    toolDetailCopyButton: {
      minHeight: 24,
      flexDirection: 'row',
      alignItems: 'center',
      justifyContent: 'center',
      gap: 3,
      paddingHorizontal: 6,
      paddingVertical: 2,
      borderRadius: radius.sm,
      backgroundColor: hexToRgba(theme.colors.mutedForeground, 0.08),
      flexShrink: 0,
    },
    toolDetailCopyButtonPressed: {
      opacity: 0.7,
    },
    toolDetailCopyButtonText: {
      fontSize: 8,
      fontWeight: '600',
      color: theme.colors.mutedForeground,
    },
    toolExecutionStatsText: {
      marginTop: 2,
      marginBottom: 2,
      fontSize: 9,
      fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
      color: theme.colors.mutedForeground,
    },
    toolParamsScroll: {
      maxHeight: 80,
      borderRadius: radius.sm,
      overflow: 'hidden',
    },
    toolParamsScrollExpanded: {
      maxHeight: 400,
      borderRadius: radius.sm,
      overflow: 'hidden',
    },
    toolParamsCode: {
      fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
      fontSize: 8,
      color: theme.colors.foreground,
      backgroundColor: theme.colors.muted,
      padding: 3,
      borderRadius: radius.sm,
    },
    toolResponseSection: {
      paddingHorizontal: spacing.xs,
      paddingVertical: 2,
    },
    toolResponsePending: {
      // No background - let parent handle it
    },
    toolResponseSuccess: {
      // No background - let parent handle it
    },
    toolResponseError: {
      // No background - let parent handle it
    },
    toolResponseSectionTitle: {
      fontSize: 9,
      fontWeight: '600',
      color: theme.colors.mutedForeground,
      marginBottom: 2,
      opacity: 0.7,
    },
    toolResponsePendingText: {
      fontSize: 9,
      fontStyle: 'italic',
      color: theme.colors.mutedForeground,
      textAlign: 'center',
      paddingVertical: 2,
    },
    toolResultItem: {
      marginBottom: 2,
    },
    toolResultHeader: {
      flexDirection: 'row',
      alignItems: 'center',
      justifyContent: 'space-between',
      flexWrap: 'wrap',
      gap: 4,
      marginBottom: 1,
    },
    toolResultCharCount: {
      fontSize: 8,
      fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
      color: theme.colors.mutedForeground,
      opacity: 0.6,
    },
    toolResultBadge: {
      fontSize: 9,
      fontWeight: '600',
      paddingHorizontal: 4,
      paddingVertical: 1,
      borderRadius: radius.sm,
    },
    toolResultBadgeSuccess: {
      backgroundColor: hexToRgba(theme.colors.success, 0.12),
      color: theme.colors.success,
    },
    toolResultBadgeError: {
      backgroundColor: hexToRgba(theme.colors.destructive, 0.12),
      color: theme.colors.destructive,
    },
    toolResultScroll: {
      maxHeight: 80,
      borderRadius: radius.sm,
      overflow: 'hidden',
    },
    toolResultScrollExpanded: {
      maxHeight: 400,
      borderRadius: radius.sm,
      overflow: 'hidden',
    },
    toolResultCode: {
      fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
      fontSize: 8,
      color: theme.colors.foreground,
      backgroundColor: theme.colors.muted,
      padding: 3,
      borderRadius: radius.sm,
    },
    toolResultErrorSection: {
      marginTop: 1,
    },
    toolResultErrorLabel: {
      fontSize: 8,
      fontWeight: '500',
      color: theme.colors.destructive,
    },
    toolResultErrorText: {
      fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
      fontSize: 8,
      color: theme.colors.destructive,
      backgroundColor: hexToRgba(theme.colors.destructive, 0.06),
      padding: 3,
      borderRadius: radius.sm,
    },
    assistantMessageRow: {
      flexDirection: 'row',
      alignItems: 'flex-start',
      gap: spacing.xs,
      width: '100%',
    },
    assistantMessageBody: {
      flex: 1,
      minWidth: 0,
    },
    collapsedMessagePreview: {
      color: theme.colors.foreground,
      fontSize: 13,
      lineHeight: 18,
      flex: 1,
      minWidth: 0,
    },
    messageActionsRow: {
      flexDirection: 'row',
      alignItems: 'center',
      justifyContent: 'flex-end',
      marginTop: 2,
      gap: spacing.xs,
    },
    messageActionButton: {
      ...createMinimumTouchTargetStyle({
        horizontalPadding: spacing.sm,
        verticalPadding: spacing.xs,
        horizontalMargin: 0,
      }),
      flexDirection: 'row',
      gap: 4,
      borderRadius: radius.lg,
      borderWidth: 1,
      borderColor: theme.colors.border,
      backgroundColor: theme.colors.background,
      alignItems: 'center',
      justifyContent: 'center',
    },
    messageActionButtonActive: {
      borderColor: hexToRgba(theme.colors.success, 0.35),
      backgroundColor: hexToRgba(theme.colors.success, 0.08),
    },
    messageActionButtonDisabled: {
      opacity: 0.65,
    },
    messageActionButtonText: {
      ...theme.typography.caption,
      color: theme.colors.primary,
      fontWeight: '600',
    },
    messageActionButtonTextActive: {
      color: theme.colors.success,
    },
    // Per-message TTS button styles (#1078)
    speakButton: {
      alignSelf: 'flex-start',
      width: 24,
      height: 24,
      marginTop: 1,
      borderRadius: 12,
      backgroundColor: hexToRgba(theme.colors.mutedForeground, 0.1),
      alignItems: 'center',
      justifyContent: 'center',
      flexShrink: 0,
    } as const,
    speakButtonActive: {
      backgroundColor: hexToRgba(theme.colors.primary, 0.15),
    } as const,
    speakButtonText: {
      fontSize: 12,
      color: theme.colors.mutedForeground,
    } as const,
    speakButtonTextActive: {
      color: theme.colors.primary,
    } as const,
  });
}
