feat: add git branch/worktree setup, message queueing, file explorer improvements, and server restart
This commit is contained in:
@@ -4,13 +4,15 @@ import {
|
||||
Pressable,
|
||||
NativeSyntheticEvent,
|
||||
TextInputContentSizeChangeEventData,
|
||||
TextInputKeyPressEventData,
|
||||
Image,
|
||||
Platform,
|
||||
Text,
|
||||
ActivityIndicator,
|
||||
} from "react-native";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useState, useEffect, useRef, useLayoutEffect } from "react";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { Mic, ArrowUp, AudioLines, Square, Paperclip, X } from "lucide-react-native";
|
||||
import { Mic, ArrowUp, AudioLines, Square, Paperclip, X, Pencil } from "lucide-react-native";
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
@@ -32,18 +34,45 @@ interface AgentInputAreaProps {
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
const MIN_INPUT_HEIGHT = 40;
|
||||
const MIN_INPUT_HEIGHT = 32;
|
||||
const MAX_INPUT_HEIGHT = 160;
|
||||
const MAX_INPUT_WIDTH = 960;
|
||||
const BASE_VERTICAL_PADDING = (FOOTER_HEIGHT - MIN_INPUT_HEIGHT) / 2;
|
||||
// Android currently crashes inside ViewGroup.dispatchDraw when running Reanimated
|
||||
// entering/exiting animations (see react-native-reanimated#8422), so guard them.
|
||||
const SHOULD_DISABLE_ENTRY_EXIT_ANIMATIONS = Platform.OS === "android";
|
||||
const REALTIME_FADE_IN = SHOULD_DISABLE_ENTRY_EXIT_ANIMATIONS ? undefined : FadeIn.duration(250);
|
||||
const REALTIME_FADE_OUT = SHOULD_DISABLE_ENTRY_EXIT_ANIMATIONS ? undefined : FadeOut.duration(250);
|
||||
const IS_WEB = Platform.OS === "web";
|
||||
const SHOULD_DEBUG_INPUT_HEIGHT = IS_WEB;
|
||||
type WebTextInputKeyPressEvent = NativeSyntheticEvent<
|
||||
TextInputKeyPressEventData & {
|
||||
metaKey?: boolean;
|
||||
ctrlKey?: boolean;
|
||||
shiftKey?: boolean;
|
||||
}
|
||||
>;
|
||||
type TextAreaHandle = {
|
||||
scrollHeight?: number;
|
||||
style?: {
|
||||
height?: string;
|
||||
overflowY?: string;
|
||||
} & Record<string, unknown>;
|
||||
};
|
||||
|
||||
export function AgentInputArea({ agentId }: AgentInputAreaProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { ws, sendAgentMessage, sendAgentAudio, agents, cancelAgentRun } = useSession();
|
||||
const {
|
||||
ws,
|
||||
sendAgentMessage,
|
||||
sendAgentAudio,
|
||||
agents,
|
||||
cancelAgentRun,
|
||||
draftInputs,
|
||||
setDraftInputs,
|
||||
queuedMessages: queuedMessagesByAgent,
|
||||
setQueuedMessages: setQueuedMessagesByAgent,
|
||||
} = useSession();
|
||||
const { startRealtime, stopRealtime, isRealtimeMode } = useRealtime();
|
||||
|
||||
const [userInput, setUserInput] = useState("");
|
||||
@@ -54,8 +83,10 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
|
||||
const [recordingVolume, setRecordingVolume] = useState(0);
|
||||
const [recordingDuration, setRecordingDuration] = useState(0);
|
||||
const [transcribingRequestId, setTranscribingRequestId] = useState<string | null>(null);
|
||||
const [isCancellingAgent, setIsCancellingAgent] = useState(false);
|
||||
|
||||
const recordingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const textInputRef = useRef<TextInput | (TextInput & { getNativeRef?: () => unknown }) | null>(null);
|
||||
const overlayTransition = useSharedValue(0);
|
||||
const { pickImages } = useImageAttachmentPicker();
|
||||
|
||||
@@ -65,6 +96,13 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
|
||||
},
|
||||
});
|
||||
|
||||
const debugInputHeight = (label: string, payload: Record<string, unknown>) => {
|
||||
if (!SHOULD_DEBUG_INPUT_HEIGHT) {
|
||||
return;
|
||||
}
|
||||
console.log(`[AgentInput][InputHeight] ${label}`, payload);
|
||||
};
|
||||
|
||||
async function handleSendMessage() {
|
||||
if (!userInput.trim() || !ws.isConnected) return;
|
||||
|
||||
@@ -218,24 +256,108 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
|
||||
};
|
||||
}, []);
|
||||
|
||||
function handleContentSizeChange(
|
||||
event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>,
|
||||
) {
|
||||
const contentHeight = event.nativeEvent.contentSize.height;
|
||||
function isTextAreaLike(value: unknown): value is TextAreaHandle {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
const candidate = value as TextAreaHandle;
|
||||
return typeof candidate.scrollHeight === "number" && typeof candidate.style === "object";
|
||||
}
|
||||
|
||||
function getWebTextArea(): TextAreaHandle | null {
|
||||
if (!IS_WEB) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const node = textInputRef.current;
|
||||
if (!node) {
|
||||
debugInputHeight("missing-ref", {});
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isTextAreaLike(node)) {
|
||||
debugInputHeight("using-ref", {
|
||||
scrollHeight: node.scrollHeight,
|
||||
inlineHeight: node.style?.height,
|
||||
});
|
||||
return node;
|
||||
}
|
||||
|
||||
if (typeof (node as { getNativeRef?: () => unknown }).getNativeRef === "function") {
|
||||
const native = (node as { getNativeRef?: () => unknown }).getNativeRef?.();
|
||||
if (isTextAreaLike(native)) {
|
||||
debugInputHeight("using-native-ref", {
|
||||
scrollHeight: native.scrollHeight,
|
||||
inlineHeight: native.style?.height,
|
||||
});
|
||||
return native;
|
||||
}
|
||||
}
|
||||
|
||||
debugInputHeight("no-textarea-found", {});
|
||||
return null;
|
||||
}
|
||||
|
||||
function measureWebInputHeight(source: string): boolean {
|
||||
if (!IS_WEB) {
|
||||
return false;
|
||||
}
|
||||
const element = getWebTextArea();
|
||||
if (!element?.style || typeof element.scrollHeight !== "number") {
|
||||
debugInputHeight(`${source}-missing`, {});
|
||||
return false;
|
||||
}
|
||||
const previousHeight = element.style.height;
|
||||
element.style.height = "auto";
|
||||
const measuredHeight = element.scrollHeight;
|
||||
element.style.height = previousHeight ?? "";
|
||||
const bounded = Math.min(
|
||||
MAX_INPUT_HEIGHT,
|
||||
Math.max(MIN_INPUT_HEIGHT, measuredHeight),
|
||||
);
|
||||
debugInputHeight(source, {
|
||||
measuredHeight,
|
||||
bounded,
|
||||
scrollHeight: element.scrollHeight,
|
||||
inlineHeight: previousHeight,
|
||||
});
|
||||
setBoundedInputHeight(bounded);
|
||||
return true;
|
||||
}
|
||||
|
||||
function setBoundedInputHeight(nextHeight: number) {
|
||||
const boundedHeight = Math.min(
|
||||
MAX_INPUT_HEIGHT,
|
||||
Math.max(MIN_INPUT_HEIGHT, contentHeight),
|
||||
Math.max(MIN_INPUT_HEIGHT, nextHeight),
|
||||
);
|
||||
|
||||
setInputHeight((currentHeight) => {
|
||||
if (Math.abs(currentHeight - boundedHeight) < 1) {
|
||||
return currentHeight;
|
||||
}
|
||||
|
||||
debugInputHeight("set-state", {
|
||||
nextHeight,
|
||||
boundedHeight,
|
||||
previousHeight: currentHeight,
|
||||
});
|
||||
return boundedHeight;
|
||||
});
|
||||
}
|
||||
|
||||
function handleContentSizeChange(
|
||||
event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>,
|
||||
) {
|
||||
if (IS_WEB && measureWebInputHeight("web-measure")) {
|
||||
return;
|
||||
}
|
||||
|
||||
debugInputHeight("native-measure", {
|
||||
height: event.nativeEvent.contentSize.height,
|
||||
width: event.nativeEvent.contentSize.width,
|
||||
});
|
||||
setBoundedInputHeight(event.nativeEvent.contentSize.height);
|
||||
}
|
||||
|
||||
const agent = agents.get(agentId);
|
||||
const isAgentRunning = agent?.status === "running";
|
||||
const hasText = userInput.trim().length > 0;
|
||||
@@ -243,6 +365,73 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
|
||||
const hasSendableContent = hasText || hasImages;
|
||||
const shouldShowSendButton = !isAgentRunning && hasSendableContent;
|
||||
const shouldShowVoiceControls = !isAgentRunning && !hasSendableContent;
|
||||
const queuedMessages = queuedMessagesByAgent.get(agentId) ?? [];
|
||||
const shouldHandleDesktopSubmit = IS_WEB;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!IS_WEB) {
|
||||
return;
|
||||
}
|
||||
measureWebInputHeight("layout-effect");
|
||||
}, [userInput]);
|
||||
|
||||
function handleDesktopSubmitKeyPress(event: WebTextInputKeyPressEvent) {
|
||||
if (!shouldHandleDesktopSubmit) {
|
||||
return;
|
||||
}
|
||||
if (event.nativeEvent.key !== "Enter") {
|
||||
return;
|
||||
}
|
||||
const { metaKey, ctrlKey, shiftKey } = event.nativeEvent;
|
||||
if (shiftKey || metaKey || ctrlKey) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
if (!shouldShowSendButton || isProcessing || !ws.isConnected) {
|
||||
return;
|
||||
}
|
||||
void handleSendMessage();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAgentRunning || !ws.isConnected) {
|
||||
setIsCancellingAgent(false);
|
||||
}
|
||||
}, [isAgentRunning, ws.isConnected]);
|
||||
|
||||
// Hydrate draft only when switching agents
|
||||
useEffect(() => {
|
||||
const draft = draftInputs.get(agentId);
|
||||
if (!draft) {
|
||||
setUserInput("");
|
||||
setSelectedImages([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setUserInput(draft.text);
|
||||
setSelectedImages(draft.images);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [agentId]);
|
||||
|
||||
// Persist drafts into context with change detection to avoid render loops
|
||||
useEffect(() => {
|
||||
setDraftInputs((prev) => {
|
||||
const existing = prev.get(agentId);
|
||||
const isSameText = existing?.text === userInput;
|
||||
const existingImages = existing?.images ?? [];
|
||||
const isSameImages =
|
||||
existingImages.length === selectedImages.length &&
|
||||
existingImages.every((img, idx) => img.uri === selectedImages[idx]?.uri && img.mimeType === selectedImages[idx]?.mimeType);
|
||||
|
||||
if (isSameText && isSameImages) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, { text: userInput, images: selectedImages });
|
||||
return next;
|
||||
});
|
||||
}, [agentId, userInput, selectedImages, setDraftInputs]);
|
||||
|
||||
const overlayAnimatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
@@ -275,15 +464,59 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
|
||||
}
|
||||
|
||||
function handleCancelAgent() {
|
||||
if (!agent || agent.status !== "running") {
|
||||
if (!agent || agent.status !== "running" || isCancellingAgent) {
|
||||
return;
|
||||
}
|
||||
if (!ws.isConnected) {
|
||||
return;
|
||||
}
|
||||
setIsCancellingAgent(true);
|
||||
cancelAgentRun(agentId);
|
||||
}
|
||||
|
||||
function updateQueue(updater: (current: typeof queuedMessages) => typeof queuedMessages) {
|
||||
setQueuedMessagesByAgent((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, updater(prev.get(agentId) ?? []));
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function handleQueueCurrentInput() {
|
||||
if (!hasSendableContent) return;
|
||||
|
||||
const newItem = {
|
||||
id: generateMessageId(),
|
||||
text: userInput.trim(),
|
||||
images: selectedImages.length ? selectedImages : undefined,
|
||||
};
|
||||
|
||||
updateQueue((current) => [...current, newItem]);
|
||||
setUserInput("");
|
||||
setSelectedImages([]);
|
||||
setInputHeight(MIN_INPUT_HEIGHT);
|
||||
}
|
||||
|
||||
function handleEditQueuedMessage(id: string) {
|
||||
const item = queuedMessages.find((q) => q.id === id);
|
||||
if (!item) return;
|
||||
|
||||
updateQueue((current) => current.filter((q) => q.id !== id));
|
||||
setUserInput(item.text);
|
||||
setSelectedImages(item.images ?? []);
|
||||
}
|
||||
|
||||
async function handleSendQueuedNow(id: string) {
|
||||
const item = queuedMessages.find((q) => q.id === id);
|
||||
if (!item || !ws.isConnected) return;
|
||||
|
||||
updateQueue((current) => current.filter((q) => q.id !== id));
|
||||
|
||||
// Cancels current agent run before sending queued prompt
|
||||
handleCancelAgent();
|
||||
await sendAgentMessage(agentId, item.text, item.images?.map((img) => img.uri));
|
||||
}
|
||||
|
||||
const realtimeButton = (
|
||||
<Pressable
|
||||
onPress={handleRealtimePress}
|
||||
@@ -320,8 +553,33 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
|
||||
|
||||
{/* Input area */}
|
||||
<View style={styles.inputAreaContainer}>
|
||||
{/* Regular input controls */}
|
||||
<Animated.View style={[styles.inputContainer, inputAnimatedStyle]}>
|
||||
<View style={styles.inputAreaContent}>
|
||||
{/* Regular input controls */}
|
||||
<Animated.View style={[styles.inputContainer, inputAnimatedStyle]}>
|
||||
{/* Queue list */}
|
||||
{queuedMessages.length > 0 && (
|
||||
<View style={styles.queueContainer}>
|
||||
{queuedMessages.map((item) => (
|
||||
<View key={item.id} style={styles.queueItem}>
|
||||
<Text style={styles.queueText} numberOfLines={2} ellipsizeMode="tail">
|
||||
{item.text}
|
||||
</Text>
|
||||
<View style={styles.queueActions}>
|
||||
<Pressable onPress={() => handleEditQueuedMessage(item.id)} style={styles.queueActionButton}>
|
||||
<Pencil size={14} color={theme.colors.foreground} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => handleSendQueuedNow(item.id)}
|
||||
style={[styles.queueActionButton, styles.queueSendButton]}
|
||||
>
|
||||
<ArrowUp size={14} color={theme.colors.background} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Image preview pills */}
|
||||
{hasImages && (
|
||||
<View style={styles.imagePreviewContainer}>
|
||||
@@ -338,6 +596,7 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
|
||||
|
||||
{/* Full-width text input */}
|
||||
<TextInput
|
||||
ref={textInputRef}
|
||||
value={userInput}
|
||||
onChangeText={setUserInput}
|
||||
placeholder="Message agent..."
|
||||
@@ -350,6 +609,7 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
|
||||
scrollEnabled={inputHeight >= MAX_INPUT_HEIGHT}
|
||||
onContentSizeChange={handleContentSizeChange}
|
||||
editable={!isRecording && ws.isConnected}
|
||||
onKeyPress={shouldHandleDesktopSubmit ? handleDesktopSubmitKeyPress : undefined}
|
||||
/>
|
||||
|
||||
{/* Button row below input */}
|
||||
@@ -373,18 +633,33 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
|
||||
<View style={styles.rightButtonGroup}>
|
||||
{isAgentRunning ? (
|
||||
<>
|
||||
{hasSendableContent && (
|
||||
<Pressable
|
||||
onPress={handleQueueCurrentInput}
|
||||
disabled={!ws.isConnected}
|
||||
accessibilityLabel="Queue message while agent is running"
|
||||
accessibilityRole="button"
|
||||
style={[styles.queueButton, !ws.isConnected && styles.buttonDisabled]}
|
||||
>
|
||||
<ArrowUp size={20} color="white" />
|
||||
</Pressable>
|
||||
)}
|
||||
{realtimeButton}
|
||||
<Pressable
|
||||
onPress={handleCancelAgent}
|
||||
disabled={!ws.isConnected}
|
||||
accessibilityLabel="Stop agent"
|
||||
disabled={!ws.isConnected || isCancellingAgent}
|
||||
accessibilityLabel={isCancellingAgent ? "Canceling agent" : "Stop agent"}
|
||||
accessibilityRole="button"
|
||||
style={[
|
||||
styles.cancelButton,
|
||||
!ws.isConnected && styles.buttonDisabled,
|
||||
(!ws.isConnected || isCancellingAgent) && styles.buttonDisabled,
|
||||
]}
|
||||
>
|
||||
<Square size={18} color={theme.colors.background} fill={theme.colors.background} />
|
||||
{isCancellingAgent ? (
|
||||
<ActivityIndicator size="small" color="white" />
|
||||
) : (
|
||||
<Square size={18} color="white" fill="white" />
|
||||
)}
|
||||
</Pressable>
|
||||
</>
|
||||
) : shouldShowSendButton ? (
|
||||
@@ -420,17 +695,20 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
|
||||
{/* Voice note recording overlay */}
|
||||
<Animated.View style={[styles.overlayContainer, overlayAnimatedStyle]}>
|
||||
<VoiceNoteRecordingOverlay
|
||||
volume={recordingVolume}
|
||||
duration={recordingDuration}
|
||||
onCancel={handleCancelRecording}
|
||||
onSend={handleSendRecording}
|
||||
isTranscribing={transcribingRequestId !== null}
|
||||
/>
|
||||
<View style={styles.inputAreaContent}>
|
||||
<VoiceNoteRecordingOverlay
|
||||
volume={recordingVolume}
|
||||
duration={recordingDuration}
|
||||
onCancel={handleCancelRecording}
|
||||
onSend={handleSendRecording}
|
||||
isTranscribing={transcribingRequestId !== null}
|
||||
/>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</View>
|
||||
@@ -451,6 +729,12 @@ const styles = StyleSheet.create((theme) => ({
|
||||
inputAreaContainer: {
|
||||
position: "relative",
|
||||
minHeight: FOOTER_HEIGHT,
|
||||
alignItems: "center",
|
||||
width: "100%",
|
||||
},
|
||||
inputAreaContent: {
|
||||
width: "100%",
|
||||
maxWidth: MAX_INPUT_WIDTH,
|
||||
},
|
||||
inputContainer: {
|
||||
flexDirection: "column",
|
||||
@@ -458,6 +742,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingVertical: theme.spacing[3],
|
||||
gap: theme.spacing[3],
|
||||
minHeight: FOOTER_HEIGHT,
|
||||
width: "100%",
|
||||
},
|
||||
overlayContainer: {
|
||||
position: "absolute",
|
||||
@@ -465,6 +750,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: FOOTER_HEIGHT,
|
||||
alignItems: "center",
|
||||
},
|
||||
imagePreviewContainer: {
|
||||
flexDirection: "row",
|
||||
@@ -499,6 +785,13 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.lg,
|
||||
lineHeight: theme.fontSize.lg * 1.4,
|
||||
...(IS_WEB
|
||||
? {
|
||||
outlineStyle: "none" as const,
|
||||
outlineWidth: 0,
|
||||
outlineColor: "transparent",
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
buttonRow: {
|
||||
flexDirection: "row",
|
||||
@@ -559,7 +852,52 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
queueButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.palette.blue[600],
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
buttonDisabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
queueContainer: {
|
||||
flexDirection: "column",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
queueItem: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
backgroundColor: theme.colors.card,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
queueText: {
|
||||
flex: 1,
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
queueActions: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
queueActionButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: theme.colors.muted,
|
||||
},
|
||||
queueSendButton: {
|
||||
backgroundColor: theme.colors.palette.blue[600],
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -18,10 +18,12 @@ export function AgentList({ agents }: AgentListProps) {
|
||||
const [actionAgent, setActionAgent] = useState<Agent | null>(null);
|
||||
const isActionSheetVisible = actionAgent !== null;
|
||||
|
||||
// Sort agents by lastActivityAt (most recent first)
|
||||
// Sort agents by the persisted timestamp of the last user message, falling back to last activity
|
||||
const agentArray = useMemo(() => {
|
||||
return Array.from(agents.values()).sort((a, b) => {
|
||||
return b.lastActivityAt.getTime() - a.lastActivityAt.getTime();
|
||||
const sortA = (a.lastUserMessageAt ?? a.lastActivityAt).getTime();
|
||||
const sortB = (b.lastUserMessageAt ?? b.lastActivityAt).getTime();
|
||||
return sortB - sortA;
|
||||
});
|
||||
}, [agents]);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState, useCallback } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -39,6 +40,7 @@ import {
|
||||
extractReadEntries,
|
||||
} from "@/utils/tool-call-parsers";
|
||||
|
||||
const MAX_CHAT_WIDTH = 960;
|
||||
export interface AgentStreamViewProps {
|
||||
agentId: string;
|
||||
agent: Agent;
|
||||
@@ -219,30 +221,31 @@ export function AgentStreamView({
|
||||
}
|
||||
|
||||
function renderStreamItem({ item }: ListRenderItemInfo<StreamItem>) {
|
||||
let content: React.ReactNode = null;
|
||||
|
||||
switch (item.kind) {
|
||||
case "user_message":
|
||||
return (
|
||||
content = (
|
||||
<UserMessage
|
||||
message={item.text}
|
||||
timestamp={item.timestamp.getTime()}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
||||
case "assistant_message":
|
||||
return (
|
||||
content = (
|
||||
<AssistantMessage
|
||||
message={item.text}
|
||||
timestamp={item.timestamp.getTime()}
|
||||
onInlinePathPress={handleInlinePathPress}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
||||
case "thought":
|
||||
return (
|
||||
<AgentThoughtMessage
|
||||
message={item.text}
|
||||
/>
|
||||
);
|
||||
content = <AgentThoughtMessage message={item.text} />;
|
||||
break;
|
||||
|
||||
case "tool_call": {
|
||||
const { payload } = item;
|
||||
@@ -250,7 +253,7 @@ export function AgentStreamView({
|
||||
if (payload.source === "agent") {
|
||||
const data = payload.data;
|
||||
const toolLabel = data.displayName ?? `${data.server}/${data.tool}`;
|
||||
return (
|
||||
content = (
|
||||
<ToolCall
|
||||
toolName={toolLabel}
|
||||
kind={data.kind}
|
||||
@@ -264,22 +267,23 @@ export function AgentStreamView({
|
||||
onOpenDetails={() => handleOpenToolCallDetails({ payload })}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
const data = payload.data;
|
||||
content = (
|
||||
<ToolCall
|
||||
toolName={data.toolName}
|
||||
args={data.arguments}
|
||||
result={data.result}
|
||||
status={data.status}
|
||||
onOpenDetails={() => handleOpenToolCallDetails({ payload })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const data = payload.data;
|
||||
return (
|
||||
<ToolCall
|
||||
toolName={data.toolName}
|
||||
args={data.arguments}
|
||||
result={data.result}
|
||||
status={data.status}
|
||||
onOpenDetails={() => handleOpenToolCallDetails({ payload })}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case "activity_log":
|
||||
return (
|
||||
content = (
|
||||
<ActivityLog
|
||||
type={item.activityType}
|
||||
message={item.message}
|
||||
@@ -287,19 +291,27 @@ export function AgentStreamView({
|
||||
metadata={item.metadata}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
||||
case "todo_list":
|
||||
return (
|
||||
content = (
|
||||
<TodoListCard
|
||||
provider={item.provider}
|
||||
timestamp={item.timestamp.getTime()}
|
||||
items={item.items}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
||||
default:
|
||||
return null;
|
||||
content = null;
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <View style={stylesheet.streamItemWrapper}>{content}</View>;
|
||||
}
|
||||
|
||||
const pendingPermissionItems = useMemo(
|
||||
@@ -318,24 +330,26 @@ export function AgentStreamView({
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={stylesheet.listHeaderContent}>
|
||||
{pendingPermissionItems.length > 0 ? (
|
||||
<View style={stylesheet.permissionsContainer}>
|
||||
{pendingPermissionItems.map((permission) => (
|
||||
<PermissionRequestCard
|
||||
key={permission.key}
|
||||
permission={permission}
|
||||
onResponse={onPermissionResponse}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
<View style={stylesheet.contentWrapper}>
|
||||
<View style={stylesheet.listHeaderContent}>
|
||||
{pendingPermissionItems.length > 0 ? (
|
||||
<View style={stylesheet.permissionsContainer}>
|
||||
{pendingPermissionItems.map((permission) => (
|
||||
<PermissionRequestCard
|
||||
key={permission.key}
|
||||
permission={permission}
|
||||
onResponse={onPermissionResponse}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{showWorkingIndicator ? (
|
||||
<View style={stylesheet.workingIndicatorWrapper}>
|
||||
<WorkingIndicator />
|
||||
</View>
|
||||
) : null}
|
||||
{showWorkingIndicator ? (
|
||||
<View style={stylesheet.workingIndicatorWrapper}>
|
||||
<WorkingIndicator />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}, [onPermissionResponse, pendingPermissionItems, showWorkingIndicator]);
|
||||
@@ -371,7 +385,7 @@ export function AgentStreamView({
|
||||
onMomentumScrollEnd={handleScrollEnd}
|
||||
scrollEventThrottle={16}
|
||||
ListEmptyComponent={
|
||||
<View style={stylesheet.emptyState}>
|
||||
<View style={[stylesheet.emptyState, stylesheet.contentWrapper]}>
|
||||
<Text style={stylesheet.emptyStateText}>
|
||||
Start chatting with this agent...
|
||||
</Text>
|
||||
@@ -395,15 +409,17 @@ export function AgentStreamView({
|
||||
entering={scrollIndicatorFadeIn}
|
||||
exiting={scrollIndicatorFadeOut}
|
||||
>
|
||||
<Pressable
|
||||
style={stylesheet.scrollToBottomButton}
|
||||
onPress={scrollToBottom}
|
||||
>
|
||||
<ChevronDown
|
||||
size={24}
|
||||
color={stylesheet.scrollToBottomIcon.color}
|
||||
/>
|
||||
</Pressable>
|
||||
<View style={stylesheet.scrollToBottomInner}>
|
||||
<Pressable
|
||||
style={stylesheet.scrollToBottomButton}
|
||||
onPress={scrollToBottom}
|
||||
>
|
||||
<ChevronDown
|
||||
size={24}
|
||||
color={stylesheet.scrollToBottomIcon.color}
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
@@ -462,16 +478,18 @@ function WorkingIndicator() {
|
||||
const dotOne = useSharedValue(0);
|
||||
const dotTwo = useSharedValue(0);
|
||||
const dotThree = useSharedValue(0);
|
||||
const bounceDuration = 600;
|
||||
const bounceDelayOffset = 160;
|
||||
|
||||
useEffect(() => {
|
||||
const sharedValues = [dotOne, dotTwo, dotThree];
|
||||
sharedValues.forEach((value, index) => {
|
||||
value.value = withDelay(
|
||||
index * 120,
|
||||
index * bounceDelayOffset,
|
||||
withRepeat(
|
||||
withSequence(
|
||||
withTiming(1, { duration: 320 }),
|
||||
withTiming(0, { duration: 320 })
|
||||
withTiming(1, { duration: bounceDuration }),
|
||||
withTiming(0, { duration: bounceDuration })
|
||||
),
|
||||
-1,
|
||||
true
|
||||
@@ -487,19 +505,20 @@ function WorkingIndicator() {
|
||||
};
|
||||
}, [dotOne, dotTwo, dotThree]);
|
||||
|
||||
const translateDistance = -4;
|
||||
const dotOneStyle = useAnimatedStyle(() => ({
|
||||
opacity: 0.3 + dotOne.value * 0.7,
|
||||
transform: [{ translateY: dotOne.value * -4 }],
|
||||
transform: [{ translateY: dotOne.value * translateDistance }],
|
||||
}));
|
||||
|
||||
const dotTwoStyle = useAnimatedStyle(() => ({
|
||||
opacity: 0.3 + dotTwo.value * 0.7,
|
||||
transform: [{ translateY: dotTwo.value * -4 }],
|
||||
transform: [{ translateY: dotTwo.value * translateDistance }],
|
||||
}));
|
||||
|
||||
const dotThreeStyle = useAnimatedStyle(() => ({
|
||||
opacity: 0.3 + dotThree.value * 0.7,
|
||||
transform: [{ translateY: dotThree.value * -4 }],
|
||||
transform: [{ translateY: dotThree.value * translateDistance }],
|
||||
}));
|
||||
|
||||
return (
|
||||
@@ -566,8 +585,13 @@ function PermissionRequestCard({
|
||||
() => ({
|
||||
body: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: 20,
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: 22,
|
||||
},
|
||||
text: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: 22,
|
||||
},
|
||||
paragraph: {
|
||||
marginBottom: theme.spacing[1],
|
||||
@@ -823,10 +847,20 @@ const stylesheet = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.background,
|
||||
},
|
||||
contentWrapper: {
|
||||
width: "100%",
|
||||
maxWidth: MAX_CHAT_WIDTH,
|
||||
alignSelf: "center",
|
||||
},
|
||||
list: {
|
||||
flex: 1,
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
},
|
||||
streamItemWrapper: {
|
||||
width: "100%",
|
||||
maxWidth: MAX_CHAT_WIDTH,
|
||||
alignSelf: "center",
|
||||
},
|
||||
emptyState: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
@@ -840,18 +874,19 @@ const stylesheet = StyleSheet.create((theme) => ({
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
workingIndicatorWrapper: {
|
||||
alignItems: "center",
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
workingIndicatorBubble: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: 0,
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingLeft: theme.spacing[1],
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.card,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: "transparent",
|
||||
borderWidth: 0,
|
||||
alignSelf: "flex-start",
|
||||
},
|
||||
workingIndicatorText: {
|
||||
color: theme.colors.mutedForeground,
|
||||
@@ -886,6 +921,12 @@ const stylesheet = StyleSheet.create((theme) => ({
|
||||
alignItems: "center",
|
||||
pointerEvents: "box-none",
|
||||
},
|
||||
scrollToBottomInner: {
|
||||
width: "100%",
|
||||
maxWidth: MAX_CHAT_WIDTH,
|
||||
alignSelf: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
scrollToBottomButton: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -77,6 +77,9 @@ export function GlobalFooter() {
|
||||
? FOOTER_HEIGHT * 2 + insets.bottom
|
||||
: FOOTER_HEIGHT + insets.bottom;
|
||||
|
||||
const iconSize = 24;
|
||||
const iconStyle = { width: iconSize, height: iconSize };
|
||||
|
||||
return (
|
||||
<>
|
||||
<Animated.View
|
||||
@@ -110,7 +113,13 @@ export function GlobalFooter() {
|
||||
pressed && styles.buttonPressed,
|
||||
]}
|
||||
>
|
||||
<Users size={20} color={theme.colors.foreground} />
|
||||
<View style={styles.footerIconWrapper}>
|
||||
<Users
|
||||
size={iconSize}
|
||||
color={theme.colors.foreground}
|
||||
style={iconStyle}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.footerButtonText}>Agents</Text>
|
||||
</Pressable>
|
||||
|
||||
@@ -124,7 +133,13 @@ export function GlobalFooter() {
|
||||
pressed && styles.buttonPressed,
|
||||
]}
|
||||
>
|
||||
<Plus size={20} color={theme.colors.foreground} />
|
||||
<View style={styles.footerIconWrapper}>
|
||||
<Plus
|
||||
size={iconSize}
|
||||
color={theme.colors.foreground}
|
||||
style={iconStyle}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.footerButtonText}>New Agent</Text>
|
||||
</Pressable>
|
||||
|
||||
@@ -137,7 +152,13 @@ export function GlobalFooter() {
|
||||
pressed && !ws.isConnected && styles.buttonPressed,
|
||||
]}
|
||||
>
|
||||
<AudioLines size={20} color={theme.colors.foreground} />
|
||||
<View style={styles.footerIconWrapper}>
|
||||
<AudioLines
|
||||
size={iconSize}
|
||||
color={theme.colors.foreground}
|
||||
style={iconStyle}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.footerButtonText}>Realtime</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
@@ -180,6 +201,12 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingVertical: theme.spacing[3],
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
footerIconWrapper: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
footerButtonText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.xs,
|
||||
|
||||
@@ -8,9 +8,10 @@ import { ScreenHeader } from "./screen-header";
|
||||
interface BackHeaderProps {
|
||||
title?: string;
|
||||
rightContent?: ReactNode;
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
export function BackHeader({ title, rightContent }: BackHeaderProps) {
|
||||
export function BackHeader({ title, rightContent, onBack }: BackHeaderProps) {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
return (
|
||||
@@ -18,7 +19,7 @@ export function BackHeader({ title, rightContent }: BackHeaderProps) {
|
||||
left={
|
||||
<>
|
||||
<Pressable
|
||||
onPress={() => router.back()}
|
||||
onPress={onBack ?? (() => router.back())}
|
||||
style={styles.backButton}
|
||||
>
|
||||
<ArrowLeft size={20} color={theme.colors.foreground} />
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
Brain,
|
||||
Search,
|
||||
} from "lucide-react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { baseColors, theme } from "@/styles/theme";
|
||||
import { Colors } from "@/constants/theme";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
@@ -166,6 +166,11 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: 24,
|
||||
},
|
||||
markdownText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: 22,
|
||||
},
|
||||
markdownParagraph: {
|
||||
marginTop: 0,
|
||||
marginBottom: theme.spacing[2],
|
||||
@@ -241,22 +246,6 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const markdownStyles = {
|
||||
body: assistantMessageStylesheet.markdownBody,
|
||||
paragraph: assistantMessageStylesheet.markdownParagraph,
|
||||
strong: assistantMessageStylesheet.markdownStrong,
|
||||
em: assistantMessageStylesheet.markdownEm,
|
||||
code_inline: assistantMessageStylesheet.markdownCodeInline,
|
||||
code_block: assistantMessageStylesheet.markdownCodeBlock,
|
||||
fence: assistantMessageStylesheet.markdownFence,
|
||||
link: assistantMessageStylesheet.markdownLink,
|
||||
bullet_list: assistantMessageStylesheet.markdownList,
|
||||
ordered_list: assistantMessageStylesheet.markdownList,
|
||||
list_item: assistantMessageStylesheet.markdownListItem,
|
||||
blockquote: assistantMessageStylesheet.markdownBlockquote,
|
||||
blockquote_text: assistantMessageStylesheet.markdownBlockquoteText,
|
||||
};
|
||||
|
||||
function isLikelyPathToken(value: string): boolean {
|
||||
if (!value || value.length > 300) {
|
||||
return false;
|
||||
@@ -347,9 +336,87 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
isStreaming = false,
|
||||
onInlinePathPress,
|
||||
}: AssistantMessageProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const fadeAnim = useRef(new Animated.Value(0.3)).current;
|
||||
const lastPathRef = useRef<string | null>(null);
|
||||
|
||||
const markdownStyles = useMemo(
|
||||
() => ({
|
||||
body: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: 24,
|
||||
},
|
||||
text: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
paragraph: {
|
||||
marginTop: 0,
|
||||
marginBottom: theme.spacing[2],
|
||||
},
|
||||
strong: {
|
||||
fontWeight: theme.fontWeight.bold,
|
||||
},
|
||||
em: {
|
||||
fontStyle: "italic" as const,
|
||||
},
|
||||
code_inline: {
|
||||
backgroundColor: theme.colors.secondary,
|
||||
color: theme.colors.secondaryForeground,
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: 2,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
fontFamily: "monospace",
|
||||
fontSize: 13,
|
||||
},
|
||||
code_block: {
|
||||
backgroundColor: theme.colors.secondary,
|
||||
color: theme.colors.secondaryForeground,
|
||||
padding: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
fontFamily: "monospace",
|
||||
fontSize: 13,
|
||||
},
|
||||
fence: {
|
||||
backgroundColor: theme.colors.secondary,
|
||||
borderColor: theme.colors.border,
|
||||
color: theme.colors.secondaryForeground,
|
||||
padding: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
marginVertical: theme.spacing[2],
|
||||
fontFamily: "monospace",
|
||||
fontSize: 13,
|
||||
},
|
||||
link: {
|
||||
color: theme.colors.primary,
|
||||
textDecorationLine: "underline" as const,
|
||||
},
|
||||
bullet_list: {
|
||||
marginBottom: theme.spacing[2],
|
||||
},
|
||||
ordered_list: {
|
||||
marginBottom: theme.spacing[2],
|
||||
},
|
||||
list_item: {
|
||||
marginBottom: theme.spacing[1],
|
||||
},
|
||||
blockquote: {
|
||||
backgroundColor: theme.colors.secondary,
|
||||
borderLeftWidth: 4,
|
||||
borderLeftColor: theme.colors.primary,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
marginVertical: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
},
|
||||
blockquote_text: {
|
||||
color: theme.colors.foreground,
|
||||
fontStyle: "italic" as const,
|
||||
},
|
||||
}),
|
||||
[theme]
|
||||
);
|
||||
|
||||
const markdownRules = useMemo(() => {
|
||||
if (!onInlinePathPress) {
|
||||
return undefined;
|
||||
|
||||
Reference in New Issue
Block a user