mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-08 09:24:41 +00:00
* Keep a disclosure's trigger in place on web Expanding or collapsing a turn summary, tool-call group, tool call details or todo badge now holds the row that owns the trigger. Rows inside the timeline get a disclosure context; each toggle runs its state change through `useDisclosureHold()`, and the viewport's new `holdRow(rowId, apply)` replaces `holdReadingPosition()`. On web the hold pins the reading anchor to that row, stops following output, and resumes following when the change leaves the list at its end. Native keeps its previous behaviour (detach, then apply) until the anchored toggle lands. * Render the native chat timeline with LegendList in forward order The native timeline was an inverted FlatList. Disclosure toggles moved their trigger, and a cold open settled visibly. It is now a forward-order LegendList that stays virtualized, and one owner moves the scroll offset at a time: - Before placement: LegendList's initial scroll, behind its hidden reveal. - While following: the end pin (native-end-pin.tsx). This is a zero-height first child at the bottom of the content, which native maintainVisibleContentPosition keeps in place. A content shrink or growth never scrolls from JS; only a viewport resize or an explicit jump does. - During a disclosure hold and while detached: LegendList MVCP with shouldRestorePosition (native-disclosure-hold.ts), so a toggle keeps its trigger at the same y and paints one layout change. - Every newly mounted row starts at a per-kind estimate (new-row-estimates.ts). PaseoScrollView defers React Native's content clamp while MVCP is set. A shrink below an unmoved anchor no longer draws a frame past the end (RN #58578, PR #58695). VisibleContentPositionClampTest covers this. LegendList 3.7.1 patch, one "Paseo patch" comment per hunk: - The content height comes from React state on the new architecture. - Rows get a per-row size estimate. - The MVCP anchor lock runs on native too. Upstream gated it to web in d24a9d93 without a native reason. Without the lock, the size pass for prepended history re-anchors on a prepended row and moves the rows on screen. Native ends the lock when its row leaves the view, not on the 300 ms clock. - Header and footer reveal together with the rows. - Only lists that keep their end preserve the initial end. - A remeasure within one pixel plus the 1/8 dp floor of the known size is noise. Upstream compares the raw size with the floored one, so the live turn's assistant row (1380 -> 1381 px) read as a resize and moved every row a frame after the reveal. - The Android silent initial scroll retry sends its 1 dp nudge and the return to the target in one task. Upstream returns on the next frame, but its 16 ms check can finish the scroll first and cancel that frame, so the list revealed 3 px short of its end and moved into place a frame later. - The top-padding shrink hold applies on iOS only. Android, emulator. Evidence is in .artifacts/legend-impl. - J1, J2 at the end, J3, J4, J5, J6, J7: pass. - J2 while streaming: not reachable on the Ten second stream fixture; its turns never fold. - J8: no blank frames over 10 fresh flings, at host load 32-37. J4: still. Before these two hunks, 6 of 7 cold opens moved every row one frame after the reveal. After them, 9 valid cold opens show no shift. 5 of those were traced through the nudge path. Runs that hit the dev client's startup SIGSEGV in MountingCoordinator::pullTransaction were left out. That crash is in the emulator's dropbox from before this work. * Revise a history row when its turn summary line changes LegendList renders a row again only when its item changes. The forward-order timeline in d1f2ae352 folds a finished turn into its host user message, but the host's item does not change when the fold appears, so a turn that folded live lost its summary line until something else revised the row. collapseTurns now reports the hosts whose summary line appeared, changed or went away since the previous fold (updatedHostIds), and the history row revision treats them as changed content. * Stop following the end while a user scroll owns the viewport The bottom anchor controller kept sticky-bottom for the whole of a drag and its momentum and only decided at the end. While it is sticky the native list mounts the end pin, so a drag started during a stream had every new line move the rows under the finger: on the iOS simulator the offset jumped by each line's height (+22, +35, +22 pt) mid-drag, and Android does the same with the same code. beginUserScroll now detaches. endUserScroll resumes following when the gesture ends at the end (scrolling there without animation) and otherwise stays detached and drops any pending anchor, as before. The re-stick is not animated: on Android a line that mounts during a smooth scroll cancels it, and the list then followed 40 pt short of the end until the controller detached it. Measured after the change, frame by frame: - iOS and Android, drag up mid-stream: the content tracks the finger and momentum with no line steps; the stream grows below; tapping the pill re-sticks and follows again. - A short drag at the end, mid-stream: following resumes; the release lands on the end in one frame (the drag distance, here 8 pt on iOS and 21 dp on Android). A line already mounting when the drag begins can still move the rows once in its first frame, because the detach reaches native one commit after the touch. * Correct the iOS scroll view under maintainVisibleContentPosition Phase 2 of the LegendList timeline. The native timeline relies on maintainVisibleContentPosition for the end pin and the disclosure hold, and React Native 0.86 on iOS gets two cases wrong. modules/paseo-scroll now registers PaseoScrollViewComponentView, a subclass of RCTScrollViewComponentView, for the iOS scroll view, like PaseoScrollView on Android. - A mount that removes its own anchor: detaching removes the end pin in the same mount that opens the tapped disclosure, and the offset jumped by the removed view's frame. The subclass keeps the offset when the anchor's tag is no longer registered, the check React Native 0.88 adds in https://github.com/react/react-native/pull/57294. That PR's extra superview check is not copied because it skips anchors detached by removeClippedSubviews (https://github.com/react/react-native/issues/58910). - A shrink at the end: UIKit clamps the offset to the new end during the mount, then React Native adds the anchor's movement, so a turn folding while following landed above the end (https://github.com/react/react-native/issues/58578). The subclass restores the pre-mount offset for the adjustment and clamps after it, as https://github.com/react/react-native/pull/58695 does. The dev client was rebuilt with the module. Verified on the iPhone 16 Pro simulator, frame by frame: the summary far up and the latest turn's summary while following each keep the trigger at 0 px with one layout change per toggle, the todo card the same, the cold open lands still, older history pages keep the visible rows in place, and the keyboard, send-time shrink and streaming stay pinned to the end. Freshly mounted markdown on iOS paints one frame after its layout, because enriched-markdown renders asynchronously (https://github.com/software-mansion/enriched-markdown/issues/1131). A synchronous first render removed that frame but raised fling frame time at p90 and p99, so it is not part of this change. docs/agent-stream-disclosure.md now describes the clamp correction on both platforms. * Keep the rows under the finger at a drag's start and its release Two one-frame row movements remained around a user scroll while the timeline streams. A drag's first frames. The end pin stays mounted until JS stops following, one round trip after the drag begins, so a line mounting in those frames scrolled the rows under the finger by its height. Both scroll view subclasses now skip the content-position correction while the user drags and the anchor is the end pin, which they recognise by its native ID ("paseo-end-pin"). Android overrides scrollToPreservingMomentum, the position keeper's only scroll call, from the begin-drag event until the finger lifts; iOS skips the adjustment while the scroll view is dragging. The skip is limited to the pin: LegendList's own anchoring for the detached list also uses the native correction, and a history page landing mid-drag needs it. It starts at the drag rather than at touch-down: a tap or long press while following would otherwise leave the list stranded short of its end, still following, once the finger lifts. The release. A gesture ending within 32 pt of the end re-stuck with an instant scroll, a snap of up to 56 px. Following now resumes only when the finger lifts at or past the end, or momentum stops there, within 1 px, the distance web resumes at (strategy-web.tsx). Anything else stays detached with the pill. The check runs when the gesture is decided, so output that arrived below meanwhile counts. A release past the end counts as at the end: on iOS it bounces back, and a line landing during the bounce scrolls in as following would. endUserScroll takes isAtEnd, like releaseDisclosure. Tests: VisibleContentPositionClampTest gains growth at the end during a drag (red before the change: the offset moved by the 120 px growth, 352 -> 472) and growth without a touch (the pin still follows); the module suite passes on emulator-5558, 11 tests. bottom-anchor-controller.test.ts: a release near but short of the end stays detached. Device, frame scans while streaming (dev clients rebuilt): - Android, 8 slow drags with a pill tap between: no frame moves against the finger once a drag starts; the stream follows between drags. Three short releases 50 px short of the end stay put until the pill; three drags back past the end resume following. - iOS simulator, 6 drags: no line step during any drag or its momentum. Two short releases stay detached with no snap; three drags past the end resume following within 0.5 s of the bounce. * Document the LegendList timeline and remove what the inverted list left The native section of docs/agent-stream-disclosure.md described the FlatList hold (anchor neighbour, native child index, frame spacing, render window). It now covers why the timeline is forward order, who owns the offset in each state, the hold and its sync layout trigger, user scroll and release, draw distance numbers, the LegendList patch and the scroll view corrections. Draw distance stays at 2000. The spike measured it on a chat with a 15,000 px turn expanded: 500 blanked the viewport 1.0 to 1.5 s per fling and 2000 not at all. A re-measure on the collapsed chat (500, 1000, 1500, 2000, interleaved) blanked at no value and its jank followed host load, which stayed between 19 and 40, so it could not separate 1000 or 1500 from 2000. The patch file now opens with a header listing its eight hunks by purpose and linking LegendApp/legend-list#593, which reports each one upstream. The markdown doc records the iOS first-paint delay (software-mansion/enriched-markdown#1131). Removed with the inverted list: PaseoScrollView's child-reveal override (a no-op without the inverting transform) and ScrollSelectionTest, the invertedWrapper style, and the inverted-list passages in android.md, native-text-selection.md, mobile-testing.md, floating-panels.md and agent-stream-performance.md. model.test, layout.test and footer-spacing.test still asserted the inverted native order and failed; they now assert forward order, and footer-spacing uses the real strategies instead of an inverted stub. * fix(app): show a reply's footer when the next prompt arrives on native LegendList re-renders a mounted row only when its item or extraData changes. When a new prompt arrives, the previous reply's footer moves from the live auxiliary slot into that reply's layout item (completedFooter). The reply's StreamItem keeps its identity, and renderItem is a stable event, so the row never rendered its new layout and the footer (copy, timing, fork) disappeared until the row remounted. FlatList had the same gap. The history renderer is now LegendList's extraData. Its identity changes with the history layout map, and StreamRow's memo limits the pass to rows whose layout item changed. * test(app): run the web disclosure hold cases in a real browser The three hold cases simulated layout in jsdom: hand-written row heights, scroll clamping and resize callbacks. They could pass while Chromium paints the trigger somewhere else. They now run in Vitest browser mode with real layout, ResizeObserver and scroll clamping. The two hold cases fail when the hold is bypassed. The browser project pre-bundles @tanstack/react-virtual, so its first import no longer reloads the test mid-run. The lucide stub gains ArrowDown, which pane-find imports. * fix(app): let a drag end a disclosure hold on native A drag detached the bottom controller but kept the disclosure hold. Every item size change restarts the hold's quiet frames, including a streaming row's, so the hold can outlive the toggle. While it lasts, shouldRestorePosition accepts only the held row. Once the drag scrolls that row away, a history page arriving mid-gesture gets no anchor, and the rows the reader was on move by the page's height. The drag now cancels the hold without releasing it. The controller keeps no hold state of its own beyond detached, and the gesture's end decides whether following resumes. * perf(app): keep the scroll-to-bottom button's state out of the timeline Opening a tool call or turn at the end of the chat now holds its trigger in place, so the timeline detaches and the scroll-to-bottom button appears. That visibility was useState in AgentStreamView, so showing the button re-rendered the whole timeline view for a change only the button reads. The viewport now writes near-bottom into a per-agent store that only the button subscribes to. * fix(app): show the scroll-to-bottom button on native whenever following has stopped A gesture now resumes following only when it ends within 1 px of the end, but the button still hid within 32 px. A release 2-32 px short of the end left the list detached with no button to return. While detached the button now hides only at the end; while following it keeps the 32 px tolerance so content growth that the offset trails for a frame does not flash it. The button also updates when following starts or stops without a scroll. * fix(app): stop the timeline jumping when the keyboard opens or closes on native The keyboard's settled height was committed to the list after the motion, as paddingTop on Android and contentInset.top on iOS, to keep the top of the history reachable under the translated dock. While following, the end pin absorbed it in the same mount. While detached or holding a disclosure, nothing did: Android's native anchor is LegendList's ScrollAdjust view, which padding does not move, and LegendList compensates a padding change only on iOS. Every row jumped by the keyboard height 0.2-1 s after the keyboard settled, and back after it closed. On send, the same commit raced the jump to the end. The keyboard now only translates the list with the composer. The cost: while the keyboard is open, the composer viewport clips the top keyboard-height of the list, so the start of a short conversation is hidden until it closes. * chore(app): describe the native timeline as LegendList in stream comments * fix(app): keep a disclosure's trigger in place when its row is cut off at the top on native LegendList's idsInView starts at the first fully visible row. When a turn host's prompt bubble sat above the viewport, a disclosure hold found no anchor in the data pass (shouldRestorePosition rejects every other row), and the size pass anchored on the row below the host instead, so the trigger moved by the host's size change. The patch appends the partly visible rows to idsInView after the fully visible ones, so the held row anchors while every pass that does not restrict the anchor keeps the first fully visible row. The browser test renders the patched native build, which vitest now aliases instead of the package's unpatched web build. * fix(app): let a turn's summary line collapse again after it opens The React Compiler cached StreamRow's `renderStreamItem(layoutItem)` on the layout item and the renderer. The revised item that useRevisedHistoryRows hands a row whose display state changed only fed the row id, so the row reused its cached content. Opening a turn usually inserts its rows directly below the host, which also gives the host a new layout item. When another row sits between them, such as a compaction marker, the host kept its layout item, its summary line stayed collapsed, and every later tap asked to open the turn again. The renderer now takes the item it renders, so the compiler's cache keys on the revision. StreamRow moves to its own module, which the tests compile the way Expo does. * test(app): alias the patched LegendList build in browser tests only Unit tests load @legendapp/list outside Vite, so the native build's react-native import skipped the react-native-web alias and reached React Native's Flow source, failing every unit suite that imports the native strategy. * fix(app): keep a size-only disclosure's trigger in place when its row is cut off at the top on native LegendList's prepareMVCP picks the anchor for a size change with no data change as the first row in view, without asking shouldRestorePosition. A disclosure that only grows its own row (a todo card) while that row's top was above the viewport therefore held the row below it, and the trigger moved by the row's size change. The patch now applies shouldRestorePosition to size changes as it already does to data changes. * test(app): render the timeline row revision test in a real browser The repo's unit tests do not mount components in JSDOM. The test still compiles stream-row.tsx with the React Compiler, so it fails when a revised row ignores the item it was handed.
2450 lines
72 KiB
TypeScript
2450 lines
72 KiB
TypeScript
import { useDisclosureHold } from "@/agent-stream/disclosure";
|
||
import { MessagePreview, MessageSender } from "@/components/user-message";
|
||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||
import { getAgentAttachmentPillContent } from "@/attachments/attachment-pill-content";
|
||
import { AssistantForkMenu, type AssistantForkTarget } from "@/components/assistant-fork-menu";
|
||
import { AttachmentLightbox, type ImageLightboxSource } from "@/components/attachment-lightbox";
|
||
import {
|
||
AttachmentFrame,
|
||
AttachmentLabel,
|
||
AttachmentThumbnail,
|
||
} from "@/components/attachment-pill";
|
||
import { MarkdownRenderer } from "@/components/markdown/renderer";
|
||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||
import { RewindMenu, type RewindMode } from "@/components/rewind/rewind-menu";
|
||
import { useRewindAgentMutation } from "@/components/rewind/use-rewind-agent-mutation";
|
||
import { TaskListRow } from "@/components/task-list-row";
|
||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||
import { isNative, isWeb } from "@/constants/platform";
|
||
import { useRevealedText, type MarkdownPhase } from "@/hooks/use-revealed-text";
|
||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||
import { buildToolCallPresentation } from "@/tool-calls/presentation";
|
||
import type { TaskActivity, TodoEntry, UserMessageImageAttachment } from "@/types/stream";
|
||
import { setAssistantMessageHeight } from "@/utils/assistant-message-height-estimate";
|
||
import { isRenderProfileEnabled } from "@/utils/render-profiler";
|
||
import { writeMarkdownToRichClipboard } from "@/utils/rich-clipboard";
|
||
import { getDefaultMarkdownClipboardEnvironment } from "@/utils/rich-clipboard-default-environment";
|
||
import { formatDuration, formatMessageTimestamp } from "@/utils/time";
|
||
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
|
||
import { useToolCallExpanded } from "@/tool-calls/expansion-context";
|
||
import { useTargetAgentTitle } from "@/tool-calls/use-target-agent-title";
|
||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||
import type { AgentMessage } from "@getpaseo/protocol/agent-message";
|
||
import type { AgentCapabilityFlags, ToolCallDetail } from "@getpaseo/protocol/agent-types";
|
||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||
import MaskedView from "@react-native-masked-view/masked-view";
|
||
import {
|
||
Check,
|
||
CheckSquare,
|
||
ChevronRight,
|
||
CircleDot,
|
||
Copy,
|
||
FileSymlink,
|
||
Info,
|
||
MicVocal,
|
||
Plus,
|
||
Scissors,
|
||
TriangleAlertIcon,
|
||
XCircle,
|
||
} from "lucide-react-native";
|
||
import type { ComponentType, ReactNode } from "react";
|
||
import * as React from "react";
|
||
import {
|
||
createContext,
|
||
memo,
|
||
useCallback,
|
||
useContext,
|
||
useEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
} from "react";
|
||
import { useTranslation } from "react-i18next";
|
||
import {
|
||
Pressable,
|
||
StyleProp,
|
||
Text,
|
||
View,
|
||
ViewStyle,
|
||
type GestureResponderEvent,
|
||
type LayoutChangeEvent,
|
||
type TextStyle,
|
||
} from "react-native";
|
||
import Animated, {
|
||
Easing,
|
||
cancelAnimation,
|
||
useAnimatedStyle,
|
||
useSharedValue,
|
||
withRepeat,
|
||
withTiming,
|
||
} from "react-native-reanimated";
|
||
import Svg, { Defs, Rect, Stop, LinearGradient as SvgLinearGradient } from "react-native-svg";
|
||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||
import { capAssistantMessageForRender, getUtf8ByteLength } from "./assistant-message-render-limit";
|
||
import { getTurnDurationLabel } from "./assistant-turn-footer-label";
|
||
import { getCompactionMarkerLabel } from "./message-compaction-label";
|
||
import { PlanCard } from "./plan-card";
|
||
import { ToolCallDetailsContent } from "./tool-call-details";
|
||
import { useToolCallSheet } from "./tool-call-sheet";
|
||
export type { InlinePathTarget } from "@/assistant-file-links";
|
||
export type { AssistantForkTarget };
|
||
|
||
interface UserMessageProps {
|
||
sender?: AgentMessage["sender"];
|
||
serverId?: string;
|
||
agentId?: string;
|
||
messageId?: string;
|
||
message: string;
|
||
images?: UserMessageImageAttachment[];
|
||
attachments?: AgentAttachment[];
|
||
timestamp: number;
|
||
capabilities?: AgentCapabilityFlags;
|
||
client?: DaemonClient | null;
|
||
isFirstInGroup?: boolean;
|
||
isLastInGroup?: boolean;
|
||
isPending?: boolean;
|
||
disableOuterSpacing?: boolean;
|
||
}
|
||
|
||
const MessageOuterSpacingContext = createContext(false);
|
||
|
||
export function MessageOuterSpacingProvider({
|
||
disableOuterSpacing,
|
||
children,
|
||
}: {
|
||
disableOuterSpacing: boolean;
|
||
children: ReactNode;
|
||
}) {
|
||
return (
|
||
<MessageOuterSpacingContext.Provider value={disableOuterSpacing}>
|
||
{children}
|
||
</MessageOuterSpacingContext.Provider>
|
||
);
|
||
}
|
||
|
||
function useDisableOuterSpacing(disableOuterSpacing: boolean | undefined) {
|
||
const contextValue = useContext(MessageOuterSpacingContext);
|
||
return disableOuterSpacing ?? contextValue;
|
||
}
|
||
|
||
const WEB_TOOLCALL_SHIMMER_KEYFRAME_ID = "paseo-toolcall-shimmer-keyframes";
|
||
const WEB_TOOLCALL_SHIMMER_ANIMATION_NAME = "paseo-toolcall-shimmer";
|
||
const ThemedMicVocal = withUnistyles(MicVocal);
|
||
const ThemedFileSymlinkIcon = withUnistyles(FileSymlink);
|
||
const ThemedTriangleAlertIcon = withUnistyles(TriangleAlertIcon);
|
||
const ThemedChevronRightIcon = withUnistyles(ChevronRight);
|
||
const ThemedNotificationInfo = withUnistyles(Info);
|
||
const ThemedNotificationWarning = withUnistyles(TriangleAlertIcon);
|
||
const ThemedNotificationError = withUnistyles(XCircle);
|
||
|
||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||
color: theme.colors.foregroundMuted,
|
||
});
|
||
const mutedForegroundColorMapping = (theme: Theme) => ({
|
||
color: theme.colors.mutedForeground,
|
||
});
|
||
const destructiveColorMapping = (theme: Theme) => ({ color: theme.colors.destructive });
|
||
const infoColorMapping = (theme: Theme) => ({ color: theme.colors.palette.blue[300] });
|
||
const warningColorMapping = (theme: Theme) => ({ color: theme.colors.palette.amber[500] });
|
||
const WEB_TOOLCALL_SHIMMER_KEYFRAME_CSS = `
|
||
@keyframes ${WEB_TOOLCALL_SHIMMER_ANIMATION_NAME} {
|
||
0% {
|
||
background-position: var(--paseo-shimmer-start, -200px) 0;
|
||
}
|
||
100% {
|
||
background-position: var(--paseo-shimmer-end, 200px) 0;
|
||
}
|
||
}
|
||
`;
|
||
let webToolCallShimmerRegistered = false;
|
||
const SCROLL_EDGE_EPSILON = 0.5;
|
||
|
||
// Font size for stream metadata (timestamps, durations, live elapsed timer).
|
||
// Lives between theme.fontSize.sm (12) and theme.fontSize.base (14); no token.
|
||
export const STREAM_METADATA_FONT_SIZE = 13;
|
||
type ScrollAxis = "x" | "y";
|
||
|
||
function ensureWebToolCallShimmerKeyframes() {
|
||
if (isNative) {
|
||
return;
|
||
}
|
||
if (typeof document === "undefined") {
|
||
return;
|
||
}
|
||
const existing = document.getElementById(WEB_TOOLCALL_SHIMMER_KEYFRAME_ID);
|
||
if (existing) {
|
||
if (existing.textContent !== WEB_TOOLCALL_SHIMMER_KEYFRAME_CSS) {
|
||
existing.textContent = WEB_TOOLCALL_SHIMMER_KEYFRAME_CSS;
|
||
}
|
||
webToolCallShimmerRegistered = true;
|
||
return;
|
||
}
|
||
if (webToolCallShimmerRegistered) {
|
||
return;
|
||
}
|
||
const styleElement = document.createElement("style");
|
||
styleElement.id = WEB_TOOLCALL_SHIMMER_KEYFRAME_ID;
|
||
styleElement.textContent = WEB_TOOLCALL_SHIMMER_KEYFRAME_CSS;
|
||
document.head.appendChild(styleElement);
|
||
webToolCallShimmerRegistered = true;
|
||
}
|
||
|
||
function getWheelEventElementTarget(event: WheelEvent, fallback: HTMLElement): HTMLElement {
|
||
const { target } = event;
|
||
if (target instanceof HTMLElement) {
|
||
return target;
|
||
}
|
||
if (target instanceof Node && target.parentElement) {
|
||
return target.parentElement;
|
||
}
|
||
return fallback;
|
||
}
|
||
|
||
function canElementScrollInDirection(
|
||
element: HTMLElement,
|
||
axis: ScrollAxis,
|
||
delta: number,
|
||
): boolean {
|
||
if (delta === 0) {
|
||
return false;
|
||
}
|
||
|
||
const computedStyle = window.getComputedStyle(element);
|
||
const overflow = axis === "x" ? computedStyle.overflowX : computedStyle.overflowY;
|
||
const isScrollableOverflow =
|
||
overflow === "auto" || overflow === "scroll" || overflow === "overlay";
|
||
if (!isScrollableOverflow) {
|
||
return false;
|
||
}
|
||
|
||
const scrollPosition = axis === "x" ? element.scrollLeft : element.scrollTop;
|
||
const scrollSize =
|
||
axis === "x"
|
||
? element.scrollWidth - element.clientWidth
|
||
: element.scrollHeight - element.clientHeight;
|
||
if (scrollSize <= SCROLL_EDGE_EPSILON) {
|
||
return false;
|
||
}
|
||
|
||
if (delta > 0) {
|
||
return scrollPosition < scrollSize - SCROLL_EDGE_EPSILON;
|
||
}
|
||
return scrollPosition > SCROLL_EDGE_EPSILON;
|
||
}
|
||
|
||
function canScrollInsideDetailFromTarget(
|
||
detailRoot: HTMLElement,
|
||
startElement: HTMLElement,
|
||
axis: ScrollAxis,
|
||
delta: number,
|
||
): boolean {
|
||
if (delta === 0) {
|
||
return false;
|
||
}
|
||
|
||
let current: HTMLElement | null = startElement;
|
||
while (current) {
|
||
if (canElementScrollInDirection(current, axis, delta)) {
|
||
return true;
|
||
}
|
||
if (current === detailRoot) {
|
||
break;
|
||
}
|
||
current = current.parentElement;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function shouldStopDetailWheelPropagation(detailRoot: HTMLElement, event: WheelEvent): boolean {
|
||
const startElement = getWheelEventElementTarget(event, detailRoot);
|
||
const verticalDelta = event.deltaY;
|
||
let horizontalDelta: number;
|
||
if (event.deltaX !== 0) horizontalDelta = event.deltaX;
|
||
else if (event.shiftKey) horizontalDelta = event.deltaY;
|
||
else horizontalDelta = 0;
|
||
|
||
const hasVerticalIntent = Math.abs(verticalDelta) > SCROLL_EDGE_EPSILON;
|
||
const hasHorizontalIntent = Math.abs(horizontalDelta) > SCROLL_EDGE_EPSILON;
|
||
if (!hasVerticalIntent && !hasHorizontalIntent) {
|
||
return false;
|
||
}
|
||
|
||
const canScrollVertically = hasVerticalIntent
|
||
? canScrollInsideDetailFromTarget(detailRoot, startElement, "y", verticalDelta)
|
||
: false;
|
||
const canScrollHorizontally = hasHorizontalIntent
|
||
? canScrollInsideDetailFromTarget(detailRoot, startElement, "x", horizontalDelta)
|
||
: false;
|
||
|
||
if (hasVerticalIntent && hasHorizontalIntent) {
|
||
const isVerticalDominant = Math.abs(verticalDelta) >= Math.abs(horizontalDelta);
|
||
return isVerticalDominant
|
||
? canScrollVertically || canScrollHorizontally
|
||
: canScrollHorizontally || canScrollVertically;
|
||
}
|
||
|
||
if (hasVerticalIntent) {
|
||
return canScrollVertically;
|
||
}
|
||
return canScrollHorizontally;
|
||
}
|
||
|
||
const userMessageStylesheet = StyleSheet.create((theme) => ({
|
||
container: {
|
||
flexDirection: "row",
|
||
justifyContent: "flex-end",
|
||
...(isWeb ? { userSelect: "text" as const } : {}),
|
||
},
|
||
content: {
|
||
alignItems: "flex-end",
|
||
maxWidth: "100%",
|
||
cursor: "auto",
|
||
},
|
||
containerSpacing: {
|
||
marginBottom: theme.spacing[1],
|
||
},
|
||
containerFirstInGroup: {
|
||
marginTop: theme.spacing[4],
|
||
},
|
||
containerLastInGroup: {
|
||
marginBottom: theme.spacing[4],
|
||
},
|
||
bubble: {
|
||
backgroundColor: theme.colors.surface3,
|
||
borderRadius: theme.borderRadius["2xl"],
|
||
borderTopRightRadius: theme.borderRadius.sm,
|
||
paddingHorizontal: theme.spacing[4],
|
||
paddingVertical: theme.spacing[4],
|
||
minWidth: 0,
|
||
flexShrink: 1,
|
||
},
|
||
text: {
|
||
color: theme.colors.foreground,
|
||
fontSize: theme.fontSize.content,
|
||
...(isWeb
|
||
? {
|
||
lineHeight: Math.round(theme.fontSize.content * 1.4),
|
||
overflowWrap: "anywhere" as const,
|
||
}
|
||
: {}),
|
||
},
|
||
imagePreviewContainer: {
|
||
flexDirection: "row",
|
||
gap: theme.spacing[2],
|
||
flexWrap: "wrap",
|
||
},
|
||
attachmentPreviewContainer: {
|
||
flexDirection: "row",
|
||
gap: theme.spacing[2],
|
||
flexWrap: "wrap",
|
||
},
|
||
imagePreviewSpacing: {
|
||
marginBottom: theme.spacing[2],
|
||
},
|
||
copyButton: {
|
||
alignSelf: "center",
|
||
padding: theme.spacing[1],
|
||
paddingTop: theme.spacing[1],
|
||
marginTop: 0,
|
||
marginRight: -theme.spacing[1],
|
||
},
|
||
trailingRow: {
|
||
alignSelf: "flex-end",
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
height: 24,
|
||
gap: theme.spacing[2],
|
||
marginTop: theme.spacing[2],
|
||
},
|
||
trailingRowHidden: {
|
||
opacity: 0,
|
||
},
|
||
trailingRowVisible: {
|
||
opacity: 1,
|
||
},
|
||
timestampText: {
|
||
color: theme.colors.foregroundMuted,
|
||
fontSize: STREAM_METADATA_FONT_SIZE,
|
||
},
|
||
}));
|
||
|
||
interface UserMessageImagePillProps {
|
||
image: UserMessageImageAttachment;
|
||
onOpen: (image: UserMessageImageAttachment) => void;
|
||
accessibilityLabel: string;
|
||
}
|
||
|
||
function UserMessageImagePill({ image, onOpen, accessibilityLabel }: UserMessageImagePillProps) {
|
||
const handlePress = useCallback(() => {
|
||
onOpen(image);
|
||
}, [onOpen, image]);
|
||
return (
|
||
<AttachmentFrame onPress={handlePress} accessibilityLabel={accessibilityLabel}>
|
||
<AttachmentThumbnail metadata={image} />
|
||
</AttachmentFrame>
|
||
);
|
||
}
|
||
|
||
const MESSAGE_TEXT_DATASET = { messageText: "true" };
|
||
|
||
export const UserMessage = memo(function UserMessage({
|
||
sender,
|
||
serverId,
|
||
agentId,
|
||
messageId,
|
||
message,
|
||
images = [],
|
||
attachments = [],
|
||
timestamp,
|
||
capabilities,
|
||
client,
|
||
isFirstInGroup = true,
|
||
isLastInGroup = true,
|
||
isPending = false,
|
||
disableOuterSpacing,
|
||
}: UserMessageProps) {
|
||
const isCompact = useIsCompactFormFactor();
|
||
const { t } = useTranslation();
|
||
const [isHovered, setIsHovered] = useState(false);
|
||
const [lightboxMetadata, setLightboxMetadata] = useState<UserMessageImageAttachment | null>(null);
|
||
const handleLightboxClose = useCallback(() => setLightboxMetadata(null), []);
|
||
const lightboxSource = useMemo<ImageLightboxSource | null>(
|
||
() => (lightboxMetadata ? { type: "attachment", metadata: lightboxMetadata } : null),
|
||
[lightboxMetadata],
|
||
);
|
||
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
|
||
const hasText = message.trim().length > 0;
|
||
const hasImages = images.length > 0;
|
||
const hasAttachments = attachments.length > 0;
|
||
const showTrailingRow = !isPending && hasText && (isCompact || isNative || isHovered);
|
||
const formattedTimestamp = useMemo(
|
||
() => formatMessageTimestamp(new Date(timestamp)),
|
||
[timestamp],
|
||
);
|
||
const rewindMutation = useRewindAgentMutation({ serverId, agentId, client, messageId });
|
||
|
||
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
|
||
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
|
||
const getMessageContent = useCallback(() => message, [message]);
|
||
const handleRewind = useCallback(
|
||
(input: { mode: RewindMode; rewoundText: string }) => {
|
||
return rewindMutation.rewindAgent(input);
|
||
},
|
||
[rewindMutation],
|
||
);
|
||
|
||
const containerStyle = useMemo(
|
||
() => [
|
||
userMessageStylesheet.container,
|
||
!resolvedDisableOuterSpacing && [
|
||
isFirstInGroup ? userMessageStylesheet.containerFirstInGroup : null,
|
||
isLastInGroup ? userMessageStylesheet.containerLastInGroup : null,
|
||
!isFirstInGroup || !isLastInGroup ? userMessageStylesheet.containerSpacing : null,
|
||
],
|
||
],
|
||
[resolvedDisableOuterSpacing, isFirstInGroup, isLastInGroup],
|
||
);
|
||
const imagePreviewContainerStyle = useMemo(
|
||
() => [
|
||
userMessageStylesheet.imagePreviewContainer,
|
||
hasText || hasAttachments ? userMessageStylesheet.imagePreviewSpacing : undefined,
|
||
],
|
||
[hasAttachments, hasText],
|
||
);
|
||
const attachmentPreviewContainerStyle = useMemo(
|
||
() => [
|
||
userMessageStylesheet.attachmentPreviewContainer,
|
||
hasText ? userMessageStylesheet.imagePreviewSpacing : undefined,
|
||
],
|
||
[hasText],
|
||
);
|
||
const trailingRowStyle = useMemo(
|
||
() => [
|
||
userMessageStylesheet.trailingRow,
|
||
showTrailingRow
|
||
? userMessageStylesheet.trailingRowVisible
|
||
: userMessageStylesheet.trailingRowHidden,
|
||
],
|
||
[showTrailingRow],
|
||
);
|
||
|
||
return (
|
||
<View style={containerStyle} testID="user-message" aria-busy={isPending}>
|
||
<View
|
||
style={userMessageStylesheet.content}
|
||
onPointerEnter={handlePointerEnter}
|
||
onPointerLeave={handlePointerLeave}
|
||
>
|
||
{sender ? <MessageSender sender={sender} serverId={serverId} /> : null}
|
||
<View style={userMessageStylesheet.bubble}>
|
||
<MessagePreview>
|
||
{hasImages ? (
|
||
<View style={imagePreviewContainerStyle}>
|
||
{images.map((image) => (
|
||
<UserMessageImagePill
|
||
key={image.id}
|
||
image={image}
|
||
onOpen={setLightboxMetadata}
|
||
accessibilityLabel={t("composer.attachments.openImage")}
|
||
/>
|
||
))}
|
||
</View>
|
||
) : null}
|
||
{hasAttachments ? (
|
||
<View style={attachmentPreviewContainerStyle}>
|
||
{attachments.map((attachment, index) => {
|
||
const content = getAgentAttachmentPillContent(attachment, t);
|
||
return (
|
||
<AttachmentFrame
|
||
key={`${attachment.type}:${"number" in attachment ? attachment.number : index}`}
|
||
>
|
||
<AttachmentLabel
|
||
icon={content.icon}
|
||
title={content.title}
|
||
subtitle={content.subtitle}
|
||
/>
|
||
</AttachmentFrame>
|
||
);
|
||
})}
|
||
</View>
|
||
) : null}
|
||
{hasText ? (
|
||
<Text selectable style={userMessageStylesheet.text} dataSet={MESSAGE_TEXT_DATASET}>
|
||
{message}
|
||
</Text>
|
||
) : null}
|
||
</MessagePreview>
|
||
</View>
|
||
{hasText ? (
|
||
<View
|
||
style={trailingRowStyle}
|
||
pointerEvents={showTrailingRow ? "auto" : "none"}
|
||
testID="user-message-trailing-row"
|
||
>
|
||
<Text style={userMessageStylesheet.timestampText} testID="user-message-timestamp">
|
||
{formattedTimestamp}
|
||
</Text>
|
||
{capabilities && messageId ? (
|
||
<RewindMenu
|
||
capabilities={capabilities}
|
||
isPending={rewindMutation.isPending}
|
||
rewoundText={message}
|
||
onRewind={handleRewind}
|
||
/>
|
||
) : null}
|
||
<TurnCopyButton
|
||
getContent={getMessageContent}
|
||
containerStyle={userMessageStylesheet.copyButton}
|
||
accessibilityLabel={t("message.actions.copyMessage")}
|
||
/>
|
||
</View>
|
||
) : null}
|
||
</View>
|
||
<AttachmentLightbox source={lightboxSource} onClose={handleLightboxClose} />
|
||
</View>
|
||
);
|
||
});
|
||
|
||
interface AssistantTurnFooterProps {
|
||
getContent?: () => string;
|
||
completedAt?: Date;
|
||
durationMs?: number | null;
|
||
onFork?: (target: AssistantForkTarget) => Promise<void> | void;
|
||
}
|
||
|
||
const assistantTurnFooterStylesheet = StyleSheet.create((theme) => ({
|
||
container: {
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
gap: theme.spacing[2],
|
||
},
|
||
copyButton: {
|
||
alignSelf: "center",
|
||
padding: theme.spacing[1],
|
||
paddingTop: theme.spacing[1],
|
||
marginTop: 0,
|
||
marginLeft: -theme.spacing[1],
|
||
},
|
||
labelWrapper: {
|
||
position: "relative",
|
||
},
|
||
labelSizer: {
|
||
color: theme.colors.foregroundMuted,
|
||
fontSize: STREAM_METADATA_FONT_SIZE,
|
||
opacity: 0,
|
||
},
|
||
labelOverlay: {
|
||
position: "absolute",
|
||
top: 0,
|
||
left: 0,
|
||
color: theme.colors.foregroundMuted,
|
||
fontSize: STREAM_METADATA_FONT_SIZE,
|
||
},
|
||
}));
|
||
|
||
const TIMESTAMP_REVEAL_MS = 3000;
|
||
|
||
/**
|
||
* Footer rendered next to the copy button at the end of an assistant turn.
|
||
* Shows the turn duration and swaps to the end timestamp when both are known.
|
||
* A turn without a visible start shows its end timestamp directly.
|
||
*/
|
||
export const AssistantTurnFooter = memo(function AssistantTurnFooter({
|
||
getContent,
|
||
completedAt,
|
||
durationMs,
|
||
onFork,
|
||
}: AssistantTurnFooterProps) {
|
||
const { t } = useTranslation();
|
||
const [hovered, setHovered] = useState(false);
|
||
const [pressedReveal, setPressedReveal] = useState(false);
|
||
const revealTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
if (revealTimerRef.current) {
|
||
clearTimeout(revealTimerRef.current);
|
||
revealTimerRef.current = null;
|
||
}
|
||
};
|
||
}, []);
|
||
|
||
const durationLabel = useMemo(
|
||
() =>
|
||
durationMs !== undefined && durationMs !== null ? getTurnDurationLabel(durationMs, t) : "",
|
||
[durationMs, t],
|
||
);
|
||
const timestampLabel = useMemo(
|
||
() => (completedAt ? formatMessageTimestamp(completedAt) : ""),
|
||
[completedAt],
|
||
);
|
||
|
||
const primaryLabel = durationLabel || timestampLabel;
|
||
const canSwap = Boolean(durationLabel && timestampLabel);
|
||
const showTimestamp = canSwap && (isWeb ? hovered : pressedReveal);
|
||
|
||
const handleHoverIn = useCallback(() => setHovered(true), []);
|
||
const handleHoverOut = useCallback(() => setHovered(false), []);
|
||
const handlePress = useCallback(() => {
|
||
if (isWeb || !canSwap) return;
|
||
if (revealTimerRef.current) {
|
||
clearTimeout(revealTimerRef.current);
|
||
}
|
||
setPressedReveal((prev) => !prev);
|
||
revealTimerRef.current = setTimeout(() => {
|
||
setPressedReveal(false);
|
||
revealTimerRef.current = null;
|
||
}, TIMESTAMP_REVEAL_MS);
|
||
}, [canSwap]);
|
||
const handleFork = useCallback(
|
||
(target: AssistantForkTarget) => {
|
||
return onFork?.(target);
|
||
},
|
||
[onFork],
|
||
);
|
||
const canFork = Boolean(onFork);
|
||
|
||
return (
|
||
<View style={assistantTurnFooterStylesheet.container}>
|
||
{getContent ? (
|
||
<TurnCopyButton
|
||
getContent={getContent}
|
||
containerStyle={assistantTurnFooterStylesheet.copyButton}
|
||
/>
|
||
) : null}
|
||
{canFork ? <AssistantForkMenu onFork={handleFork} /> : null}
|
||
{primaryLabel ? (
|
||
<Pressable
|
||
onPress={handlePress}
|
||
onHoverIn={handleHoverIn}
|
||
onHoverOut={handleHoverOut}
|
||
accessibilityRole={canSwap ? "button" : undefined}
|
||
accessibilityLabel={canSwap ? `${durationLabel}, ended ${timestampLabel}` : primaryLabel}
|
||
>
|
||
<View style={assistantTurnFooterStylesheet.labelWrapper}>
|
||
{/* Sizer reserves space for whichever label is longer so the
|
||
container width is stable across hover transitions. */}
|
||
<Text style={assistantTurnFooterStylesheet.labelSizer} aria-hidden>
|
||
{primaryLabel.length >= timestampLabel.length ? primaryLabel : timestampLabel}
|
||
</Text>
|
||
<Text style={assistantTurnFooterStylesheet.labelOverlay}>
|
||
{showTimestamp ? timestampLabel : primaryLabel}
|
||
</Text>
|
||
</View>
|
||
</Pressable>
|
||
) : null}
|
||
</View>
|
||
);
|
||
});
|
||
|
||
interface LiveElapsedProps {
|
||
startedAt: Date;
|
||
active?: boolean;
|
||
style?: StyleProp<TextStyle>;
|
||
testID?: string;
|
||
}
|
||
|
||
/**
|
||
* Ticks every second to render an elapsed duration. Isolated from parents so
|
||
* only this component re-renders on each tick.
|
||
*/
|
||
export const LiveElapsed = memo(function LiveElapsed({
|
||
startedAt,
|
||
active = true,
|
||
style,
|
||
testID,
|
||
}: LiveElapsedProps) {
|
||
const startedAtMs = startedAt.getTime();
|
||
const [elapsedMs, setElapsedMs] = useState(() => Math.max(0, Date.now() - startedAtMs));
|
||
const visibleElapsedMs = active ? Math.max(0, Date.now() - startedAtMs) : elapsedMs;
|
||
|
||
useEffect(() => {
|
||
if (!active) {
|
||
return;
|
||
}
|
||
setElapsedMs(Math.max(0, Date.now() - startedAtMs));
|
||
const handle = setInterval(() => {
|
||
setElapsedMs(Math.max(0, Date.now() - startedAtMs));
|
||
}, 1000);
|
||
return () => clearInterval(handle);
|
||
}, [active, startedAtMs]);
|
||
|
||
return (
|
||
<Text style={style} testID={testID}>
|
||
{formatDuration(visibleElapsedMs)}
|
||
</Text>
|
||
);
|
||
});
|
||
|
||
interface AssistantMessageProps {
|
||
renderFullContent?: boolean;
|
||
occurrenceKey: string;
|
||
message: string;
|
||
timestamp: number;
|
||
workspaceRoot?: string;
|
||
serverId?: string;
|
||
client?: DaemonClient | null;
|
||
spacing?: "default" | "compactTop" | "compactBottom" | "compactBoth";
|
||
phase: MarkdownPhase;
|
||
}
|
||
|
||
export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
|
||
container: {
|
||
paddingVertical: theme.spacing[3],
|
||
...(isWeb ? { userSelect: "text" as const } : {}),
|
||
},
|
||
containerCompactTop: {
|
||
paddingTop: 0,
|
||
},
|
||
containerCompactBottom: {
|
||
paddingBottom: 0,
|
||
},
|
||
cappedNotice: {
|
||
marginTop: theme.spacing[3],
|
||
fontFamily: theme.fontFamily.ui,
|
||
fontSize: theme.fontSize.base,
|
||
fontStyle: "italic",
|
||
color: theme.colors.foregroundMuted,
|
||
},
|
||
imageFrame: {
|
||
width: "100%",
|
||
minHeight: 160,
|
||
marginHorizontal: -theme.spacing[1],
|
||
},
|
||
imageSurface: {
|
||
width: "100%",
|
||
overflow: "hidden",
|
||
position: "relative",
|
||
},
|
||
image: {
|
||
width: "100%",
|
||
height: "100%",
|
||
},
|
||
imageLoadingOverlay: {
|
||
position: "absolute",
|
||
top: 0,
|
||
right: 0,
|
||
bottom: 0,
|
||
left: 0,
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
},
|
||
imageState: {
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
paddingHorizontal: theme.spacing[4],
|
||
paddingVertical: theme.spacing[6],
|
||
gap: theme.spacing[2],
|
||
},
|
||
imageErrorText: {
|
||
color: theme.colors.foregroundMuted,
|
||
fontSize: theme.fontSize.base,
|
||
textAlign: "center",
|
||
},
|
||
}));
|
||
|
||
const turnCopyButtonStylesheet = StyleSheet.create((theme) => ({
|
||
container: {
|
||
alignSelf: "flex-start",
|
||
padding: theme.spacing[2],
|
||
paddingTop: 0,
|
||
marginTop: theme.spacing[2],
|
||
},
|
||
iconColor: {
|
||
color: theme.colors.foregroundMuted,
|
||
},
|
||
iconHoveredColor: {
|
||
color: theme.colors.foreground,
|
||
},
|
||
}));
|
||
|
||
interface TurnCopyButtonProps {
|
||
getContent: () => string;
|
||
containerStyle?: StyleProp<ViewStyle>;
|
||
accessibilityLabel?: string;
|
||
copiedAccessibilityLabel?: string;
|
||
}
|
||
|
||
export const TurnCopyButton = memo(function TurnCopyButton({
|
||
getContent,
|
||
containerStyle,
|
||
accessibilityLabel,
|
||
copiedAccessibilityLabel,
|
||
}: TurnCopyButtonProps) {
|
||
const { t } = useTranslation();
|
||
const [copied, setCopied] = useState(false);
|
||
const copyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
|
||
const handleCopy = useCallback(async () => {
|
||
const content = getContent();
|
||
if (!content) {
|
||
return;
|
||
}
|
||
|
||
await writeMarkdownToRichClipboard(content, getDefaultMarkdownClipboardEnvironment());
|
||
setCopied(true);
|
||
|
||
if (copyTimeoutRef.current) {
|
||
clearTimeout(copyTimeoutRef.current);
|
||
}
|
||
|
||
copyTimeoutRef.current = setTimeout(() => {
|
||
setCopied(false);
|
||
copyTimeoutRef.current = null;
|
||
}, 1500);
|
||
}, [getContent]);
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
if (copyTimeoutRef.current) {
|
||
clearTimeout(copyTimeoutRef.current);
|
||
}
|
||
};
|
||
}, []);
|
||
|
||
const pressableStyle = useMemo(
|
||
() => [turnCopyButtonStylesheet.container, containerStyle],
|
||
[containerStyle],
|
||
);
|
||
|
||
return (
|
||
<Pressable
|
||
onPress={handleCopy}
|
||
style={pressableStyle}
|
||
accessibilityRole="button"
|
||
accessibilityLabel={
|
||
copied
|
||
? (copiedAccessibilityLabel ?? t("message.actions.copied"))
|
||
: (accessibilityLabel ?? t("message.actions.copyTurn"))
|
||
}
|
||
>
|
||
{({ hovered }) => {
|
||
const iconColor = hovered
|
||
? turnCopyButtonStylesheet.iconHoveredColor.color
|
||
: turnCopyButtonStylesheet.iconColor.color;
|
||
return copied ? (
|
||
<Check size={ICON_SIZE.sm} color={iconColor} />
|
||
) : (
|
||
<Copy size={ICON_SIZE.sm} color={iconColor} />
|
||
);
|
||
}}
|
||
</Pressable>
|
||
);
|
||
});
|
||
|
||
const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
|
||
container: {
|
||
marginHorizontal: -13,
|
||
},
|
||
containerSpacing: {
|
||
marginBottom: theme.spacing[1],
|
||
},
|
||
containerLastInSequence: {
|
||
marginBottom: theme.spacing[4],
|
||
},
|
||
pressable: {
|
||
borderRadius: theme.borderRadius.lg,
|
||
borderWidth: theme.borderWidth[1],
|
||
borderColor: "transparent",
|
||
paddingHorizontal: theme.spacing[2],
|
||
paddingVertical: theme.spacing[1],
|
||
overflow: "hidden",
|
||
},
|
||
pressablePressed: {
|
||
opacity: 0.9,
|
||
},
|
||
headerRow: {
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
},
|
||
labelRow: {
|
||
flex: 1,
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
overflow: "hidden",
|
||
},
|
||
iconBadge: {
|
||
width: 22,
|
||
height: 22,
|
||
borderRadius: 11,
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
marginRight: theme.spacing[1],
|
||
backgroundColor: "transparent",
|
||
},
|
||
label: {
|
||
color: theme.colors.foregroundMuted,
|
||
fontSize: theme.fontSize.base,
|
||
fontWeight: theme.fontWeight.normal,
|
||
flexShrink: 0,
|
||
},
|
||
labelActive: {
|
||
color: theme.colors.foreground,
|
||
},
|
||
labelLoading: {
|
||
color: theme.colors.foreground,
|
||
opacity: 0.72,
|
||
},
|
||
secondaryLabel: {
|
||
flexShrink: 1,
|
||
minWidth: 0,
|
||
color: theme.colors.foregroundMuted,
|
||
fontSize: theme.fontSize.base,
|
||
fontWeight: theme.fontWeight.normal,
|
||
marginLeft: theme.spacing[2],
|
||
},
|
||
secondaryLabelActive: {
|
||
color: theme.colors.foreground,
|
||
},
|
||
shimmerText: {
|
||
color: "transparent",
|
||
fontSize: theme.fontSize.base,
|
||
fontWeight: theme.fontWeight.normal,
|
||
},
|
||
spacer: {
|
||
flex: 1,
|
||
},
|
||
chevron: {
|
||
flexShrink: 0,
|
||
},
|
||
openFileButton: {
|
||
marginLeft: theme.spacing[1],
|
||
padding: theme.spacing[1],
|
||
borderRadius: theme.borderRadius.md,
|
||
flexShrink: 0,
|
||
},
|
||
openFileButtonPlaceholderIcon: {
|
||
width: 14,
|
||
height: 14,
|
||
},
|
||
detailWrapper: {
|
||
borderBottomLeftRadius: theme.borderRadius.lg,
|
||
borderBottomRightRadius: theme.borderRadius.lg,
|
||
borderWidth: theme.borderWidth[1],
|
||
borderTopWidth: 0,
|
||
borderColor: theme.colors.border,
|
||
padding: 0,
|
||
gap: 0,
|
||
flexShrink: 1,
|
||
minWidth: 0,
|
||
overflow: "hidden",
|
||
...(isWeb ? { cursor: "auto" as const, userSelect: "text" as const } : {}),
|
||
},
|
||
pressableExpanded: {
|
||
backgroundColor: theme.colors.surface1,
|
||
},
|
||
pressableExpandedAttached: {
|
||
borderColor: theme.colors.border,
|
||
borderBottomLeftRadius: 0,
|
||
borderBottomRightRadius: 0,
|
||
},
|
||
// Details that do not sit in a card leave the header as a lone highlighted pill.
|
||
pressableExpandedDetached: {
|
||
borderRadius: theme.borderRadius.full,
|
||
},
|
||
detailWrapperBorderless: {
|
||
borderWidth: 0,
|
||
},
|
||
shimmerOverlay: {
|
||
position: "absolute",
|
||
top: 0,
|
||
right: 0,
|
||
bottom: 0,
|
||
left: 0,
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
overflow: "hidden",
|
||
},
|
||
shimmerMaskRow: {
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
width: "100%",
|
||
height: "100%",
|
||
},
|
||
nativeShimmerTrack: {
|
||
position: "absolute",
|
||
top: 0,
|
||
right: 0,
|
||
bottom: 0,
|
||
left: 0,
|
||
overflow: "hidden",
|
||
},
|
||
nativeShimmerPeak: {
|
||
position: "absolute",
|
||
top: 0,
|
||
bottom: 0,
|
||
left: 0,
|
||
},
|
||
}));
|
||
|
||
interface NativeExpandableBadgeShimmerProps {
|
||
label: string;
|
||
secondaryLabel?: string;
|
||
rowWidth: number;
|
||
rowHeight: number;
|
||
peakWidth: number;
|
||
durationSeconds: number;
|
||
gradientId: string;
|
||
}
|
||
|
||
const NativeExpandableBadgeShimmer = memo(function NativeExpandableBadgeShimmer({
|
||
label,
|
||
secondaryLabel,
|
||
rowWidth,
|
||
rowHeight,
|
||
peakWidth,
|
||
durationSeconds,
|
||
gradientId,
|
||
}: NativeExpandableBadgeShimmerProps) {
|
||
const isPanelActive = useRetainedPanelActive();
|
||
const shimmerTranslateX = useSharedValue(0);
|
||
|
||
useEffect(() => {
|
||
if (!isPanelActive) {
|
||
cancelAnimation(shimmerTranslateX);
|
||
return;
|
||
}
|
||
const startPosition = -peakWidth;
|
||
const endPosition = rowWidth + peakWidth;
|
||
shimmerTranslateX.value = startPosition;
|
||
shimmerTranslateX.value = withRepeat(
|
||
withTiming(endPosition, {
|
||
duration: durationSeconds * 1000,
|
||
easing: Easing.linear,
|
||
}),
|
||
-1,
|
||
false,
|
||
);
|
||
return () => {
|
||
cancelAnimation(shimmerTranslateX);
|
||
};
|
||
}, [durationSeconds, isPanelActive, peakWidth, rowWidth, shimmerTranslateX]);
|
||
|
||
const nativeShimmerPeakStyle = useAnimatedStyle(() => ({
|
||
transform: [{ translateX: shimmerTranslateX.value }],
|
||
}));
|
||
|
||
const nativeShimmerTrackStyle = useMemo(
|
||
() => [expandableBadgeStylesheet.nativeShimmerTrack, { width: rowWidth, height: rowHeight }],
|
||
[rowHeight, rowWidth],
|
||
);
|
||
|
||
const nativeShimmerMaskStyle = useMemo(
|
||
() => [expandableBadgeStylesheet.shimmerMaskRow, { width: rowWidth, height: rowHeight }],
|
||
[rowHeight, rowWidth],
|
||
);
|
||
|
||
const nativeLabelMaskStyle = useMemo(
|
||
() => [expandableBadgeStylesheet.label, { color: "#000000", opacity: 1 }],
|
||
[],
|
||
);
|
||
|
||
const nativeSecondaryMaskStyle = useMemo(
|
||
() => [expandableBadgeStylesheet.secondaryLabel, { color: "#000000", opacity: 1 }],
|
||
[],
|
||
);
|
||
|
||
const nativeShimmerPeakCombinedStyle = useMemo(
|
||
() => [
|
||
expandableBadgeStylesheet.nativeShimmerPeak,
|
||
nativeShimmerPeakStyle,
|
||
{ width: peakWidth, height: rowHeight },
|
||
],
|
||
[nativeShimmerPeakStyle, peakWidth, rowHeight],
|
||
);
|
||
|
||
const maskElement = useMemo(
|
||
() => (
|
||
<View pointerEvents="none" style={nativeShimmerMaskStyle}>
|
||
<Text style={nativeLabelMaskStyle} numberOfLines={1}>
|
||
{label}
|
||
</Text>
|
||
{secondaryLabel ? (
|
||
<Text style={nativeSecondaryMaskStyle} numberOfLines={1}>
|
||
{secondaryLabel}
|
||
</Text>
|
||
) : (
|
||
<View style={expandableBadgeStylesheet.spacer} />
|
||
)}
|
||
</View>
|
||
),
|
||
[nativeShimmerMaskStyle, nativeLabelMaskStyle, nativeSecondaryMaskStyle, label, secondaryLabel],
|
||
);
|
||
|
||
return (
|
||
<View style={expandableBadgeStylesheet.shimmerOverlay} pointerEvents="none">
|
||
<MaskedView pointerEvents="none" style={nativeShimmerTrackStyle} maskElement={maskElement}>
|
||
<View pointerEvents="none" style={nativeShimmerTrackStyle}>
|
||
<Animated.View pointerEvents="none" style={nativeShimmerPeakCombinedStyle}>
|
||
<NativeShimmerPeakSvg gradientId={gradientId} />
|
||
</Animated.View>
|
||
</View>
|
||
</MaskedView>
|
||
</View>
|
||
);
|
||
});
|
||
|
||
function NativeShimmerPeakSvg({ gradientId }: { gradientId: string }) {
|
||
return (
|
||
<Svg width="100%" height="100%" preserveAspectRatio="none">
|
||
<Defs>
|
||
<SvgLinearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">
|
||
<Stop offset="0%" stopColor="#ffffff" stopOpacity={0} />
|
||
<Stop offset="50%" stopColor="#ffffff" stopOpacity={1} />
|
||
<Stop offset="100%" stopColor="#ffffff" stopOpacity={0} />
|
||
</SvgLinearGradient>
|
||
</Defs>
|
||
<Rect x="0" y="0" width="100%" height="100%" fill={`url(#${gradientId})`} />
|
||
</Svg>
|
||
);
|
||
}
|
||
|
||
export const AssistantMessage = memo(function AssistantMessage({
|
||
renderFullContent = false,
|
||
occurrenceKey,
|
||
message,
|
||
timestamp: _timestamp,
|
||
workspaceRoot,
|
||
serverId,
|
||
client,
|
||
spacing = "default",
|
||
phase,
|
||
}: AssistantMessageProps) {
|
||
const { t } = useTranslation();
|
||
const renderedMessage = useMemo(
|
||
() =>
|
||
renderFullContent ? { text: message, capped: false } : capAssistantMessageForRender(message),
|
||
[message, renderFullContent],
|
||
);
|
||
const revealedText = useRevealedText(renderedMessage.text, phase);
|
||
const revealedMessage = renderFullContent ? renderedMessage.text : revealedText;
|
||
const fullMessageByteLength = useMemo(
|
||
() => (renderedMessage.capped && phase === "complete" ? getUtf8ByteLength(message) : null),
|
||
[message, phase, renderedMessage.capped],
|
||
);
|
||
|
||
const assistantContainerStyle = useMemo(
|
||
() => [
|
||
assistantMessageStylesheet.container,
|
||
(spacing === "compactTop" || spacing === "compactBoth") &&
|
||
assistantMessageStylesheet.containerCompactTop,
|
||
(spacing === "compactBottom" || spacing === "compactBoth") &&
|
||
assistantMessageStylesheet.containerCompactBottom,
|
||
],
|
||
[spacing],
|
||
);
|
||
const revealDataSet = useMemo(
|
||
() =>
|
||
isRenderProfileEnabled()
|
||
? {
|
||
...MESSAGE_TEXT_DATASET,
|
||
revealKey: occurrenceKey,
|
||
revealLength: String(revealedMessage.length),
|
||
}
|
||
: MESSAGE_TEXT_DATASET,
|
||
[occurrenceKey, revealedMessage.length],
|
||
);
|
||
|
||
const measureMessage = useStableEvent((event: import("react-native").LayoutChangeEvent) => {
|
||
setAssistantMessageHeight({ markdown: revealedMessage, ...event.nativeEvent.layout });
|
||
});
|
||
return (
|
||
<View
|
||
testID="assistant-message"
|
||
dataSet={revealDataSet}
|
||
style={assistantContainerStyle}
|
||
onLayout={measureMessage}
|
||
>
|
||
<MarkdownRenderer
|
||
text={revealedMessage}
|
||
agentCommands
|
||
streaming={phase !== "complete" || revealedMessage.length < renderedMessage.text.length}
|
||
client={client}
|
||
workspaceRoot={workspaceRoot}
|
||
serverId={serverId}
|
||
/>
|
||
{fullMessageByteLength !== null ? (
|
||
<Text
|
||
testID="assistant-message-capped-notice"
|
||
style={assistantMessageStylesheet.cappedNotice}
|
||
>
|
||
{t("agentStream.messageCapped", { bytes: fullMessageByteLength })}
|
||
</Text>
|
||
) : null}
|
||
</View>
|
||
);
|
||
});
|
||
|
||
interface SpeakMessageProps {
|
||
message: string;
|
||
timestamp: number;
|
||
disableOuterSpacing?: boolean;
|
||
}
|
||
|
||
const speakMessageStylesheet = StyleSheet.create((theme) => ({
|
||
container: {
|
||
paddingVertical: theme.spacing[3],
|
||
},
|
||
containerSpacing: {
|
||
marginBottom: theme.spacing[4],
|
||
},
|
||
header: {
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
gap: theme.spacing[2],
|
||
marginBottom: theme.spacing[2],
|
||
},
|
||
headerLabel: {
|
||
fontFamily: theme.fontFamily.ui,
|
||
fontSize: theme.fontSize.base,
|
||
fontWeight: theme.fontWeight.normal,
|
||
color: theme.colors.foregroundMuted,
|
||
},
|
||
text: {
|
||
fontFamily: theme.fontFamily.ui,
|
||
fontSize: theme.fontSize.content,
|
||
lineHeight: Math.round(theme.fontSize.content * 1.4),
|
||
color: theme.colors.foreground,
|
||
},
|
||
}));
|
||
|
||
export const SpeakMessage = memo(function SpeakMessage({
|
||
message,
|
||
timestamp: _timestamp,
|
||
disableOuterSpacing,
|
||
}: SpeakMessageProps) {
|
||
const { t } = useTranslation();
|
||
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
|
||
const containerStyle = useMemo(
|
||
() => [
|
||
speakMessageStylesheet.container,
|
||
!resolvedDisableOuterSpacing && speakMessageStylesheet.containerSpacing,
|
||
],
|
||
[resolvedDisableOuterSpacing],
|
||
);
|
||
|
||
return (
|
||
<View testID="speak-message" style={containerStyle}>
|
||
<View style={speakMessageStylesheet.header}>
|
||
<ThemedMicVocal size={12} uniProps={foregroundMutedColorMapping} />
|
||
<Text style={speakMessageStylesheet.headerLabel}>{t("message.speak.header")}</Text>
|
||
</View>
|
||
<Text style={speakMessageStylesheet.text}>{message}</Text>
|
||
</View>
|
||
);
|
||
});
|
||
|
||
interface NotificationProps {
|
||
level: "info" | "warning" | "error";
|
||
message: string;
|
||
disableOuterSpacing?: boolean;
|
||
}
|
||
|
||
const notificationStylesheet = StyleSheet.create((theme) => ({
|
||
container: {
|
||
borderRadius: theme.borderRadius.md,
|
||
overflow: "hidden",
|
||
},
|
||
containerSpacing: {
|
||
marginBottom: theme.spacing[1],
|
||
},
|
||
infoBg: {
|
||
backgroundColor: "rgba(147, 197, 253, 0.1)",
|
||
},
|
||
warningBg: {
|
||
backgroundColor: "rgba(245, 158, 11, 0.1)",
|
||
},
|
||
errorBg: {
|
||
backgroundColor: "rgba(239, 68, 68, 0.1)",
|
||
},
|
||
content: {
|
||
paddingHorizontal: theme.spacing[3],
|
||
paddingVertical: 10,
|
||
},
|
||
row: {
|
||
flexDirection: "row",
|
||
alignItems: "flex-start",
|
||
gap: theme.spacing[2],
|
||
},
|
||
iconContainer: {
|
||
flexShrink: 0,
|
||
height: 20,
|
||
justifyContent: "center",
|
||
},
|
||
textContainer: {
|
||
flex: 1,
|
||
},
|
||
messageText: {
|
||
color: theme.colors.foreground,
|
||
fontSize: theme.fontSize.base,
|
||
lineHeight: 20,
|
||
},
|
||
}));
|
||
|
||
export const Notification = memo(function Notification({
|
||
level,
|
||
message,
|
||
disableOuterSpacing,
|
||
}: NotificationProps) {
|
||
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
|
||
|
||
const typeConfig = {
|
||
info: {
|
||
bg: notificationStylesheet.infoBg,
|
||
iconColorMapping: infoColorMapping,
|
||
Icon: ThemedNotificationInfo,
|
||
},
|
||
warning: {
|
||
bg: notificationStylesheet.warningBg,
|
||
iconColorMapping: warningColorMapping,
|
||
Icon: ThemedNotificationWarning,
|
||
},
|
||
error: {
|
||
bg: notificationStylesheet.errorBg,
|
||
iconColorMapping: destructiveColorMapping,
|
||
Icon: ThemedNotificationError,
|
||
},
|
||
};
|
||
|
||
const config = typeConfig[level];
|
||
const IconComponent = config.Icon;
|
||
|
||
const containerStyle = useMemo(
|
||
() => [
|
||
notificationStylesheet.container,
|
||
!resolvedDisableOuterSpacing && notificationStylesheet.containerSpacing,
|
||
config.bg,
|
||
],
|
||
[resolvedDisableOuterSpacing, config.bg],
|
||
);
|
||
return (
|
||
<View style={containerStyle}>
|
||
<View style={notificationStylesheet.content}>
|
||
<View style={notificationStylesheet.row}>
|
||
<View style={notificationStylesheet.iconContainer}>
|
||
<IconComponent size={16} uniProps={config.iconColorMapping} />
|
||
</View>
|
||
<View style={notificationStylesheet.textContainer}>
|
||
<Text style={notificationStylesheet.messageText} selectable>
|
||
{message}
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
);
|
||
});
|
||
|
||
interface CompactionMarkerProps {
|
||
status: "loading" | "completed";
|
||
trigger?: "auto" | "manual";
|
||
preTokens?: number;
|
||
}
|
||
|
||
const compactionStylesheet = StyleSheet.create((theme) => ({
|
||
container: {
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
paddingVertical: theme.spacing[3],
|
||
paddingHorizontal: theme.spacing[4],
|
||
gap: theme.spacing[2],
|
||
},
|
||
line: {
|
||
flex: 1,
|
||
height: 1,
|
||
backgroundColor: theme.colors.border,
|
||
},
|
||
label: {
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
gap: theme.spacing[2],
|
||
},
|
||
text: {
|
||
fontFamily: theme.fontFamily.ui,
|
||
fontSize: 13,
|
||
color: theme.colors.foregroundMuted,
|
||
},
|
||
}));
|
||
|
||
export const CompactionMarker = memo(function CompactionMarker({
|
||
status,
|
||
trigger,
|
||
preTokens,
|
||
}: CompactionMarkerProps) {
|
||
const label = getCompactionMarkerLabel({ status, trigger, preTokens });
|
||
|
||
return (
|
||
<View style={compactionStylesheet.container}>
|
||
<View style={compactionStylesheet.line} />
|
||
<View style={compactionStylesheet.label}>
|
||
{status === "loading" ? (
|
||
<LoadingSpinner size="small" color="#a1a1aa" />
|
||
) : (
|
||
<Scissors size={12} color="#a1a1aa" />
|
||
)}
|
||
<Text style={compactionStylesheet.text}>{label}</Text>
|
||
</View>
|
||
<View style={compactionStylesheet.line} />
|
||
</View>
|
||
);
|
||
});
|
||
|
||
interface TodoListCardProps {
|
||
items: TodoEntry[];
|
||
activity: TaskActivity;
|
||
disableOuterSpacing?: boolean;
|
||
}
|
||
|
||
function taskActivityIcon(activity: TaskActivity) {
|
||
switch (activity.type) {
|
||
case "added":
|
||
return Plus;
|
||
case "started":
|
||
return CircleDot;
|
||
case "completed":
|
||
return Check;
|
||
default:
|
||
return CheckSquare;
|
||
}
|
||
}
|
||
|
||
const todoListCardStylesheet = StyleSheet.create((theme) => ({
|
||
detailsWrapper: {
|
||
padding: theme.spacing[2],
|
||
},
|
||
list: {
|
||
gap: theme.spacing[1],
|
||
},
|
||
emptyText: {
|
||
color: theme.colors.foregroundMuted,
|
||
fontSize: theme.fontSize.base,
|
||
},
|
||
}));
|
||
|
||
export const TodoListCard = memo(function TodoListCard({
|
||
items,
|
||
activity,
|
||
disableOuterSpacing,
|
||
}: TodoListCardProps) {
|
||
const { t } = useTranslation();
|
||
const [isExpanded, setIsExpanded] = useState(false);
|
||
const activityDisplay = useMemo(() => {
|
||
if (activity.type === "created") {
|
||
return {
|
||
label: t("message.todo.activity.created", { count: activity.count }),
|
||
secondaryLabel: undefined,
|
||
};
|
||
}
|
||
return {
|
||
label: t(`message.todo.activity.${activity.type}`),
|
||
secondaryLabel: activity.task,
|
||
};
|
||
}, [activity, t]);
|
||
|
||
const hold = useDisclosureHold();
|
||
const toggleExpanded = useCallback(() => setIsExpanded((prev) => !prev), []);
|
||
const handleToggle = useCallback(() => hold(toggleExpanded), [hold, toggleExpanded]);
|
||
|
||
const renderDetails = useCallback(() => {
|
||
return (
|
||
<View style={todoListCardStylesheet.detailsWrapper}>
|
||
<View style={todoListCardStylesheet.list}>
|
||
{items.length === 0 ? (
|
||
<Text style={todoListCardStylesheet.emptyText}>{t("message.todo.empty")}</Text>
|
||
) : (
|
||
items.map((item) => <TaskListRow key={item.id ?? item.text} task={item} />)
|
||
)}
|
||
</View>
|
||
</View>
|
||
);
|
||
}, [items, t]);
|
||
|
||
return (
|
||
<ExpandableBadge
|
||
label={activityDisplay.label}
|
||
secondaryLabel={activityDisplay.secondaryLabel}
|
||
icon={taskActivityIcon(activity)}
|
||
isExpanded={isExpanded}
|
||
onToggle={handleToggle}
|
||
renderDetails={renderDetails}
|
||
disableOuterSpacing={disableOuterSpacing}
|
||
/>
|
||
);
|
||
});
|
||
|
||
interface ExpandableBadgeProps {
|
||
label: string;
|
||
secondaryLabel?: string;
|
||
icon?: ComponentType<{ size?: number; color?: string }>;
|
||
isExpanded: boolean;
|
||
style?: StyleProp<ViewStyle>;
|
||
onToggle?: () => void;
|
||
onOpenFile?: () => void;
|
||
onDetailHoverChange?: (hovered: boolean) => void;
|
||
renderDetails?: () => ReactNode;
|
||
isLoading?: boolean;
|
||
isError?: boolean;
|
||
isLastInSequence?: boolean;
|
||
disableOuterSpacing?: boolean;
|
||
borderlessWhenExpanded?: boolean;
|
||
testID?: string;
|
||
}
|
||
|
||
interface ExpandableBadgeSecondaryLabelProps {
|
||
secondaryLabel?: string;
|
||
secondaryLabelStyle: StyleProp<TextStyle>;
|
||
shouldMeasureWebShimmer: boolean;
|
||
onSecondaryLayout: (event: LayoutChangeEvent) => void;
|
||
}
|
||
|
||
function ExpandableBadgeSecondaryLabel({
|
||
secondaryLabel,
|
||
secondaryLabelStyle,
|
||
shouldMeasureWebShimmer,
|
||
onSecondaryLayout,
|
||
}: ExpandableBadgeSecondaryLabelProps) {
|
||
if (!secondaryLabel) {
|
||
return null;
|
||
}
|
||
return (
|
||
<Text
|
||
style={secondaryLabelStyle}
|
||
numberOfLines={1}
|
||
onLayout={shouldMeasureWebShimmer ? onSecondaryLayout : undefined}
|
||
>
|
||
{secondaryLabel}
|
||
</Text>
|
||
);
|
||
}
|
||
|
||
interface ExpandableBadgeWebShimmerOverlayProps {
|
||
label: string;
|
||
secondaryLabel?: string;
|
||
shimmerLabelTextStyle: StyleProp<TextStyle>;
|
||
shimmerSecondaryTextStyle: StyleProp<TextStyle>;
|
||
showOpenFileButton: boolean;
|
||
}
|
||
|
||
function ExpandableBadgeWebShimmerOverlay({
|
||
label,
|
||
secondaryLabel,
|
||
shimmerLabelTextStyle,
|
||
shimmerSecondaryTextStyle,
|
||
showOpenFileButton,
|
||
}: ExpandableBadgeWebShimmerOverlayProps) {
|
||
return (
|
||
<View style={expandableBadgeStylesheet.shimmerOverlay} pointerEvents="none">
|
||
<Text style={shimmerLabelTextStyle} numberOfLines={1}>
|
||
{label}
|
||
</Text>
|
||
{secondaryLabel ? (
|
||
<Text style={shimmerSecondaryTextStyle} numberOfLines={1}>
|
||
{secondaryLabel}
|
||
</Text>
|
||
) : null}
|
||
{showOpenFileButton ? (
|
||
<View style={expandableBadgeStylesheet.openFileButton}>
|
||
<View style={expandableBadgeStylesheet.openFileButtonPlaceholderIcon} />
|
||
</View>
|
||
) : null}
|
||
{!secondaryLabel && !showOpenFileButton ? (
|
||
<View style={expandableBadgeStylesheet.spacer} />
|
||
) : null}
|
||
</View>
|
||
);
|
||
}
|
||
|
||
interface ExpandableBadgeLabelRowProps {
|
||
label: string;
|
||
labelStyle: StyleProp<TextStyle>;
|
||
secondaryLabel?: string;
|
||
secondaryLabelStyle: StyleProp<TextStyle>;
|
||
shouldMeasureWebShimmer: boolean;
|
||
shouldMeasureNativeShimmer: boolean;
|
||
isWebShimmer: boolean;
|
||
isNativeShimmer: boolean;
|
||
shimmerLabelTextStyle: StyleProp<TextStyle>;
|
||
shimmerSecondaryTextStyle: StyleProp<TextStyle>;
|
||
labelRowWidth: number;
|
||
labelRowHeight: number;
|
||
nativeShimmerPeakWidth: number;
|
||
shimmerDuration: number;
|
||
nativeGradientId: string;
|
||
onLabelRowLayout: (event: LayoutChangeEvent) => void;
|
||
onLabelLayout: (event: LayoutChangeEvent) => void;
|
||
onSecondaryLayout: (event: LayoutChangeEvent) => void;
|
||
showOpenFileButton: boolean;
|
||
isOpenFileHovered: boolean;
|
||
onOpenFilePress: (event: GestureResponderEvent) => void;
|
||
onOpenFileHoverIn: () => void;
|
||
onOpenFileHoverOut: () => void;
|
||
}
|
||
|
||
function ExpandableBadgeLabelRow({
|
||
label,
|
||
labelStyle,
|
||
secondaryLabel,
|
||
secondaryLabelStyle,
|
||
shouldMeasureWebShimmer,
|
||
shouldMeasureNativeShimmer,
|
||
isWebShimmer,
|
||
isNativeShimmer,
|
||
shimmerLabelTextStyle,
|
||
shimmerSecondaryTextStyle,
|
||
labelRowWidth,
|
||
labelRowHeight,
|
||
nativeShimmerPeakWidth,
|
||
shimmerDuration,
|
||
nativeGradientId,
|
||
onLabelRowLayout,
|
||
onLabelLayout,
|
||
onSecondaryLayout,
|
||
showOpenFileButton,
|
||
isOpenFileHovered,
|
||
onOpenFilePress,
|
||
onOpenFileHoverIn,
|
||
onOpenFileHoverOut,
|
||
}: ExpandableBadgeLabelRowProps) {
|
||
const { t } = useTranslation();
|
||
return (
|
||
<View
|
||
style={expandableBadgeStylesheet.labelRow}
|
||
onLayout={shouldMeasureNativeShimmer ? onLabelRowLayout : undefined}
|
||
>
|
||
<Text
|
||
style={labelStyle}
|
||
numberOfLines={1}
|
||
onLayout={shouldMeasureWebShimmer ? onLabelLayout : undefined}
|
||
>
|
||
{label}
|
||
</Text>
|
||
<ExpandableBadgeSecondaryLabel
|
||
secondaryLabel={secondaryLabel}
|
||
secondaryLabelStyle={secondaryLabelStyle}
|
||
shouldMeasureWebShimmer={shouldMeasureWebShimmer}
|
||
onSecondaryLayout={onSecondaryLayout}
|
||
/>
|
||
{showOpenFileButton ? (
|
||
<Pressable
|
||
onPress={onOpenFilePress}
|
||
onHoverIn={onOpenFileHoverIn}
|
||
onHoverOut={onOpenFileHoverOut}
|
||
accessibilityRole="button"
|
||
accessibilityLabel={t("message.actions.openFile")}
|
||
testID="tool-call-open-file"
|
||
style={expandableBadgeStylesheet.openFileButton}
|
||
hitSlop={6}
|
||
>
|
||
<ThemedFileSymlinkIcon
|
||
size={14}
|
||
uniProps={isOpenFileHovered ? foregroundColorMapping : foregroundMutedColorMapping}
|
||
/>
|
||
</Pressable>
|
||
) : null}
|
||
{isWebShimmer ? (
|
||
<ExpandableBadgeWebShimmerOverlay
|
||
label={label}
|
||
secondaryLabel={secondaryLabel}
|
||
shimmerLabelTextStyle={shimmerLabelTextStyle}
|
||
shimmerSecondaryTextStyle={shimmerSecondaryTextStyle}
|
||
showOpenFileButton={showOpenFileButton}
|
||
/>
|
||
) : null}
|
||
{isNativeShimmer ? (
|
||
<NativeExpandableBadgeShimmer
|
||
label={label}
|
||
secondaryLabel={secondaryLabel}
|
||
rowWidth={labelRowWidth}
|
||
rowHeight={labelRowHeight}
|
||
peakWidth={nativeShimmerPeakWidth}
|
||
durationSeconds={shimmerDuration}
|
||
gradientId={nativeGradientId}
|
||
/>
|
||
) : null}
|
||
</View>
|
||
);
|
||
}
|
||
|
||
// HACK: lucide ships every icon inside a 24×24 viewBox where the path
|
||
// doesn't touch the edges — there's per-icon internal padding. The layout
|
||
// already places the SVG element's box on the rail, but the visible glyph
|
||
// inside the SVG sits inset by a few pixels (and the inset amount differs
|
||
// per icon — chevron-right paints only in the right half of its viewBox,
|
||
// regular tool icons paint roughly the full viewBox minus ~1 unit margin).
|
||
//
|
||
// Lucide has no viewBox knob, so the only way to nudge the visible glyph
|
||
// flush with the rail is a per-icon negative margin. Cosmetic; not exact —
|
||
// every lucide icon has slightly different padding and we're not measuring
|
||
// each one. Two buckets is the compromise:
|
||
// - LUCIDE_TOOL_ICON_NUDGE_LEFT: regular tool icons (path mostly fills
|
||
// the viewBox); needs ~1px left shift.
|
||
// - LUCIDE_CHEVRON_NUDGE_LEFT: chevron-right (path in right half of
|
||
// viewBox, and we scale it 1.3×); needs ~4px left shift.
|
||
// If we ever want this exact, the principled fix is a custom <Svg> wrapper
|
||
// with a tight viewBox per icon — see option (2) in the design discussion.
|
||
const LUCIDE_TOOL_ICON_NUDGE_LEFT: ViewStyle = { marginLeft: -1 };
|
||
const LUCIDE_CHEVRON_NUDGE_LEFT: ViewStyle = { marginLeft: -4 };
|
||
|
||
function renderExpandableBadgeIcon({
|
||
isError,
|
||
isActive,
|
||
ThemedIcon,
|
||
}: {
|
||
isError: boolean;
|
||
isActive: boolean;
|
||
ThemedIcon: ComponentType<{ size?: number; uniProps?: typeof foregroundColorMapping }> | null;
|
||
}): ReactNode {
|
||
if (isError) {
|
||
return (
|
||
<View style={LUCIDE_TOOL_ICON_NUDGE_LEFT}>
|
||
<ThemedTriangleAlertIcon size={12} opacity={0.8} uniProps={destructiveColorMapping} />
|
||
</View>
|
||
);
|
||
}
|
||
if (ThemedIcon) {
|
||
return (
|
||
<View style={LUCIDE_TOOL_ICON_NUDGE_LEFT}>
|
||
<ThemedIcon
|
||
size={12}
|
||
uniProps={isActive ? foregroundColorMapping : mutedForegroundColorMapping}
|
||
/>
|
||
</View>
|
||
);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function renderExpandableBadgeIconSlot({
|
||
showChevron,
|
||
chevronStyle,
|
||
iconNode,
|
||
}: {
|
||
showChevron: boolean;
|
||
chevronStyle: StyleProp<ViewStyle>;
|
||
iconNode: ReactNode;
|
||
}): ReactNode {
|
||
if (showChevron) {
|
||
return (
|
||
<View style={chevronStyle}>
|
||
<ThemedChevronRightIcon size={12} uniProps={foregroundColorMapping} />
|
||
</View>
|
||
);
|
||
}
|
||
return iconNode;
|
||
}
|
||
|
||
function computeShimmerMetrics(input: {
|
||
label: string;
|
||
secondaryLabel: string | undefined;
|
||
isLoading: boolean;
|
||
labelRowWidth: number;
|
||
labelRowHeight: number;
|
||
labelOffsetX: number;
|
||
labelWidth: number;
|
||
secondaryOffsetX: number;
|
||
secondaryWidth: number;
|
||
}) {
|
||
const totalShimmerChars = input.label.trim().length + (input.secondaryLabel?.trim().length ?? 0);
|
||
const shortTextDurationAdjustment = totalShimmerChars <= 12 ? 0.25 : 0;
|
||
const shimmerDuration = Math.max(
|
||
1,
|
||
Math.min(2.3, 1.25 + totalShimmerChars * 0.008 - shortTextDurationAdjustment),
|
||
);
|
||
const nativeShimmerPeakWidth = Math.max(
|
||
32,
|
||
Math.min(120, input.labelRowWidth > 0 ? input.labelRowWidth * 0.28 : 0),
|
||
);
|
||
const isWebShimmer = input.isLoading && isWeb;
|
||
// React Native Web only observes a node when onLayout exists at mount. Keep
|
||
// measuring while idle so a retained badge has dimensions when it starts loading.
|
||
const shouldMeasureWebShimmer = isWeb;
|
||
const shouldMeasureNativeShimmer = input.isLoading && isNative;
|
||
const isNativeShimmer =
|
||
shouldMeasureNativeShimmer && input.labelRowWidth > 0 && input.labelRowHeight > 0;
|
||
const webShimmerSpanStartX = input.labelOffsetX;
|
||
const webShimmerSpanEndX = input.secondaryLabel
|
||
? input.secondaryOffsetX + input.secondaryWidth
|
||
: input.labelOffsetX + input.labelWidth;
|
||
const webShimmerSpanWidth = Math.max(1, webShimmerSpanEndX - webShimmerSpanStartX);
|
||
const webShimmerPeakWidth = Math.max(42, Math.min(120, webShimmerSpanWidth * 0.22));
|
||
const webShimmerTrackStart = webShimmerSpanStartX - webShimmerPeakWidth;
|
||
const webShimmerTrackEnd = webShimmerSpanEndX;
|
||
return {
|
||
shimmerDuration,
|
||
nativeShimmerPeakWidth,
|
||
isWebShimmer,
|
||
shouldMeasureWebShimmer,
|
||
shouldMeasureNativeShimmer,
|
||
isNativeShimmer,
|
||
webShimmerPeakWidth,
|
||
webShimmerTrackStart,
|
||
webShimmerTrackEnd,
|
||
};
|
||
}
|
||
|
||
function useDetailWheelPropagationBlocker(input: {
|
||
detailWrapperRef: React.RefObject<View | null>;
|
||
enabled: boolean;
|
||
}): void {
|
||
const { detailWrapperRef, enabled } = input;
|
||
useEffect(() => {
|
||
if (!enabled) {
|
||
return () => {};
|
||
}
|
||
const rawRef: unknown = detailWrapperRef.current;
|
||
if (!(rawRef instanceof HTMLElement)) {
|
||
return () => {};
|
||
}
|
||
const node = rawRef;
|
||
const stopWheelPropagation = (event: WheelEvent) => {
|
||
if (shouldStopDetailWheelPropagation(node, event)) {
|
||
event.stopPropagation();
|
||
}
|
||
};
|
||
node.addEventListener("wheel", stopWheelPropagation, { passive: true });
|
||
return () => {
|
||
node.removeEventListener("wheel", stopWheelPropagation);
|
||
};
|
||
}, [detailWrapperRef, enabled]);
|
||
}
|
||
|
||
const SHIMMER_GRADIENT =
|
||
"linear-gradient(90deg, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.45) 24%, #ffffff 40%, #ffffff 60%, rgba(255, 255, 255, 0.45) 76%, rgba(255, 255, 255, 0) 100%)";
|
||
|
||
function buildShimmerTextStyle(input: {
|
||
isWebShimmer: boolean;
|
||
webShimmerPeakWidth: number;
|
||
shimmerDuration: number;
|
||
webShimmerTrackStart: number;
|
||
webShimmerTrackEnd: number;
|
||
offsetX: number;
|
||
}): object | null {
|
||
if (!input.isWebShimmer) return null;
|
||
return inlineUnistylesStyle({
|
||
opacity: 1,
|
||
color: "transparent",
|
||
backgroundImage: SHIMMER_GRADIENT,
|
||
backgroundSize: `${input.webShimmerPeakWidth}px 100%`,
|
||
backgroundRepeat: "no-repeat",
|
||
backgroundClip: "text",
|
||
WebkitBackgroundClip: "text",
|
||
WebkitTextFillColor: "transparent",
|
||
animation: `${WEB_TOOLCALL_SHIMMER_ANIMATION_NAME} ${input.shimmerDuration}s linear infinite`,
|
||
"--paseo-shimmer-start": `${input.webShimmerTrackStart - input.offsetX}px`,
|
||
"--paseo-shimmer-end": `${input.webShimmerTrackEnd - input.offsetX}px`,
|
||
});
|
||
}
|
||
|
||
export const ExpandableBadge = memo(function ExpandableBadge({
|
||
label,
|
||
style,
|
||
secondaryLabel,
|
||
icon,
|
||
isExpanded,
|
||
onToggle,
|
||
onOpenFile,
|
||
onDetailHoverChange,
|
||
renderDetails,
|
||
isLoading = false,
|
||
isError = false,
|
||
isLastInSequence = false,
|
||
disableOuterSpacing,
|
||
borderlessWhenExpanded = false,
|
||
testID,
|
||
}: ExpandableBadgeProps) {
|
||
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
|
||
const [isHovered, setIsHovered] = useState(false);
|
||
const [isOpenFileHovered, setIsOpenFileHovered] = useState(false);
|
||
const [isPressed, setIsPressed] = useState(false);
|
||
const isInteractive = Boolean(onToggle);
|
||
const hasDetailContent = Boolean(renderDetails);
|
||
const detailContent = hasDetailContent && isExpanded ? renderDetails?.() : null;
|
||
const detailWrapperRef = useRef<View | null>(null);
|
||
|
||
const handleHoverIn = useCallback(() => setIsHovered(true), []);
|
||
const handleHoverOut = useCallback(() => {
|
||
setIsHovered(false);
|
||
setIsPressed(false);
|
||
}, []);
|
||
const handlePressIn = useCallback(() => setIsPressed(true), []);
|
||
const handlePressOut = useCallback(() => setIsPressed(false), []);
|
||
const handleDetailHoverIn = useCallback(() => onDetailHoverChange?.(true), [onDetailHoverChange]);
|
||
const handleDetailHoverOut = useCallback(
|
||
() => onDetailHoverChange?.(false),
|
||
[onDetailHoverChange],
|
||
);
|
||
const handleOpenFilePress = useCallback(
|
||
(event: GestureResponderEvent) => {
|
||
event.stopPropagation?.();
|
||
onOpenFile?.();
|
||
},
|
||
[onOpenFile],
|
||
);
|
||
const handleOpenFileHoverIn = useCallback(() => setIsOpenFileHovered(true), []);
|
||
const handleOpenFileHoverOut = useCallback(() => setIsOpenFileHovered(false), []);
|
||
|
||
const nativeGradientIdRef = useRef(
|
||
`shimmer-gradient-${Math.random().toString(36).substring(2, 9)}`,
|
||
);
|
||
const [labelRowWidth, setLabelRowWidth] = useState(0);
|
||
const [labelRowHeight, setLabelRowHeight] = useState(0);
|
||
const [labelOffsetX, setLabelOffsetX] = useState(0);
|
||
const [labelWidth, setLabelWidth] = useState(0);
|
||
const [secondaryOffsetX, setSecondaryOffsetX] = useState(0);
|
||
const [secondaryWidth, setSecondaryWidth] = useState(0);
|
||
|
||
const {
|
||
shimmerDuration,
|
||
nativeShimmerPeakWidth,
|
||
isWebShimmer,
|
||
shouldMeasureWebShimmer,
|
||
shouldMeasureNativeShimmer,
|
||
isNativeShimmer,
|
||
webShimmerPeakWidth,
|
||
webShimmerTrackStart,
|
||
webShimmerTrackEnd,
|
||
} = computeShimmerMetrics({
|
||
label,
|
||
secondaryLabel,
|
||
isLoading,
|
||
labelRowWidth,
|
||
labelRowHeight,
|
||
labelOffsetX,
|
||
labelWidth,
|
||
secondaryOffsetX,
|
||
secondaryWidth,
|
||
});
|
||
|
||
const handleLabelRowLayout = useCallback(
|
||
(event: LayoutChangeEvent) => {
|
||
if (!shouldMeasureNativeShimmer) {
|
||
return;
|
||
}
|
||
const { width, height } = event.nativeEvent.layout;
|
||
setLabelRowWidth((previous) => (Math.abs(previous - width) > 0.5 ? width : previous));
|
||
setLabelRowHeight((previous) => (Math.abs(previous - height) > 0.5 ? height : previous));
|
||
},
|
||
[shouldMeasureNativeShimmer],
|
||
);
|
||
|
||
const handleLabelLayout = useCallback(
|
||
(event: LayoutChangeEvent) => {
|
||
if (!shouldMeasureWebShimmer) {
|
||
return;
|
||
}
|
||
const { x, width } = event.nativeEvent.layout;
|
||
setLabelOffsetX((previous) => (Math.abs(previous - x) > 0.5 ? x : previous));
|
||
setLabelWidth((previous) => (Math.abs(previous - width) > 0.5 ? width : previous));
|
||
},
|
||
[shouldMeasureWebShimmer],
|
||
);
|
||
|
||
const handleSecondaryLayout = useCallback(
|
||
(event: LayoutChangeEvent) => {
|
||
if (!shouldMeasureWebShimmer || !secondaryLabel) {
|
||
return;
|
||
}
|
||
const { x, width } = event.nativeEvent.layout;
|
||
setSecondaryOffsetX((previous) => (Math.abs(previous - x) > 0.5 ? x : previous));
|
||
setSecondaryWidth((previous) => (Math.abs(previous - width) > 0.5 ? width : previous));
|
||
},
|
||
[shouldMeasureWebShimmer, secondaryLabel],
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (!isWebShimmer) {
|
||
return;
|
||
}
|
||
ensureWebToolCallShimmerKeyframes();
|
||
}, [isWebShimmer]);
|
||
|
||
useDetailWheelPropagationBlocker({
|
||
detailWrapperRef,
|
||
enabled: !isNative && isExpanded && hasDetailContent,
|
||
});
|
||
|
||
const shimmerLabelStyle = useMemo<StyleProp<TextStyle>>(
|
||
() =>
|
||
buildShimmerTextStyle({
|
||
isWebShimmer,
|
||
webShimmerPeakWidth,
|
||
shimmerDuration,
|
||
webShimmerTrackStart,
|
||
webShimmerTrackEnd,
|
||
offsetX: labelOffsetX,
|
||
}),
|
||
[
|
||
isWebShimmer,
|
||
webShimmerPeakWidth,
|
||
shimmerDuration,
|
||
webShimmerTrackStart,
|
||
webShimmerTrackEnd,
|
||
labelOffsetX,
|
||
],
|
||
);
|
||
|
||
const shimmerSecondaryStyle = useMemo<StyleProp<TextStyle>>(
|
||
() =>
|
||
buildShimmerTextStyle({
|
||
isWebShimmer,
|
||
webShimmerPeakWidth,
|
||
shimmerDuration,
|
||
webShimmerTrackStart,
|
||
webShimmerTrackEnd,
|
||
offsetX: secondaryOffsetX,
|
||
}),
|
||
[
|
||
isWebShimmer,
|
||
webShimmerPeakWidth,
|
||
shimmerDuration,
|
||
webShimmerTrackStart,
|
||
webShimmerTrackEnd,
|
||
secondaryOffsetX,
|
||
],
|
||
);
|
||
|
||
const containerStyle = useMemo(
|
||
() => [
|
||
expandableBadgeStylesheet.container,
|
||
!resolvedDisableOuterSpacing &&
|
||
(isLastInSequence
|
||
? expandableBadgeStylesheet.containerLastInSequence
|
||
: expandableBadgeStylesheet.containerSpacing),
|
||
style,
|
||
],
|
||
[isLastInSequence, resolvedDisableOuterSpacing, style],
|
||
);
|
||
|
||
const pressableStyle = useMemo(
|
||
() => [
|
||
expandableBadgeStylesheet.pressable,
|
||
isPressed && isInteractive ? expandableBadgeStylesheet.pressablePressed : null,
|
||
isExpanded && expandableBadgeStylesheet.pressableExpanded,
|
||
isExpanded &&
|
||
(borderlessWhenExpanded
|
||
? expandableBadgeStylesheet.pressableExpandedDetached
|
||
: expandableBadgeStylesheet.pressableExpandedAttached),
|
||
],
|
||
[borderlessWhenExpanded, isExpanded, isInteractive, isPressed],
|
||
);
|
||
|
||
const detailWrapperStyle = useMemo(
|
||
() => [
|
||
expandableBadgeStylesheet.detailWrapper,
|
||
borderlessWhenExpanded && expandableBadgeStylesheet.detailWrapperBorderless,
|
||
],
|
||
[borderlessWhenExpanded],
|
||
);
|
||
|
||
const accessibilityState = useMemo(
|
||
() => (isInteractive ? { expanded: isExpanded } : undefined),
|
||
[isExpanded, isInteractive],
|
||
);
|
||
|
||
const isActive = isHovered || isExpanded;
|
||
|
||
const labelStyle = useMemo(
|
||
() => [
|
||
expandableBadgeStylesheet.label,
|
||
isActive && expandableBadgeStylesheet.labelActive,
|
||
isLoading && expandableBadgeStylesheet.labelLoading,
|
||
],
|
||
[isActive, isLoading],
|
||
);
|
||
|
||
const secondaryLabelStyle = useMemo(
|
||
() => [
|
||
expandableBadgeStylesheet.secondaryLabel,
|
||
isActive && expandableBadgeStylesheet.secondaryLabelActive,
|
||
],
|
||
[isActive],
|
||
);
|
||
|
||
const shimmerLabelTextStyle = useMemo(
|
||
() => [
|
||
expandableBadgeStylesheet.label,
|
||
isLoading && expandableBadgeStylesheet.labelLoading,
|
||
expandableBadgeStylesheet.shimmerText,
|
||
shimmerLabelStyle,
|
||
],
|
||
[isLoading, shimmerLabelStyle],
|
||
);
|
||
|
||
const shimmerSecondaryTextStyle = useMemo(
|
||
() => [
|
||
expandableBadgeStylesheet.secondaryLabel,
|
||
expandableBadgeStylesheet.shimmerText,
|
||
shimmerSecondaryStyle,
|
||
],
|
||
[shimmerSecondaryStyle],
|
||
);
|
||
|
||
const chevronStyle = useMemo(
|
||
() => [
|
||
expandableBadgeStylesheet.chevron,
|
||
LUCIDE_CHEVRON_NUDGE_LEFT,
|
||
inlineUnistylesStyle({
|
||
transform: isExpanded ? [{ scale: 1.3 }, { rotate: "90deg" }] : [{ scale: 1.3 }],
|
||
}),
|
||
],
|
||
[isExpanded],
|
||
);
|
||
|
||
const ThemedIcon = useMemo(() => (icon ? withUnistyles(icon) : null), [icon]);
|
||
const iconNode = renderExpandableBadgeIcon({ isError, isActive, ThemedIcon });
|
||
const iconSlotNode = renderExpandableBadgeIconSlot({
|
||
showChevron: isInteractive && (isHovered || isExpanded),
|
||
chevronStyle,
|
||
iconNode,
|
||
});
|
||
|
||
const pressHandlers = isInteractive
|
||
? {
|
||
onPress: onToggle,
|
||
onPressIn: handlePressIn,
|
||
onPressOut: handlePressOut,
|
||
accessibilityRole: "button" as const,
|
||
}
|
||
: {};
|
||
|
||
return (
|
||
<View
|
||
style={containerStyle}
|
||
testID={testID}
|
||
onPointerEnter={isWeb ? handleHoverIn : undefined}
|
||
onPointerLeave={isWeb ? handleHoverOut : undefined}
|
||
>
|
||
<Pressable
|
||
{...pressHandlers}
|
||
disabled={!isInteractive}
|
||
accessibilityState={accessibilityState}
|
||
style={pressableStyle}
|
||
>
|
||
<View style={expandableBadgeStylesheet.headerRow}>
|
||
<View style={expandableBadgeStylesheet.iconBadge}>{iconSlotNode}</View>
|
||
<ExpandableBadgeLabelRow
|
||
label={label}
|
||
labelStyle={labelStyle}
|
||
secondaryLabel={secondaryLabel}
|
||
secondaryLabelStyle={secondaryLabelStyle}
|
||
shouldMeasureWebShimmer={shouldMeasureWebShimmer}
|
||
shouldMeasureNativeShimmer={shouldMeasureNativeShimmer}
|
||
isWebShimmer={isWebShimmer}
|
||
isNativeShimmer={isNativeShimmer}
|
||
shimmerLabelTextStyle={shimmerLabelTextStyle}
|
||
shimmerSecondaryTextStyle={shimmerSecondaryTextStyle}
|
||
labelRowWidth={labelRowWidth}
|
||
labelRowHeight={labelRowHeight}
|
||
nativeShimmerPeakWidth={nativeShimmerPeakWidth}
|
||
shimmerDuration={shimmerDuration}
|
||
nativeGradientId={nativeGradientIdRef.current}
|
||
onLabelRowLayout={handleLabelRowLayout}
|
||
onLabelLayout={handleLabelLayout}
|
||
onSecondaryLayout={handleSecondaryLayout}
|
||
showOpenFileButton={Boolean(onOpenFile && isHovered)}
|
||
isOpenFileHovered={isOpenFileHovered}
|
||
onOpenFilePress={handleOpenFilePress}
|
||
onOpenFileHoverIn={handleOpenFileHoverIn}
|
||
onOpenFileHoverOut={handleOpenFileHoverOut}
|
||
/>
|
||
</View>
|
||
</Pressable>
|
||
{detailContent ? (
|
||
<Pressable
|
||
ref={detailWrapperRef}
|
||
style={detailWrapperStyle}
|
||
onHoverIn={handleDetailHoverIn}
|
||
onHoverOut={handleDetailHoverOut}
|
||
>
|
||
{detailContent}
|
||
</Pressable>
|
||
) : null}
|
||
</View>
|
||
);
|
||
}, areExpandableBadgePropsEqual);
|
||
|
||
function areExpandableBadgePropsEqual(previous: ExpandableBadgeProps, next: ExpandableBadgeProps) {
|
||
if (previous.label !== next.label) return false;
|
||
if (previous.secondaryLabel !== next.secondaryLabel) return false;
|
||
if (previous.icon !== next.icon) return false;
|
||
if (previous.isExpanded !== next.isExpanded) return false;
|
||
if (previous.style !== next.style) return false;
|
||
if (previous.isLoading !== next.isLoading) return false;
|
||
if (previous.isError !== next.isError) return false;
|
||
if (previous.isLastInSequence !== next.isLastInSequence) return false;
|
||
if (previous.disableOuterSpacing !== next.disableOuterSpacing) return false;
|
||
if (previous.borderlessWhenExpanded !== next.borderlessWhenExpanded) return false;
|
||
if (previous.testID !== next.testID) return false;
|
||
if (previous.onToggle !== next.onToggle) return false;
|
||
if (previous.onOpenFile !== next.onOpenFile) return false;
|
||
if (previous.onDetailHoverChange !== next.onDetailHoverChange) return false;
|
||
if (previous.renderDetails !== next.renderDetails) return false;
|
||
return true;
|
||
}
|
||
|
||
interface ToolCallProps {
|
||
agentMessage?: AgentMessage;
|
||
serverId?: string;
|
||
toolName: string;
|
||
args?: unknown;
|
||
result?: unknown;
|
||
error?: unknown;
|
||
status: "executing" | "running" | "completed" | "failed" | "canceled";
|
||
detail?: ToolCallDetail;
|
||
cwd?: string;
|
||
metadata?: Record<string, unknown>;
|
||
isLastInSequence?: boolean;
|
||
disableOuterSpacing?: boolean;
|
||
onInlineDetailsHoverChange?: (hovered: boolean) => void;
|
||
onInlineDetailsExpandedChange?: (expanded: boolean) => void;
|
||
onOpenFilePath?: (filePath: string) => void;
|
||
defaultExpanded?: boolean;
|
||
forceInline?: boolean;
|
||
maxDetailHeight?: number;
|
||
itemId: string;
|
||
/** Shown as its own row rather than inside a tool call group. */
|
||
standalone?: boolean;
|
||
}
|
||
|
||
export const ToolCall = memo(function ToolCall({
|
||
agentMessage,
|
||
serverId,
|
||
toolName,
|
||
args,
|
||
result,
|
||
error,
|
||
status,
|
||
detail,
|
||
cwd,
|
||
metadata,
|
||
isLastInSequence = false,
|
||
disableOuterSpacing,
|
||
onInlineDetailsHoverChange,
|
||
onInlineDetailsExpandedChange,
|
||
onOpenFilePath,
|
||
defaultExpanded,
|
||
forceInline = false,
|
||
maxDetailHeight = 400,
|
||
itemId,
|
||
standalone = true,
|
||
}: ToolCallProps) {
|
||
const { openToolCall } = useToolCallSheet();
|
||
const { expanded: isExpanded, toggle: toggleExpanded } = useToolCallExpanded({
|
||
callId: itemId,
|
||
defaultExpanded: defaultExpanded ?? false,
|
||
standalone,
|
||
});
|
||
|
||
const isMobile = useIsCompactFormFactor();
|
||
const shouldRenderInline = !isMobile || forceInline;
|
||
const hold = useDisclosureHold();
|
||
|
||
const effectiveDetail = useMemo<ToolCallDetail | undefined>(() => {
|
||
if (detail) {
|
||
return detail;
|
||
}
|
||
if (args !== undefined || result !== undefined) {
|
||
return {
|
||
type: "unknown",
|
||
input: args ?? null,
|
||
output: result ?? null,
|
||
};
|
||
}
|
||
return undefined;
|
||
}, [detail, args, result]);
|
||
|
||
const targetAgentTitle = useTargetAgentTitle({ serverId, toolName, detail: effectiveDetail });
|
||
const presentation = useMemo(
|
||
() =>
|
||
buildToolCallPresentation({
|
||
agentMessage,
|
||
toolName,
|
||
status,
|
||
error: error ?? null,
|
||
detail: effectiveDetail,
|
||
metadata,
|
||
cwd,
|
||
targetAgentTitle,
|
||
resolveIcon: resolveToolCallIcon,
|
||
}),
|
||
[toolName, status, error, effectiveDetail, metadata, cwd, agentMessage, targetAgentTitle],
|
||
);
|
||
const handleOpenFile = useMemo(() => {
|
||
const openFilePath = presentation.openFilePath;
|
||
if (!openFilePath || !onOpenFilePath) {
|
||
return undefined;
|
||
}
|
||
return () => onOpenFilePath(openFilePath);
|
||
}, [presentation.openFilePath, onOpenFilePath]);
|
||
|
||
const handleToggle = useCallback(() => {
|
||
if (!shouldRenderInline) {
|
||
openToolCall({
|
||
toolName,
|
||
displayName: presentation.displayName,
|
||
summary: presentation.summary,
|
||
detail: presentation.detail,
|
||
errorText: presentation.errorText,
|
||
icon: presentation.icon,
|
||
showLoadingSkeleton: presentation.isLoadingDetails,
|
||
});
|
||
} else {
|
||
hold(toggleExpanded);
|
||
}
|
||
}, [
|
||
hold,
|
||
shouldRenderInline,
|
||
toggleExpanded,
|
||
openToolCall,
|
||
toolName,
|
||
presentation.detail,
|
||
presentation.displayName,
|
||
presentation.summary,
|
||
presentation.errorText,
|
||
presentation.icon,
|
||
presentation.isLoadingDetails,
|
||
]);
|
||
|
||
useEffect(() => {
|
||
if (!onInlineDetailsHoverChange || !shouldRenderInline || isExpanded) {
|
||
return;
|
||
}
|
||
onInlineDetailsHoverChange(false);
|
||
}, [isExpanded, shouldRenderInline, onInlineDetailsHoverChange]);
|
||
|
||
useEffect(() => {
|
||
if (!onInlineDetailsExpandedChange) {
|
||
return;
|
||
}
|
||
if (!shouldRenderInline) {
|
||
onInlineDetailsExpandedChange(false);
|
||
return;
|
||
}
|
||
onInlineDetailsExpandedChange(isExpanded);
|
||
}, [isExpanded, shouldRenderInline, onInlineDetailsExpandedChange]);
|
||
|
||
useEffect(() => {
|
||
if (!onInlineDetailsExpandedChange) {
|
||
return () => {};
|
||
}
|
||
return () => {
|
||
onInlineDetailsExpandedChange(false);
|
||
};
|
||
}, [onInlineDetailsExpandedChange]);
|
||
|
||
// Render inline details for desktop
|
||
const renderDetails = useCallback(() => {
|
||
if (!shouldRenderInline) return null;
|
||
return (
|
||
<ToolCallDetailsContent
|
||
toolName={toolName}
|
||
detail={presentation.detail}
|
||
errorText={presentation.errorText}
|
||
maxHeight={maxDetailHeight}
|
||
showLoadingSkeleton={presentation.isLoadingDetails}
|
||
/>
|
||
);
|
||
}, [
|
||
shouldRenderInline,
|
||
toolName,
|
||
presentation.detail,
|
||
presentation.errorText,
|
||
presentation.isLoadingDetails,
|
||
maxDetailHeight,
|
||
]);
|
||
|
||
if (presentation.isPlan && effectiveDetail?.type === "plan") {
|
||
return (
|
||
<PlanCard
|
||
text={effectiveDetail.text}
|
||
outcome={presentation.planOutcome}
|
||
testID="timeline-plan-card"
|
||
disableOuterSpacing={disableOuterSpacing}
|
||
/>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<ExpandableBadge
|
||
testID="tool-call-badge"
|
||
label={presentation.displayName}
|
||
secondaryLabel={presentation.summary}
|
||
icon={presentation.icon}
|
||
isExpanded={shouldRenderInline && isExpanded}
|
||
onToggle={presentation.canOpenDetails ? handleToggle : undefined}
|
||
onOpenFile={handleOpenFile}
|
||
renderDetails={presentation.canOpenDetails && shouldRenderInline ? renderDetails : undefined}
|
||
isLoading={status === "running" || status === "executing"}
|
||
isError={status === "failed"}
|
||
isLastInSequence={isLastInSequence}
|
||
disableOuterSpacing={disableOuterSpacing}
|
||
onDetailHoverChange={onInlineDetailsHoverChange}
|
||
/>
|
||
);
|
||
}, areToolCallPropsEqual);
|
||
|
||
function areToolCallPropsEqual(previous: ToolCallProps, next: ToolCallProps) {
|
||
if (previous.toolName !== next.toolName) return false;
|
||
if (previous.serverId !== next.serverId) return false;
|
||
if (previous.agentMessage !== next.agentMessage) return false;
|
||
if (previous.args !== next.args) return false;
|
||
if (previous.result !== next.result) return false;
|
||
if (previous.error !== next.error) return false;
|
||
if (previous.status !== next.status) return false;
|
||
if (previous.detail !== next.detail) return false;
|
||
if (previous.cwd !== next.cwd) return false;
|
||
if (previous.metadata !== next.metadata) return false;
|
||
if (previous.isLastInSequence !== next.isLastInSequence) return false;
|
||
if (previous.disableOuterSpacing !== next.disableOuterSpacing) return false;
|
||
if (previous.onOpenFilePath !== next.onOpenFilePath) return false;
|
||
if (previous.defaultExpanded !== next.defaultExpanded) return false;
|
||
if (previous.forceInline !== next.forceInline) return false;
|
||
if (previous.maxDetailHeight !== next.maxDetailHeight) return false;
|
||
if (previous.itemId !== next.itemId) return false;
|
||
if (previous.standalone !== next.standalone) return false;
|
||
return true;
|
||
}
|