Files
paseo/packages/app/src/components/message.tsx
T
Mohamed Boudra eb1a8a2ec7 fix(markdown): one renderer on react-native-enriched-markdown for iOS, Android and web (#6973)
* fix(app): group native assistant prose into selectable runs

* fix(app): render selectable native prose with enriched markdown

* docs: explain native prose selection boundaries and tradeoffs

* fix(app): preserve markdown semantics and render limits in prose runs

* docs: record native prose compatibility fixes and heading limits

* fix(app): allow additive task lists and admonitions in native prose

* fix(app): preserve inline image formatting and reference scope

* fix(app): preserve markdown source and file actions in native prose

Prepare original source blocks with source-position edits from the assistant parser. Keep reference scope and live/history projection consistent, and retain link text when dispatching workspace file actions.

* fix(app): preserve native prose entities and links while bounding preparation cost

* fix(app): preserve provisional syntax at cached reveal boundaries

* fix(app): bound prose append work and preserve markdown edge cases

* fix(app): preserve home paths and normalized prose links

* fix(app): preserve parser-owned links and streaming tilde tails

* fix(app): keep growing markdown on the streaming block renderer

* fix(app): preserve bracketed IPv6 links in native prose

* fix(app): skip prose classification for blocks over the render cap

* fix(app): share selectable native prose across Markdown surfaces

* fix(app): preserve Markdown containers before grouping prose

* revert(app): remove shared prose-run renderer changes

Reverts ab4737bb2 and ac47e1063 before replacing the Markdown engine across all platforms.

* feat(markdown): use enriched renderer across all platforms

* test: verify library image controls and isolate website config

* fix(markdown): preserve file links and image revisions

* fix(markdown): restore diagrams and preserve link origin

* fix(markdown): retain diagram identity and reference scope

* fix(markdown): avoid empty web history measurements

* fix(markdown): preserve authored empty link titles

* test(markdown): exercise find across whole-message history

* fix(markdown): preserve encoded image paths after rebase

Keep the shared renderer image normalization and file-read regression coverage that originally lived in the main integration merge.

* fix(markdown): derive table and task list colours from the theme

* fix(markdown): restore selection highlight on web

* fix(markdown): restore file link hover tooltips on web

* fix(markdown): normalize inline HTML before rendering

* fix(markdown): create renderer types when applying package patch

The published nightly omits both MarkdownRenderers type files. Represent them as new files so patch-package succeeds on a fresh npm install, matching the already patched development package.

* fix(markdown): keep the renderer tree stable while images resolve

* fix(markdown): size inline images to the line and collapse failed loads

* fix(markdown): normalize only parsed HTML nodes

* fix(markdown): keep the first image reference definition

* fix(markdown): preserve complete assistant messages on native

* fix(markdown): classify web links explicitly and complete renderer types

* fix(markdown): preserve spacing between HTML elements

* fix(markdown): normalize HTML following code elements

* fix(markdown): preserve literal image fallback labels

* fix(markdown): retry transient image decode failures

* docs(markdown): record native selection and image limitations

* fix(markdown): preserve first definitions across Mermaid segments

* fix(markdown): reserve pending images and harden web rendering

* test(markdown): cover state retention during image arrival

* fix(markdown): preserve reader position while responses grow

Keep the same message component boundary when a live row becomes retained history, preserving Mermaid runtimes and selection nodes. Observe paced child text commits at the web viewport so sticky-bottom follows before the next frame.

* test(markdown): require growth while holding a streaming paragraph

* test(markdown): isolate image arrival from reload positioning

Mounted row totals change as history virtualizes, so observe the new response and tool directly. Hold the real PNG response after reload until the reading position settles, then verify its reserved height and viewport instead of attributing initial bottom-follow positioning to image decoding.

* fix(markdown): preserve adjacency around inline HTML

* fix(markdown): keep summary block boundaries outside bold text

* perf(markdown): scan HTML block boundaries incrementally

* test(markdown): gate delivery while establishing a held reader

* test(markdown): name shared renderer setup and file actions

* test(markdown): assert linear HTML boundary work without timing

* test(markdown): check large HTML table output without observing built-ins

* fix(markdown): restore accentBright link colour

* fix(markdown): require letters and known extensions in bare path recognition

* fix(markdown): match main's blockquote, bullet and h6 colours

* fix(android): keep inverted chat still during text selection

React Native decomposes Android list inversion into a 180-degree rotation with positive scaleY, so the existing visibility-scroll guard never recognized the inverted viewport. Inspect the composed matrix and reproduce the actual list transform through the native view manager in regression tests.

* fix(markdown): linked inline code keeps inline code size

* fix(markdown): reserve a placeholder box with an image glyph while images load

* fix(markdown): bare prose paths need an extension, line suffix or depth

Slash-separated English pairs were recognized as file links in prose. Require stronger path intent there while preserving the existing inline-code recognition rule.

* fix(markdown): horizontal blocks own their drags on compact layouts

* perf(markdown): keep native streaming updates in the unfinished tail

Native markdown reparses each updated view in full. Keep parser-closed blocks in a memoized head during paced reveal, then reunite completed prose for cross-paragraph selection. Preserve trailing spacing and source indentation at the split.

* perf(markdown): reuse Android measurements across view mounts

* test(markdown): bare Fabel/model and daemon/client stay plain text when copied

a502cfd0d stopped linking bare two-segment prose paths; the clipboard
expectation still carried the links from the earlier loose pattern and
failed on CI (playwright shard 1/4 on 60b4e2d05). main expects the plain
text, so restore that.

* perf(app): limit native chat mount batches

Whole-message Markdown rows measure synchronously during Fabric layout. Render fewer history rows per batch and retain a smaller offscreen window so opening a chat does not measure twelve history rows at once.

* fix(app): keep populated markdown in sync with native layout

Fabric can collapse streaming blocks before asynchronous rendering replaces the old text, producing a blank frame. Commit populated-view updates synchronously while retaining worker preparation for first mounts to avoid moving large history parsing onto the UI thread.

* perf(app): preserve unchanged sidebar workspace projections

Reopening a retained sidebar rebuilt its entry collection and invalidated every project row despite unchanged workspace data. Preserve collection identity as well as row identity, while keeping membership and ordering changes observable.

* Revert "perf(app): preserve unchanged sidebar workspace projections"

This reverts commit a3919faed4.
2026-10-16 21:03:37 +07:00

2411 lines
70 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<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({
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}
>
<View style={userMessageStylesheet.bubble}>
{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}
</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}>
<TurnCopyButton
getContent={getContent}
containerStyle={assistantTurnFooterStylesheet.copyButton}
/>
{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,
},
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}
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.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;
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;
}
export const ToolCall = memo(function ToolCall({
agentMessage,
toolName,
args,
result,
error,
status,
detail,
cwd,
metadata,
isLastInSequence = false,
disableOuterSpacing,
onInlineDetailsHoverChange,
onInlineDetailsExpandedChange,
onOpenFilePath,
defaultExpanded,
forceInline = false,
maxDetailHeight = 400,
}: ToolCallProps) {
const { openToolCall } = useToolCallSheet();
const [isExpanded, setIsExpanded] = useState(defaultExpanded ?? false);
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 presentation = useMemo(
() =>
buildToolCallPresentation({
agentMessage,
toolName,
status,
error: error ?? null,
detail: effectiveDetail,
metadata,
cwd,
resolveIcon: resolveToolCallIcon,
}),
[toolName, status, error, effectiveDetail, metadata, cwd, agentMessage],
);
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 {
setIsExpanded((prev) => !prev);
}
}, [
shouldRenderInline,
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.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;
return true;
}