658 lines
19 KiB
TypeScript
658 lines
19 KiB
TypeScript
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 = <UserMessage text={item.text} />;
|
|
} else if (item.kind === "tool") {
|
|
content = (
|
|
<ToolCallCard
|
|
toolName={item.toolName}
|
|
status={item.status}
|
|
argsPreview={item.argsPreview}
|
|
detail={item.detail}
|
|
/>
|
|
);
|
|
} else {
|
|
content = <AssistantMessage text={item.text} streaming={item.streaming} />;
|
|
}
|
|
|
|
return (
|
|
<MessageRow sender={sender} groupStart={groupStart}>
|
|
{content}
|
|
</MessageRow>
|
|
);
|
|
}
|
|
|
|
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<string | null>(
|
|
isDraftChat ? null : (paramRuntimeId ?? null),
|
|
);
|
|
const [items, setItems] = useState<ChatItem[]>([]);
|
|
const [draft, setDraft] = useState("");
|
|
const [streaming, setStreaming] = useState(false);
|
|
const [creating, setCreating] = useState(false);
|
|
const [models, setModels] = useState<ModelSummary[]>([]);
|
|
const [selectedModel, setSelectedModel] = useState<ModelRef | undefined>();
|
|
const [modelLoading, setModelLoading] = useState(false);
|
|
const [modelError, setModelError] = useState<string | null>(null);
|
|
const [thinkingLevel, setThinkingLevel] = useState<ThinkingLevel>("off");
|
|
const [availableThinkingLevels, setAvailableThinkingLevels] =
|
|
useState<ThinkingLevel[]>(ALL_THINKING_LEVELS);
|
|
const assistantIdRef = useRef<string | null>(null);
|
|
const toolIdsRef = useRef(new Map<string, string>());
|
|
const pendingMessageRef = useRef<string | null>(null);
|
|
const listRef = useRef<FlatList<ChatItem>>(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<NativeScrollEvent>) => {
|
|
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 (
|
|
<KeyboardAvoidingView
|
|
style={styles.container}
|
|
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
|
keyboardVerticalOffset={88}
|
|
>
|
|
<FlatList
|
|
ref={listRef}
|
|
data={items}
|
|
keyExtractor={(item) => item.id}
|
|
contentContainerStyle={styles.list}
|
|
onScroll={handleScroll}
|
|
scrollEventThrottle={100}
|
|
onContentSizeChange={handleContentSizeChange}
|
|
ListEmptyComponent={
|
|
<View style={styles.empty}>
|
|
<Text style={styles.emptyText}>no messages yet</Text>
|
|
</View>
|
|
}
|
|
renderItem={({ item, index }) => renderChatItem(item, index, items)}
|
|
/>
|
|
|
|
<View style={styles.composer}>
|
|
<View style={styles.selectorRow}>
|
|
<View style={styles.modelSelector}>
|
|
<ModelSelector
|
|
models={models}
|
|
selected={selectedModel}
|
|
loading={modelLoading}
|
|
error={modelError}
|
|
disabled={streaming || modelLoading}
|
|
onSelect={(model) => void selectModel(model)}
|
|
/>
|
|
</View>
|
|
<ReasoningEffortButton
|
|
level={thinkingLevel}
|
|
disabled={streaming || modelLoading || availableThinkingLevels.length < 2}
|
|
onPress={() => void cycleReasoningEffort()}
|
|
/>
|
|
</View>
|
|
<View style={styles.inputRow}>
|
|
{streaming ? (
|
|
<Pressable
|
|
style={styles.abortButton}
|
|
onPress={() => activeRuntimeId && abortPrompt(activeRuntimeId)}
|
|
>
|
|
<Text style={styles.abortText}>Stop</Text>
|
|
</Pressable>
|
|
) : null}
|
|
<TextInput
|
|
value={draft}
|
|
onChangeText={setDraft}
|
|
placeholder="Message Pi…"
|
|
placeholderTextColor={colors.textMuted}
|
|
style={styles.input}
|
|
multiline
|
|
/>
|
|
<Pressable
|
|
style={[styles.sendButton, !canSend && styles.sendButtonDisabled]}
|
|
onPress={() => void send()}
|
|
disabled={!canSend}
|
|
>
|
|
<Text style={styles.sendText}>Send</Text>
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
</KeyboardAvoidingView>
|
|
);
|
|
}
|
|
|
|
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",
|
|
},
|
|
});
|