import { router, useLocalSearchParams } from "expo-router"; import { useEffect, useRef, useState } from "react"; import { FlatList, KeyboardAvoidingView, type NativeScrollEvent, type NativeSyntheticEvent, Platform, Pressable, StyleSheet, Text, TextInput, View, } from "react-native"; import { AssistantMessage } from "@/components/AssistantMessage"; import { MessageRow, type Sender } from "@/components/MessageRow"; import { ModelSelector } from "@/components/ModelSelector"; import { ReasoningEffortButton } from "@/components/ReasoningEffortButton"; import { ToolCallCard } from "@/components/ToolCallCard"; import { UserMessage } from "@/components/UserMessage"; import { useSocket } from "@/contexts/SocketContext"; import { saveLastModel } from "@/lib/api"; import { extractResultText, toolArgsPreview, truncateDetail } from "@/lib/tool-format"; import { colors, monoFont } from "@/lib/theme"; import type { ChatHistoryMessage, ChatItem, ModelRef, ModelSummary, ThinkingLevel, } from "@/lib/types"; const ALL_THINKING_LEVELS: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"]; function extractText(content: unknown): string { if (typeof content === "string") { return content; } if (Array.isArray(content)) { return content .map((block) => { if (typeof block === "object" && block && "text" in block) { return String((block as { text?: string }).text ?? ""); } return ""; }) .join(""); } return ""; } function historyToItems(messages: ChatHistoryMessage[]): ChatItem[] { return messages.map((message, index) => { if (message.role === "user") { return { id: `history-user-${index}`, kind: "user", text: message.content }; } if (message.role === "assistant") { return { id: `history-assistant-${index}`, kind: "assistant", text: message.content, streaming: false, }; } return { id: `history-tool-${message.toolCallId || index}`, kind: "tool", toolName: message.toolName, status: message.isError ? "error" : "done", argsPreview: toolArgsPreview(message.args), detail: message.content ? truncateDetail(message.content) : undefined, }; }); } function senderOf(item: ChatItem): Sender { return item.kind === "user" ? "user" : "agent"; } /** Skip rows that render nothing so they don't produce an empty labeled block. */ function isRenderable(item: ChatItem): boolean { return item.kind !== "assistant" || Boolean(item.text.trim() || item.streaming); } function renderChatItem(item: ChatItem, index: number, items: ChatItem[]) { if (!isRenderable(item)) { return null; } const sender = senderOf(item); let previous: ChatItem | undefined; for (let i = index - 1; i >= 0; i--) { if (isRenderable(items[i])) { previous = items[i]; break; } } const groupStart = !previous || senderOf(previous) !== sender; let content; if (item.kind === "user") { content = ; } else if (item.kind === "tool") { content = ( ); } else { content = ; } return ( {content} ); } export default function ChatScreen() { const params = useLocalSearchParams<{ runtimeId: string | string[]; cwd?: string }>(); const paramRuntimeId = Array.isArray(params.runtimeId) ? params.runtimeId[0] : params.runtimeId; const isDraftChat = paramRuntimeId === "new"; const projectCwd = params.cwd ?? ""; const { attachRuntime, createRuntime, lastModel, listModels, sendPrompt, setRuntimeModel, setRuntimeThinkingLevel, abortPrompt, subscribeChat, subscribeChatHistory, waitForRuntime, } = useSocket(); const [activeRuntimeId, setActiveRuntimeId] = useState( isDraftChat ? null : (paramRuntimeId ?? null), ); const [items, setItems] = useState([]); const [draft, setDraft] = useState(""); const [streaming, setStreaming] = useState(false); const [creating, setCreating] = useState(false); const [models, setModels] = useState([]); const [selectedModel, setSelectedModel] = useState(); const [modelLoading, setModelLoading] = useState(false); const [modelError, setModelError] = useState(null); const [thinkingLevel, setThinkingLevel] = useState("off"); const [availableThinkingLevels, setAvailableThinkingLevels] = useState(ALL_THINKING_LEVELS); const assistantIdRef = useRef(null); const toolIdsRef = useRef(new Map()); const pendingMessageRef = useRef(null); const listRef = useRef>(null); const atBottomRef = useRef(true); useEffect(() => { let cancelled = false; setModelLoading(true); setModelError(null); void listModels(isDraftChat ? { cwd: projectCwd } : { runtimeId: activeRuntimeId ?? undefined }) .then((payload) => { if (cancelled) { return; } setModels(payload.models); const persistedModel = lastModel ? payload.models.some( (model) => model.provider === lastModel.provider && model.id === lastModel.id, ) ? lastModel : undefined : undefined; setSelectedModel( payload.currentModel ?? persistedModel ?? (payload.models[0] ? { provider: payload.models[0].provider, id: payload.models[0].id } : undefined), ); const selected = payload.currentModel ?? persistedModel ?? payload.models[0]; const selectedSummary = selected ? payload.models.find( (model) => model.provider === selected.provider && model.id === selected.id, ) : undefined; setAvailableThinkingLevels( payload.availableThinkingLevels ?? (selectedSummary?.reasoning ? ALL_THINKING_LEVELS : ["off"]), ); setThinkingLevel(payload.currentThinkingLevel ?? "off"); if (payload.loadError) { setModelError(payload.loadError); } }) .catch((error: unknown) => { if (!cancelled) { setModelError(error instanceof Error ? error.message : "Failed to load models"); } }) .finally(() => { if (!cancelled) { setModelLoading(false); } }); return () => { cancelled = true; }; }, [activeRuntimeId, isDraftChat, lastModel, listModels, projectCwd]); useEffect(() => { if (!activeRuntimeId) { return; } if (!pendingMessageRef.current) { setItems([]); } setStreaming(false); assistantIdRef.current = null; toolIdsRef.current.clear(); const unsubscribeHistory = subscribeChatHistory((payload) => { if (payload.runtimeId !== activeRuntimeId) { return; } const historyItems = historyToItems(payload.messages); setItems((current) => (historyItems.length > 0 ? historyItems : current)); }); const unsubscribeChat = subscribeChat((payload) => { if (payload.runtimeId !== activeRuntimeId) { return; } const event = payload.event; switch (event.type) { case "agent_start": setStreaming(true); break; case "message_start": { const role = (event as { message?: { role?: string } }).message?.role; if (role === "assistant") { const id = `assistant-${Date.now()}`; assistantIdRef.current = id; setItems((current) => [ ...current, { id, kind: "assistant", text: "", streaming: true }, ]); } break; } case "message_update": { const assistantEvent = ( event as { assistantMessageEvent?: { type?: string; delta?: string } } ).assistantMessageEvent; if (assistantEvent?.type === "text_delta" && assistantEvent.delta) { const activeId = assistantIdRef.current; if (!activeId) { break; } setItems((current) => current.map((item) => item.id === activeId && item.kind === "assistant" ? { ...item, text: item.text + assistantEvent.delta } : item, ), ); } break; } case "message_end": { const activeId = assistantIdRef.current; if (!activeId) { break; } setItems((current) => current.map((item) => item.id === activeId && item.kind === "assistant" ? { ...item, streaming: false } : item, ), ); assistantIdRef.current = null; break; } case "tool_execution_start": { const toolName = String((event as { toolName?: string }).toolName ?? "tool"); const toolCallId = String((event as { toolCallId?: string }).toolCallId ?? toolName); const id = `tool-${toolCallId}`; toolIdsRef.current.set(toolCallId, id); setItems((current) => [ ...current, { id, kind: "tool", toolName, status: "running", argsPreview: toolArgsPreview((event as { args?: unknown }).args), }, ]); break; } case "tool_execution_update": { const toolCallId = String((event as { toolCallId?: string }).toolCallId ?? ""); const id = toolIdsRef.current.get(toolCallId); if (!id) { break; } const partial = (event as { partialResult?: unknown; output?: unknown }).partialResult; const fallback = (event as { output?: unknown }).output; const detail = extractResultText(partial ?? fallback); if (!detail.trim()) { break; } setItems((current) => current.map((item) => item.id === id && item.kind === "tool" ? { ...item, detail: truncateDetail(detail) } : item, ), ); break; } case "tool_execution_end": { const toolCallId = String((event as { toolCallId?: string }).toolCallId ?? ""); const id = toolIdsRef.current.get(toolCallId); if (!id) { break; } const isError = Boolean((event as { isError?: boolean }).isError); const resultText = extractResultText((event as { result?: unknown }).result); setItems((current) => current.map((item) => item.id === id && item.kind === "tool" ? { ...item, status: isError ? "error" : "done", detail: resultText.trim() ? truncateDetail(resultText) : item.detail, } : item, ), ); break; } case "agent_end": { setStreaming(false); const messages = (event as { messages?: Array<{ role?: string; content?: unknown }> }) .messages; if (!messages?.length) { break; } setItems((current) => { const next = [...current]; for (const message of messages) { if (message.role === "user") { const text = extractText(message.content); if (text && !next.some((item) => item.kind === "user" && item.text === text)) { next.push({ id: `user-${Date.now()}-${text.slice(0, 8)}`, kind: "user", text }); } } } return next; }); break; } } }); attachRuntime(activeRuntimeId); const pendingMessage = pendingMessageRef.current; if (pendingMessage) { pendingMessageRef.current = null; sendPrompt(activeRuntimeId, pendingMessage); } return () => { unsubscribeHistory(); unsubscribeChat(); }; }, [ activeRuntimeId, attachRuntime, sendPrompt, subscribeChat, subscribeChatHistory, ]); const send = async () => { const message = draft.trim(); if (!message || creating) { return; } atBottomRef.current = true; setItems((current) => [...current, { id: `user-${Date.now()}`, kind: "user", text: message }]); setDraft(""); if (!activeRuntimeId) { if (!projectCwd) { setItems((current) => current.slice(0, -1)); return; } pendingMessageRef.current = message; setCreating(true); createRuntime(projectCwd, undefined, selectedModel, thinkingLevel); try { const runtime = await waitForRuntime("created"); setActiveRuntimeId(runtime.runtimeId); router.replace(`/chat/${runtime.runtimeId}`); } catch { pendingMessageRef.current = null; setItems((current) => current.slice(0, -1)); } finally { setCreating(false); } return; } sendPrompt(activeRuntimeId, message); }; const selectModel = async (model: ModelRef) => { if (streaming) { return; } const previous = selectedModel; setSelectedModel(model); setModelError(null); if (!activeRuntimeId) { const summary = models.find( (entry) => entry.provider === model.provider && entry.id === model.id, ); const nextLevels = summary?.reasoning ? ALL_THINKING_LEVELS : ["off" as ThinkingLevel]; setAvailableThinkingLevels(nextLevels); if (!nextLevels.includes(thinkingLevel)) { setThinkingLevel("off"); } void saveLastModel(model); return; } try { const runtime = await setRuntimeModel(activeRuntimeId, model); setThinkingLevel(runtime.thinkingLevel ?? "off"); setAvailableThinkingLevels(runtime.availableThinkingLevels ?? ["off"]); } catch (error: unknown) { setSelectedModel(previous); setModelError(error instanceof Error ? error.message : "Failed to set model"); } }; const cycleReasoningEffort = async () => { if (streaming || availableThinkingLevels.length < 2) { return; } const currentIndex = availableThinkingLevels.indexOf(thinkingLevel); const nextLevel = availableThinkingLevels[(currentIndex + 1) % availableThinkingLevels.length] ?? "off"; setThinkingLevel(nextLevel); if (!activeRuntimeId) { return; } try { const runtime = await setRuntimeThinkingLevel(activeRuntimeId, nextLevel); setThinkingLevel(runtime.thinkingLevel ?? nextLevel); setAvailableThinkingLevels(runtime.availableThinkingLevels ?? availableThinkingLevels); } catch { setThinkingLevel(thinkingLevel); } }; const handleScroll = (scrollEvent: NativeSyntheticEvent) => { const { contentOffset, contentSize, layoutMeasurement } = scrollEvent.nativeEvent; atBottomRef.current = contentOffset.y + layoutMeasurement.height >= contentSize.height - 80; }; const handleContentSizeChange = () => { if (atBottomRef.current) { listRef.current?.scrollToEnd({ animated: false }); } }; const canSend = draft.trim().length > 0 && !creating; return ( item.id} contentContainerStyle={styles.list} onScroll={handleScroll} scrollEventThrottle={100} onContentSizeChange={handleContentSizeChange} ListEmptyComponent={ no messages yet } renderItem={({ item, index }) => renderChatItem(item, index, items)} /> void selectModel(model)} /> void cycleReasoningEffort()} /> {streaming ? ( activeRuntimeId && abortPrompt(activeRuntimeId)} > Stop ) : null} void send()} disabled={!canSend} > Send ); } const styles = StyleSheet.create({ container: { flex: 1, }, list: { paddingHorizontal: 14, paddingTop: 2, paddingBottom: 28, flexGrow: 1, }, empty: { flex: 1, alignItems: "center", justifyContent: "center", }, emptyText: { color: colors.textMuted, fontFamily: monoFont, fontSize: 13, }, composer: { borderTopWidth: 1, borderTopColor: colors.borderSubtle, paddingHorizontal: 12, paddingVertical: 10, gap: 8, }, selectorRow: { flexDirection: "row", alignItems: "stretch", gap: 8, }, modelSelector: { flex: 1, minWidth: 0, }, inputRow: { gap: 8, flexDirection: "row", alignItems: "flex-end", }, input: { flex: 1, minHeight: 40, maxHeight: 120, backgroundColor: colors.surfaceDeep, borderRadius: 8, borderWidth: 1, borderColor: colors.border, color: colors.text, paddingHorizontal: 12, paddingVertical: 10, fontSize: 15, }, sendButton: { borderRadius: 8, borderWidth: 1, borderColor: colors.border, backgroundColor: colors.surface, paddingHorizontal: 14, paddingVertical: 11, }, sendButtonDisabled: { opacity: 0.4, }, sendText: { color: colors.text, fontFamily: monoFont, fontSize: 13, fontWeight: "700", }, abortButton: { borderWidth: 1, borderColor: colors.errorBorder, borderRadius: 8, paddingHorizontal: 12, paddingVertical: 11, }, abortText: { color: colors.errorMuted, fontFamily: monoFont, fontSize: 13, fontWeight: "700", }, });