From 925752c8a3671c0764da4f933bbba661c291fd65 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 8 Feb 2026 17:50:43 +0700 Subject: [PATCH] Update files --- .../app/src/components/agent-input-area.tsx | 57 +++++-- .../app/src/components/agent-status-bar.tsx | 54 +++++-- packages/app/src/components/message-input.tsx | 75 ++++++---- packages/app/src/components/message.tsx | 141 ++++++++++++------ packages/app/src/contexts/session-context.tsx | 39 +++-- packages/app/src/types/stream.ts | 32 +++- 6 files changed, 289 insertions(+), 109 deletions(-) diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index 95258af1e..5f86207c2 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -13,7 +13,7 @@ import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useIsFocused } from "@react-navigation/native"; import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout"; -import { generateMessageId } from "@/types/stream"; +import { generateMessageId, type StreamItem } from "@/types/stream"; import { AgentStatusBar } from "./agent-status-bar"; import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker"; import { useSessionStore } from "@/stores/session-store"; @@ -94,6 +94,7 @@ export function AgentInputArea({ const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages); const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail); + const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead); const [internalInput, setInternalInput] = useState(""); const userInput = value ?? internalInput; @@ -184,18 +185,32 @@ export function AgentInputArea({ } const messageId = generateMessageId(); - setAgentStreamTail(serverId, (prev) => { - const currentStream = prev.get(agentId) || []; - const nextItem: any = { - kind: "user_message", - id: messageId, - text, - timestamp: new Date(), - }; - const updated = new Map(prev); - updated.set(agentId, [...currentStream, nextItem]); - return updated; - }); + const userMessage: StreamItem = { + kind: "user_message", + id: messageId, + text, + timestamp: new Date(), + }; + + // Append to head if streaming (keeps the user message with the current + // turn so late text_deltas still find the existing assistant_message). + // Otherwise append to tail. + const currentHead = useSessionStore.getState().sessions[serverId]?.agentStreamHead?.get(agentId); + if (currentHead && currentHead.length > 0) { + setAgentStreamHead(serverId, (prev) => { + const head = prev.get(agentId) || []; + const updated = new Map(prev); + updated.set(agentId, [...head, userMessage]); + return updated; + }); + } else { + setAgentStreamTail(serverId, (prev) => { + const currentStream = prev.get(agentId) || []; + const updated = new Map(prev); + updated.set(agentId, [...currentStream, userMessage]); + return updated; + }); + } const imagesData = await encodeImages(images); await client.sendAgentMessage(agentId, text, { @@ -203,7 +218,7 @@ export function AgentInputArea({ ...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}), }); }; - }, [client, serverId, setAgentStreamTail]); + }, [client, serverId, setAgentStreamTail, setAgentStreamHead]); useEffect(() => { onSubmitMessageRef.current = onSubmitMessage; @@ -211,6 +226,15 @@ export function AgentInputArea({ const isAgentRunning = agent?.status === "running"; + const prevIsAgentRunningRef = useRef(isAgentRunning); + useEffect(() => { + const wasRunning = prevIsAgentRunningRef.current; + prevIsAgentRunningRef.current = isAgentRunning; + if (!wasRunning && isAgentRunning && isProcessing) { + setIsProcessing(false); + } + }, [isAgentRunning, isProcessing]); + const updateQueue = useCallback( (updater: (current: QueuedMessage[]) => QueuedMessage[]) => { setQueuedMessages(serverId, (prev: Map) => { @@ -296,7 +320,6 @@ export function AgentInputArea({ setSendError( error instanceof Error ? error.message : "Failed to send message" ); - } finally { setIsProcessing(false); } } @@ -539,7 +562,9 @@ export function AgentInputArea({ ] ); - const cancelButton = isAgentRunning ? ( + const hasSendableContent = userInput.trim().length > 0 || selectedImages.length > 0; + + const cancelButton = isAgentRunning && !hasSendableContent ? ( m.id === agent.currentModeId)?.label || + agent.currentModeId || + "default"; + return ( - - {/* Agent Mode Badge */} - {agent.availableModes && agent.availableModes.length > 0 && ( + + {/* Agent Mode Badge (desktop only — on mobile, mode is in the preferences sheet) */} + {IS_WEB && agent.availableModes && agent.availableModes.length > 0 && ( [ @@ -234,7 +239,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) { accessibilityLabel="Agent preferences" testID="agent-preferences-button" > - + setPrefsOpen(false)} testID="agent-preferences-sheet" > + {agent.availableModes && agent.availableModes.length > 0 && ( + + + [ + styles.sheetSelect, + pressed && styles.sheetSelectPressed, + ]} + accessibilityRole="button" + accessibilityLabel="Select agent mode" + testID="agent-preferences-mode" + > + {displayMode} + + + + {agent.availableModes.map((mode) => { + const isActive = mode.id === agent.currentModeId; + return ( + handleModeChange(mode.id)} + > + {mode.label} + + ); + })} + + + + )} + ({ flexDirection: "row", alignItems: "center", gap: theme.spacing[1], - marginBottom: -theme.spacing[1], }, modeBadge: { flexDirection: "row", @@ -358,12 +395,11 @@ const styles = StyleSheet.create((theme) => ({ fontWeight: theme.fontWeight.normal, }, prefsButton: { - width: 32, - height: 32, - borderRadius: theme.borderRadius["2xl"], + width: 34, + height: 34, + borderRadius: theme.borderRadius.full, alignItems: "center", justifyContent: "center", - backgroundColor: theme.colors.surface2, }, prefsButtonPressed: { backgroundColor: theme.colors.surface0, diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index 288785a3a..5c1702034 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -19,7 +19,7 @@ import { forwardRef, } from "react"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { Mic, MicOff, ArrowUp, Paperclip, X, Square } from "lucide-react-native"; +import { Mic, MicOff, ArrowUp, Paperclip, Plus, X, Square } from "lucide-react-native"; import Animated, { useSharedValue, useAnimatedStyle, @@ -367,17 +367,26 @@ export const MessageInput = forwardRef( const payload = { text: trimmed, images: images.length > 0 ? images : undefined, + forceSend: isAgentRunning || undefined, }; - if (isAgentRunning && onQueue) { - onQueue(payload); - onChangeText(""); - } else { - onSubmit(payload); - } - // Reset input height + onSubmit(payload); inputHeightRef.current = MIN_INPUT_HEIGHT; setInputHeight(MIN_INPUT_HEIGHT); - }, [value, images, onSubmit, onChangeText, isAgentRunning, onQueue]); + }, [value, images, onSubmit, isAgentRunning]); + + const handleQueueMessage = useCallback(() => { + if (!onQueue) return; + const trimmed = value.trim(); + if (!trimmed && images.length === 0) return; + const payload = { + text: trimmed, + images: images.length > 0 ? images : undefined, + }; + onQueue(payload); + onChangeText(""); + inputHeightRef.current = MIN_INPUT_HEIGHT; + setInputHeight(MIN_INPUT_HEIGHT); + }, [value, images, onQueue, onChangeText]); // Web input height measurement function isTextAreaLike(v: unknown): v is TextAreaHandle { @@ -509,24 +518,15 @@ export const MessageInput = forwardRef( // Shift+Enter: add newline (default behavior, don't intercept) if (shiftKey) return; - // Cmd+Enter (Mac) or Ctrl+Enter (Windows/Linux): force send immediately - if (metaKey || ctrlKey) { + // Cmd+Enter (Mac) or Ctrl+Enter (Windows/Linux): queue when agent is running + if ((metaKey || ctrlKey) && isAgentRunning && onQueue) { if (isSubmitDisabled || isSubmitLoading || disabled) return; event.preventDefault(); - const trimmed = value.trim(); - if (!trimmed && images.length === 0) return; - const payload = { - text: trimmed, - images: images.length > 0 ? images : undefined, - forceSend: true, - }; - onSubmit(payload); - inputHeightRef.current = MIN_INPUT_HEIGHT; - setInputHeight(MIN_INPUT_HEIGHT); + handleQueueMessage(); return; } - // Plain Enter: normal send (respects queue behavior) + // Enter: send (interrupts agent if running) if (isSubmitDisabled || isSubmitLoading || disabled) return; event.preventDefault(); handleSendMessage(); @@ -658,6 +658,20 @@ export const MessageInput = forwardRef( )} {rightContent} + {shouldShowSendButton && isAgentRunning && onQueue && ( + + + + )} {shouldShowSendButton && ( ( isSubmitLoading || disabled } + accessibilityLabel={isAgentRunning ? "Send and interrupt" : "Send message"} + accessibilityRole="button" style={[ styles.sendButton, (!isConnected || @@ -805,7 +821,7 @@ const styles = StyleSheet.create(((theme: any) => ({ }, leftButtonGroup: { flexDirection: "row", - alignItems: "flex-end", + alignItems: "center", gap: theme.spacing[2], }, rightButtonGroup: { @@ -814,8 +830,9 @@ const styles = StyleSheet.create(((theme: any) => ({ gap: theme.spacing[2], }, attachButton: { - width: 20, - height: 20, + width: 34, + height: 34, + borderRadius: theme.borderRadius.full, alignItems: "center", justifyContent: "center", }, @@ -829,6 +846,14 @@ const styles = StyleSheet.create(((theme: any) => ({ voiceButtonRecording: { backgroundColor: theme.colors.destructive, }, + queueButton: { + width: 34, + height: 34, + borderRadius: theme.borderRadius.full, + backgroundColor: theme.colors.surface1, + alignItems: "center", + justifyContent: "center", + }, sendButton: { width: 34, height: 34, diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 4333e542c..77c39b181 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -3,6 +3,7 @@ import { Text, Pressable, ActivityIndicator, + LayoutChangeEvent, StyleProp, ViewStyle, Platform, @@ -26,6 +27,12 @@ import Animated, { Easing, cancelAnimation, } from "react-native-reanimated"; +import Svg, { + Defs, + LinearGradient, + Stop, + Rect, +} from "react-native-svg"; import Markdown, { MarkdownIt } from "react-native-markdown-display"; import * as Linking from "expo-linking"; import { @@ -36,7 +43,6 @@ import { FileText, ChevronRight, ChevronDown, - Loader2, Check, CheckSquare, X, @@ -348,6 +354,7 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({ backgroundColor: theme.colors.surface1, paddingHorizontal: theme.spacing[2], paddingVertical: theme.spacing[1], + overflow: "hidden", }, pressablePressed: { opacity: 0.9, @@ -356,6 +363,12 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({ flexDirection: "row", alignItems: "center", }, + labelRow: { + flex: 1, + flexDirection: "row", + alignItems: "center", + overflow: "hidden", + }, iconBadge: { width: 22, height: 22, @@ -403,6 +416,12 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({ borderBottomLeftRadius: 0, borderBottomRightRadius: 0, }, + shimmerOverlay: { + position: "absolute", + top: 0, + bottom: 0, + width: 100, + }, })); export const AssistantMessage = memo(function AssistantMessage({ @@ -987,23 +1006,38 @@ const ExpandableBadge = memo(function ExpandableBadge({ const hasDetails = Boolean(renderDetails); const detailContent = hasDetails && isExpanded ? renderDetails?.() : null; - const rotation = useSharedValue(0); + const [badgeWidth, setBadgeWidth] = useState(0); + const handleLayout = useCallback((e: LayoutChangeEvent) => { + setBadgeWidth(e.nativeEvent.layout.width); + }, []); + + const shimmer = useSharedValue(-1); useEffect(() => { if (isLoading) { - rotation.value = 0; - rotation.value = withRepeat( - withTiming(360, { duration: 1400, easing: Easing.linear }), + shimmer.value = -1; + shimmer.value = withRepeat( + withTiming(1, { duration: 2400, easing: Easing.bezier(0.4, 0, 0.6, 1) }), -1 ); } else { - cancelAnimation(rotation); + cancelAnimation(shimmer); + shimmer.value = -1; } }, [isLoading]); - const spinStyle = useAnimatedStyle(() => ({ - transform: [{ rotate: `${rotation.value}deg` }], - })); + const shimmerBandWidth = 100; + const shimmerStyle = useAnimatedStyle(() => { + const travel = badgeWidth + shimmerBandWidth; + return { + transform: [ + { + translateX: + -shimmerBandWidth + ((shimmer.value + 1) / 2) * travel, + }, + ], + }; + }); const IconComponent = icon; const iconColor = isError @@ -1011,13 +1045,7 @@ const ExpandableBadge = memo(function ExpandableBadge({ : theme.colors.mutedForeground; let iconNode: ReactNode = null; - if (isLoading) { - iconNode = ( - - - - ); - } else if (isError) { + if (isError) { iconNode = ; } else if (IconComponent) { iconNode = ; @@ -1037,6 +1065,7 @@ const ExpandableBadge = memo(function ExpandableBadge({ > {({ hovered }) => ( - - {iconNode} - - {label} - - {secondaryLabel ? ( - - {secondaryLabel} - - ) : ( - - )} - {hasDetails && hovered ? ( - - ) : null} - + <> + + {iconNode} + + + {label} + + {secondaryLabel ? ( + + {secondaryLabel} + + ) : ( + + )} + {isLoading && badgeWidth > 0 ? ( + + + + + + + + + + + + + + ) : null} + + {hasDetails && hovered ? ( + + ) : null} + + )} {detailContent ? ( diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index eeb50cfa8..7a91fbe35 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -1453,19 +1453,32 @@ export function SessionProvider({ images?: Array<{ uri: string; mimeType?: string }> ) => { const messageId = generateMessageId(); + const userMessage: StreamItem = { + kind: "user_message", + id: messageId, + text: message, + timestamp: new Date(), + }; - setAgentStreamTail(serverId, (prev) => { - const currentStream = prev.get(agentId) || []; - const nextItem: any = { - kind: "user_message", - id: messageId, - text: message, - timestamp: new Date(), - }; - const updated = new Map(prev); - updated.set(agentId, [...currentStream, nextItem]); - return updated; - }); + // Append to head if streaming (keeps the user message with the current + // turn so late text_deltas still find the existing assistant_message). + // Otherwise append to tail. + const currentHead = useSessionStore.getState().sessions[serverId]?.agentStreamHead?.get(agentId); + if (currentHead && currentHead.length > 0) { + setAgentStreamHead(serverId, (prev) => { + const head = prev.get(agentId) || []; + const updated = new Map(prev); + updated.set(agentId, [...head, userMessage]); + return updated; + }); + } else { + setAgentStreamTail(serverId, (prev) => { + const currentStream = prev.get(agentId) || []; + const updated = new Map(prev); + updated.set(agentId, [...currentStream, userMessage]); + return updated; + }); + } const imagesData = await encodeImages(images); if (!client) { @@ -1483,7 +1496,7 @@ export function SessionProvider({ console.error("[Session] Failed to send agent message:", error); }); }, - [encodeImages, serverId, client, setAgentStreamTail] + [encodeImages, serverId, client, setAgentStreamTail, setAgentStreamHead] ); // Keep the ref updated so the agent_update handler can call it diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index 6e53cc1cd..345e11ac1 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -235,6 +235,18 @@ function appendAssistantMessage( return [...state.slice(0, -1), updated]; } + // If the last item is a user_message (optimistic append to head during + // interrupt), look one further back for the streaming assistant_message. + const secondLast = state[state.length - 2]; + if (last?.kind === "user_message" && secondLast?.kind === "assistant_message") { + const updated: AssistantMessageItem = { + ...(secondLast as AssistantMessageItem), + text: `${secondLast.text}${chunk}`, + timestamp, + }; + return [...state.slice(0, -2), updated, last]; + } + if (!hasContent) { return state; } @@ -1011,15 +1023,27 @@ function shouldFlushHead( return false; } - const lastHeadItem = head[head.length - 1]; - // If incoming is not streamable, flush current head if (!isStreamableKind(incomingKind)) { return true; } - // If incoming kind is different from current head kind, flush - if (lastHeadItem.kind !== incomingKind) { + // Find the last streamable item in head (skip trailing non-streamable + // items like an optimistic user_message appended during interrupt). + let lastStreamable: StreamItem | undefined; + for (let i = head.length - 1; i >= 0; i--) { + if (isStreamableKind(head[i].kind)) { + lastStreamable = head[i]; + break; + } + } + + if (!lastStreamable) { + return true; + } + + // If incoming kind is different from current head's streamable kind, flush + if (lastStreamable.kind !== incomingKind) { return true; }