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",
},
});