mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-01 14:12:51 +00:00
* feat(composer): insert suggested commands into drafts * fix(composer): close suggestions after command insertion * fix(composer): place caret after inserted commands
2447 lines
72 KiB
TypeScript
2447 lines
72 KiB
TypeScript
import { MessagePreview, MessageSender } from "@/components/user-message";
|
||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||
import { getAgentAttachmentPillContent } from "@/attachments/attachment-pill-content";
|
||
import { AssistantForkMenu, type AssistantForkTarget } from "@/components/assistant-fork-menu";
|
||
import { AttachmentLightbox, type ImageLightboxSource } from "@/components/attachment-lightbox";
|
||
import {
|
||
AttachmentFrame,
|
||
AttachmentLabel,
|
||
AttachmentThumbnail,
|
||
} from "@/components/attachment-pill";
|
||
import { MarkdownRenderer } from "@/components/markdown/renderer";
|
||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||
import { RewindMenu, type RewindMode } from "@/components/rewind/rewind-menu";
|
||
import { useRewindAgentMutation } from "@/components/rewind/use-rewind-agent-mutation";
|
||
import { TaskListRow } from "@/components/task-list-row";
|
||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||
import { isNative, isWeb } from "@/constants/platform";
|
||
import { useRevealedText, type MarkdownPhase } from "@/hooks/use-revealed-text";
|
||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||
import { buildToolCallPresentation } from "@/tool-calls/presentation";
|
||
import type { TaskActivity, TodoEntry, UserMessageImageAttachment } from "@/types/stream";
|
||
import { setAssistantMessageHeight } from "@/utils/assistant-message-height-estimate";
|
||
import { isRenderProfileEnabled } from "@/utils/render-profiler";
|
||
import { writeMarkdownToRichClipboard } from "@/utils/rich-clipboard";
|
||
import { getDefaultMarkdownClipboardEnvironment } from "@/utils/rich-clipboard-default-environment";
|
||
import { formatDuration, formatMessageTimestamp } from "@/utils/time";
|
||
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
|
||
import { useToolCallExpanded } from "@/tool-calls/expansion-context";
|
||
import { useTargetAgentTitle } from "@/tool-calls/use-target-agent-title";
|
||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||
import type { AgentMessage } from "@getpaseo/protocol/agent-message";
|
||
import type { AgentCapabilityFlags, ToolCallDetail } from "@getpaseo/protocol/agent-types";
|
||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||
import MaskedView from "@react-native-masked-view/masked-view";
|
||
import {
|
||
Check,
|
||
CheckSquare,
|
||
ChevronRight,
|
||
CircleDot,
|
||
Copy,
|
||
FileSymlink,
|
||
Info,
|
||
MicVocal,
|
||
Plus,
|
||
Scissors,
|
||
TriangleAlertIcon,
|
||
XCircle,
|
||
} from "lucide-react-native";
|
||
import type { ComponentType, ReactNode } from "react";
|
||
import * as React from "react";
|
||
import {
|
||
createContext,
|
||
memo,
|
||
useCallback,
|
||
useContext,
|
||
useEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
} from "react";
|
||
import { useTranslation } from "react-i18next";
|
||
import {
|
||
Pressable,
|
||
StyleProp,
|
||
Text,
|
||
View,
|
||
ViewStyle,
|
||
type GestureResponderEvent,
|
||
type LayoutChangeEvent,
|
||
type TextStyle,
|
||
} from "react-native";
|
||
import Animated, {
|
||
Easing,
|
||
cancelAnimation,
|
||
useAnimatedStyle,
|
||
useSharedValue,
|
||
withRepeat,
|
||
withTiming,
|
||
} from "react-native-reanimated";
|
||
import Svg, { Defs, Rect, Stop, LinearGradient as SvgLinearGradient } from "react-native-svg";
|
||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||
import { capAssistantMessageForRender, getUtf8ByteLength } from "./assistant-message-render-limit";
|
||
import { getTurnDurationLabel } from "./assistant-turn-footer-label";
|
||
import { getCompactionMarkerLabel } from "./message-compaction-label";
|
||
import { PlanCard } from "./plan-card";
|
||
import { ToolCallDetailsContent } from "./tool-call-details";
|
||
import { useToolCallSheet } from "./tool-call-sheet";
|
||
export type { InlinePathTarget } from "@/assistant-file-links";
|
||
export type { AssistantForkTarget };
|
||
|
||
interface UserMessageProps {
|
||
sender?: AgentMessage["sender"];
|
||
serverId?: string;
|
||
agentId?: string;
|
||
messageId?: string;
|
||
message: string;
|
||
images?: UserMessageImageAttachment[];
|
||
attachments?: AgentAttachment[];
|
||
timestamp: number;
|
||
capabilities?: AgentCapabilityFlags;
|
||
client?: DaemonClient | null;
|
||
isFirstInGroup?: boolean;
|
||
isLastInGroup?: boolean;
|
||
isPending?: boolean;
|
||
disableOuterSpacing?: boolean;
|
||
}
|
||
|
||
const MessageOuterSpacingContext = createContext(false);
|
||
|
||
export function MessageOuterSpacingProvider({
|
||
disableOuterSpacing,
|
||
children,
|
||
}: {
|
||
disableOuterSpacing: boolean;
|
||
children: ReactNode;
|
||
}) {
|
||
return (
|
||
<MessageOuterSpacingContext.Provider value={disableOuterSpacing}>
|
||
{children}
|
||
</MessageOuterSpacingContext.Provider>
|
||
);
|
||
}
|
||
|
||
function useDisableOuterSpacing(disableOuterSpacing: boolean | undefined) {
|
||
const contextValue = useContext(MessageOuterSpacingContext);
|
||
return disableOuterSpacing ?? contextValue;
|
||
}
|
||
|
||
const WEB_TOOLCALL_SHIMMER_KEYFRAME_ID = "paseo-toolcall-shimmer-keyframes";
|
||
const WEB_TOOLCALL_SHIMMER_ANIMATION_NAME = "paseo-toolcall-shimmer";
|
||
const ThemedMicVocal = withUnistyles(MicVocal);
|
||
const ThemedFileSymlinkIcon = withUnistyles(FileSymlink);
|
||
const ThemedTriangleAlertIcon = withUnistyles(TriangleAlertIcon);
|
||
const ThemedChevronRightIcon = withUnistyles(ChevronRight);
|
||
const ThemedNotificationInfo = withUnistyles(Info);
|
||
const ThemedNotificationWarning = withUnistyles(TriangleAlertIcon);
|
||
const ThemedNotificationError = withUnistyles(XCircle);
|
||
|
||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||
color: theme.colors.foregroundMuted,
|
||
});
|
||
const mutedForegroundColorMapping = (theme: Theme) => ({
|
||
color: theme.colors.mutedForeground,
|
||
});
|
||
const destructiveColorMapping = (theme: Theme) => ({ color: theme.colors.destructive });
|
||
const infoColorMapping = (theme: Theme) => ({ color: theme.colors.palette.blue[300] });
|
||
const warningColorMapping = (theme: Theme) => ({ color: theme.colors.palette.amber[500] });
|
||
const WEB_TOOLCALL_SHIMMER_KEYFRAME_CSS = `
|
||
@keyframes ${WEB_TOOLCALL_SHIMMER_ANIMATION_NAME} {
|
||
0% {
|
||
background-position: var(--paseo-shimmer-start, -200px) 0;
|
||
}
|
||
100% {
|
||
background-position: var(--paseo-shimmer-end, 200px) 0;
|
||
}
|
||
}
|
||
`;
|
||
let webToolCallShimmerRegistered = false;
|
||
const SCROLL_EDGE_EPSILON = 0.5;
|
||
|
||
// Font size for stream metadata (timestamps, durations, live elapsed timer).
|
||
// Lives between theme.fontSize.sm (12) and theme.fontSize.base (14); no token.
|
||
export const STREAM_METADATA_FONT_SIZE = 13;
|
||
type ScrollAxis = "x" | "y";
|
||
|
||
function ensureWebToolCallShimmerKeyframes() {
|
||
if (isNative) {
|
||
return;
|
||
}
|
||
if (typeof document === "undefined") {
|
||
return;
|
||
}
|
||
const existing = document.getElementById(WEB_TOOLCALL_SHIMMER_KEYFRAME_ID);
|
||
if (existing) {
|
||
if (existing.textContent !== WEB_TOOLCALL_SHIMMER_KEYFRAME_CSS) {
|
||
existing.textContent = WEB_TOOLCALL_SHIMMER_KEYFRAME_CSS;
|
||
}
|
||
webToolCallShimmerRegistered = true;
|
||
return;
|
||
}
|
||
if (webToolCallShimmerRegistered) {
|
||
return;
|
||
}
|
||
const styleElement = document.createElement("style");
|
||
styleElement.id = WEB_TOOLCALL_SHIMMER_KEYFRAME_ID;
|
||
styleElement.textContent = WEB_TOOLCALL_SHIMMER_KEYFRAME_CSS;
|
||
document.head.appendChild(styleElement);
|
||
webToolCallShimmerRegistered = true;
|
||
}
|
||
|
||
function getWheelEventElementTarget(event: WheelEvent, fallback: HTMLElement): HTMLElement {
|
||
const { target } = event;
|
||
if (target instanceof HTMLElement) {
|
||
return target;
|
||
}
|
||
if (target instanceof Node && target.parentElement) {
|
||
return target.parentElement;
|
||
}
|
||
return fallback;
|
||
}
|
||
|
||
function canElementScrollInDirection(
|
||
element: HTMLElement,
|
||
axis: ScrollAxis,
|
||
delta: number,
|
||
): boolean {
|
||
if (delta === 0) {
|
||
return false;
|
||
}
|
||
|
||
const computedStyle = window.getComputedStyle(element);
|
||
const overflow = axis === "x" ? computedStyle.overflowX : computedStyle.overflowY;
|
||
const isScrollableOverflow =
|
||
overflow === "auto" || overflow === "scroll" || overflow === "overlay";
|
||
if (!isScrollableOverflow) {
|
||
return false;
|
||
}
|
||
|
||
const scrollPosition = axis === "x" ? element.scrollLeft : element.scrollTop;
|
||
const scrollSize =
|
||
axis === "x"
|
||
? element.scrollWidth - element.clientWidth
|
||
: element.scrollHeight - element.clientHeight;
|
||
if (scrollSize <= SCROLL_EDGE_EPSILON) {
|
||
return false;
|
||
}
|
||
|
||
if (delta > 0) {
|
||
return scrollPosition < scrollSize - SCROLL_EDGE_EPSILON;
|
||
}
|
||
return scrollPosition > SCROLL_EDGE_EPSILON;
|
||
}
|
||
|
||
function canScrollInsideDetailFromTarget(
|
||
detailRoot: HTMLElement,
|
||
startElement: HTMLElement,
|
||
axis: ScrollAxis,
|
||
delta: number,
|
||
): boolean {
|
||
if (delta === 0) {
|
||
return false;
|
||
}
|
||
|
||
let current: HTMLElement | null = startElement;
|
||
while (current) {
|
||
if (canElementScrollInDirection(current, axis, delta)) {
|
||
return true;
|
||
}
|
||
if (current === detailRoot) {
|
||
break;
|
||
}
|
||
current = current.parentElement;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function shouldStopDetailWheelPropagation(detailRoot: HTMLElement, event: WheelEvent): boolean {
|
||
const startElement = getWheelEventElementTarget(event, detailRoot);
|
||
const verticalDelta = event.deltaY;
|
||
let horizontalDelta: number;
|
||
if (event.deltaX !== 0) horizontalDelta = event.deltaX;
|
||
else if (event.shiftKey) horizontalDelta = event.deltaY;
|
||
else horizontalDelta = 0;
|
||
|
||
const hasVerticalIntent = Math.abs(verticalDelta) > SCROLL_EDGE_EPSILON;
|
||
const hasHorizontalIntent = Math.abs(horizontalDelta) > SCROLL_EDGE_EPSILON;
|
||
if (!hasVerticalIntent && !hasHorizontalIntent) {
|
||
return false;
|
||
}
|
||
|
||
const canScrollVertically = hasVerticalIntent
|
||
? canScrollInsideDetailFromTarget(detailRoot, startElement, "y", verticalDelta)
|
||
: false;
|
||
const canScrollHorizontally = hasHorizontalIntent
|
||
? canScrollInsideDetailFromTarget(detailRoot, startElement, "x", horizontalDelta)
|
||
: false;
|
||
|
||
if (hasVerticalIntent && hasHorizontalIntent) {
|
||
const isVerticalDominant = Math.abs(verticalDelta) >= Math.abs(horizontalDelta);
|
||
return isVerticalDominant
|
||
? canScrollVertically || canScrollHorizontally
|
||
: canScrollHorizontally || canScrollVertically;
|
||
}
|
||
|
||
if (hasVerticalIntent) {
|
||
return canScrollVertically;
|
||
}
|
||
return canScrollHorizontally;
|
||
}
|
||
|
||
const userMessageStylesheet = StyleSheet.create((theme) => ({
|
||
container: {
|
||
flexDirection: "row",
|
||
justifyContent: "flex-end",
|
||
...(isWeb ? { userSelect: "text" as const } : {}),
|
||
},
|
||
content: {
|
||
alignItems: "flex-end",
|
||
maxWidth: "100%",
|
||
cursor: "auto",
|
||
},
|
||
containerSpacing: {
|
||
marginBottom: theme.spacing[1],
|
||
},
|
||
containerFirstInGroup: {
|
||
marginTop: theme.spacing[4],
|
||
},
|
||
containerLastInGroup: {
|
||
marginBottom: theme.spacing[4],
|
||
},
|
||
bubble: {
|
||
backgroundColor: theme.colors.surface3,
|
||
borderRadius: theme.borderRadius["2xl"],
|
||
borderTopRightRadius: theme.borderRadius.sm,
|
||
paddingHorizontal: theme.spacing[4],
|
||
paddingVertical: theme.spacing[4],
|
||
minWidth: 0,
|
||
flexShrink: 1,
|
||
},
|
||
text: {
|
||
color: theme.colors.foreground,
|
||
fontSize: theme.fontSize.content,
|
||
...(isWeb
|
||
? {
|
||
lineHeight: Math.round(theme.fontSize.content * 1.4),
|
||
overflowWrap: "anywhere" as const,
|
||
}
|
||
: {}),
|
||
},
|
||
imagePreviewContainer: {
|
||
flexDirection: "row",
|
||
gap: theme.spacing[2],
|
||
flexWrap: "wrap",
|
||
},
|
||
attachmentPreviewContainer: {
|
||
flexDirection: "row",
|
||
gap: theme.spacing[2],
|
||
flexWrap: "wrap",
|
||
},
|
||
imagePreviewSpacing: {
|
||
marginBottom: theme.spacing[2],
|
||
},
|
||
copyButton: {
|
||
alignSelf: "center",
|
||
padding: theme.spacing[1],
|
||
paddingTop: theme.spacing[1],
|
||
marginTop: 0,
|
||
marginRight: -theme.spacing[1],
|
||
},
|
||
trailingRow: {
|
||
alignSelf: "flex-end",
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
height: 24,
|
||
gap: theme.spacing[2],
|
||
marginTop: theme.spacing[2],
|
||
},
|
||
trailingRowHidden: {
|
||
opacity: 0,
|
||
},
|
||
trailingRowVisible: {
|
||
opacity: 1,
|
||
},
|
||
timestampText: {
|
||
color: theme.colors.foregroundMuted,
|
||
fontSize: STREAM_METADATA_FONT_SIZE,
|
||
},
|
||
}));
|
||
|
||
interface UserMessageImagePillProps {
|
||
image: UserMessageImageAttachment;
|
||
onOpen: (image: UserMessageImageAttachment) => void;
|
||
accessibilityLabel: string;
|
||
}
|
||
|
||
function UserMessageImagePill({ image, onOpen, accessibilityLabel }: UserMessageImagePillProps) {
|
||
const handlePress = useCallback(() => {
|
||
onOpen(image);
|
||
}, [onOpen, image]);
|
||
return (
|
||
<AttachmentFrame onPress={handlePress} accessibilityLabel={accessibilityLabel}>
|
||
<AttachmentThumbnail metadata={image} />
|
||
</AttachmentFrame>
|
||
);
|
||
}
|
||
|
||
const MESSAGE_TEXT_DATASET = { messageText: "true" };
|
||
|
||
export const UserMessage = memo(function UserMessage({
|
||
sender,
|
||
serverId,
|
||
agentId,
|
||
messageId,
|
||
message,
|
||
images = [],
|
||
attachments = [],
|
||
timestamp,
|
||
capabilities,
|
||
client,
|
||
isFirstInGroup = true,
|
||
isLastInGroup = true,
|
||
isPending = false,
|
||
disableOuterSpacing,
|
||
}: UserMessageProps) {
|
||
const isCompact = useIsCompactFormFactor();
|
||
const { t } = useTranslation();
|
||
const [isHovered, setIsHovered] = useState(false);
|
||
const [lightboxMetadata, setLightboxMetadata] = useState<UserMessageImageAttachment | null>(null);
|
||
const handleLightboxClose = useCallback(() => setLightboxMetadata(null), []);
|
||
const lightboxSource = useMemo<ImageLightboxSource | null>(
|
||
() => (lightboxMetadata ? { type: "attachment", metadata: lightboxMetadata } : null),
|
||
[lightboxMetadata],
|
||
);
|
||
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
|
||
const hasText = message.trim().length > 0;
|
||
const hasImages = images.length > 0;
|
||
const hasAttachments = attachments.length > 0;
|
||
const showTrailingRow = !isPending && hasText && (isCompact || isNative || isHovered);
|
||
const formattedTimestamp = useMemo(
|
||
() => formatMessageTimestamp(new Date(timestamp)),
|
||
[timestamp],
|
||
);
|
||
const rewindMutation = useRewindAgentMutation({ serverId, agentId, client, messageId });
|
||
|
||
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
|
||
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
|
||
const getMessageContent = useCallback(() => message, [message]);
|
||
const handleRewind = useCallback(
|
||
(input: { mode: RewindMode; rewoundText: string }) => {
|
||
return rewindMutation.rewindAgent(input);
|
||
},
|
||
[rewindMutation],
|
||
);
|
||
|
||
const containerStyle = useMemo(
|
||
() => [
|
||
userMessageStylesheet.container,
|
||
!resolvedDisableOuterSpacing && [
|
||
isFirstInGroup ? userMessageStylesheet.containerFirstInGroup : null,
|
||
isLastInGroup ? userMessageStylesheet.containerLastInGroup : null,
|
||
!isFirstInGroup || !isLastInGroup ? userMessageStylesheet.containerSpacing : null,
|
||
],
|
||
],
|
||
[resolvedDisableOuterSpacing, isFirstInGroup, isLastInGroup],
|
||
);
|
||
const imagePreviewContainerStyle = useMemo(
|
||
() => [
|
||
userMessageStylesheet.imagePreviewContainer,
|
||
hasText || hasAttachments ? userMessageStylesheet.imagePreviewSpacing : undefined,
|
||
],
|
||
[hasAttachments, hasText],
|
||
);
|
||
const attachmentPreviewContainerStyle = useMemo(
|
||
() => [
|
||
userMessageStylesheet.attachmentPreviewContainer,
|
||
hasText ? userMessageStylesheet.imagePreviewSpacing : undefined,
|
||
],
|
||
[hasText],
|
||
);
|
||
const trailingRowStyle = useMemo(
|
||
() => [
|
||
userMessageStylesheet.trailingRow,
|
||
showTrailingRow
|
||
? userMessageStylesheet.trailingRowVisible
|
||
: userMessageStylesheet.trailingRowHidden,
|
||
],
|
||
[showTrailingRow],
|
||
);
|
||
|
||
return (
|
||
<View style={containerStyle} testID="user-message" aria-busy={isPending}>
|
||
<View
|
||
style={userMessageStylesheet.content}
|
||
onPointerEnter={handlePointerEnter}
|
||
onPointerLeave={handlePointerLeave}
|
||
>
|
||
{sender ? <MessageSender sender={sender} serverId={serverId} /> : null}
|
||
<View style={userMessageStylesheet.bubble}>
|
||
<MessagePreview>
|
||
{hasImages ? (
|
||
<View style={imagePreviewContainerStyle}>
|
||
{images.map((image) => (
|
||
<UserMessageImagePill
|
||
key={image.id}
|
||
image={image}
|
||
onOpen={setLightboxMetadata}
|
||
accessibilityLabel={t("composer.attachments.openImage")}
|
||
/>
|
||
))}
|
||
</View>
|
||
) : null}
|
||
{hasAttachments ? (
|
||
<View style={attachmentPreviewContainerStyle}>
|
||
{attachments.map((attachment, index) => {
|
||
const content = getAgentAttachmentPillContent(attachment, t);
|
||
return (
|
||
<AttachmentFrame
|
||
key={`${attachment.type}:${"number" in attachment ? attachment.number : index}`}
|
||
>
|
||
<AttachmentLabel
|
||
icon={content.icon}
|
||
title={content.title}
|
||
subtitle={content.subtitle}
|
||
/>
|
||
</AttachmentFrame>
|
||
);
|
||
})}
|
||
</View>
|
||
) : null}
|
||
{hasText ? (
|
||
<Text selectable style={userMessageStylesheet.text} dataSet={MESSAGE_TEXT_DATASET}>
|
||
{message}
|
||
</Text>
|
||
) : null}
|
||
</MessagePreview>
|
||
</View>
|
||
{hasText ? (
|
||
<View
|
||
style={trailingRowStyle}
|
||
pointerEvents={showTrailingRow ? "auto" : "none"}
|
||
testID="user-message-trailing-row"
|
||
>
|
||
<Text style={userMessageStylesheet.timestampText} testID="user-message-timestamp">
|
||
{formattedTimestamp}
|
||
</Text>
|
||
{capabilities && messageId ? (
|
||
<RewindMenu
|
||
capabilities={capabilities}
|
||
isPending={rewindMutation.isPending}
|
||
rewoundText={message}
|
||
onRewind={handleRewind}
|
||
/>
|
||
) : null}
|
||
<TurnCopyButton
|
||
getContent={getMessageContent}
|
||
containerStyle={userMessageStylesheet.copyButton}
|
||
accessibilityLabel={t("message.actions.copyMessage")}
|
||
/>
|
||
</View>
|
||
) : null}
|
||
</View>
|
||
<AttachmentLightbox source={lightboxSource} onClose={handleLightboxClose} />
|
||
</View>
|
||
);
|
||
});
|
||
|
||
interface AssistantTurnFooterProps {
|
||
getContent?: () => string;
|
||
completedAt?: Date;
|
||
durationMs?: number | null;
|
||
onFork?: (target: AssistantForkTarget) => Promise<void> | void;
|
||
}
|
||
|
||
const assistantTurnFooterStylesheet = StyleSheet.create((theme) => ({
|
||
container: {
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
gap: theme.spacing[2],
|
||
},
|
||
copyButton: {
|
||
alignSelf: "center",
|
||
padding: theme.spacing[1],
|
||
paddingTop: theme.spacing[1],
|
||
marginTop: 0,
|
||
marginLeft: -theme.spacing[1],
|
||
},
|
||
labelWrapper: {
|
||
position: "relative",
|
||
},
|
||
labelSizer: {
|
||
color: theme.colors.foregroundMuted,
|
||
fontSize: STREAM_METADATA_FONT_SIZE,
|
||
opacity: 0,
|
||
},
|
||
labelOverlay: {
|
||
position: "absolute",
|
||
top: 0,
|
||
left: 0,
|
||
color: theme.colors.foregroundMuted,
|
||
fontSize: STREAM_METADATA_FONT_SIZE,
|
||
},
|
||
}));
|
||
|
||
const TIMESTAMP_REVEAL_MS = 3000;
|
||
|
||
/**
|
||
* Footer rendered next to the copy button at the end of an assistant turn.
|
||
* Shows the turn duration and swaps to the end timestamp when both are known.
|
||
* A turn without a visible start shows its end timestamp directly.
|
||
*/
|
||
export const AssistantTurnFooter = memo(function AssistantTurnFooter({
|
||
getContent,
|
||
completedAt,
|
||
durationMs,
|
||
onFork,
|
||
}: AssistantTurnFooterProps) {
|
||
const { t } = useTranslation();
|
||
const [hovered, setHovered] = useState(false);
|
||
const [pressedReveal, setPressedReveal] = useState(false);
|
||
const revealTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
if (revealTimerRef.current) {
|
||
clearTimeout(revealTimerRef.current);
|
||
revealTimerRef.current = null;
|
||
}
|
||
};
|
||
}, []);
|
||
|
||
const durationLabel = useMemo(
|
||
() =>
|
||
durationMs !== undefined && durationMs !== null ? getTurnDurationLabel(durationMs, t) : "",
|
||
[durationMs, t],
|
||
);
|
||
const timestampLabel = useMemo(
|
||
() => (completedAt ? formatMessageTimestamp(completedAt) : ""),
|
||
[completedAt],
|
||
);
|
||
|
||
const primaryLabel = durationLabel || timestampLabel;
|
||
const canSwap = Boolean(durationLabel && timestampLabel);
|
||
const showTimestamp = canSwap && (isWeb ? hovered : pressedReveal);
|
||
|
||
const handleHoverIn = useCallback(() => setHovered(true), []);
|
||
const handleHoverOut = useCallback(() => setHovered(false), []);
|
||
const handlePress = useCallback(() => {
|
||
if (isWeb || !canSwap) return;
|
||
if (revealTimerRef.current) {
|
||
clearTimeout(revealTimerRef.current);
|
||
}
|
||
setPressedReveal((prev) => !prev);
|
||
revealTimerRef.current = setTimeout(() => {
|
||
setPressedReveal(false);
|
||
revealTimerRef.current = null;
|
||
}, TIMESTAMP_REVEAL_MS);
|
||
}, [canSwap]);
|
||
const handleFork = useCallback(
|
||
(target: AssistantForkTarget) => {
|
||
return onFork?.(target);
|
||
},
|
||
[onFork],
|
||
);
|
||
const canFork = Boolean(onFork);
|
||
|
||
return (
|
||
<View style={assistantTurnFooterStylesheet.container}>
|
||
{getContent ? (
|
||
<TurnCopyButton
|
||
getContent={getContent}
|
||
containerStyle={assistantTurnFooterStylesheet.copyButton}
|
||
/>
|
||
) : null}
|
||
{canFork ? <AssistantForkMenu onFork={handleFork} /> : null}
|
||
{primaryLabel ? (
|
||
<Pressable
|
||
onPress={handlePress}
|
||
onHoverIn={handleHoverIn}
|
||
onHoverOut={handleHoverOut}
|
||
accessibilityRole={canSwap ? "button" : undefined}
|
||
accessibilityLabel={canSwap ? `${durationLabel}, ended ${timestampLabel}` : primaryLabel}
|
||
>
|
||
<View style={assistantTurnFooterStylesheet.labelWrapper}>
|
||
{/* Sizer reserves space for whichever label is longer so the
|
||
container width is stable across hover transitions. */}
|
||
<Text style={assistantTurnFooterStylesheet.labelSizer} aria-hidden>
|
||
{primaryLabel.length >= timestampLabel.length ? primaryLabel : timestampLabel}
|
||
</Text>
|
||
<Text style={assistantTurnFooterStylesheet.labelOverlay}>
|
||
{showTimestamp ? timestampLabel : primaryLabel}
|
||
</Text>
|
||
</View>
|
||
</Pressable>
|
||
) : null}
|
||
</View>
|
||
);
|
||
});
|
||
|
||
interface LiveElapsedProps {
|
||
startedAt: Date;
|
||
active?: boolean;
|
||
style?: StyleProp<TextStyle>;
|
||
testID?: string;
|
||
}
|
||
|
||
/**
|
||
* Ticks every second to render an elapsed duration. Isolated from parents so
|
||
* only this component re-renders on each tick.
|
||
*/
|
||
export const LiveElapsed = memo(function LiveElapsed({
|
||
startedAt,
|
||
active = true,
|
||
style,
|
||
testID,
|
||
}: LiveElapsedProps) {
|
||
const startedAtMs = startedAt.getTime();
|
||
const [elapsedMs, setElapsedMs] = useState(() => Math.max(0, Date.now() - startedAtMs));
|
||
const visibleElapsedMs = active ? Math.max(0, Date.now() - startedAtMs) : elapsedMs;
|
||
|
||
useEffect(() => {
|
||
if (!active) {
|
||
return;
|
||
}
|
||
setElapsedMs(Math.max(0, Date.now() - startedAtMs));
|
||
const handle = setInterval(() => {
|
||
setElapsedMs(Math.max(0, Date.now() - startedAtMs));
|
||
}, 1000);
|
||
return () => clearInterval(handle);
|
||
}, [active, startedAtMs]);
|
||
|
||
return (
|
||
<Text style={style} testID={testID}>
|
||
{formatDuration(visibleElapsedMs)}
|
||
</Text>
|
||
);
|
||
});
|
||
|
||
interface AssistantMessageProps {
|
||
renderFullContent?: boolean;
|
||
occurrenceKey: string;
|
||
message: string;
|
||
timestamp: number;
|
||
workspaceRoot?: string;
|
||
serverId?: string;
|
||
client?: DaemonClient | null;
|
||
spacing?: "default" | "compactTop" | "compactBottom" | "compactBoth";
|
||
phase: MarkdownPhase;
|
||
}
|
||
|
||
export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
|
||
container: {
|
||
paddingVertical: theme.spacing[3],
|
||
...(isWeb ? { userSelect: "text" as const } : {}),
|
||
},
|
||
containerCompactTop: {
|
||
paddingTop: 0,
|
||
},
|
||
containerCompactBottom: {
|
||
paddingBottom: 0,
|
||
},
|
||
cappedNotice: {
|
||
marginTop: theme.spacing[3],
|
||
fontFamily: theme.fontFamily.ui,
|
||
fontSize: theme.fontSize.base,
|
||
fontStyle: "italic",
|
||
color: theme.colors.foregroundMuted,
|
||
},
|
||
imageFrame: {
|
||
width: "100%",
|
||
minHeight: 160,
|
||
marginHorizontal: -theme.spacing[1],
|
||
},
|
||
imageSurface: {
|
||
width: "100%",
|
||
overflow: "hidden",
|
||
position: "relative",
|
||
},
|
||
image: {
|
||
width: "100%",
|
||
height: "100%",
|
||
},
|
||
imageLoadingOverlay: {
|
||
position: "absolute",
|
||
top: 0,
|
||
right: 0,
|
||
bottom: 0,
|
||
left: 0,
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
},
|
||
imageState: {
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
paddingHorizontal: theme.spacing[4],
|
||
paddingVertical: theme.spacing[6],
|
||
gap: theme.spacing[2],
|
||
},
|
||
imageErrorText: {
|
||
color: theme.colors.foregroundMuted,
|
||
fontSize: theme.fontSize.base,
|
||
textAlign: "center",
|
||
},
|
||
}));
|
||
|
||
const turnCopyButtonStylesheet = StyleSheet.create((theme) => ({
|
||
container: {
|
||
alignSelf: "flex-start",
|
||
padding: theme.spacing[2],
|
||
paddingTop: 0,
|
||
marginTop: theme.spacing[2],
|
||
},
|
||
iconColor: {
|
||
color: theme.colors.foregroundMuted,
|
||
},
|
||
iconHoveredColor: {
|
||
color: theme.colors.foreground,
|
||
},
|
||
}));
|
||
|
||
interface TurnCopyButtonProps {
|
||
getContent: () => string;
|
||
containerStyle?: StyleProp<ViewStyle>;
|
||
accessibilityLabel?: string;
|
||
copiedAccessibilityLabel?: string;
|
||
}
|
||
|
||
export const TurnCopyButton = memo(function TurnCopyButton({
|
||
getContent,
|
||
containerStyle,
|
||
accessibilityLabel,
|
||
copiedAccessibilityLabel,
|
||
}: TurnCopyButtonProps) {
|
||
const { t } = useTranslation();
|
||
const [copied, setCopied] = useState(false);
|
||
const copyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
|
||
const handleCopy = useCallback(async () => {
|
||
const content = getContent();
|
||
if (!content) {
|
||
return;
|
||
}
|
||
|
||
await writeMarkdownToRichClipboard(content, getDefaultMarkdownClipboardEnvironment());
|
||
setCopied(true);
|
||
|
||
if (copyTimeoutRef.current) {
|
||
clearTimeout(copyTimeoutRef.current);
|
||
}
|
||
|
||
copyTimeoutRef.current = setTimeout(() => {
|
||
setCopied(false);
|
||
copyTimeoutRef.current = null;
|
||
}, 1500);
|
||
}, [getContent]);
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
if (copyTimeoutRef.current) {
|
||
clearTimeout(copyTimeoutRef.current);
|
||
}
|
||
};
|
||
}, []);
|
||
|
||
const pressableStyle = useMemo(
|
||
() => [turnCopyButtonStylesheet.container, containerStyle],
|
||
[containerStyle],
|
||
);
|
||
|
||
return (
|
||
<Pressable
|
||
onPress={handleCopy}
|
||
style={pressableStyle}
|
||
accessibilityRole="button"
|
||
accessibilityLabel={
|
||
copied
|
||
? (copiedAccessibilityLabel ?? t("message.actions.copied"))
|
||
: (accessibilityLabel ?? t("message.actions.copyTurn"))
|
||
}
|
||
>
|
||
{({ hovered }) => {
|
||
const iconColor = hovered
|
||
? turnCopyButtonStylesheet.iconHoveredColor.color
|
||
: turnCopyButtonStylesheet.iconColor.color;
|
||
return copied ? (
|
||
<Check size={ICON_SIZE.sm} color={iconColor} />
|
||
) : (
|
||
<Copy size={ICON_SIZE.sm} color={iconColor} />
|
||
);
|
||
}}
|
||
</Pressable>
|
||
);
|
||
});
|
||
|
||
const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
|
||
container: {
|
||
marginHorizontal: -13,
|
||
},
|
||
containerSpacing: {
|
||
marginBottom: theme.spacing[1],
|
||
},
|
||
containerLastInSequence: {
|
||
marginBottom: theme.spacing[4],
|
||
},
|
||
pressable: {
|
||
borderRadius: theme.borderRadius.lg,
|
||
borderWidth: theme.borderWidth[1],
|
||
borderColor: "transparent",
|
||
paddingHorizontal: theme.spacing[2],
|
||
paddingVertical: theme.spacing[1],
|
||
overflow: "hidden",
|
||
},
|
||
pressablePressed: {
|
||
opacity: 0.9,
|
||
},
|
||
headerRow: {
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
},
|
||
labelRow: {
|
||
flex: 1,
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
overflow: "hidden",
|
||
},
|
||
iconBadge: {
|
||
width: 22,
|
||
height: 22,
|
||
borderRadius: 11,
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
marginRight: theme.spacing[1],
|
||
backgroundColor: "transparent",
|
||
},
|
||
label: {
|
||
color: theme.colors.foregroundMuted,
|
||
fontSize: theme.fontSize.base,
|
||
fontWeight: theme.fontWeight.normal,
|
||
flexShrink: 0,
|
||
},
|
||
labelActive: {
|
||
color: theme.colors.foreground,
|
||
},
|
||
labelLoading: {
|
||
color: theme.colors.foreground,
|
||
opacity: 0.72,
|
||
},
|
||
secondaryLabel: {
|
||
flexShrink: 1,
|
||
minWidth: 0,
|
||
color: theme.colors.foregroundMuted,
|
||
fontSize: theme.fontSize.base,
|
||
fontWeight: theme.fontWeight.normal,
|
||
marginLeft: theme.spacing[2],
|
||
},
|
||
secondaryLabelActive: {
|
||
color: theme.colors.foreground,
|
||
},
|
||
shimmerText: {
|
||
color: "transparent",
|
||
fontSize: theme.fontSize.base,
|
||
fontWeight: theme.fontWeight.normal,
|
||
},
|
||
spacer: {
|
||
flex: 1,
|
||
},
|
||
chevron: {
|
||
flexShrink: 0,
|
||
},
|
||
openFileButton: {
|
||
marginLeft: theme.spacing[1],
|
||
padding: theme.spacing[1],
|
||
borderRadius: theme.borderRadius.md,
|
||
flexShrink: 0,
|
||
},
|
||
openFileButtonPlaceholderIcon: {
|
||
width: 14,
|
||
height: 14,
|
||
},
|
||
detailWrapper: {
|
||
borderBottomLeftRadius: theme.borderRadius.lg,
|
||
borderBottomRightRadius: theme.borderRadius.lg,
|
||
borderWidth: theme.borderWidth[1],
|
||
borderTopWidth: 0,
|
||
borderColor: theme.colors.border,
|
||
padding: 0,
|
||
gap: 0,
|
||
flexShrink: 1,
|
||
minWidth: 0,
|
||
overflow: "hidden",
|
||
...(isWeb ? { cursor: "auto" as const, userSelect: "text" as const } : {}),
|
||
},
|
||
pressableExpanded: {
|
||
backgroundColor: theme.colors.surface1,
|
||
},
|
||
pressableExpandedAttached: {
|
||
borderColor: theme.colors.border,
|
||
borderBottomLeftRadius: 0,
|
||
borderBottomRightRadius: 0,
|
||
},
|
||
// Details that do not sit in a card leave the header as a lone highlighted pill.
|
||
pressableExpandedDetached: {
|
||
borderRadius: theme.borderRadius.full,
|
||
},
|
||
detailWrapperBorderless: {
|
||
borderWidth: 0,
|
||
},
|
||
shimmerOverlay: {
|
||
position: "absolute",
|
||
top: 0,
|
||
right: 0,
|
||
bottom: 0,
|
||
left: 0,
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
overflow: "hidden",
|
||
},
|
||
shimmerMaskRow: {
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
width: "100%",
|
||
height: "100%",
|
||
},
|
||
nativeShimmerTrack: {
|
||
position: "absolute",
|
||
top: 0,
|
||
right: 0,
|
||
bottom: 0,
|
||
left: 0,
|
||
overflow: "hidden",
|
||
},
|
||
nativeShimmerPeak: {
|
||
position: "absolute",
|
||
top: 0,
|
||
bottom: 0,
|
||
left: 0,
|
||
},
|
||
}));
|
||
|
||
interface NativeExpandableBadgeShimmerProps {
|
||
label: string;
|
||
secondaryLabel?: string;
|
||
rowWidth: number;
|
||
rowHeight: number;
|
||
peakWidth: number;
|
||
durationSeconds: number;
|
||
gradientId: string;
|
||
}
|
||
|
||
const NativeExpandableBadgeShimmer = memo(function NativeExpandableBadgeShimmer({
|
||
label,
|
||
secondaryLabel,
|
||
rowWidth,
|
||
rowHeight,
|
||
peakWidth,
|
||
durationSeconds,
|
||
gradientId,
|
||
}: NativeExpandableBadgeShimmerProps) {
|
||
const isPanelActive = useRetainedPanelActive();
|
||
const shimmerTranslateX = useSharedValue(0);
|
||
|
||
useEffect(() => {
|
||
if (!isPanelActive) {
|
||
cancelAnimation(shimmerTranslateX);
|
||
return;
|
||
}
|
||
const startPosition = -peakWidth;
|
||
const endPosition = rowWidth + peakWidth;
|
||
shimmerTranslateX.value = startPosition;
|
||
shimmerTranslateX.value = withRepeat(
|
||
withTiming(endPosition, {
|
||
duration: durationSeconds * 1000,
|
||
easing: Easing.linear,
|
||
}),
|
||
-1,
|
||
false,
|
||
);
|
||
return () => {
|
||
cancelAnimation(shimmerTranslateX);
|
||
};
|
||
}, [durationSeconds, isPanelActive, peakWidth, rowWidth, shimmerTranslateX]);
|
||
|
||
const nativeShimmerPeakStyle = useAnimatedStyle(() => ({
|
||
transform: [{ translateX: shimmerTranslateX.value }],
|
||
}));
|
||
|
||
const nativeShimmerTrackStyle = useMemo(
|
||
() => [expandableBadgeStylesheet.nativeShimmerTrack, { width: rowWidth, height: rowHeight }],
|
||
[rowHeight, rowWidth],
|
||
);
|
||
|
||
const nativeShimmerMaskStyle = useMemo(
|
||
() => [expandableBadgeStylesheet.shimmerMaskRow, { width: rowWidth, height: rowHeight }],
|
||
[rowHeight, rowWidth],
|
||
);
|
||
|
||
const nativeLabelMaskStyle = useMemo(
|
||
() => [expandableBadgeStylesheet.label, { color: "#000000", opacity: 1 }],
|
||
[],
|
||
);
|
||
|
||
const nativeSecondaryMaskStyle = useMemo(
|
||
() => [expandableBadgeStylesheet.secondaryLabel, { color: "#000000", opacity: 1 }],
|
||
[],
|
||
);
|
||
|
||
const nativeShimmerPeakCombinedStyle = useMemo(
|
||
() => [
|
||
expandableBadgeStylesheet.nativeShimmerPeak,
|
||
nativeShimmerPeakStyle,
|
||
{ width: peakWidth, height: rowHeight },
|
||
],
|
||
[nativeShimmerPeakStyle, peakWidth, rowHeight],
|
||
);
|
||
|
||
const maskElement = useMemo(
|
||
() => (
|
||
<View pointerEvents="none" style={nativeShimmerMaskStyle}>
|
||
<Text style={nativeLabelMaskStyle} numberOfLines={1}>
|
||
{label}
|
||
</Text>
|
||
{secondaryLabel ? (
|
||
<Text style={nativeSecondaryMaskStyle} numberOfLines={1}>
|
||
{secondaryLabel}
|
||
</Text>
|
||
) : (
|
||
<View style={expandableBadgeStylesheet.spacer} />
|
||
)}
|
||
</View>
|
||
),
|
||
[nativeShimmerMaskStyle, nativeLabelMaskStyle, nativeSecondaryMaskStyle, label, secondaryLabel],
|
||
);
|
||
|
||
return (
|
||
<View style={expandableBadgeStylesheet.shimmerOverlay} pointerEvents="none">
|
||
<MaskedView pointerEvents="none" style={nativeShimmerTrackStyle} maskElement={maskElement}>
|
||
<View pointerEvents="none" style={nativeShimmerTrackStyle}>
|
||
<Animated.View pointerEvents="none" style={nativeShimmerPeakCombinedStyle}>
|
||
<NativeShimmerPeakSvg gradientId={gradientId} />
|
||
</Animated.View>
|
||
</View>
|
||
</MaskedView>
|
||
</View>
|
||
);
|
||
});
|
||
|
||
function NativeShimmerPeakSvg({ gradientId }: { gradientId: string }) {
|
||
return (
|
||
<Svg width="100%" height="100%" preserveAspectRatio="none">
|
||
<Defs>
|
||
<SvgLinearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">
|
||
<Stop offset="0%" stopColor="#ffffff" stopOpacity={0} />
|
||
<Stop offset="50%" stopColor="#ffffff" stopOpacity={1} />
|
||
<Stop offset="100%" stopColor="#ffffff" stopOpacity={0} />
|
||
</SvgLinearGradient>
|
||
</Defs>
|
||
<Rect x="0" y="0" width="100%" height="100%" fill={`url(#${gradientId})`} />
|
||
</Svg>
|
||
);
|
||
}
|
||
|
||
export const AssistantMessage = memo(function AssistantMessage({
|
||
renderFullContent = false,
|
||
occurrenceKey,
|
||
message,
|
||
timestamp: _timestamp,
|
||
workspaceRoot,
|
||
serverId,
|
||
client,
|
||
spacing = "default",
|
||
phase,
|
||
}: AssistantMessageProps) {
|
||
const { t } = useTranslation();
|
||
const renderedMessage = useMemo(
|
||
() =>
|
||
renderFullContent ? { text: message, capped: false } : capAssistantMessageForRender(message),
|
||
[message, renderFullContent],
|
||
);
|
||
const revealedText = useRevealedText(renderedMessage.text, phase);
|
||
const revealedMessage = renderFullContent ? renderedMessage.text : revealedText;
|
||
const fullMessageByteLength = useMemo(
|
||
() => (renderedMessage.capped && phase === "complete" ? getUtf8ByteLength(message) : null),
|
||
[message, phase, renderedMessage.capped],
|
||
);
|
||
|
||
const assistantContainerStyle = useMemo(
|
||
() => [
|
||
assistantMessageStylesheet.container,
|
||
(spacing === "compactTop" || spacing === "compactBoth") &&
|
||
assistantMessageStylesheet.containerCompactTop,
|
||
(spacing === "compactBottom" || spacing === "compactBoth") &&
|
||
assistantMessageStylesheet.containerCompactBottom,
|
||
],
|
||
[spacing],
|
||
);
|
||
const revealDataSet = useMemo(
|
||
() =>
|
||
isRenderProfileEnabled()
|
||
? {
|
||
...MESSAGE_TEXT_DATASET,
|
||
revealKey: occurrenceKey,
|
||
revealLength: String(revealedMessage.length),
|
||
}
|
||
: MESSAGE_TEXT_DATASET,
|
||
[occurrenceKey, revealedMessage.length],
|
||
);
|
||
|
||
const measureMessage = useStableEvent((event: import("react-native").LayoutChangeEvent) => {
|
||
setAssistantMessageHeight({ markdown: revealedMessage, ...event.nativeEvent.layout });
|
||
});
|
||
return (
|
||
<View
|
||
testID="assistant-message"
|
||
dataSet={revealDataSet}
|
||
style={assistantContainerStyle}
|
||
onLayout={measureMessage}
|
||
>
|
||
<MarkdownRenderer
|
||
text={revealedMessage}
|
||
agentCommands
|
||
streaming={phase !== "complete" || revealedMessage.length < renderedMessage.text.length}
|
||
client={client}
|
||
workspaceRoot={workspaceRoot}
|
||
serverId={serverId}
|
||
/>
|
||
{fullMessageByteLength !== null ? (
|
||
<Text
|
||
testID="assistant-message-capped-notice"
|
||
style={assistantMessageStylesheet.cappedNotice}
|
||
>
|
||
{t("agentStream.messageCapped", { bytes: fullMessageByteLength })}
|
||
</Text>
|
||
) : null}
|
||
</View>
|
||
);
|
||
});
|
||
|
||
interface SpeakMessageProps {
|
||
message: string;
|
||
timestamp: number;
|
||
disableOuterSpacing?: boolean;
|
||
}
|
||
|
||
const speakMessageStylesheet = StyleSheet.create((theme) => ({
|
||
container: {
|
||
paddingVertical: theme.spacing[3],
|
||
},
|
||
containerSpacing: {
|
||
marginBottom: theme.spacing[4],
|
||
},
|
||
header: {
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
gap: theme.spacing[2],
|
||
marginBottom: theme.spacing[2],
|
||
},
|
||
headerLabel: {
|
||
fontFamily: theme.fontFamily.ui,
|
||
fontSize: theme.fontSize.base,
|
||
fontWeight: theme.fontWeight.normal,
|
||
color: theme.colors.foregroundMuted,
|
||
},
|
||
text: {
|
||
fontFamily: theme.fontFamily.ui,
|
||
fontSize: theme.fontSize.content,
|
||
lineHeight: Math.round(theme.fontSize.content * 1.4),
|
||
color: theme.colors.foreground,
|
||
},
|
||
}));
|
||
|
||
export const SpeakMessage = memo(function SpeakMessage({
|
||
message,
|
||
timestamp: _timestamp,
|
||
disableOuterSpacing,
|
||
}: SpeakMessageProps) {
|
||
const { t } = useTranslation();
|
||
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
|
||
const containerStyle = useMemo(
|
||
() => [
|
||
speakMessageStylesheet.container,
|
||
!resolvedDisableOuterSpacing && speakMessageStylesheet.containerSpacing,
|
||
],
|
||
[resolvedDisableOuterSpacing],
|
||
);
|
||
|
||
return (
|
||
<View testID="speak-message" style={containerStyle}>
|
||
<View style={speakMessageStylesheet.header}>
|
||
<ThemedMicVocal size={12} uniProps={foregroundMutedColorMapping} />
|
||
<Text style={speakMessageStylesheet.headerLabel}>{t("message.speak.header")}</Text>
|
||
</View>
|
||
<Text style={speakMessageStylesheet.text}>{message}</Text>
|
||
</View>
|
||
);
|
||
});
|
||
|
||
interface NotificationProps {
|
||
level: "info" | "warning" | "error";
|
||
message: string;
|
||
disableOuterSpacing?: boolean;
|
||
}
|
||
|
||
const notificationStylesheet = StyleSheet.create((theme) => ({
|
||
container: {
|
||
borderRadius: theme.borderRadius.md,
|
||
overflow: "hidden",
|
||
},
|
||
containerSpacing: {
|
||
marginBottom: theme.spacing[1],
|
||
},
|
||
infoBg: {
|
||
backgroundColor: "rgba(147, 197, 253, 0.1)",
|
||
},
|
||
warningBg: {
|
||
backgroundColor: "rgba(245, 158, 11, 0.1)",
|
||
},
|
||
errorBg: {
|
||
backgroundColor: "rgba(239, 68, 68, 0.1)",
|
||
},
|
||
content: {
|
||
paddingHorizontal: theme.spacing[3],
|
||
paddingVertical: 10,
|
||
},
|
||
row: {
|
||
flexDirection: "row",
|
||
alignItems: "flex-start",
|
||
gap: theme.spacing[2],
|
||
},
|
||
iconContainer: {
|
||
flexShrink: 0,
|
||
height: 20,
|
||
justifyContent: "center",
|
||
},
|
||
textContainer: {
|
||
flex: 1,
|
||
},
|
||
messageText: {
|
||
color: theme.colors.foreground,
|
||
fontSize: theme.fontSize.base,
|
||
lineHeight: 20,
|
||
},
|
||
}));
|
||
|
||
export const Notification = memo(function Notification({
|
||
level,
|
||
message,
|
||
disableOuterSpacing,
|
||
}: NotificationProps) {
|
||
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
|
||
|
||
const typeConfig = {
|
||
info: {
|
||
bg: notificationStylesheet.infoBg,
|
||
iconColorMapping: infoColorMapping,
|
||
Icon: ThemedNotificationInfo,
|
||
},
|
||
warning: {
|
||
bg: notificationStylesheet.warningBg,
|
||
iconColorMapping: warningColorMapping,
|
||
Icon: ThemedNotificationWarning,
|
||
},
|
||
error: {
|
||
bg: notificationStylesheet.errorBg,
|
||
iconColorMapping: destructiveColorMapping,
|
||
Icon: ThemedNotificationError,
|
||
},
|
||
};
|
||
|
||
const config = typeConfig[level];
|
||
const IconComponent = config.Icon;
|
||
|
||
const containerStyle = useMemo(
|
||
() => [
|
||
notificationStylesheet.container,
|
||
!resolvedDisableOuterSpacing && notificationStylesheet.containerSpacing,
|
||
config.bg,
|
||
],
|
||
[resolvedDisableOuterSpacing, config.bg],
|
||
);
|
||
return (
|
||
<View style={containerStyle}>
|
||
<View style={notificationStylesheet.content}>
|
||
<View style={notificationStylesheet.row}>
|
||
<View style={notificationStylesheet.iconContainer}>
|
||
<IconComponent size={16} uniProps={config.iconColorMapping} />
|
||
</View>
|
||
<View style={notificationStylesheet.textContainer}>
|
||
<Text style={notificationStylesheet.messageText} selectable>
|
||
{message}
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
);
|
||
});
|
||
|
||
interface CompactionMarkerProps {
|
||
status: "loading" | "completed";
|
||
trigger?: "auto" | "manual";
|
||
preTokens?: number;
|
||
}
|
||
|
||
const compactionStylesheet = StyleSheet.create((theme) => ({
|
||
container: {
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
paddingVertical: theme.spacing[3],
|
||
paddingHorizontal: theme.spacing[4],
|
||
gap: theme.spacing[2],
|
||
},
|
||
line: {
|
||
flex: 1,
|
||
height: 1,
|
||
backgroundColor: theme.colors.border,
|
||
},
|
||
label: {
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
gap: theme.spacing[2],
|
||
},
|
||
text: {
|
||
fontFamily: theme.fontFamily.ui,
|
||
fontSize: 13,
|
||
color: theme.colors.foregroundMuted,
|
||
},
|
||
}));
|
||
|
||
export const CompactionMarker = memo(function CompactionMarker({
|
||
status,
|
||
trigger,
|
||
preTokens,
|
||
}: CompactionMarkerProps) {
|
||
const label = getCompactionMarkerLabel({ status, trigger, preTokens });
|
||
|
||
return (
|
||
<View style={compactionStylesheet.container}>
|
||
<View style={compactionStylesheet.line} />
|
||
<View style={compactionStylesheet.label}>
|
||
{status === "loading" ? (
|
||
<LoadingSpinner size="small" color="#a1a1aa" />
|
||
) : (
|
||
<Scissors size={12} color="#a1a1aa" />
|
||
)}
|
||
<Text style={compactionStylesheet.text}>{label}</Text>
|
||
</View>
|
||
<View style={compactionStylesheet.line} />
|
||
</View>
|
||
);
|
||
});
|
||
|
||
interface TodoListCardProps {
|
||
items: TodoEntry[];
|
||
activity: TaskActivity;
|
||
disableOuterSpacing?: boolean;
|
||
}
|
||
|
||
function taskActivityIcon(activity: TaskActivity) {
|
||
switch (activity.type) {
|
||
case "added":
|
||
return Plus;
|
||
case "started":
|
||
return CircleDot;
|
||
case "completed":
|
||
return Check;
|
||
default:
|
||
return CheckSquare;
|
||
}
|
||
}
|
||
|
||
const todoListCardStylesheet = StyleSheet.create((theme) => ({
|
||
detailsWrapper: {
|
||
padding: theme.spacing[2],
|
||
},
|
||
list: {
|
||
gap: theme.spacing[1],
|
||
},
|
||
emptyText: {
|
||
color: theme.colors.foregroundMuted,
|
||
fontSize: theme.fontSize.base,
|
||
},
|
||
}));
|
||
|
||
export const TodoListCard = memo(function TodoListCard({
|
||
items,
|
||
activity,
|
||
disableOuterSpacing,
|
||
}: TodoListCardProps) {
|
||
const { t } = useTranslation();
|
||
const [isExpanded, setIsExpanded] = useState(false);
|
||
const activityDisplay = useMemo(() => {
|
||
if (activity.type === "created") {
|
||
return {
|
||
label: t("message.todo.activity.created", { count: activity.count }),
|
||
secondaryLabel: undefined,
|
||
};
|
||
}
|
||
return {
|
||
label: t(`message.todo.activity.${activity.type}`),
|
||
secondaryLabel: activity.task,
|
||
};
|
||
}, [activity, t]);
|
||
|
||
const handleToggle = useCallback(() => {
|
||
setIsExpanded((prev) => !prev);
|
||
}, []);
|
||
|
||
const renderDetails = useCallback(() => {
|
||
return (
|
||
<View style={todoListCardStylesheet.detailsWrapper}>
|
||
<View style={todoListCardStylesheet.list}>
|
||
{items.length === 0 ? (
|
||
<Text style={todoListCardStylesheet.emptyText}>{t("message.todo.empty")}</Text>
|
||
) : (
|
||
items.map((item) => <TaskListRow key={item.id ?? item.text} task={item} />)
|
||
)}
|
||
</View>
|
||
</View>
|
||
);
|
||
}, [items, t]);
|
||
|
||
return (
|
||
<ExpandableBadge
|
||
label={activityDisplay.label}
|
||
secondaryLabel={activityDisplay.secondaryLabel}
|
||
icon={taskActivityIcon(activity)}
|
||
isExpanded={isExpanded}
|
||
onToggle={handleToggle}
|
||
renderDetails={renderDetails}
|
||
disableOuterSpacing={disableOuterSpacing}
|
||
/>
|
||
);
|
||
});
|
||
|
||
interface ExpandableBadgeProps {
|
||
label: string;
|
||
secondaryLabel?: string;
|
||
icon?: ComponentType<{ size?: number; color?: string }>;
|
||
isExpanded: boolean;
|
||
style?: StyleProp<ViewStyle>;
|
||
onToggle?: () => void;
|
||
onOpenFile?: () => void;
|
||
onDetailHoverChange?: (hovered: boolean) => void;
|
||
renderDetails?: () => ReactNode;
|
||
isLoading?: boolean;
|
||
isError?: boolean;
|
||
isLastInSequence?: boolean;
|
||
disableOuterSpacing?: boolean;
|
||
borderlessWhenExpanded?: boolean;
|
||
testID?: string;
|
||
}
|
||
|
||
interface ExpandableBadgeSecondaryLabelProps {
|
||
secondaryLabel?: string;
|
||
secondaryLabelStyle: StyleProp<TextStyle>;
|
||
shouldMeasureWebShimmer: boolean;
|
||
onSecondaryLayout: (event: LayoutChangeEvent) => void;
|
||
}
|
||
|
||
function ExpandableBadgeSecondaryLabel({
|
||
secondaryLabel,
|
||
secondaryLabelStyle,
|
||
shouldMeasureWebShimmer,
|
||
onSecondaryLayout,
|
||
}: ExpandableBadgeSecondaryLabelProps) {
|
||
if (!secondaryLabel) {
|
||
return null;
|
||
}
|
||
return (
|
||
<Text
|
||
style={secondaryLabelStyle}
|
||
numberOfLines={1}
|
||
onLayout={shouldMeasureWebShimmer ? onSecondaryLayout : undefined}
|
||
>
|
||
{secondaryLabel}
|
||
</Text>
|
||
);
|
||
}
|
||
|
||
interface ExpandableBadgeWebShimmerOverlayProps {
|
||
label: string;
|
||
secondaryLabel?: string;
|
||
shimmerLabelTextStyle: StyleProp<TextStyle>;
|
||
shimmerSecondaryTextStyle: StyleProp<TextStyle>;
|
||
showOpenFileButton: boolean;
|
||
}
|
||
|
||
function ExpandableBadgeWebShimmerOverlay({
|
||
label,
|
||
secondaryLabel,
|
||
shimmerLabelTextStyle,
|
||
shimmerSecondaryTextStyle,
|
||
showOpenFileButton,
|
||
}: ExpandableBadgeWebShimmerOverlayProps) {
|
||
return (
|
||
<View style={expandableBadgeStylesheet.shimmerOverlay} pointerEvents="none">
|
||
<Text style={shimmerLabelTextStyle} numberOfLines={1}>
|
||
{label}
|
||
</Text>
|
||
{secondaryLabel ? (
|
||
<Text style={shimmerSecondaryTextStyle} numberOfLines={1}>
|
||
{secondaryLabel}
|
||
</Text>
|
||
) : null}
|
||
{showOpenFileButton ? (
|
||
<View style={expandableBadgeStylesheet.openFileButton}>
|
||
<View style={expandableBadgeStylesheet.openFileButtonPlaceholderIcon} />
|
||
</View>
|
||
) : null}
|
||
{!secondaryLabel && !showOpenFileButton ? (
|
||
<View style={expandableBadgeStylesheet.spacer} />
|
||
) : null}
|
||
</View>
|
||
);
|
||
}
|
||
|
||
interface ExpandableBadgeLabelRowProps {
|
||
label: string;
|
||
labelStyle: StyleProp<TextStyle>;
|
||
secondaryLabel?: string;
|
||
secondaryLabelStyle: StyleProp<TextStyle>;
|
||
shouldMeasureWebShimmer: boolean;
|
||
shouldMeasureNativeShimmer: boolean;
|
||
isWebShimmer: boolean;
|
||
isNativeShimmer: boolean;
|
||
shimmerLabelTextStyle: StyleProp<TextStyle>;
|
||
shimmerSecondaryTextStyle: StyleProp<TextStyle>;
|
||
labelRowWidth: number;
|
||
labelRowHeight: number;
|
||
nativeShimmerPeakWidth: number;
|
||
shimmerDuration: number;
|
||
nativeGradientId: string;
|
||
onLabelRowLayout: (event: LayoutChangeEvent) => void;
|
||
onLabelLayout: (event: LayoutChangeEvent) => void;
|
||
onSecondaryLayout: (event: LayoutChangeEvent) => void;
|
||
showOpenFileButton: boolean;
|
||
isOpenFileHovered: boolean;
|
||
onOpenFilePress: (event: GestureResponderEvent) => void;
|
||
onOpenFileHoverIn: () => void;
|
||
onOpenFileHoverOut: () => void;
|
||
}
|
||
|
||
function ExpandableBadgeLabelRow({
|
||
label,
|
||
labelStyle,
|
||
secondaryLabel,
|
||
secondaryLabelStyle,
|
||
shouldMeasureWebShimmer,
|
||
shouldMeasureNativeShimmer,
|
||
isWebShimmer,
|
||
isNativeShimmer,
|
||
shimmerLabelTextStyle,
|
||
shimmerSecondaryTextStyle,
|
||
labelRowWidth,
|
||
labelRowHeight,
|
||
nativeShimmerPeakWidth,
|
||
shimmerDuration,
|
||
nativeGradientId,
|
||
onLabelRowLayout,
|
||
onLabelLayout,
|
||
onSecondaryLayout,
|
||
showOpenFileButton,
|
||
isOpenFileHovered,
|
||
onOpenFilePress,
|
||
onOpenFileHoverIn,
|
||
onOpenFileHoverOut,
|
||
}: ExpandableBadgeLabelRowProps) {
|
||
const { t } = useTranslation();
|
||
return (
|
||
<View
|
||
style={expandableBadgeStylesheet.labelRow}
|
||
onLayout={shouldMeasureNativeShimmer ? onLabelRowLayout : undefined}
|
||
>
|
||
<Text
|
||
style={labelStyle}
|
||
numberOfLines={1}
|
||
onLayout={shouldMeasureWebShimmer ? onLabelLayout : undefined}
|
||
>
|
||
{label}
|
||
</Text>
|
||
<ExpandableBadgeSecondaryLabel
|
||
secondaryLabel={secondaryLabel}
|
||
secondaryLabelStyle={secondaryLabelStyle}
|
||
shouldMeasureWebShimmer={shouldMeasureWebShimmer}
|
||
onSecondaryLayout={onSecondaryLayout}
|
||
/>
|
||
{showOpenFileButton ? (
|
||
<Pressable
|
||
onPress={onOpenFilePress}
|
||
onHoverIn={onOpenFileHoverIn}
|
||
onHoverOut={onOpenFileHoverOut}
|
||
accessibilityRole="button"
|
||
accessibilityLabel={t("message.actions.openFile")}
|
||
testID="tool-call-open-file"
|
||
style={expandableBadgeStylesheet.openFileButton}
|
||
hitSlop={6}
|
||
>
|
||
<ThemedFileSymlinkIcon
|
||
size={14}
|
||
uniProps={isOpenFileHovered ? foregroundColorMapping : foregroundMutedColorMapping}
|
||
/>
|
||
</Pressable>
|
||
) : null}
|
||
{isWebShimmer ? (
|
||
<ExpandableBadgeWebShimmerOverlay
|
||
label={label}
|
||
secondaryLabel={secondaryLabel}
|
||
shimmerLabelTextStyle={shimmerLabelTextStyle}
|
||
shimmerSecondaryTextStyle={shimmerSecondaryTextStyle}
|
||
showOpenFileButton={showOpenFileButton}
|
||
/>
|
||
) : null}
|
||
{isNativeShimmer ? (
|
||
<NativeExpandableBadgeShimmer
|
||
label={label}
|
||
secondaryLabel={secondaryLabel}
|
||
rowWidth={labelRowWidth}
|
||
rowHeight={labelRowHeight}
|
||
peakWidth={nativeShimmerPeakWidth}
|
||
durationSeconds={shimmerDuration}
|
||
gradientId={nativeGradientId}
|
||
/>
|
||
) : null}
|
||
</View>
|
||
);
|
||
}
|
||
|
||
// HACK: lucide ships every icon inside a 24×24 viewBox where the path
|
||
// doesn't touch the edges — there's per-icon internal padding. The layout
|
||
// already places the SVG element's box on the rail, but the visible glyph
|
||
// inside the SVG sits inset by a few pixels (and the inset amount differs
|
||
// per icon — chevron-right paints only in the right half of its viewBox,
|
||
// regular tool icons paint roughly the full viewBox minus ~1 unit margin).
|
||
//
|
||
// Lucide has no viewBox knob, so the only way to nudge the visible glyph
|
||
// flush with the rail is a per-icon negative margin. Cosmetic; not exact —
|
||
// every lucide icon has slightly different padding and we're not measuring
|
||
// each one. Two buckets is the compromise:
|
||
// - LUCIDE_TOOL_ICON_NUDGE_LEFT: regular tool icons (path mostly fills
|
||
// the viewBox); needs ~1px left shift.
|
||
// - LUCIDE_CHEVRON_NUDGE_LEFT: chevron-right (path in right half of
|
||
// viewBox, and we scale it 1.3×); needs ~4px left shift.
|
||
// If we ever want this exact, the principled fix is a custom <Svg> wrapper
|
||
// with a tight viewBox per icon — see option (2) in the design discussion.
|
||
const LUCIDE_TOOL_ICON_NUDGE_LEFT: ViewStyle = { marginLeft: -1 };
|
||
const LUCIDE_CHEVRON_NUDGE_LEFT: ViewStyle = { marginLeft: -4 };
|
||
|
||
function renderExpandableBadgeIcon({
|
||
isError,
|
||
isActive,
|
||
ThemedIcon,
|
||
}: {
|
||
isError: boolean;
|
||
isActive: boolean;
|
||
ThemedIcon: ComponentType<{ size?: number; uniProps?: typeof foregroundColorMapping }> | null;
|
||
}): ReactNode {
|
||
if (isError) {
|
||
return (
|
||
<View style={LUCIDE_TOOL_ICON_NUDGE_LEFT}>
|
||
<ThemedTriangleAlertIcon size={12} opacity={0.8} uniProps={destructiveColorMapping} />
|
||
</View>
|
||
);
|
||
}
|
||
if (ThemedIcon) {
|
||
return (
|
||
<View style={LUCIDE_TOOL_ICON_NUDGE_LEFT}>
|
||
<ThemedIcon
|
||
size={12}
|
||
uniProps={isActive ? foregroundColorMapping : mutedForegroundColorMapping}
|
||
/>
|
||
</View>
|
||
);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function renderExpandableBadgeIconSlot({
|
||
showChevron,
|
||
chevronStyle,
|
||
iconNode,
|
||
}: {
|
||
showChevron: boolean;
|
||
chevronStyle: StyleProp<ViewStyle>;
|
||
iconNode: ReactNode;
|
||
}): ReactNode {
|
||
if (showChevron) {
|
||
return (
|
||
<View style={chevronStyle}>
|
||
<ThemedChevronRightIcon size={12} uniProps={foregroundColorMapping} />
|
||
</View>
|
||
);
|
||
}
|
||
return iconNode;
|
||
}
|
||
|
||
function computeShimmerMetrics(input: {
|
||
label: string;
|
||
secondaryLabel: string | undefined;
|
||
isLoading: boolean;
|
||
labelRowWidth: number;
|
||
labelRowHeight: number;
|
||
labelOffsetX: number;
|
||
labelWidth: number;
|
||
secondaryOffsetX: number;
|
||
secondaryWidth: number;
|
||
}) {
|
||
const totalShimmerChars = input.label.trim().length + (input.secondaryLabel?.trim().length ?? 0);
|
||
const shortTextDurationAdjustment = totalShimmerChars <= 12 ? 0.25 : 0;
|
||
const shimmerDuration = Math.max(
|
||
1,
|
||
Math.min(2.3, 1.25 + totalShimmerChars * 0.008 - shortTextDurationAdjustment),
|
||
);
|
||
const nativeShimmerPeakWidth = Math.max(
|
||
32,
|
||
Math.min(120, input.labelRowWidth > 0 ? input.labelRowWidth * 0.28 : 0),
|
||
);
|
||
const isWebShimmer = input.isLoading && isWeb;
|
||
// React Native Web only observes a node when onLayout exists at mount. Keep
|
||
// measuring while idle so a retained badge has dimensions when it starts loading.
|
||
const shouldMeasureWebShimmer = isWeb;
|
||
const shouldMeasureNativeShimmer = input.isLoading && isNative;
|
||
const isNativeShimmer =
|
||
shouldMeasureNativeShimmer && input.labelRowWidth > 0 && input.labelRowHeight > 0;
|
||
const webShimmerSpanStartX = input.labelOffsetX;
|
||
const webShimmerSpanEndX = input.secondaryLabel
|
||
? input.secondaryOffsetX + input.secondaryWidth
|
||
: input.labelOffsetX + input.labelWidth;
|
||
const webShimmerSpanWidth = Math.max(1, webShimmerSpanEndX - webShimmerSpanStartX);
|
||
const webShimmerPeakWidth = Math.max(42, Math.min(120, webShimmerSpanWidth * 0.22));
|
||
const webShimmerTrackStart = webShimmerSpanStartX - webShimmerPeakWidth;
|
||
const webShimmerTrackEnd = webShimmerSpanEndX;
|
||
return {
|
||
shimmerDuration,
|
||
nativeShimmerPeakWidth,
|
||
isWebShimmer,
|
||
shouldMeasureWebShimmer,
|
||
shouldMeasureNativeShimmer,
|
||
isNativeShimmer,
|
||
webShimmerPeakWidth,
|
||
webShimmerTrackStart,
|
||
webShimmerTrackEnd,
|
||
};
|
||
}
|
||
|
||
function useDetailWheelPropagationBlocker(input: {
|
||
detailWrapperRef: React.RefObject<View | null>;
|
||
enabled: boolean;
|
||
}): void {
|
||
const { detailWrapperRef, enabled } = input;
|
||
useEffect(() => {
|
||
if (!enabled) {
|
||
return () => {};
|
||
}
|
||
const rawRef: unknown = detailWrapperRef.current;
|
||
if (!(rawRef instanceof HTMLElement)) {
|
||
return () => {};
|
||
}
|
||
const node = rawRef;
|
||
const stopWheelPropagation = (event: WheelEvent) => {
|
||
if (shouldStopDetailWheelPropagation(node, event)) {
|
||
event.stopPropagation();
|
||
}
|
||
};
|
||
node.addEventListener("wheel", stopWheelPropagation, { passive: true });
|
||
return () => {
|
||
node.removeEventListener("wheel", stopWheelPropagation);
|
||
};
|
||
}, [detailWrapperRef, enabled]);
|
||
}
|
||
|
||
const SHIMMER_GRADIENT =
|
||
"linear-gradient(90deg, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.45) 24%, #ffffff 40%, #ffffff 60%, rgba(255, 255, 255, 0.45) 76%, rgba(255, 255, 255, 0) 100%)";
|
||
|
||
function buildShimmerTextStyle(input: {
|
||
isWebShimmer: boolean;
|
||
webShimmerPeakWidth: number;
|
||
shimmerDuration: number;
|
||
webShimmerTrackStart: number;
|
||
webShimmerTrackEnd: number;
|
||
offsetX: number;
|
||
}): object | null {
|
||
if (!input.isWebShimmer) return null;
|
||
return inlineUnistylesStyle({
|
||
opacity: 1,
|
||
color: "transparent",
|
||
backgroundImage: SHIMMER_GRADIENT,
|
||
backgroundSize: `${input.webShimmerPeakWidth}px 100%`,
|
||
backgroundRepeat: "no-repeat",
|
||
backgroundClip: "text",
|
||
WebkitBackgroundClip: "text",
|
||
WebkitTextFillColor: "transparent",
|
||
animation: `${WEB_TOOLCALL_SHIMMER_ANIMATION_NAME} ${input.shimmerDuration}s linear infinite`,
|
||
"--paseo-shimmer-start": `${input.webShimmerTrackStart - input.offsetX}px`,
|
||
"--paseo-shimmer-end": `${input.webShimmerTrackEnd - input.offsetX}px`,
|
||
});
|
||
}
|
||
|
||
export const ExpandableBadge = memo(function ExpandableBadge({
|
||
label,
|
||
style,
|
||
secondaryLabel,
|
||
icon,
|
||
isExpanded,
|
||
onToggle,
|
||
onOpenFile,
|
||
onDetailHoverChange,
|
||
renderDetails,
|
||
isLoading = false,
|
||
isError = false,
|
||
isLastInSequence = false,
|
||
disableOuterSpacing,
|
||
borderlessWhenExpanded = false,
|
||
testID,
|
||
}: ExpandableBadgeProps) {
|
||
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
|
||
const [isHovered, setIsHovered] = useState(false);
|
||
const [isOpenFileHovered, setIsOpenFileHovered] = useState(false);
|
||
const [isPressed, setIsPressed] = useState(false);
|
||
const isInteractive = Boolean(onToggle);
|
||
const hasDetailContent = Boolean(renderDetails);
|
||
const detailContent = hasDetailContent && isExpanded ? renderDetails?.() : null;
|
||
const detailWrapperRef = useRef<View | null>(null);
|
||
|
||
const handleHoverIn = useCallback(() => setIsHovered(true), []);
|
||
const handleHoverOut = useCallback(() => {
|
||
setIsHovered(false);
|
||
setIsPressed(false);
|
||
}, []);
|
||
const handlePressIn = useCallback(() => setIsPressed(true), []);
|
||
const handlePressOut = useCallback(() => setIsPressed(false), []);
|
||
const handleDetailHoverIn = useCallback(() => onDetailHoverChange?.(true), [onDetailHoverChange]);
|
||
const handleDetailHoverOut = useCallback(
|
||
() => onDetailHoverChange?.(false),
|
||
[onDetailHoverChange],
|
||
);
|
||
const handleOpenFilePress = useCallback(
|
||
(event: GestureResponderEvent) => {
|
||
event.stopPropagation?.();
|
||
onOpenFile?.();
|
||
},
|
||
[onOpenFile],
|
||
);
|
||
const handleOpenFileHoverIn = useCallback(() => setIsOpenFileHovered(true), []);
|
||
const handleOpenFileHoverOut = useCallback(() => setIsOpenFileHovered(false), []);
|
||
|
||
const nativeGradientIdRef = useRef(
|
||
`shimmer-gradient-${Math.random().toString(36).substring(2, 9)}`,
|
||
);
|
||
const [labelRowWidth, setLabelRowWidth] = useState(0);
|
||
const [labelRowHeight, setLabelRowHeight] = useState(0);
|
||
const [labelOffsetX, setLabelOffsetX] = useState(0);
|
||
const [labelWidth, setLabelWidth] = useState(0);
|
||
const [secondaryOffsetX, setSecondaryOffsetX] = useState(0);
|
||
const [secondaryWidth, setSecondaryWidth] = useState(0);
|
||
|
||
const {
|
||
shimmerDuration,
|
||
nativeShimmerPeakWidth,
|
||
isWebShimmer,
|
||
shouldMeasureWebShimmer,
|
||
shouldMeasureNativeShimmer,
|
||
isNativeShimmer,
|
||
webShimmerPeakWidth,
|
||
webShimmerTrackStart,
|
||
webShimmerTrackEnd,
|
||
} = computeShimmerMetrics({
|
||
label,
|
||
secondaryLabel,
|
||
isLoading,
|
||
labelRowWidth,
|
||
labelRowHeight,
|
||
labelOffsetX,
|
||
labelWidth,
|
||
secondaryOffsetX,
|
||
secondaryWidth,
|
||
});
|
||
|
||
const handleLabelRowLayout = useCallback(
|
||
(event: LayoutChangeEvent) => {
|
||
if (!shouldMeasureNativeShimmer) {
|
||
return;
|
||
}
|
||
const { width, height } = event.nativeEvent.layout;
|
||
setLabelRowWidth((previous) => (Math.abs(previous - width) > 0.5 ? width : previous));
|
||
setLabelRowHeight((previous) => (Math.abs(previous - height) > 0.5 ? height : previous));
|
||
},
|
||
[shouldMeasureNativeShimmer],
|
||
);
|
||
|
||
const handleLabelLayout = useCallback(
|
||
(event: LayoutChangeEvent) => {
|
||
if (!shouldMeasureWebShimmer) {
|
||
return;
|
||
}
|
||
const { x, width } = event.nativeEvent.layout;
|
||
setLabelOffsetX((previous) => (Math.abs(previous - x) > 0.5 ? x : previous));
|
||
setLabelWidth((previous) => (Math.abs(previous - width) > 0.5 ? width : previous));
|
||
},
|
||
[shouldMeasureWebShimmer],
|
||
);
|
||
|
||
const handleSecondaryLayout = useCallback(
|
||
(event: LayoutChangeEvent) => {
|
||
if (!shouldMeasureWebShimmer || !secondaryLabel) {
|
||
return;
|
||
}
|
||
const { x, width } = event.nativeEvent.layout;
|
||
setSecondaryOffsetX((previous) => (Math.abs(previous - x) > 0.5 ? x : previous));
|
||
setSecondaryWidth((previous) => (Math.abs(previous - width) > 0.5 ? width : previous));
|
||
},
|
||
[shouldMeasureWebShimmer, secondaryLabel],
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (!isWebShimmer) {
|
||
return;
|
||
}
|
||
ensureWebToolCallShimmerKeyframes();
|
||
}, [isWebShimmer]);
|
||
|
||
useDetailWheelPropagationBlocker({
|
||
detailWrapperRef,
|
||
enabled: !isNative && isExpanded && hasDetailContent,
|
||
});
|
||
|
||
const shimmerLabelStyle = useMemo<StyleProp<TextStyle>>(
|
||
() =>
|
||
buildShimmerTextStyle({
|
||
isWebShimmer,
|
||
webShimmerPeakWidth,
|
||
shimmerDuration,
|
||
webShimmerTrackStart,
|
||
webShimmerTrackEnd,
|
||
offsetX: labelOffsetX,
|
||
}),
|
||
[
|
||
isWebShimmer,
|
||
webShimmerPeakWidth,
|
||
shimmerDuration,
|
||
webShimmerTrackStart,
|
||
webShimmerTrackEnd,
|
||
labelOffsetX,
|
||
],
|
||
);
|
||
|
||
const shimmerSecondaryStyle = useMemo<StyleProp<TextStyle>>(
|
||
() =>
|
||
buildShimmerTextStyle({
|
||
isWebShimmer,
|
||
webShimmerPeakWidth,
|
||
shimmerDuration,
|
||
webShimmerTrackStart,
|
||
webShimmerTrackEnd,
|
||
offsetX: secondaryOffsetX,
|
||
}),
|
||
[
|
||
isWebShimmer,
|
||
webShimmerPeakWidth,
|
||
shimmerDuration,
|
||
webShimmerTrackStart,
|
||
webShimmerTrackEnd,
|
||
secondaryOffsetX,
|
||
],
|
||
);
|
||
|
||
const containerStyle = useMemo(
|
||
() => [
|
||
expandableBadgeStylesheet.container,
|
||
!resolvedDisableOuterSpacing &&
|
||
(isLastInSequence
|
||
? expandableBadgeStylesheet.containerLastInSequence
|
||
: expandableBadgeStylesheet.containerSpacing),
|
||
style,
|
||
],
|
||
[isLastInSequence, resolvedDisableOuterSpacing, style],
|
||
);
|
||
|
||
const pressableStyle = useMemo(
|
||
() => [
|
||
expandableBadgeStylesheet.pressable,
|
||
isPressed && isInteractive ? expandableBadgeStylesheet.pressablePressed : null,
|
||
isExpanded && expandableBadgeStylesheet.pressableExpanded,
|
||
isExpanded &&
|
||
(borderlessWhenExpanded
|
||
? expandableBadgeStylesheet.pressableExpandedDetached
|
||
: expandableBadgeStylesheet.pressableExpandedAttached),
|
||
],
|
||
[borderlessWhenExpanded, isExpanded, isInteractive, isPressed],
|
||
);
|
||
|
||
const detailWrapperStyle = useMemo(
|
||
() => [
|
||
expandableBadgeStylesheet.detailWrapper,
|
||
borderlessWhenExpanded && expandableBadgeStylesheet.detailWrapperBorderless,
|
||
],
|
||
[borderlessWhenExpanded],
|
||
);
|
||
|
||
const accessibilityState = useMemo(
|
||
() => (isInteractive ? { expanded: isExpanded } : undefined),
|
||
[isExpanded, isInteractive],
|
||
);
|
||
|
||
const isActive = isHovered || isExpanded;
|
||
|
||
const labelStyle = useMemo(
|
||
() => [
|
||
expandableBadgeStylesheet.label,
|
||
isActive && expandableBadgeStylesheet.labelActive,
|
||
isLoading && expandableBadgeStylesheet.labelLoading,
|
||
],
|
||
[isActive, isLoading],
|
||
);
|
||
|
||
const secondaryLabelStyle = useMemo(
|
||
() => [
|
||
expandableBadgeStylesheet.secondaryLabel,
|
||
isActive && expandableBadgeStylesheet.secondaryLabelActive,
|
||
],
|
||
[isActive],
|
||
);
|
||
|
||
const shimmerLabelTextStyle = useMemo(
|
||
() => [
|
||
expandableBadgeStylesheet.label,
|
||
isLoading && expandableBadgeStylesheet.labelLoading,
|
||
expandableBadgeStylesheet.shimmerText,
|
||
shimmerLabelStyle,
|
||
],
|
||
[isLoading, shimmerLabelStyle],
|
||
);
|
||
|
||
const shimmerSecondaryTextStyle = useMemo(
|
||
() => [
|
||
expandableBadgeStylesheet.secondaryLabel,
|
||
expandableBadgeStylesheet.shimmerText,
|
||
shimmerSecondaryStyle,
|
||
],
|
||
[shimmerSecondaryStyle],
|
||
);
|
||
|
||
const chevronStyle = useMemo(
|
||
() => [
|
||
expandableBadgeStylesheet.chevron,
|
||
LUCIDE_CHEVRON_NUDGE_LEFT,
|
||
inlineUnistylesStyle({
|
||
transform: isExpanded ? [{ scale: 1.3 }, { rotate: "90deg" }] : [{ scale: 1.3 }],
|
||
}),
|
||
],
|
||
[isExpanded],
|
||
);
|
||
|
||
const ThemedIcon = useMemo(() => (icon ? withUnistyles(icon) : null), [icon]);
|
||
const iconNode = renderExpandableBadgeIcon({ isError, isActive, ThemedIcon });
|
||
const iconSlotNode = renderExpandableBadgeIconSlot({
|
||
showChevron: isInteractive && (isHovered || isExpanded),
|
||
chevronStyle,
|
||
iconNode,
|
||
});
|
||
|
||
const pressHandlers = isInteractive
|
||
? {
|
||
onPress: onToggle,
|
||
onPressIn: handlePressIn,
|
||
onPressOut: handlePressOut,
|
||
accessibilityRole: "button" as const,
|
||
}
|
||
: {};
|
||
|
||
return (
|
||
<View
|
||
style={containerStyle}
|
||
testID={testID}
|
||
onPointerEnter={isWeb ? handleHoverIn : undefined}
|
||
onPointerLeave={isWeb ? handleHoverOut : undefined}
|
||
>
|
||
<Pressable
|
||
{...pressHandlers}
|
||
disabled={!isInteractive}
|
||
accessibilityState={accessibilityState}
|
||
style={pressableStyle}
|
||
>
|
||
<View style={expandableBadgeStylesheet.headerRow}>
|
||
<View style={expandableBadgeStylesheet.iconBadge}>{iconSlotNode}</View>
|
||
<ExpandableBadgeLabelRow
|
||
label={label}
|
||
labelStyle={labelStyle}
|
||
secondaryLabel={secondaryLabel}
|
||
secondaryLabelStyle={secondaryLabelStyle}
|
||
shouldMeasureWebShimmer={shouldMeasureWebShimmer}
|
||
shouldMeasureNativeShimmer={shouldMeasureNativeShimmer}
|
||
isWebShimmer={isWebShimmer}
|
||
isNativeShimmer={isNativeShimmer}
|
||
shimmerLabelTextStyle={shimmerLabelTextStyle}
|
||
shimmerSecondaryTextStyle={shimmerSecondaryTextStyle}
|
||
labelRowWidth={labelRowWidth}
|
||
labelRowHeight={labelRowHeight}
|
||
nativeShimmerPeakWidth={nativeShimmerPeakWidth}
|
||
shimmerDuration={shimmerDuration}
|
||
nativeGradientId={nativeGradientIdRef.current}
|
||
onLabelRowLayout={handleLabelRowLayout}
|
||
onLabelLayout={handleLabelLayout}
|
||
onSecondaryLayout={handleSecondaryLayout}
|
||
showOpenFileButton={Boolean(onOpenFile && isHovered)}
|
||
isOpenFileHovered={isOpenFileHovered}
|
||
onOpenFilePress={handleOpenFilePress}
|
||
onOpenFileHoverIn={handleOpenFileHoverIn}
|
||
onOpenFileHoverOut={handleOpenFileHoverOut}
|
||
/>
|
||
</View>
|
||
</Pressable>
|
||
{detailContent ? (
|
||
<Pressable
|
||
ref={detailWrapperRef}
|
||
style={detailWrapperStyle}
|
||
onHoverIn={handleDetailHoverIn}
|
||
onHoverOut={handleDetailHoverOut}
|
||
>
|
||
{detailContent}
|
||
</Pressable>
|
||
) : null}
|
||
</View>
|
||
);
|
||
}, areExpandableBadgePropsEqual);
|
||
|
||
function areExpandableBadgePropsEqual(previous: ExpandableBadgeProps, next: ExpandableBadgeProps) {
|
||
if (previous.label !== next.label) return false;
|
||
if (previous.secondaryLabel !== next.secondaryLabel) return false;
|
||
if (previous.icon !== next.icon) return false;
|
||
if (previous.isExpanded !== next.isExpanded) return false;
|
||
if (previous.style !== next.style) return false;
|
||
if (previous.isLoading !== next.isLoading) return false;
|
||
if (previous.isError !== next.isError) return false;
|
||
if (previous.isLastInSequence !== next.isLastInSequence) return false;
|
||
if (previous.disableOuterSpacing !== next.disableOuterSpacing) return false;
|
||
if (previous.borderlessWhenExpanded !== next.borderlessWhenExpanded) return false;
|
||
if (previous.testID !== next.testID) return false;
|
||
if (previous.onToggle !== next.onToggle) return false;
|
||
if (previous.onOpenFile !== next.onOpenFile) return false;
|
||
if (previous.onDetailHoverChange !== next.onDetailHoverChange) return false;
|
||
if (previous.renderDetails !== next.renderDetails) return false;
|
||
return true;
|
||
}
|
||
|
||
interface ToolCallProps {
|
||
agentMessage?: AgentMessage;
|
||
serverId?: string;
|
||
toolName: string;
|
||
args?: unknown;
|
||
result?: unknown;
|
||
error?: unknown;
|
||
status: "executing" | "running" | "completed" | "failed" | "canceled";
|
||
detail?: ToolCallDetail;
|
||
cwd?: string;
|
||
metadata?: Record<string, unknown>;
|
||
isLastInSequence?: boolean;
|
||
disableOuterSpacing?: boolean;
|
||
onInlineDetailsHoverChange?: (hovered: boolean) => void;
|
||
onInlineDetailsExpandedChange?: (expanded: boolean) => void;
|
||
onOpenFilePath?: (filePath: string) => void;
|
||
defaultExpanded?: boolean;
|
||
forceInline?: boolean;
|
||
maxDetailHeight?: number;
|
||
itemId: string;
|
||
/** Shown as its own row rather than inside a tool call group. */
|
||
standalone?: boolean;
|
||
}
|
||
|
||
export const ToolCall = memo(function ToolCall({
|
||
agentMessage,
|
||
serverId,
|
||
toolName,
|
||
args,
|
||
result,
|
||
error,
|
||
status,
|
||
detail,
|
||
cwd,
|
||
metadata,
|
||
isLastInSequence = false,
|
||
disableOuterSpacing,
|
||
onInlineDetailsHoverChange,
|
||
onInlineDetailsExpandedChange,
|
||
onOpenFilePath,
|
||
defaultExpanded,
|
||
forceInline = false,
|
||
maxDetailHeight = 400,
|
||
itemId,
|
||
standalone = true,
|
||
}: ToolCallProps) {
|
||
const { openToolCall } = useToolCallSheet();
|
||
const { expanded: isExpanded, toggle: toggleExpanded } = useToolCallExpanded({
|
||
callId: itemId,
|
||
defaultExpanded: defaultExpanded ?? false,
|
||
standalone,
|
||
});
|
||
|
||
const isMobile = useIsCompactFormFactor();
|
||
const shouldRenderInline = !isMobile || forceInline;
|
||
|
||
const effectiveDetail = useMemo<ToolCallDetail | undefined>(() => {
|
||
if (detail) {
|
||
return detail;
|
||
}
|
||
if (args !== undefined || result !== undefined) {
|
||
return {
|
||
type: "unknown",
|
||
input: args ?? null,
|
||
output: result ?? null,
|
||
};
|
||
}
|
||
return undefined;
|
||
}, [detail, args, result]);
|
||
|
||
const targetAgentTitle = useTargetAgentTitle({ serverId, toolName, detail: effectiveDetail });
|
||
const presentation = useMemo(
|
||
() =>
|
||
buildToolCallPresentation({
|
||
agentMessage,
|
||
toolName,
|
||
status,
|
||
error: error ?? null,
|
||
detail: effectiveDetail,
|
||
metadata,
|
||
cwd,
|
||
targetAgentTitle,
|
||
resolveIcon: resolveToolCallIcon,
|
||
}),
|
||
[toolName, status, error, effectiveDetail, metadata, cwd, agentMessage, targetAgentTitle],
|
||
);
|
||
const handleOpenFile = useMemo(() => {
|
||
const openFilePath = presentation.openFilePath;
|
||
if (!openFilePath || !onOpenFilePath) {
|
||
return undefined;
|
||
}
|
||
return () => onOpenFilePath(openFilePath);
|
||
}, [presentation.openFilePath, onOpenFilePath]);
|
||
|
||
const handleToggle = useCallback(() => {
|
||
if (!shouldRenderInline) {
|
||
openToolCall({
|
||
toolName,
|
||
displayName: presentation.displayName,
|
||
summary: presentation.summary,
|
||
detail: presentation.detail,
|
||
errorText: presentation.errorText,
|
||
icon: presentation.icon,
|
||
showLoadingSkeleton: presentation.isLoadingDetails,
|
||
});
|
||
} else {
|
||
toggleExpanded();
|
||
}
|
||
}, [
|
||
shouldRenderInline,
|
||
toggleExpanded,
|
||
openToolCall,
|
||
toolName,
|
||
presentation.detail,
|
||
presentation.displayName,
|
||
presentation.summary,
|
||
presentation.errorText,
|
||
presentation.icon,
|
||
presentation.isLoadingDetails,
|
||
]);
|
||
|
||
useEffect(() => {
|
||
if (!onInlineDetailsHoverChange || !shouldRenderInline || isExpanded) {
|
||
return;
|
||
}
|
||
onInlineDetailsHoverChange(false);
|
||
}, [isExpanded, shouldRenderInline, onInlineDetailsHoverChange]);
|
||
|
||
useEffect(() => {
|
||
if (!onInlineDetailsExpandedChange) {
|
||
return;
|
||
}
|
||
if (!shouldRenderInline) {
|
||
onInlineDetailsExpandedChange(false);
|
||
return;
|
||
}
|
||
onInlineDetailsExpandedChange(isExpanded);
|
||
}, [isExpanded, shouldRenderInline, onInlineDetailsExpandedChange]);
|
||
|
||
useEffect(() => {
|
||
if (!onInlineDetailsExpandedChange) {
|
||
return () => {};
|
||
}
|
||
return () => {
|
||
onInlineDetailsExpandedChange(false);
|
||
};
|
||
}, [onInlineDetailsExpandedChange]);
|
||
|
||
// Render inline details for desktop
|
||
const renderDetails = useCallback(() => {
|
||
if (!shouldRenderInline) return null;
|
||
return (
|
||
<ToolCallDetailsContent
|
||
toolName={toolName}
|
||
detail={presentation.detail}
|
||
errorText={presentation.errorText}
|
||
maxHeight={maxDetailHeight}
|
||
showLoadingSkeleton={presentation.isLoadingDetails}
|
||
/>
|
||
);
|
||
}, [
|
||
shouldRenderInline,
|
||
toolName,
|
||
presentation.detail,
|
||
presentation.errorText,
|
||
presentation.isLoadingDetails,
|
||
maxDetailHeight,
|
||
]);
|
||
|
||
if (presentation.isPlan && effectiveDetail?.type === "plan") {
|
||
return (
|
||
<PlanCard
|
||
text={effectiveDetail.text}
|
||
outcome={presentation.planOutcome}
|
||
testID="timeline-plan-card"
|
||
disableOuterSpacing={disableOuterSpacing}
|
||
/>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<ExpandableBadge
|
||
testID="tool-call-badge"
|
||
label={presentation.displayName}
|
||
secondaryLabel={presentation.summary}
|
||
icon={presentation.icon}
|
||
isExpanded={shouldRenderInline && isExpanded}
|
||
onToggle={presentation.canOpenDetails ? handleToggle : undefined}
|
||
onOpenFile={handleOpenFile}
|
||
renderDetails={presentation.canOpenDetails && shouldRenderInline ? renderDetails : undefined}
|
||
isLoading={status === "running" || status === "executing"}
|
||
isError={status === "failed"}
|
||
isLastInSequence={isLastInSequence}
|
||
disableOuterSpacing={disableOuterSpacing}
|
||
onDetailHoverChange={onInlineDetailsHoverChange}
|
||
/>
|
||
);
|
||
}, areToolCallPropsEqual);
|
||
|
||
function areToolCallPropsEqual(previous: ToolCallProps, next: ToolCallProps) {
|
||
if (previous.toolName !== next.toolName) return false;
|
||
if (previous.serverId !== next.serverId) return false;
|
||
if (previous.agentMessage !== next.agentMessage) return false;
|
||
if (previous.args !== next.args) return false;
|
||
if (previous.result !== next.result) return false;
|
||
if (previous.error !== next.error) return false;
|
||
if (previous.status !== next.status) return false;
|
||
if (previous.detail !== next.detail) return false;
|
||
if (previous.cwd !== next.cwd) return false;
|
||
if (previous.metadata !== next.metadata) return false;
|
||
if (previous.isLastInSequence !== next.isLastInSequence) return false;
|
||
if (previous.disableOuterSpacing !== next.disableOuterSpacing) return false;
|
||
if (previous.onOpenFilePath !== next.onOpenFilePath) return false;
|
||
if (previous.defaultExpanded !== next.defaultExpanded) return false;
|
||
if (previous.forceInline !== next.forceInline) return false;
|
||
if (previous.maxDetailHeight !== next.maxDetailHeight) return false;
|
||
if (previous.itemId !== next.itemId) return false;
|
||
if (previous.standalone !== next.standalone) return false;
|
||
return true;
|
||
}
|