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
@@ -13,7 +13,7 @@ import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useIsFocused } from "@react-navigation/native"; import { useIsFocused } from "@react-navigation/native";
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout"; 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 { AgentStatusBar } from "./agent-status-bar";
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker"; import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
@@ -94,6 +94,7 @@ export function AgentInputArea({
const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages); const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages);
const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail); const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail);
const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead);
const [internalInput, setInternalInput] = useState(""); const [internalInput, setInternalInput] = useState("");
const userInput = value ?? internalInput; const userInput = value ?? internalInput;
@@ -184,18 +185,32 @@ export function AgentInputArea({
} }
const messageId = generateMessageId(); const messageId = generateMessageId();
setAgentStreamTail(serverId, (prev) => { const userMessage: StreamItem = {
const currentStream = prev.get(agentId) || []; kind: "user_message",
const nextItem: any = { id: messageId,
kind: "user_message", text,
id: messageId, timestamp: new Date(),
text, };
timestamp: new Date(),
}; // Append to head if streaming (keeps the user message with the current
const updated = new Map(prev); // turn so late text_deltas still find the existing assistant_message).
updated.set(agentId, [...currentStream, nextItem]); // Otherwise append to tail.
return updated; 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); const imagesData = await encodeImages(images);
await client.sendAgentMessage(agentId, text, { await client.sendAgentMessage(agentId, text, {
@@ -203,7 +218,7 @@ export function AgentInputArea({
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}), ...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
}); });
}; };
}, [client, serverId, setAgentStreamTail]); }, [client, serverId, setAgentStreamTail, setAgentStreamHead]);
useEffect(() => { useEffect(() => {
onSubmitMessageRef.current = onSubmitMessage; onSubmitMessageRef.current = onSubmitMessage;
@@ -211,6 +226,15 @@ export function AgentInputArea({
const isAgentRunning = agent?.status === "running"; 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( const updateQueue = useCallback(
(updater: (current: QueuedMessage[]) => QueuedMessage[]) => { (updater: (current: QueuedMessage[]) => QueuedMessage[]) => {
setQueuedMessages(serverId, (prev: Map<string, QueuedMessage[]>) => { setQueuedMessages(serverId, (prev: Map<string, QueuedMessage[]>) => {
@@ -296,7 +320,6 @@ export function AgentInputArea({
setSendError( setSendError(
error instanceof Error ? error.message : "Failed to send message" error instanceof Error ? error.message : "Failed to send message"
); );
} finally {
setIsProcessing(false); 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 <Pressable
onPress={handleCancelAgent} onPress={handleCancelAgent}
disabled={!isConnected || isCancellingAgent} disabled={!isConnected || isCancellingAgent}
@@ -82,10 +82,15 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
selectedThinking?.label ?? selectedThinking?.label ??
(selectedThinkingId === "default" ? "Model default" : selectedThinkingId ?? "auto"); (selectedThinkingId === "default" ? "Model default" : selectedThinkingId ?? "auto");
const displayMode =
agent.availableModes?.find((m) => m.id === agent.currentModeId)?.label ||
agent.currentModeId ||
"default";
return ( return (
<View style={styles.container}> <View style={[styles.container, IS_WEB && { marginBottom: -theme.spacing[1] }]}>
{/* Agent Mode Badge */} {/* Agent Mode Badge (desktop only — on mobile, mode is in the preferences sheet) */}
{agent.availableModes && agent.availableModes.length > 0 && ( {IS_WEB && agent.availableModes && agent.availableModes.length > 0 && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
style={({ pressed, hovered, open }) => [ style={({ pressed, hovered, open }) => [
@@ -234,7 +239,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
accessibilityLabel="Agent preferences" accessibilityLabel="Agent preferences"
testID="agent-preferences-button" testID="agent-preferences-button"
> >
<SlidersHorizontal size={16} color={theme.colors.foregroundMuted} /> <SlidersHorizontal size={20} color={theme.colors.foreground} />
</Pressable> </Pressable>
<AdaptiveModalSheet <AdaptiveModalSheet
@@ -243,6 +248,39 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
onClose={() => setPrefsOpen(false)} onClose={() => setPrefsOpen(false)}
testID="agent-preferences-sheet" 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}> <View style={styles.sheetSection}>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
@@ -335,7 +373,6 @@ const styles = StyleSheet.create((theme) => ({
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
gap: theme.spacing[1], gap: theme.spacing[1],
marginBottom: -theme.spacing[1],
}, },
modeBadge: { modeBadge: {
flexDirection: "row", flexDirection: "row",
@@ -358,12 +395,11 @@ const styles = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.normal, fontWeight: theme.fontWeight.normal,
}, },
prefsButton: { prefsButton: {
width: 32, width: 34,
height: 32, height: 34,
borderRadius: theme.borderRadius["2xl"], borderRadius: theme.borderRadius.full,
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
backgroundColor: theme.colors.surface2,
}, },
prefsButtonPressed: { prefsButtonPressed: {
backgroundColor: theme.colors.surface0, backgroundColor: theme.colors.surface0,
+50 -25
View File
@@ -19,7 +19,7 @@ import {
forwardRef, forwardRef,
} from "react"; } from "react";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; 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, { import Animated, {
useSharedValue, useSharedValue,
useAnimatedStyle, useAnimatedStyle,
@@ -367,17 +367,26 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const payload = { const payload = {
text: trimmed, text: trimmed,
images: images.length > 0 ? images : undefined, images: images.length > 0 ? images : undefined,
forceSend: isAgentRunning || undefined,
}; };
if (isAgentRunning && onQueue) { onSubmit(payload);
onQueue(payload);
onChangeText("");
} else {
onSubmit(payload);
}
// Reset input height
inputHeightRef.current = MIN_INPUT_HEIGHT; inputHeightRef.current = MIN_INPUT_HEIGHT;
setInputHeight(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 // Web input height measurement
function isTextAreaLike(v: unknown): v is TextAreaHandle { 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) // Shift+Enter: add newline (default behavior, don't intercept)
if (shiftKey) return; if (shiftKey) return;
// Cmd+Enter (Mac) or Ctrl+Enter (Windows/Linux): force send immediately // Cmd+Enter (Mac) or Ctrl+Enter (Windows/Linux): queue when agent is running
if (metaKey || ctrlKey) { if ((metaKey || ctrlKey) && isAgentRunning && onQueue) {
if (isSubmitDisabled || isSubmitLoading || disabled) return; if (isSubmitDisabled || isSubmitLoading || disabled) return;
event.preventDefault(); event.preventDefault();
const trimmed = value.trim(); handleQueueMessage();
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);
return; return;
} }
// Plain Enter: normal send (respects queue behavior) // Enter: send (interrupts agent if running)
if (isSubmitDisabled || isSubmitLoading || disabled) return; if (isSubmitDisabled || isSubmitLoading || disabled) return;
event.preventDefault(); event.preventDefault();
handleSendMessage(); handleSendMessage();
@@ -658,6 +658,20 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
)} )}
</Pressable> </Pressable>
{rightContent} {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 && ( {shouldShowSendButton && (
<Pressable <Pressable
onPress={handleSendMessage} onPress={handleSendMessage}
@@ -667,6 +681,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
isSubmitLoading || isSubmitLoading ||
disabled disabled
} }
accessibilityLabel={isAgentRunning ? "Send and interrupt" : "Send message"}
accessibilityRole="button"
style={[ style={[
styles.sendButton, styles.sendButton,
(!isConnected || (!isConnected ||
@@ -805,7 +821,7 @@ const styles = StyleSheet.create(((theme: any) => ({
}, },
leftButtonGroup: { leftButtonGroup: {
flexDirection: "row", flexDirection: "row",
alignItems: "flex-end", alignItems: "center",
gap: theme.spacing[2], gap: theme.spacing[2],
}, },
rightButtonGroup: { rightButtonGroup: {
@@ -814,8 +830,9 @@ const styles = StyleSheet.create(((theme: any) => ({
gap: theme.spacing[2], gap: theme.spacing[2],
}, },
attachButton: { attachButton: {
width: 20, width: 34,
height: 20, height: 34,
borderRadius: theme.borderRadius.full,
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
}, },
@@ -829,6 +846,14 @@ const styles = StyleSheet.create(((theme: any) => ({
voiceButtonRecording: { voiceButtonRecording: {
backgroundColor: theme.colors.destructive, backgroundColor: theme.colors.destructive,
}, },
queueButton: {
width: 34,
height: 34,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface1,
alignItems: "center",
justifyContent: "center",
},
sendButton: { sendButton: {
width: 34, width: 34,
height: 34, height: 34,
+99 -42
View File
@@ -3,6 +3,7 @@ import {
Text, Text,
Pressable, Pressable,
ActivityIndicator, ActivityIndicator,
LayoutChangeEvent,
StyleProp, StyleProp,
ViewStyle, ViewStyle,
Platform, Platform,
@@ -26,6 +27,12 @@ import Animated, {
Easing, Easing,
cancelAnimation, cancelAnimation,
} from "react-native-reanimated"; } from "react-native-reanimated";
import Svg, {
Defs,
LinearGradient,
Stop,
Rect,
} from "react-native-svg";
import Markdown, { MarkdownIt } from "react-native-markdown-display"; import Markdown, { MarkdownIt } from "react-native-markdown-display";
import * as Linking from "expo-linking"; import * as Linking from "expo-linking";
import { import {
@@ -36,7 +43,6 @@ import {
FileText, FileText,
ChevronRight, ChevronRight,
ChevronDown, ChevronDown,
Loader2,
Check, Check,
CheckSquare, CheckSquare,
X, X,
@@ -348,6 +354,7 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface1, backgroundColor: theme.colors.surface1,
paddingHorizontal: theme.spacing[2], paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1], paddingVertical: theme.spacing[1],
overflow: "hidden",
}, },
pressablePressed: { pressablePressed: {
opacity: 0.9, opacity: 0.9,
@@ -356,6 +363,12 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
}, },
labelRow: {
flex: 1,
flexDirection: "row",
alignItems: "center",
overflow: "hidden",
},
iconBadge: { iconBadge: {
width: 22, width: 22,
height: 22, height: 22,
@@ -403,6 +416,12 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
borderBottomLeftRadius: 0, borderBottomLeftRadius: 0,
borderBottomRightRadius: 0, borderBottomRightRadius: 0,
}, },
shimmerOverlay: {
position: "absolute",
top: 0,
bottom: 0,
width: 100,
},
})); }));
export const AssistantMessage = memo(function AssistantMessage({ export const AssistantMessage = memo(function AssistantMessage({
@@ -987,23 +1006,38 @@ const ExpandableBadge = memo(function ExpandableBadge({
const hasDetails = Boolean(renderDetails); const hasDetails = Boolean(renderDetails);
const detailContent = hasDetails && isExpanded ? renderDetails?.() : null; 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(() => { useEffect(() => {
if (isLoading) { if (isLoading) {
rotation.value = 0; shimmer.value = -1;
rotation.value = withRepeat( shimmer.value = withRepeat(
withTiming(360, { duration: 1400, easing: Easing.linear }), withTiming(1, { duration: 2400, easing: Easing.bezier(0.4, 0, 0.6, 1) }),
-1 -1
); );
} else { } else {
cancelAnimation(rotation); cancelAnimation(shimmer);
shimmer.value = -1;
} }
}, [isLoading]); }, [isLoading]);
const spinStyle = useAnimatedStyle(() => ({ const shimmerBandWidth = 100;
transform: [{ rotate: `${rotation.value}deg` }], const shimmerStyle = useAnimatedStyle(() => {
})); const travel = badgeWidth + shimmerBandWidth;
return {
transform: [
{
translateX:
-shimmerBandWidth + ((shimmer.value + 1) / 2) * travel,
},
],
};
});
const IconComponent = icon; const IconComponent = icon;
const iconColor = isError const iconColor = isError
@@ -1011,13 +1045,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
: theme.colors.mutedForeground; : theme.colors.mutedForeground;
let iconNode: ReactNode = null; let iconNode: ReactNode = null;
if (isLoading) { if (isError) {
iconNode = (
<Animated.View style={spinStyle}>
<Loader2 size={12} color={iconColor} />
</Animated.View>
);
} else if (isError) {
iconNode = <TriangleAlertIcon size={12} color={iconColor} opacity={0.8} />; iconNode = <TriangleAlertIcon size={12} color={iconColor} opacity={0.8} />;
} else if (IconComponent) { } else if (IconComponent) {
iconNode = <IconComponent size={12} color={iconColor} />; iconNode = <IconComponent size={12} color={iconColor} />;
@@ -1037,6 +1065,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
> >
<Pressable <Pressable
onPress={hasDetails ? onToggle : undefined} onPress={hasDetails ? onToggle : undefined}
onLayout={handleLayout}
disabled={!hasDetails} disabled={!hasDetails}
accessibilityRole={hasDetails ? "button" : undefined} accessibilityRole={hasDetails ? "button" : undefined}
accessibilityState={hasDetails ? { expanded: isExpanded } : undefined} accessibilityState={hasDetails ? { expanded: isExpanded } : undefined}
@@ -1049,32 +1078,60 @@ const ExpandableBadge = memo(function ExpandableBadge({
]} ]}
> >
{({ hovered }) => ( {({ hovered }) => (
<View style={expandableBadgeStylesheet.headerRow}> <>
<View style={expandableBadgeStylesheet.iconBadge}>{iconNode}</View> <View style={expandableBadgeStylesheet.headerRow}>
<Text style={expandableBadgeStylesheet.label} numberOfLines={1}> <View style={expandableBadgeStylesheet.iconBadge}>{iconNode}</View>
{label} <View style={expandableBadgeStylesheet.labelRow}>
</Text> <Text style={expandableBadgeStylesheet.label} numberOfLines={1}>
{secondaryLabel ? ( {label}
<Text </Text>
style={expandableBadgeStylesheet.secondaryLabel} {secondaryLabel ? (
numberOfLines={1} <Text
> style={expandableBadgeStylesheet.secondaryLabel}
{secondaryLabel} numberOfLines={1}
</Text> >
) : ( {secondaryLabel}
<View style={expandableBadgeStylesheet.spacer} /> </Text>
)} ) : (
{hasDetails && hovered ? ( <View style={expandableBadgeStylesheet.spacer} />
<ChevronRight )}
size={14} {isLoading && badgeWidth > 0 ? (
color={theme.colors.foregroundMuted} <Animated.View
style={[ pointerEvents="none"
expandableBadgeStylesheet.chevron, style={[expandableBadgeStylesheet.shimmerOverlay, shimmerStyle]}
{ transform: [{ rotate: isExpanded ? "90deg" : "0deg" }] }, >
]} <Svg width="100%" height="100%" preserveAspectRatio="none">
/> <Defs>
) : null} <LinearGradient
</View> 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> </Pressable>
{detailContent ? ( {detailContent ? (
+26 -13
View File
@@ -1453,19 +1453,32 @@ export function SessionProvider({
images?: Array<{ uri: string; mimeType?: string }> images?: Array<{ uri: string; mimeType?: string }>
) => { ) => {
const messageId = generateMessageId(); const messageId = generateMessageId();
const userMessage: StreamItem = {
kind: "user_message",
id: messageId,
text: message,
timestamp: new Date(),
};
setAgentStreamTail(serverId, (prev) => { // Append to head if streaming (keeps the user message with the current
const currentStream = prev.get(agentId) || []; // turn so late text_deltas still find the existing assistant_message).
const nextItem: any = { // Otherwise append to tail.
kind: "user_message", const currentHead = useSessionStore.getState().sessions[serverId]?.agentStreamHead?.get(agentId);
id: messageId, if (currentHead && currentHead.length > 0) {
text: message, setAgentStreamHead(serverId, (prev) => {
timestamp: new Date(), const head = prev.get(agentId) || [];
}; const updated = new Map(prev);
const updated = new Map(prev); updated.set(agentId, [...head, userMessage]);
updated.set(agentId, [...currentStream, nextItem]); return updated;
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); const imagesData = await encodeImages(images);
if (!client) { if (!client) {
@@ -1483,7 +1496,7 @@ export function SessionProvider({
console.error("[Session] Failed to send agent message:", error); 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 // Keep the ref updated so the agent_update handler can call it
+28 -4
View File
@@ -235,6 +235,18 @@ function appendAssistantMessage(
return [...state.slice(0, -1), updated]; 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) { if (!hasContent) {
return state; return state;
} }
@@ -1011,15 +1023,27 @@ function shouldFlushHead(
return false; return false;
} }
const lastHeadItem = head[head.length - 1];
// If incoming is not streamable, flush current head // If incoming is not streamable, flush current head
if (!isStreamableKind(incomingKind)) { if (!isStreamableKind(incomingKind)) {
return true; return true;
} }
// If incoming kind is different from current head kind, flush // Find the last streamable item in head (skip trailing non-streamable
if (lastHeadItem.kind !== incomingKind) { // 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; return true;
} }