mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 18:22:38 +00:00
* feat(app): render mermaid diagrams in agent chat with pan/zoom Fenced mermaid blocks in assistant messages now render as diagrams instead of code blocks. Web: mermaid renders inline via dynamic import (code-split, loads on first diagram). Drag to pan, ctrl/cmd+wheel or trackpad pinch to zoom about the cursor, double-click to reset; plain wheel still scrolls the chat. Rendered SVGs are cached across virtualization remounts. iOS/Android: diagrams render in a WebView running a self-contained generated bundle (build:mermaid-webview, mirrors the native terminal's xterm pattern — no CDN, mermaid ships in the app). The inline preview is touch-shielded and height-driven by a bridge message; tapping opens a fullscreen viewer where the platform WebView owns pinch-zoom. Streaming source that doesn't parse yet falls back to the highlighted code block and keeps the last good render; renders are serialized through one queue with stale-task skipping so token-by-token updates cost at most one in-flight layout. Security: mermaid can fetch external resources during layout (image shapes, CSS url()) before output sanitization runs — see mermaid-js/mermaid#7645 — so resource-bearing source is rejected up front (containsUnsafeMermaidSource) on every platform, htmlLabels stay off, securityLevel is strict, and the mermaid secure list is extended so directives can't override app theming or themeCSS. The lockfile diff includes npm 11.17 normalization churn beyond the mermaid closure; a no-op npm install produces the same rewrite. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(app): serialize native mermaid renders and pin viewer to last good source Serialize render messages inside the mermaid webview entry so a newer streamed update can't re-initialize global mermaid state (e.g. flip the theme) while a prior layout is still running; superseded queued renders are skipped. Track the last successfully rendered source on the RN side and open the fullscreen viewer with that, so tapping mid-stream while the latest chunk is still invalid can't show a blank viewer. Addresses PR review feedback. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(app): pair native mermaid renders with their source via request id A "rendered" bridge event was credited to the newest sent source, but an older render can complete while a newer (possibly invalid) chunk's send is in flight, mis-crediting the older render's success to the newer source. Render messages now carry a request id that the entry echoes on rendered/renderError; the RN side resolves the source by id, so the fullscreen viewer always opens the exact source that produced the visible render. Addresses PR review feedback. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(app): harden mermaid guard and native preview per adversarial review - Guard bypass (blocker): mermaid accepts quoted and unicode-escaped object keys, so quoted "img": evaded the resource denylist while still parsing into a real img property. The guard now decodes u/x escapes and strips quoting characters before matching, and tests both raw and normalized source. - structuredClone polyfill banner in the webview bundle: the app's iOS floor is 15.1 but structuredClone exists only from 15.4; esbuild targets don't polyfill runtime APIs. - Preview height sizes the inner content view so fence padding/borders no longer clip the reported SVG height, and measurement happens at the final content width (measuring overlay keeps the fence insets). - The invisible measuring overlay is hit-test transparent, so an invalid diagram's fallback code block keeps its copy button and gestures. - renderError now prunes all superseded source-map entries, matching the rendered path, so a long broken stream can't accumulate sources. - The entry's render chain can no longer be poisoned: the whole task body is inside try/catch and the chain appends a trailing catch. - "Mermaid diagram" accessibility label is translated in all locales (message.mermaidDiagram) on web and native. - esbuild pinned in packages/app devDependencies so the generated webview artifact doesn't depend on hoisting layout; </script is escaped in the generated JS. Addresses adversarial review round 2. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(app): mermaid source toggle and visible zoom controls Web: a hover control cluster on the diagram (zoom in/out anchored to the viewport center, reset view, view source) mirroring the code block's hover-copy idiom; always visible on compact form factors where hover doesn't exist. View source swaps to the highlighted code block (with its own copy button) and a view-diagram button swaps back. Native: the fullscreen viewer gains a source toggle beside the close button, swapping the webview for the highlighted source in a scrollable view. All control labels are translated in every locale under message.mermaid.*; the flat mermaidDiagram key from the previous commit is folded into the same group. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(app): align mermaid control icons with the app's icon vocabulary Network is the settings Connections icon — reusing it for "view diagram" gave one glyph two meanings; use Workflow instead. RotateCw is the app's established reset/reload glyph (RotateCcw had no precedent). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(app): use Scan for mermaid reset-view control RotateCw is the app's reload glyph (19 uses) — a reset-zoom button wearing it invites a reload misread. Scan (corner-bracket fit-to-frame) is unused and carries the intended fit/reset semantics. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(app): remove raw NUL byte from mermaid cache key separator A literal 0x00 in the template string made git classify the file as binary, rendering the PR diff for it unreviewable. Use the escaped sequence instead — identical runtime key, plain-text source. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(app): align mermaid source-view toggle with the copy button The source view wrapped the code block while the fence margins stayed inside it, so the floating view-diagram toggle hung in the margin whitespace above the visible box — misaligned with the code block's copy button and outside its hover region. Hoist the margins onto the wrapper (both buttons now share the box edge) and render the toggle in the copy button's plain style so the pair reads as one control row. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(app): close mermaid review findings * chore(app): finish Mermaid PR rebase cleanup * refactor(app): isolate diagram rendering and stabilize streaming * fix(app): keep Mermaid previews stable while streaming * fix(nix): update npm dependency hash * fix(app): preserve streamed rows through completion --------- Co-authored-by: Damon Meledones <dmeledon@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
3261 lines
95 KiB
TypeScript
3261 lines
95 KiB
TypeScript
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||
import {
|
||
View,
|
||
Text,
|
||
Image,
|
||
Pressable,
|
||
type GestureResponderEvent,
|
||
type LayoutChangeEvent,
|
||
StyleProp,
|
||
ViewStyle,
|
||
type TextStyle,
|
||
} from "react-native";
|
||
import { useTranslation } from "react-i18next";
|
||
import { MarkdownParagraphView, MarkdownTextSpan } from "@/components/markdown-text";
|
||
import { MarkdownTableCellText } from "@/components/markdown-text-selection";
|
||
import * as React from "react";
|
||
import {
|
||
useState,
|
||
useEffect,
|
||
useRef,
|
||
memo,
|
||
useMemo,
|
||
useCallback,
|
||
createContext,
|
||
useContext,
|
||
isValidElement,
|
||
Children,
|
||
cloneElement,
|
||
} from "react";
|
||
import type { ComponentType, ReactNode } from "react";
|
||
import type MarkdownIt from "markdown-it";
|
||
import { type ASTNode, type RenderRules } from "react-native-markdown-display";
|
||
import MaskedView from "@react-native-masked-view/masked-view";
|
||
import {
|
||
Circle,
|
||
Info,
|
||
CheckCircle,
|
||
XCircle,
|
||
FileText,
|
||
ChevronRight,
|
||
ChevronDown,
|
||
Check,
|
||
CheckSquare,
|
||
Copy,
|
||
TriangleAlertIcon,
|
||
Scissors,
|
||
MicVocal,
|
||
FileSymlink,
|
||
} from "lucide-react-native";
|
||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||
import Animated, {
|
||
Easing,
|
||
cancelAnimation,
|
||
useAnimatedStyle,
|
||
useSharedValue,
|
||
withRepeat,
|
||
withTiming,
|
||
} from "react-native-reanimated";
|
||
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
|
||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||
import { MarkdownRenderer, type MarkdownStyles } from "@/components/markdown/renderer";
|
||
import type { TodoEntry, UserMessageImageAttachment } from "@/types/stream";
|
||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
|
||
import { buildToolCallPresentation } from "@/tool-calls/presentation";
|
||
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
|
||
import { getMarkdownListMarker, getMarkdownListSpacing } from "@/utils/markdown-list";
|
||
import { markdownNodeContainsType } from "@/utils/markdown-ast";
|
||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
|
||
import { MarkdownFenceBlock } from "@/components/markdown/fence";
|
||
import type { MarkdownPhase } from "@/components/markdown/fence/types";
|
||
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
|
||
import { createAssistantMarkdownParser } from "@/utils/assistant-markdown-parser";
|
||
import { formatDuration, formatMessageTimestamp } from "@/utils/time";
|
||
import { writeMarkdownToRichClipboard } from "@/utils/rich-clipboard";
|
||
import { getDefaultMarkdownClipboardEnvironment } from "@/utils/rich-clipboard-default-environment";
|
||
import { setAssistantMarkdownBlockHeight } from "@/utils/assistant-message-height-estimate";
|
||
import { getAgentAttachmentPillContent } from "@/attachments/attachment-pill-content";
|
||
import { PlanCard } from "./plan-card";
|
||
import { useToolCallSheet } from "./tool-call-sheet";
|
||
import { ToolCallDetailsContent } from "./tool-call-details";
|
||
import {
|
||
AssistantInlineCodePathLink,
|
||
type AssistantFileLinkSource,
|
||
AssistantMarkdownCodeLink,
|
||
AssistantMarkdownLink,
|
||
type InlinePathTarget,
|
||
useAssistantFileLinkActions,
|
||
useAssistantLinkPress,
|
||
} from "@/assistant-file-links";
|
||
import { getCompactionMarkerLabel } from "./message-compaction-label";
|
||
import { useAssistantImage } from "@/assistant-image/use-assistant-image";
|
||
import {
|
||
AttachmentFrame,
|
||
AttachmentLabel,
|
||
AttachmentThumbnail,
|
||
} from "@/components/attachment-pill";
|
||
import { AttachmentLightbox } from "@/components/attachment-lightbox";
|
||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||
import { isWeb, isNative } from "@/constants/platform";
|
||
import type { AgentCapabilityFlags } from "@getpaseo/protocol/agent-types";
|
||
import { RewindMenu, type RewindMode } from "@/components/rewind/rewind-menu";
|
||
import { useRewindAgentMutation } from "@/components/rewind/use-rewind-agent-mutation";
|
||
import { AssistantForkMenu, type AssistantForkTarget } from "@/components/assistant-fork-menu";
|
||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||
import {
|
||
markdownCopyDataSet,
|
||
markdownCopyOrderedListDataSet,
|
||
markdownCopyTableCellDataSet,
|
||
type MarkdownCopyInlineTag,
|
||
} from "@/assistant-selection-copy/markup";
|
||
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 MARKDOWN_ALLOWED_IMAGE_HANDLERS = [
|
||
"data:image/png;base64",
|
||
"data:image/gif;base64",
|
||
"data:image/jpeg;base64",
|
||
"https://",
|
||
"http://",
|
||
] as const;
|
||
const MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM = <Text key="dotdotdot">...</Text>;
|
||
|
||
const ThemedMicVocal = withUnistyles(MicVocal);
|
||
const ThemedTodoCheckIcon = withUnistyles(Check);
|
||
const ThemedFileSymlinkIcon = withUnistyles(FileSymlink);
|
||
const ThemedTriangleAlertIcon = withUnistyles(TriangleAlertIcon);
|
||
const ThemedChevronRightIcon = withUnistyles(ChevronRight);
|
||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||
|
||
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 primaryForegroundColorMapping = (theme: Theme) => ({
|
||
color: theme.colors.primaryForeground,
|
||
});
|
||
const destructiveColorMapping = (theme: Theme) => ({ color: theme.colors.destructive });
|
||
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.xs (12) and theme.fontSize.sm (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.base,
|
||
...(isWeb ? { lineHeight: 22, 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>
|
||
);
|
||
}
|
||
|
||
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 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}>
|
||
{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 metadata={lightboxMetadata} onClose={handleLightboxClose} />
|
||
</View>
|
||
);
|
||
});
|
||
|
||
interface AssistantTurnFooterProps {
|
||
getContent: () => string;
|
||
completedAt?: Date;
|
||
durationMs?: number;
|
||
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.
|
||
* Always shows the turn duration; swaps to the end timestamp on hover (web)
|
||
* or tap (native). The hidden sizer keeps the label width stable while the
|
||
* visible text swaps.
|
||
*/
|
||
export const AssistantTurnFooter = memo(function AssistantTurnFooter({
|
||
getContent,
|
||
completedAt,
|
||
durationMs,
|
||
onFork,
|
||
}: AssistantTurnFooterProps) {
|
||
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 ? `Worked for ${formatDuration(durationMs)}` : ""),
|
||
[durationMs],
|
||
);
|
||
const timestampLabel = useMemo(
|
||
() => (completedAt ? formatMessageTimestamp(completedAt) : ""),
|
||
[completedAt],
|
||
);
|
||
|
||
const canSwap = Boolean(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}
|
||
{durationLabel ? (
|
||
<Pressable
|
||
onPress={handlePress}
|
||
onHoverIn={handleHoverIn}
|
||
onHoverOut={handleHoverOut}
|
||
accessibilityRole={canSwap ? "button" : undefined}
|
||
accessibilityLabel={canSwap ? `${durationLabel}, ended ${timestampLabel}` : durationLabel}
|
||
>
|
||
<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>
|
||
{durationLabel.length >= timestampLabel.length ? durationLabel : timestampLabel}
|
||
</Text>
|
||
<Text style={assistantTurnFooterStylesheet.labelOverlay}>
|
||
{showTimestamp ? timestampLabel : durationLabel}
|
||
</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 {
|
||
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,
|
||
},
|
||
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.sm,
|
||
textAlign: "center",
|
||
},
|
||
}));
|
||
|
||
const ASSISTANT_IMAGE_MIN_HEIGHT = 160;
|
||
|
||
function AssistantMarkdownImage({
|
||
source,
|
||
occurrenceKey,
|
||
alt,
|
||
hasLeadingContent,
|
||
client,
|
||
workspaceRoot,
|
||
serverId,
|
||
}: {
|
||
source: string;
|
||
occurrenceKey: string;
|
||
alt?: string;
|
||
hasLeadingContent: boolean;
|
||
client?: DaemonClient | null;
|
||
workspaceRoot?: string;
|
||
serverId?: string;
|
||
}) {
|
||
const containerStyle = useMemo<StyleProp<ViewStyle>>(
|
||
() => ({
|
||
marginTop: hasLeadingContent ? 16 : 0,
|
||
marginBottom: 0,
|
||
}),
|
||
[hasLeadingContent],
|
||
);
|
||
const image = useAssistantImage({
|
||
source,
|
||
occurrenceKey,
|
||
client,
|
||
workspaceRoot,
|
||
serverId,
|
||
});
|
||
const binding = image.status === "failed" ? null : image.binding;
|
||
const aspectRatio = image.status === "failed" ? null : image.aspectRatio;
|
||
const imageUri = binding?.uri ?? "";
|
||
const imageSource = useMemo(() => ({ uri: imageUri }), [imageUri]);
|
||
const frameStyle = useMemo<StyleProp<ViewStyle>>(
|
||
() => [assistantMessageStylesheet.imageFrame, containerStyle],
|
||
[containerStyle],
|
||
);
|
||
const imageSizeStyle = useMemo<ViewStyle>(() => {
|
||
if (aspectRatio) {
|
||
return { aspectRatio };
|
||
}
|
||
return { height: ASSISTANT_IMAGE_MIN_HEIGHT };
|
||
}, [aspectRatio]);
|
||
const surfaceStyle = useMemo<StyleProp<ViewStyle>>(
|
||
() => [assistantMessageStylesheet.imageSurface, imageSizeStyle],
|
||
[imageSizeStyle],
|
||
);
|
||
|
||
const stateFrameStyle = useMemo<StyleProp<ViewStyle>>(
|
||
() => [
|
||
assistantMessageStylesheet.imageFrame,
|
||
containerStyle,
|
||
{ height: ASSISTANT_IMAGE_MIN_HEIGHT },
|
||
assistantMessageStylesheet.imageState,
|
||
],
|
||
[containerStyle],
|
||
);
|
||
|
||
if (image.status === "failed") {
|
||
return (
|
||
<View style={stateFrameStyle}>
|
||
<Text style={assistantMessageStylesheet.imageErrorText}>{image.message}</Text>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
if (!binding) {
|
||
return (
|
||
<View style={stateFrameStyle}>
|
||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||
</View>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<View style={frameStyle}>
|
||
<View style={surfaceStyle} accessibilityRole="image" accessibilityLabel={alt}>
|
||
<Image
|
||
ref={binding.onRef}
|
||
source={imageSource}
|
||
style={assistantMessageStylesheet.image}
|
||
resizeMode="contain"
|
||
onLoad={binding.onLoad}
|
||
onError={binding.onError}
|
||
/>
|
||
{image.status === "loading" ? (
|
||
<View pointerEvents="none" style={assistantMessageStylesheet.imageLoadingOverlay}>
|
||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||
</View>
|
||
) : null}
|
||
</View>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
function getInlineCodeAutoLinkUrl(markdownParser: MarkdownIt, content: string): string | null {
|
||
const trimmed = content.trim();
|
||
if (!trimmed) {
|
||
return null;
|
||
}
|
||
|
||
const matches:
|
||
| {
|
||
index: number;
|
||
lastIndex: number;
|
||
url: string;
|
||
}[]
|
||
| null = markdownParser.linkify.match(trimmed);
|
||
if (!matches || matches.length !== 1) {
|
||
return null;
|
||
}
|
||
|
||
const [match] = matches;
|
||
if (!match || match.index !== 0 || match.lastIndex !== trimmed.length) {
|
||
return null;
|
||
}
|
||
|
||
return match.url;
|
||
}
|
||
|
||
function getInlineCodeAutoLinkSource(input: {
|
||
href: string;
|
||
content: string;
|
||
}): AssistantFileLinkSource {
|
||
return {
|
||
href: input.href,
|
||
text: input.content,
|
||
markup: "linkify",
|
||
sourceInfo: "auto",
|
||
};
|
||
}
|
||
|
||
interface AssistantMarkdownAstNode extends ASTNode {
|
||
sourceInfo?: string;
|
||
}
|
||
|
||
function getMarkdownLinkSource(node: AssistantMarkdownAstNode): AssistantFileLinkSource {
|
||
return {
|
||
href: typeof node.attributes?.href === "string" ? node.attributes.href : "",
|
||
text: getMarkdownNodeText(node),
|
||
title: typeof node.attributes?.title === "string" ? node.attributes.title : undefined,
|
||
markup: node.markup,
|
||
sourceInfo: node.sourceInfo,
|
||
sourceType: node.sourceType === "inline-code" ? "inline-code" : undefined,
|
||
};
|
||
}
|
||
|
||
function getMarkdownNodeText(node: ASTNode): string {
|
||
if (!node.children.length) {
|
||
return node.content ?? "";
|
||
}
|
||
|
||
return node.children.map(getMarkdownNodeText).join("");
|
||
}
|
||
|
||
function nodeHasParentType(parent: unknown, type: string): boolean {
|
||
if (Array.isArray(parent)) {
|
||
return parent.some((entry) => entry?.type === type);
|
||
}
|
||
|
||
return (
|
||
typeof parent === "object" &&
|
||
parent !== null &&
|
||
"type" in parent &&
|
||
(parent as Record<"type", unknown>)["type"] === type
|
||
);
|
||
}
|
||
|
||
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>
|
||
);
|
||
}
|
||
|
||
interface AssistantMessageBlockContainerProps {
|
||
block: string;
|
||
marginBottom: number;
|
||
children: ReactNode;
|
||
}
|
||
|
||
function AssistantMessageBlockContainer({
|
||
block,
|
||
marginBottom,
|
||
children,
|
||
}: AssistantMessageBlockContainerProps) {
|
||
const style = useMemo(() => (marginBottom > 0 ? { marginBottom } : undefined), [marginBottom]);
|
||
const handleLayout = useCallback(
|
||
(event: LayoutChangeEvent) => {
|
||
const { width, height } = event.nativeEvent.layout;
|
||
setAssistantMarkdownBlockHeight({ block, width, height });
|
||
},
|
||
[block],
|
||
);
|
||
return (
|
||
<View style={style} onLayout={isWeb ? handleLayout : undefined}>
|
||
{children}
|
||
</View>
|
||
);
|
||
}
|
||
|
||
interface MemoizedMarkdownBlockProps {
|
||
text: string;
|
||
rules: RenderRules;
|
||
parser: MarkdownIt;
|
||
onLinkPress: (url: string) => boolean;
|
||
}
|
||
|
||
const MemoizedMarkdownBlock = React.memo(function MemoizedMarkdownBlock({
|
||
text,
|
||
rules,
|
||
parser,
|
||
onLinkPress,
|
||
}: MemoizedMarkdownBlockProps) {
|
||
return (
|
||
<MarkdownRenderer
|
||
text={text}
|
||
enableHtmlish={false}
|
||
rules={rules}
|
||
markdownit={parser}
|
||
onLinkPress={onLinkPress}
|
||
allowedImageHandlers={MARKDOWN_ALLOWED_IMAGE_HANDLERS}
|
||
topLevelMaxExceededItem={MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM}
|
||
/>
|
||
);
|
||
});
|
||
|
||
interface MarkdownInheritedTextProps {
|
||
inheritedStyles: TextStyle;
|
||
textStyle: TextStyle;
|
||
style?: StyleProp<TextStyle>;
|
||
monoSurface?: boolean;
|
||
copyTag?: MarkdownCopyInlineTag;
|
||
children: ReactNode;
|
||
}
|
||
|
||
function MarkdownInheritedText({
|
||
inheritedStyles,
|
||
textStyle,
|
||
style: overrideStyle,
|
||
monoSurface,
|
||
copyTag,
|
||
children,
|
||
}: MarkdownInheritedTextProps) {
|
||
const style = useMemo(
|
||
() => [inheritedStyles, textStyle, overrideStyle],
|
||
[inheritedStyles, textStyle, overrideStyle],
|
||
);
|
||
// When this span renders link label text on iOS, pick up the link's press
|
||
// handler from context and hand it to MarkdownTextSpan, which forwards it to
|
||
// the leaf string children react-native-uitextview makes tappable. Null
|
||
// outside a link (and on every other platform, where no provider mounts), so
|
||
// ordinary text is unaffected. See assistant-file-links/link-press-context.
|
||
const linkPress = useAssistantLinkPress();
|
||
return (
|
||
<MarkdownTextSpan
|
||
monoSurface={monoSurface}
|
||
copyTag={copyTag}
|
||
style={style}
|
||
onPress={linkPress?.onPress}
|
||
accessibilityRole={linkPress?.accessibilityRole}
|
||
>
|
||
{children}
|
||
</MarkdownTextSpan>
|
||
);
|
||
}
|
||
|
||
interface MarkdownListItemContentProps {
|
||
contentStyle: ViewStyle;
|
||
children: ReactNode;
|
||
}
|
||
|
||
const MARKDOWN_LIST_ITEM_CONTENT_FLEX: ViewStyle = { flex: 1, flexShrink: 1, minWidth: 0 };
|
||
|
||
function MarkdownListItemContent({ contentStyle, children }: MarkdownListItemContentProps) {
|
||
const style = useMemo(() => [contentStyle, MARKDOWN_LIST_ITEM_CONTENT_FLEX], [contentStyle]);
|
||
return <View style={style}>{children}</View>;
|
||
}
|
||
|
||
interface MarkdownListViewProps {
|
||
baseStyle: ViewStyle;
|
||
copyTag: "ol" | "ul";
|
||
orderedStart?: unknown;
|
||
spacing: { marginTop: number; marginBottom: number };
|
||
children: ReactNode;
|
||
}
|
||
|
||
function MarkdownListView({
|
||
baseStyle,
|
||
copyTag,
|
||
orderedStart,
|
||
spacing,
|
||
children,
|
||
}: MarkdownListViewProps) {
|
||
const style = useMemo(() => [baseStyle, spacing], [baseStyle, spacing]);
|
||
const copyDataSet =
|
||
copyTag === "ol" ? markdownCopyOrderedListDataSet(orderedStart) : markdownCopyDataSet.ul;
|
||
return (
|
||
<View style={style} dataSet={copyDataSet}>
|
||
{children}
|
||
</View>
|
||
);
|
||
}
|
||
|
||
export const AssistantMessage = memo(function AssistantMessage({
|
||
occurrenceKey,
|
||
message,
|
||
timestamp: _timestamp,
|
||
workspaceRoot,
|
||
serverId,
|
||
client,
|
||
spacing = "default",
|
||
phase,
|
||
}: AssistantMessageProps) {
|
||
const markdownParser = useMemo(createAssistantMarkdownParser, []);
|
||
|
||
const fileLinkActions = useAssistantFileLinkActions();
|
||
const handleMarkdownLinkPress = useStableEvent((url: string) => {
|
||
fileLinkActions.open({ href: url }, "main");
|
||
// react-native-markdown-display opens the link itself when this returns true.
|
||
// We already handled it above, so return false to avoid duplicate opens.
|
||
return false;
|
||
});
|
||
|
||
const markdownRules = useMemo<RenderRules>(() => {
|
||
return {
|
||
heading1: (
|
||
node: ASTNode,
|
||
children: ReactNode[],
|
||
_parent: ASTNode[],
|
||
styles: MarkdownStyles,
|
||
) => (
|
||
<View key={node.key} style={styles._VIEW_SAFE_heading1} dataSet={markdownCopyDataSet.h1}>
|
||
{children}
|
||
</View>
|
||
),
|
||
heading2: (
|
||
node: ASTNode,
|
||
children: ReactNode[],
|
||
_parent: ASTNode[],
|
||
styles: MarkdownStyles,
|
||
) => (
|
||
<View key={node.key} style={styles._VIEW_SAFE_heading2} dataSet={markdownCopyDataSet.h2}>
|
||
{children}
|
||
</View>
|
||
),
|
||
heading3: (
|
||
node: ASTNode,
|
||
children: ReactNode[],
|
||
_parent: ASTNode[],
|
||
styles: MarkdownStyles,
|
||
) => (
|
||
<View key={node.key} style={styles._VIEW_SAFE_heading3} dataSet={markdownCopyDataSet.h3}>
|
||
{children}
|
||
</View>
|
||
),
|
||
heading4: (
|
||
node: ASTNode,
|
||
children: ReactNode[],
|
||
_parent: ASTNode[],
|
||
styles: MarkdownStyles,
|
||
) => (
|
||
<View key={node.key} style={styles._VIEW_SAFE_heading4} dataSet={markdownCopyDataSet.h4}>
|
||
{children}
|
||
</View>
|
||
),
|
||
heading5: (
|
||
node: ASTNode,
|
||
children: ReactNode[],
|
||
_parent: ASTNode[],
|
||
styles: MarkdownStyles,
|
||
) => (
|
||
<View key={node.key} style={styles._VIEW_SAFE_heading5} dataSet={markdownCopyDataSet.h5}>
|
||
{children}
|
||
</View>
|
||
),
|
||
heading6: (
|
||
node: ASTNode,
|
||
children: ReactNode[],
|
||
_parent: ASTNode[],
|
||
styles: MarkdownStyles,
|
||
) => (
|
||
<View key={node.key} style={styles._VIEW_SAFE_heading6} dataSet={markdownCopyDataSet.h6}>
|
||
{children}
|
||
</View>
|
||
),
|
||
blockquote: (
|
||
node: ASTNode,
|
||
children: ReactNode[],
|
||
_parent: ASTNode[],
|
||
styles: MarkdownStyles,
|
||
) => (
|
||
<View
|
||
key={node.key}
|
||
style={styles._VIEW_SAFE_blockquote}
|
||
dataSet={markdownCopyDataSet.blockquote}
|
||
>
|
||
{children}
|
||
</View>
|
||
),
|
||
hr: (node: ASTNode, _children: ReactNode[], _parent: ASTNode[], styles: MarkdownStyles) => (
|
||
<View key={node.key} style={styles._VIEW_SAFE_hr} dataSet={markdownCopyDataSet.hr} />
|
||
),
|
||
table: (node: ASTNode, children: ReactNode[], _parent: ASTNode[], styles: MarkdownStyles) => (
|
||
<View key={node.key} style={styles._VIEW_SAFE_table} dataSet={markdownCopyDataSet.table}>
|
||
{children}
|
||
</View>
|
||
),
|
||
thead: (node: ASTNode, children: ReactNode[], _parent: ASTNode[], styles: MarkdownStyles) => (
|
||
<View key={node.key} style={styles._VIEW_SAFE_thead} dataSet={markdownCopyDataSet.thead}>
|
||
{children}
|
||
</View>
|
||
),
|
||
tbody: (node: ASTNode, children: ReactNode[], _parent: ASTNode[], styles: MarkdownStyles) => (
|
||
<View key={node.key} style={styles._VIEW_SAFE_tbody} dataSet={markdownCopyDataSet.tbody}>
|
||
{children}
|
||
</View>
|
||
),
|
||
tr: (node: ASTNode, children: ReactNode[], _parent: ASTNode[], styles: MarkdownStyles) => (
|
||
<View key={node.key} style={styles._VIEW_SAFE_tr} dataSet={markdownCopyDataSet.tr}>
|
||
{children}
|
||
</View>
|
||
),
|
||
text: (
|
||
node: ASTNode,
|
||
_children: ReactNode[],
|
||
_parent: ASTNode[],
|
||
styles: MarkdownStyles,
|
||
inheritedStyles: TextStyle = {},
|
||
) => (
|
||
<MarkdownInheritedText
|
||
key={node.key}
|
||
inheritedStyles={inheritedStyles}
|
||
textStyle={styles.text}
|
||
>
|
||
{node.content}
|
||
</MarkdownInheritedText>
|
||
),
|
||
textgroup: (
|
||
node: ASTNode,
|
||
children: ReactNode[],
|
||
_parent: ASTNode[],
|
||
styles: MarkdownStyles,
|
||
inheritedStyles: TextStyle = {},
|
||
) => (
|
||
<MarkdownInheritedText
|
||
key={node.key}
|
||
inheritedStyles={inheritedStyles}
|
||
textStyle={styles.textgroup}
|
||
>
|
||
{children}
|
||
</MarkdownInheritedText>
|
||
),
|
||
// strong/em/s have no custom rule in react-native-markdown-display's
|
||
// defaults beyond wrapping children in a plain RN <Text>. On iOS the
|
||
// paragraph/textgroup are native UITextViews (see markdown-text.ios.tsx),
|
||
// and a plain <Text> nested inside one is not hoisted into a
|
||
// UITextViewChild, so its content renders invisibly. Route these inline
|
||
// marks through MarkdownTextSpan (same path as text/textgroup) so the
|
||
// styled content composes and stays visible + selectable on iOS.
|
||
strong: (
|
||
node: ASTNode,
|
||
children: ReactNode[],
|
||
_parent: ASTNode[],
|
||
styles: MarkdownStyles,
|
||
inheritedStyles: TextStyle = {},
|
||
) => (
|
||
<MarkdownInheritedText
|
||
key={node.key}
|
||
copyTag="strong"
|
||
inheritedStyles={inheritedStyles}
|
||
textStyle={styles.strong}
|
||
>
|
||
{children}
|
||
</MarkdownInheritedText>
|
||
),
|
||
em: (
|
||
node: ASTNode,
|
||
children: ReactNode[],
|
||
_parent: ASTNode[],
|
||
styles: MarkdownStyles,
|
||
inheritedStyles: TextStyle = {},
|
||
) => (
|
||
<MarkdownInheritedText
|
||
key={node.key}
|
||
copyTag="em"
|
||
inheritedStyles={inheritedStyles}
|
||
textStyle={styles.em}
|
||
>
|
||
{children}
|
||
</MarkdownInheritedText>
|
||
),
|
||
s: (
|
||
node: ASTNode,
|
||
children: ReactNode[],
|
||
_parent: ASTNode[],
|
||
styles: MarkdownStyles,
|
||
inheritedStyles: TextStyle = {},
|
||
) => (
|
||
<MarkdownInheritedText
|
||
key={node.key}
|
||
copyTag="s"
|
||
inheritedStyles={inheritedStyles}
|
||
textStyle={styles.s}
|
||
>
|
||
{children}
|
||
</MarkdownInheritedText>
|
||
),
|
||
// hardbreak/softbreak fall back to react-native-markdown-display's
|
||
// default, a plain RN <Text>{"\n"}. Inside the paragraph UITextView that
|
||
// plain <Text> is not hoisted into a UITextViewChild and is dropped (same
|
||
// root cause as strong/em/s) — so on iOS a hard line break vanished, and
|
||
// a softbreak between words jammed them together ("one\ntwo" -> "onetwo").
|
||
// Emit the break through MarkdownTextSpan so it composes on iOS. Keep
|
||
// the resolved break styles: hardbreak is a full-width flex-row child on
|
||
// Android, and dropping that width joins the surrounding text spans.
|
||
hardbreak: (
|
||
node: ASTNode,
|
||
_children: ReactNode[],
|
||
_parent: ASTNode[],
|
||
styles: MarkdownStyles,
|
||
) => (
|
||
<MarkdownTextSpan key={node.key} style={styles.hardbreak} copyTag="br">
|
||
{"\n"}
|
||
</MarkdownTextSpan>
|
||
),
|
||
softbreak: (
|
||
node: ASTNode,
|
||
_children: ReactNode[],
|
||
_parent: ASTNode[],
|
||
styles: MarkdownStyles,
|
||
) => (
|
||
<MarkdownTextSpan key={node.key} style={styles.softbreak}>
|
||
{"\n"}
|
||
</MarkdownTextSpan>
|
||
),
|
||
code_block: (
|
||
node: ASTNode,
|
||
_children: ReactNode[],
|
||
_parent: ASTNode[],
|
||
styles: MarkdownStyles,
|
||
inheritedStyles: TextStyle = {},
|
||
) => (
|
||
<HighlightedCodeBlock
|
||
key={node.key}
|
||
code={node.content}
|
||
language={null}
|
||
inheritedStyles={inheritedStyles}
|
||
textStyle={styles.code_block}
|
||
/>
|
||
),
|
||
fence: (
|
||
node: ASTNode,
|
||
_children: ReactNode[],
|
||
_parent: ASTNode[],
|
||
styles: MarkdownStyles,
|
||
inheritedStyles: TextStyle = {},
|
||
) => (
|
||
<MarkdownFenceBlock
|
||
key={node.key}
|
||
code={node.content}
|
||
info={node.sourceInfo}
|
||
phase={phase}
|
||
inheritedStyles={inheritedStyles}
|
||
textStyle={styles.fence}
|
||
/>
|
||
),
|
||
code_inline: (
|
||
node: ASTNode,
|
||
_children: ReactNode[],
|
||
parent: ASTNode[],
|
||
styles: MarkdownStyles,
|
||
inheritedStyles: TextStyle = {},
|
||
) => {
|
||
const content = node.content ?? "";
|
||
const isLinkedInlineCode = nodeHasParentType(parent, "link");
|
||
const inlineCodeSource: AssistantFileLinkSource = {
|
||
href: content,
|
||
text: content,
|
||
sourceType: "inline-code",
|
||
};
|
||
const shouldResolveInlinePath =
|
||
!isLinkedInlineCode && fileLinkActions.canResolveFile(inlineCodeSource);
|
||
|
||
if (shouldResolveInlinePath) {
|
||
return (
|
||
<AssistantInlineCodePathLink
|
||
key={node.key}
|
||
content={content}
|
||
inheritedStyles={inheritedStyles}
|
||
codeInlineStyle={styles.code_inline}
|
||
linkStyle={styles.link}
|
||
/>
|
||
);
|
||
}
|
||
|
||
const inlineCodeLinkUrl = getInlineCodeAutoLinkUrl(markdownParser, content);
|
||
if (inlineCodeLinkUrl) {
|
||
const source = getInlineCodeAutoLinkSource({
|
||
href: inlineCodeLinkUrl,
|
||
content,
|
||
});
|
||
return (
|
||
<AssistantMarkdownCodeLink
|
||
key={node.key}
|
||
source={source}
|
||
inheritedStyles={inheritedStyles}
|
||
codeInlineStyle={styles.code_inline}
|
||
linkStyle={styles.link}
|
||
>
|
||
{content}
|
||
</AssistantMarkdownCodeLink>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<MarkdownInheritedText
|
||
key={node.key}
|
||
copyTag="code"
|
||
inheritedStyles={inheritedStyles}
|
||
textStyle={styles.code_inline}
|
||
monoSurface
|
||
>
|
||
{content}
|
||
</MarkdownInheritedText>
|
||
);
|
||
},
|
||
bullet_list: (
|
||
node: ASTNode,
|
||
children: ReactNode[],
|
||
parent: ASTNode[],
|
||
styles: MarkdownStyles,
|
||
) => (
|
||
<MarkdownListView
|
||
key={node.key}
|
||
baseStyle={styles.bullet_list}
|
||
copyTag="ul"
|
||
spacing={getMarkdownListSpacing(node, parent)}
|
||
>
|
||
{children}
|
||
</MarkdownListView>
|
||
),
|
||
ordered_list: (
|
||
node: ASTNode,
|
||
children: ReactNode[],
|
||
parent: ASTNode[],
|
||
styles: MarkdownStyles,
|
||
) => (
|
||
<MarkdownListView
|
||
key={node.key}
|
||
baseStyle={styles.ordered_list}
|
||
copyTag="ol"
|
||
orderedStart={node.attributes?.start}
|
||
spacing={getMarkdownListSpacing(node, parent)}
|
||
>
|
||
{children}
|
||
</MarkdownListView>
|
||
),
|
||
list_item: (
|
||
node: ASTNode,
|
||
children: ReactNode[],
|
||
parent: ASTNode[],
|
||
styles: MarkdownStyles,
|
||
) => {
|
||
const { isOrdered, marker } = getMarkdownListMarker(node, parent);
|
||
const iconStyle = isOrdered ? styles.ordered_list_icon : styles.bullet_list_icon;
|
||
const contentStyle = isOrdered ? styles.ordered_list_content : styles.bullet_list_content;
|
||
|
||
return (
|
||
<View key={node.key} style={styles.list_item} dataSet={markdownCopyDataSet.li}>
|
||
<Text style={iconStyle} dataSet={markdownCopyDataSet.listMarker}>
|
||
{marker}
|
||
</Text>
|
||
<MarkdownListItemContent contentStyle={contentStyle}>
|
||
{children}
|
||
</MarkdownListItemContent>
|
||
</View>
|
||
);
|
||
},
|
||
th: (node: ASTNode, children: ReactNode[], _parent: ASTNode[], styles: MarkdownStyles) => (
|
||
<MarkdownTableCellText key={node.key}>
|
||
<View
|
||
style={styles._VIEW_SAFE_th}
|
||
dataSet={markdownCopyTableCellDataSet("th", node.attributes?.style)}
|
||
>
|
||
{children}
|
||
</View>
|
||
</MarkdownTableCellText>
|
||
),
|
||
td: (node: ASTNode, children: ReactNode[], _parent: ASTNode[], styles: MarkdownStyles) => (
|
||
<MarkdownTableCellText key={node.key}>
|
||
<View
|
||
style={styles._VIEW_SAFE_td}
|
||
dataSet={markdownCopyTableCellDataSet("td", node.attributes?.style)}
|
||
>
|
||
{children}
|
||
</View>
|
||
</MarkdownTableCellText>
|
||
),
|
||
paragraph: (
|
||
node: ASTNode,
|
||
children: ReactNode[],
|
||
_parent: ASTNode[],
|
||
styles: MarkdownStyles,
|
||
) => (
|
||
<MarkdownParagraphView
|
||
key={node.key}
|
||
paragraphStyle={styles.paragraph}
|
||
containsImage={markdownNodeContainsType(node, "image")}
|
||
>
|
||
{children}
|
||
</MarkdownParagraphView>
|
||
),
|
||
link: (node: ASTNode, children: ReactNode[], _parent: ASTNode[], styles: MarkdownStyles) => (
|
||
<AssistantMarkdownLink
|
||
key={node.key}
|
||
source={getMarkdownLinkSource(node)}
|
||
style={styles.link}
|
||
>
|
||
{Children.map(children, (child) => {
|
||
if (!isValidElement(child)) return child;
|
||
const childProps = child.props as { style?: StyleProp<TextStyle> };
|
||
return cloneElement(child, {
|
||
style: [childProps.style, { color: styles.link.color }],
|
||
} as Partial<{ style: StyleProp<TextStyle> }>);
|
||
})}
|
||
</AssistantMarkdownLink>
|
||
),
|
||
image: (
|
||
node: ASTNode,
|
||
_children: ReactNode[],
|
||
parent: ASTNode[],
|
||
_styles: MarkdownStyles,
|
||
) => {
|
||
const paragraphNode = Array.isArray(parent)
|
||
? parent.find((ancestor) => ancestor?.type === "paragraph")
|
||
: null;
|
||
const paragraphChildren = Array.isArray(paragraphNode?.children)
|
||
? paragraphNode.children
|
||
: [];
|
||
const imageIndex = paragraphChildren.findIndex((child: ASTNode) => child?.key === node.key);
|
||
const hasLeadingContent = imageIndex > 0;
|
||
|
||
return (
|
||
<AssistantMarkdownImage
|
||
key={node.key}
|
||
source={String(node.attributes?.src ?? "")}
|
||
occurrenceKey={`${occurrenceKey}:${node.key}`}
|
||
alt={typeof node.attributes?.alt === "string" ? node.attributes.alt : undefined}
|
||
hasLeadingContent={hasLeadingContent}
|
||
client={client}
|
||
workspaceRoot={workspaceRoot}
|
||
serverId={serverId}
|
||
/>
|
||
);
|
||
},
|
||
};
|
||
}, [client, fileLinkActions, markdownParser, occurrenceKey, phase, serverId, workspaceRoot]);
|
||
|
||
const blocks = useMemo(() => splitMarkdownBlocks(message), [message]);
|
||
const keyedBlocks = useMemo(
|
||
() => blocks.map((block, index) => ({ key: `block:${index}`, block })),
|
||
[blocks],
|
||
);
|
||
|
||
const assistantContainerStyle = useMemo(
|
||
() => [
|
||
assistantMessageStylesheet.container,
|
||
(spacing === "compactTop" || spacing === "compactBoth") &&
|
||
assistantMessageStylesheet.containerCompactTop,
|
||
(spacing === "compactBottom" || spacing === "compactBoth") &&
|
||
assistantMessageStylesheet.containerCompactBottom,
|
||
],
|
||
[spacing],
|
||
);
|
||
|
||
return (
|
||
<View testID="assistant-message" style={assistantContainerStyle}>
|
||
{keyedBlocks.map(({ key, block }, index) => (
|
||
<AssistantMessageBlockContainer
|
||
key={key}
|
||
block={block}
|
||
marginBottom={index < keyedBlocks.length - 1 ? 12 : 0}
|
||
>
|
||
<MemoizedMarkdownBlock
|
||
text={block}
|
||
rules={markdownRules}
|
||
parser={markdownParser}
|
||
onLinkPress={handleMarkdownLinkPress}
|
||
/>
|
||
</AssistantMessageBlockContainer>
|
||
))}
|
||
</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.base,
|
||
lineHeight: 22,
|
||
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 ActivityLogProps {
|
||
type: "system" | "info" | "success" | "error" | "artifact";
|
||
message: string;
|
||
timestamp: number;
|
||
metadata?: Record<string, unknown>;
|
||
artifactId?: string;
|
||
artifactType?: string;
|
||
title?: string;
|
||
onArtifactClick?: (artifactId: string) => void;
|
||
disableOuterSpacing?: boolean;
|
||
}
|
||
|
||
const activityLogStylesheet = StyleSheet.create((theme) => ({
|
||
pressable: {
|
||
borderRadius: theme.borderRadius.md,
|
||
overflow: "hidden",
|
||
},
|
||
pressableSpacing: {
|
||
marginBottom: theme.spacing[1],
|
||
},
|
||
pressableActive: {
|
||
opacity: 0.7,
|
||
},
|
||
systemBg: {
|
||
backgroundColor: "rgba(39, 39, 42, 0.5)",
|
||
},
|
||
infoBg: {
|
||
backgroundColor: "rgba(30, 58, 138, 0.3)",
|
||
},
|
||
successBg: {
|
||
backgroundColor: "rgba(20, 83, 45, 0.3)",
|
||
},
|
||
errorBg: {},
|
||
artifactBg: {
|
||
backgroundColor: "rgba(30, 58, 138, 0.4)",
|
||
},
|
||
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: {
|
||
fontSize: theme.fontSize.sm,
|
||
lineHeight: 20,
|
||
},
|
||
detailsRow: {
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
marginTop: theme.spacing[1],
|
||
},
|
||
detailsText: {
|
||
color: theme.colors.foregroundMuted,
|
||
fontSize: theme.fontSize.xs,
|
||
marginRight: theme.spacing[1],
|
||
},
|
||
metadataContainer: {
|
||
marginTop: theme.spacing[2],
|
||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||
borderRadius: theme.borderRadius.base,
|
||
padding: theme.spacing[2],
|
||
borderWidth: theme.borderWidth[1],
|
||
borderColor: theme.colors.border,
|
||
},
|
||
metadataText: {
|
||
color: theme.colors.foreground,
|
||
fontSize: theme.fontSize.code,
|
||
fontFamily: theme.fontFamily.mono,
|
||
lineHeight: 16,
|
||
},
|
||
}));
|
||
|
||
export const ActivityLog = memo(function ActivityLog({
|
||
type,
|
||
message,
|
||
timestamp: _timestamp,
|
||
metadata,
|
||
artifactId,
|
||
artifactType,
|
||
title,
|
||
onArtifactClick,
|
||
disableOuterSpacing,
|
||
}: ActivityLogProps) {
|
||
const { t } = useTranslation();
|
||
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
|
||
const [isExpanded, setIsExpanded] = useState(false);
|
||
|
||
const typeConfig = {
|
||
system: {
|
||
bg: activityLogStylesheet.systemBg,
|
||
color: "#a1a1aa",
|
||
Icon: Circle,
|
||
},
|
||
info: { bg: activityLogStylesheet.infoBg, color: "#60a5fa", Icon: Info },
|
||
success: {
|
||
bg: activityLogStylesheet.successBg,
|
||
color: "#4ade80",
|
||
Icon: CheckCircle,
|
||
},
|
||
error: {
|
||
bg: activityLogStylesheet.errorBg,
|
||
color: "#f87171",
|
||
Icon: XCircle,
|
||
},
|
||
artifact: {
|
||
bg: activityLogStylesheet.artifactBg,
|
||
color: "#93c5fd",
|
||
Icon: FileText,
|
||
},
|
||
};
|
||
|
||
const config = typeConfig[type];
|
||
const IconComponent = config.Icon;
|
||
|
||
const handlePress = useCallback(() => {
|
||
if (type === "artifact" && artifactId && onArtifactClick) {
|
||
onArtifactClick(artifactId);
|
||
} else if (metadata) {
|
||
setIsExpanded((prev) => !prev);
|
||
}
|
||
}, [type, artifactId, onArtifactClick, metadata]);
|
||
|
||
const displayMessage =
|
||
type === "artifact" && artifactType && title ? `${artifactType}: ${title}` : message;
|
||
|
||
const isInteractive = type === "artifact" || metadata;
|
||
const pressableStyle = useMemo(
|
||
() => [
|
||
activityLogStylesheet.pressable,
|
||
!resolvedDisableOuterSpacing && activityLogStylesheet.pressableSpacing,
|
||
config.bg,
|
||
isInteractive && activityLogStylesheet.pressableActive,
|
||
],
|
||
[resolvedDisableOuterSpacing, config.bg, isInteractive],
|
||
);
|
||
const messageTextStyle = useMemo(
|
||
() => [activityLogStylesheet.messageText, { color: config.color }],
|
||
[config.color],
|
||
);
|
||
|
||
return (
|
||
<Pressable onPress={handlePress} disabled={!isInteractive} style={pressableStyle}>
|
||
<View style={activityLogStylesheet.content}>
|
||
<View style={activityLogStylesheet.row}>
|
||
<View style={activityLogStylesheet.iconContainer}>
|
||
<IconComponent size={16} color={config.color} />
|
||
</View>
|
||
<View style={activityLogStylesheet.textContainer}>
|
||
<Text style={messageTextStyle} selectable>
|
||
{displayMessage}
|
||
</Text>
|
||
{metadata && (
|
||
<View style={activityLogStylesheet.detailsRow}>
|
||
<Text style={activityLogStylesheet.detailsText}>
|
||
{t("message.activity.details")}
|
||
</Text>
|
||
{isExpanded ? (
|
||
<ChevronDown size={12} color="#71717a" />
|
||
) : (
|
||
<ChevronRight size={12} color="#71717a" />
|
||
)}
|
||
</View>
|
||
)}
|
||
</View>
|
||
</View>
|
||
{isExpanded && metadata && (
|
||
<View style={activityLogStylesheet.metadataContainer} dataSet={CODE_SURFACE_DATASET}>
|
||
<Text style={activityLogStylesheet.metadataText}>
|
||
{JSON.stringify(metadata, null, 2)}
|
||
</Text>
|
||
</View>
|
||
)}
|
||
</View>
|
||
</Pressable>
|
||
);
|
||
});
|
||
|
||
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[];
|
||
disableOuterSpacing?: boolean;
|
||
}
|
||
|
||
interface TodoListItemRowProps {
|
||
text: string;
|
||
completed: boolean;
|
||
}
|
||
|
||
function TodoListItemRow({ text, completed }: TodoListItemRowProps) {
|
||
const badgeStyle = useMemo(
|
||
() => [
|
||
todoListCardStylesheet.radioBadge,
|
||
completed
|
||
? todoListCardStylesheet.radioBadgeComplete
|
||
: todoListCardStylesheet.radioBadgeIncomplete,
|
||
],
|
||
[completed],
|
||
);
|
||
const textStyle = useMemo(
|
||
() => [todoListCardStylesheet.itemText, completed && todoListCardStylesheet.itemTextCompleted],
|
||
[completed],
|
||
);
|
||
return (
|
||
<View style={todoListCardStylesheet.itemRow}>
|
||
<View style={badgeStyle}>
|
||
{completed ? (
|
||
<ThemedTodoCheckIcon size={12} uniProps={primaryForegroundColorMapping} />
|
||
) : null}
|
||
</View>
|
||
<Text style={textStyle}>{text}</Text>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
const todoListCardStylesheet = StyleSheet.create((theme) => ({
|
||
detailsWrapper: {
|
||
padding: theme.spacing[2],
|
||
},
|
||
list: {
|
||
gap: theme.spacing[1],
|
||
},
|
||
itemRow: {
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
gap: theme.spacing[2],
|
||
},
|
||
radioBadge: {
|
||
width: 16,
|
||
height: 16,
|
||
borderRadius: 8,
|
||
backgroundColor: theme.colors.foregroundMuted,
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
},
|
||
radioBadgeIncomplete: {
|
||
opacity: 0.55,
|
||
},
|
||
radioBadgeComplete: {
|
||
opacity: 0.95,
|
||
},
|
||
itemText: {
|
||
flex: 1,
|
||
color: theme.colors.foreground,
|
||
fontSize: theme.fontSize.base,
|
||
},
|
||
itemTextCompleted: {
|
||
color: theme.colors.foregroundMuted,
|
||
textDecorationLine: "line-through",
|
||
},
|
||
emptyText: {
|
||
color: theme.colors.foregroundMuted,
|
||
fontSize: theme.fontSize.base,
|
||
},
|
||
}));
|
||
|
||
export const TodoListCard = memo(function TodoListCard({
|
||
items,
|
||
disableOuterSpacing,
|
||
}: TodoListCardProps) {
|
||
const { t } = useTranslation();
|
||
const [isExpanded, setIsExpanded] = useState(false);
|
||
|
||
const nextTask = useMemo(() => items.find((item) => !item.completed)?.text, [items]);
|
||
|
||
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) => (
|
||
<TodoListItemRow key={item.text} text={item.text} completed={item.completed} />
|
||
))
|
||
)}
|
||
</View>
|
||
</View>
|
||
);
|
||
}, [items, t]);
|
||
|
||
return (
|
||
<ExpandableBadge
|
||
label={t("message.todo.title")}
|
||
secondaryLabel={nextTask}
|
||
icon={CheckSquare}
|
||
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 {
|
||
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({
|
||
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({
|
||
toolName,
|
||
status,
|
||
error: error ?? null,
|
||
detail: effectiveDetail,
|
||
metadata,
|
||
cwd,
|
||
resolveIcon: resolveToolCallIcon,
|
||
}),
|
||
[toolName, status, error, effectiveDetail, metadata, cwd],
|
||
);
|
||
const handleOpenFile = useMemo(() => {
|
||
const openFilePath = presentation.openFilePath;
|
||
if (!openFilePath || !onOpenFilePath) {
|
||
return undefined;
|
||
}
|
||
return () => onOpenFilePath(openFilePath);
|
||
}, [presentation.openFilePath, onOpenFilePath]);
|
||
|
||
const handleToggle = useCallback(() => {
|
||
if (!shouldRenderInline) {
|
||
openToolCall({
|
||
displayName: presentation.displayName,
|
||
summary: presentation.summary,
|
||
detail: effectiveDetail,
|
||
errorText: presentation.errorText,
|
||
icon: presentation.icon,
|
||
showLoadingSkeleton: presentation.isLoadingDetails,
|
||
});
|
||
} else {
|
||
setIsExpanded((prev) => !prev);
|
||
}
|
||
}, [
|
||
shouldRenderInline,
|
||
openToolCall,
|
||
presentation.displayName,
|
||
presentation.summary,
|
||
presentation.errorText,
|
||
presentation.icon,
|
||
presentation.isLoadingDetails,
|
||
effectiveDetail,
|
||
]);
|
||
|
||
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
|
||
detail={effectiveDetail}
|
||
errorText={presentation.errorText}
|
||
maxHeight={maxDetailHeight}
|
||
showLoadingSkeleton={presentation.isLoadingDetails}
|
||
/>
|
||
);
|
||
}, [
|
||
shouldRenderInline,
|
||
effectiveDetail,
|
||
presentation.errorText,
|
||
presentation.isLoadingDetails,
|
||
maxDetailHeight,
|
||
]);
|
||
|
||
if (presentation.isPlan && effectiveDetail?.type === "plan") {
|
||
return (
|
||
<PlanCard
|
||
text={effectiveDetail.text}
|
||
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;
|
||
}
|