Update files

This commit is contained in:
Mohamed Boudra
2026-02-08 17:50:43 +07:00
parent 1b7490d1ae
commit 925752c8a3
6 changed files with 289 additions and 109 deletions

View File

@@ -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<string, QueuedMessage[]>) => {
@@ -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 ? (
<Pressable
onPress={handleCancelAgent}
disabled={!isConnected || isCancellingAgent}

View File

@@ -82,10 +82,15 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
selectedThinking?.label ??
(selectedThinkingId === "default" ? "Model default" : selectedThinkingId ?? "auto");
const displayMode =
agent.availableModes?.find((m) => m.id === agent.currentModeId)?.label ||
agent.currentModeId ||
"default";
return (
<View style={styles.container}>
{/* Agent Mode Badge */}
{agent.availableModes && agent.availableModes.length > 0 && (
<View style={[styles.container, IS_WEB && { marginBottom: -theme.spacing[1] }]}>
{/* Agent Mode Badge (desktop only — on mobile, mode is in the preferences sheet) */}
{IS_WEB && agent.availableModes && agent.availableModes.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger
style={({ pressed, hovered, open }) => [
@@ -234,7 +239,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
accessibilityLabel="Agent preferences"
testID="agent-preferences-button"
>
<SlidersHorizontal size={16} color={theme.colors.foregroundMuted} />
<SlidersHorizontal size={20} color={theme.colors.foreground} />
</Pressable>
<AdaptiveModalSheet
@@ -243,6 +248,39 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
onClose={() => setPrefsOpen(false)}
testID="agent-preferences-sheet"
>
{agent.availableModes && agent.availableModes.length > 0 && (
<View style={styles.sheetSection}>
<DropdownMenu>
<DropdownMenuTrigger
style={({ pressed }) => [
styles.sheetSelect,
pressed && styles.sheetSelectPressed,
]}
accessibilityRole="button"
accessibilityLabel="Select agent mode"
testID="agent-preferences-mode"
>
<Text style={styles.sheetSelectText}>{displayMode}</Text>
<ChevronDown size={16} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start">
{agent.availableModes.map((mode) => {
const isActive = mode.id === agent.currentModeId;
return (
<DropdownMenuItem
key={mode.id}
selected={isActive}
onSelect={() => handleModeChange(mode.id)}
>
{mode.label}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</View>
)}
<View style={styles.sheetSection}>
<DropdownMenu>
<DropdownMenuTrigger
@@ -335,7 +373,6 @@ const styles = StyleSheet.create((theme) => ({
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,

View File

@@ -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<MessageInputRef, MessageInputProps>(
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<MessageInputRef, MessageInputProps>(
// 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<MessageInputRef, MessageInputProps>(
)}
</Pressable>
{rightContent}
{shouldShowSendButton && isAgentRunning && onQueue && (
<Pressable
onPress={handleQueueMessage}
disabled={!isConnected || disabled}
accessibilityLabel="Queue message"
accessibilityRole="button"
style={[
styles.queueButton,
(!isConnected || disabled) && styles.buttonDisabled,
]}
>
<Plus size={20} color="white" />
</Pressable>
)}
{shouldShowSendButton && (
<Pressable
onPress={handleSendMessage}
@@ -667,6 +681,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
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,

View File

@@ -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 = (
<Animated.View style={spinStyle}>
<Loader2 size={12} color={iconColor} />
</Animated.View>
);
} else if (isError) {
if (isError) {
iconNode = <TriangleAlertIcon size={12} color={iconColor} opacity={0.8} />;
} else if (IconComponent) {
iconNode = <IconComponent size={12} color={iconColor} />;
@@ -1037,6 +1065,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
>
<Pressable
onPress={hasDetails ? onToggle : undefined}
onLayout={handleLayout}
disabled={!hasDetails}
accessibilityRole={hasDetails ? "button" : undefined}
accessibilityState={hasDetails ? { expanded: isExpanded } : undefined}
@@ -1049,32 +1078,60 @@ const ExpandableBadge = memo(function ExpandableBadge({
]}
>
{({ hovered }) => (
<View style={expandableBadgeStylesheet.headerRow}>
<View style={expandableBadgeStylesheet.iconBadge}>{iconNode}</View>
<Text style={expandableBadgeStylesheet.label} numberOfLines={1}>
{label}
</Text>
{secondaryLabel ? (
<Text
style={expandableBadgeStylesheet.secondaryLabel}
numberOfLines={1}
>
{secondaryLabel}
</Text>
) : (
<View style={expandableBadgeStylesheet.spacer} />
)}
{hasDetails && hovered ? (
<ChevronRight
size={14}
color={theme.colors.foregroundMuted}
style={[
expandableBadgeStylesheet.chevron,
{ transform: [{ rotate: isExpanded ? "90deg" : "0deg" }] },
]}
/>
) : null}
</View>
<>
<View style={expandableBadgeStylesheet.headerRow}>
<View style={expandableBadgeStylesheet.iconBadge}>{iconNode}</View>
<View style={expandableBadgeStylesheet.labelRow}>
<Text style={expandableBadgeStylesheet.label} numberOfLines={1}>
{label}
</Text>
{secondaryLabel ? (
<Text
style={expandableBadgeStylesheet.secondaryLabel}
numberOfLines={1}
>
{secondaryLabel}
</Text>
) : (
<View style={expandableBadgeStylesheet.spacer} />
)}
{isLoading && badgeWidth > 0 ? (
<Animated.View
pointerEvents="none"
style={[expandableBadgeStylesheet.shimmerOverlay, shimmerStyle]}
>
<Svg width="100%" height="100%" preserveAspectRatio="none">
<Defs>
<LinearGradient
id="shimmerGrad"
x1="0"
y1="0"
x2="1"
y2="0"
>
<Stop offset="0" stopColor={theme.colors.surface1} stopOpacity="0" />
<Stop offset="0.35" stopColor={theme.colors.surface1} stopOpacity="1" />
<Stop offset="0.65" stopColor={theme.colors.surface1} stopOpacity="1" />
<Stop offset="1" stopColor={theme.colors.surface1} stopOpacity="0" />
</LinearGradient>
</Defs>
<Rect width="100%" height="100%" fill="url(#shimmerGrad)" />
</Svg>
</Animated.View>
) : null}
</View>
{hasDetails && hovered ? (
<ChevronRight
size={14}
color={theme.colors.foregroundMuted}
style={[
expandableBadgeStylesheet.chevron,
{ transform: [{ rotate: isExpanded ? "90deg" : "0deg" }] },
]}
/>
) : null}
</View>
</>
)}
</Pressable>
{detailContent ? (

View File

@@ -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

View File

@@ -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;
}