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 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 {
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 (
{children}
);
}
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 (
);
}
const MESSAGE_TEXT_DATASET = { messageText: "true" };
export const UserMessage = memo(function UserMessage({
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(null);
const handleLightboxClose = useCallback(() => setLightboxMetadata(null), []);
const lightboxSource = useMemo(
() => (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 (
{hasImages ? (
{images.map((image) => (
))}
) : null}
{hasAttachments ? (
{attachments.map((attachment, index) => {
const content = getAgentAttachmentPillContent(attachment, t);
return (
);
})}
) : null}
{hasText ? (
{message}
) : null}
{hasText ? (
{formattedTimestamp}
{capabilities && messageId ? (
) : null}
) : null}
);
});
interface AssistantTurnFooterProps {
getContent: () => string;
completedAt?: Date;
durationMs?: number | null;
onFork?: (target: AssistantForkTarget) => Promise | 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 | 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 (
{canFork ? : null}
{primaryLabel ? (
{/* Sizer reserves space for whichever label is longer so the
container width is stable across hover transitions. */}
{primaryLabel.length >= timestampLabel.length ? primaryLabel : timestampLabel}
{showTimestamp ? timestampLabel : primaryLabel}
) : null}
);
});
interface LiveElapsedProps {
startedAt: Date;
active?: boolean;
style?: StyleProp;
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 (
{formatDuration(visibleElapsedMs)}
);
});
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;
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 | 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 (
{({ hovered }) => {
const iconColor = hovered
? turnCopyButtonStylesheet.iconHoveredColor.color
: turnCopyButtonStylesheet.iconColor.color;
return copied ? (
) : (
);
}}
);
});
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,
},
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(
() => (
{label}
{secondaryLabel ? (
{secondaryLabel}
) : (
)}
),
[nativeShimmerMaskStyle, nativeLabelMaskStyle, nativeSecondaryMaskStyle, label, secondaryLabel],
);
return (
);
});
function NativeShimmerPeakSvg({ gradientId }: { gradientId: string }) {
return (
);
}
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 (
{fullMessageByteLength !== null ? (
{t("agentStream.messageCapped", { bytes: fullMessageByteLength })}
) : null}
);
});
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 (
{t("message.speak.header")}
{message}
);
});
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 (
{message}
);
});
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 (
{status === "loading" ? (
) : (
)}
{label}
);
});
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 (
{items.length === 0 ? (
{t("message.todo.empty")}
) : (
items.map((item) => )
)}
);
}, [items, t]);
return (
);
});
interface ExpandableBadgeProps {
label: string;
secondaryLabel?: string;
icon?: ComponentType<{ size?: number; color?: string }>;
isExpanded: boolean;
style?: StyleProp;
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;
shouldMeasureWebShimmer: boolean;
onSecondaryLayout: (event: LayoutChangeEvent) => void;
}
function ExpandableBadgeSecondaryLabel({
secondaryLabel,
secondaryLabelStyle,
shouldMeasureWebShimmer,
onSecondaryLayout,
}: ExpandableBadgeSecondaryLabelProps) {
if (!secondaryLabel) {
return null;
}
return (
{secondaryLabel}
);
}
interface ExpandableBadgeWebShimmerOverlayProps {
label: string;
secondaryLabel?: string;
shimmerLabelTextStyle: StyleProp;
shimmerSecondaryTextStyle: StyleProp;
showOpenFileButton: boolean;
}
function ExpandableBadgeWebShimmerOverlay({
label,
secondaryLabel,
shimmerLabelTextStyle,
shimmerSecondaryTextStyle,
showOpenFileButton,
}: ExpandableBadgeWebShimmerOverlayProps) {
return (
{label}
{secondaryLabel ? (
{secondaryLabel}
) : null}
{showOpenFileButton ? (
) : null}
{!secondaryLabel && !showOpenFileButton ? (
) : null}
);
}
interface ExpandableBadgeLabelRowProps {
label: string;
labelStyle: StyleProp;
secondaryLabel?: string;
secondaryLabelStyle: StyleProp;
shouldMeasureWebShimmer: boolean;
shouldMeasureNativeShimmer: boolean;
isWebShimmer: boolean;
isNativeShimmer: boolean;
shimmerLabelTextStyle: StyleProp;
shimmerSecondaryTextStyle: StyleProp;
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 (
{label}
{showOpenFileButton ? (
) : null}
{isWebShimmer ? (
) : null}
{isNativeShimmer ? (
) : null}
);
}
// 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