mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Update files
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user