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 { 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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user