import { QuestionAnswerHistory } from "./QuestionAnswerHistory";
import {
getQuestionAnswerPreview,
hasQuestionAnswer,
} from "@t3tools/client-runtime/work-log/user-input";
import * as Haptics from "expo-haptics";
import { Image } from "expo-image";
import { type AppSymbolName, SymbolView } from "../../components/AppSymbol";
import { MaskedView } from "@expo/ui/community/masked-view";
import type { LegendListRef } from "@legendapp/list/react-native";
import { AnimatedLegendList } from "@legendapp/list/reanimated";
import { useIsFocused } from "@react-navigation/native";
import {
memo,
useCallback,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
type ComponentProps,
type ReactNode,
} from "react";
import {
AccessibilityInfo,
AppState,
type ColorValue,
Pressable,
ScrollView,
StyleSheet,
View,
} from "react-native";
import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";
import type { EnvironmentId, ToolActivityIcon } from "@t3tools/contracts";
import { toolActivityFaviconUrl } from "@t3tools/shared/favicon";
import { AppText as Text } from "../../components/AppText";
import { T3Wordmark } from "../../components/T3Wordmark";
import { cn } from "../../lib/cn";
import { THREAD_WORK_ROW_MIN_HEIGHT, type deriveThreadWorkLogSizing } from "../../lib/layout";
import {
type AgentSpawnSummary,
type ThreadFeedActivity,
workEntryRowLabel,
} from "../../lib/threadActivity";
import {
resolveThreadWorkGroupInitialScroll,
shouldFollowThreadWorkGroupAppend,
type ThreadWorkGroupScrollPosition,
} from "./thread-feed-live-follow";
import {
resolveWorkEntryToolPresentation,
type ToolGroupSummaryKind,
workEntryViewedImagePath,
} from "@t3tools/client-runtime/work-log/presentation";
import { resolveWorkGroupScrollAnchor } from "@t3tools/client-runtime/work-log/scroll-anchor";
import type { MarkdownImageRenderer } from "../../native/SelectableMarkdownText";
import Animated, {
cancelAnimation,
Easing,
FadeIn,
FadeOut,
LinearTransition,
ReduceMotion,
useAnimatedStyle,
useSharedValue,
withDelay,
withRepeat,
withSequence,
withTiming,
} from "react-native-reanimated";
import { useAssetUrl } from "../../state/assets";
const SHIMMER_WIDTH = 72;
const SHIMMER_SWEEP_MS = 1_350;
const SHIMMER_PAUSE_MS = 1_450;
const SHIMMER_ICON_AND_GAP_WIDTH = 30;
export const THREAD_DISCLOSURE_TRANSITION_MS = 180;
const WORK_LOG_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS);
const WORK_LOG_DETAIL_ENTER_TRANSITION = FadeIn.duration(140);
const WORK_LOG_DETAIL_EXIT_TRANSITION = FadeOut.duration(120);
type WorkContentIcon = AppSymbolName | "browser" | "device" | "t3-code" | "pull-request";
function WorkLogIcon(props: {
readonly icon: WorkContentIcon;
readonly color: ColorValue;
readonly colorClassName?: string;
readonly highlighted?: boolean;
}) {
const colorClassName = props.highlighted ? "accent-foreground" : props.colorClassName;
if (props.icon === "t3-code") {
return (
);
}
return (
);
}
export function ThreadDisclosureChevron(props: {
readonly expanded: boolean;
readonly collapsedDirection: "right" | "down";
readonly size: number;
readonly tintColor: ColorValue;
}) {
const expandedAngle = props.collapsedDirection === "right" ? 90 : 180;
const rotation = useSharedValue(props.expanded ? expandedAngle : 0);
useLayoutEffect(() => {
rotation.value = withTiming(props.expanded ? expandedAngle : 0, {
duration: THREAD_DISCLOSURE_TRANSITION_MS,
reduceMotion: ReduceMotion.System,
});
}, [expandedAngle, props.expanded, rotation]);
const rotationStyle = useAnimatedStyle(() => ({
transform: [{ rotate: `${rotation.value}deg` }],
}));
return (
);
}
function ShimmerWorkContent(props: {
readonly textClassName?: string;
readonly compact?: boolean;
readonly environmentId?: EnvironmentId;
readonly highlighted: boolean;
readonly icon: WorkContentIcon;
readonly iconSubtleColor: ColorValue;
readonly label: string;
readonly onTextLayout?: ComponentProps["onTextLayout"];
readonly showIcon: boolean;
readonly themeAppearance?: "light" | "dark";
readonly toolIcon?: ToolActivityIcon;
}) {
return (
{props.showIcon ? (
{props.toolIcon && props.environmentId ? (
) : (
)}
) : null}
{props.label}
);
}
export function ShimmeringWorkContent(props: {
readonly className?: string;
readonly textClassName?: string;
/** Secondary line: no icon slot, caption size. */
readonly compact?: boolean;
readonly environmentId?: EnvironmentId;
readonly icon: WorkContentIcon;
readonly iconSubtleColor: ColorValue;
readonly label: string;
readonly showIcon: boolean;
readonly themeAppearance?: "light" | "dark";
readonly toolIcon?: ToolActivityIcon;
}) {
const [availableWidth, setAvailableWidth] = useState(0);
const [textWidth, setTextWidth] = useState(0);
const [appIsActive, setAppIsActive] = useState(AppState.currentState === "active");
const [reducedMotion, setReducedMotion] = useState(true);
const screenIsFocused = useIsFocused();
const progress = useSharedValue(0);
const gradientId = `work-shimmer-${useId().replaceAll(":", "")}`;
const contentWidth = Math.min(
availableWidth,
(props.showIcon ? SHIMMER_ICON_AND_GAP_WIDTH : 0) + Math.ceil(textWidth),
);
useEffect(() => {
const subscription = AppState.addEventListener("change", (state) => {
setAppIsActive(state === "active");
});
return () => subscription.remove();
}, []);
useEffect(() => {
void AccessibilityInfo.isReduceMotionEnabled().then(setReducedMotion);
const subscription = AccessibilityInfo.addEventListener(
"reduceMotionChanged",
setReducedMotion,
);
return () => subscription.remove();
}, []);
useEffect(() => {
cancelAnimation(progress);
progress.value = 0;
if (contentWidth <= 0 || reducedMotion || !appIsActive || !screenIsFocused) return;
progress.value = withRepeat(
withSequence(
withTiming(1, {
duration: SHIMMER_SWEEP_MS,
easing: Easing.linear,
reduceMotion: ReduceMotion.Never,
}),
withDelay(
SHIMMER_PAUSE_MS,
withTiming(0, { duration: 0, reduceMotion: ReduceMotion.Never }),
),
),
-1,
false,
undefined,
ReduceMotion.Never,
);
return () => cancelAnimation(progress);
}, [appIsActive, contentWidth, progress, reducedMotion, screenIsFocused]);
const sweepStyle = useAnimatedStyle(() => ({
transform: [{ translateX: -SHIMMER_WIDTH + progress.value * (contentWidth + SHIMMER_WIDTH) }],
}));
const counterSweepStyle = useAnimatedStyle(() => ({
transform: [{ translateX: SHIMMER_WIDTH - progress.value * (contentWidth + SHIMMER_WIDTH) }],
}));
return (
setAvailableWidth(event.nativeEvent.layout.width)}
>
setTextWidth(event.nativeEvent.lines[0]?.width ?? 0)}
/>
{!reducedMotion && appIsActive && screenIsFocused && contentWidth > 0 ? (
}
>
) : null}
);
}
function workRowSymbolName(icon: ThreadFeedActivity["icon"]): AppSymbolName {
switch (icon) {
case "agent":
return { ios: "sparkles", android: "auto_awesome" };
case "alert":
return { ios: "exclamationmark.triangle", android: "error" };
case "browser":
return { ios: "safari", android: "public" };
case "check":
return { ios: "checkmark", android: "check" };
case "command":
return { ios: "terminal", android: "terminal" };
case "computer":
return { ios: "desktopcomputer", android: "desktop_windows" };
case "edit":
return { ios: "square.and.pencil", android: "edit" };
case "eye":
return { ios: "eye", android: "visibility" };
case "globe":
return { ios: "globe", android: "public" };
case "hammer":
return { ios: "hammer", android: "construction" };
case "message":
return { ios: "bubble.left", android: "chat_bubble" };
case "warning":
return { ios: "xmark", android: "close" };
case "wrench":
return { ios: "wrench", android: "build" };
case "zap":
return { ios: "bolt", android: "bolt" };
}
}
// Entering fades only for rows created moments ago: rows remount whenever the
// list scrolls them back into view, and old rows must not replay an entrance.
const FRESH_ROW_WINDOW_MS = 3_000;
function isFreshRow(createdAt: string): boolean {
const timestamp = Date.parse(createdAt);
return Number.isFinite(timestamp) && Date.now() - timestamp < FRESH_ROW_WINDOW_MS;
}
// The minimum matches min-h-8 below. Exact sizing is disabled when native
// accessibility scaling can make the single-line text taller than that minimum.
const WORK_ROW_HEIGHT = THREAD_WORK_ROW_MIN_HEIGHT;
const WORK_ROW_GAP = 1; // gap-px
const WORK_LOG_BOTTOM_MARGIN = 3.5; // mb-1 with the mobile 14px rem
const WORK_GROUP_MAX_HEIGHT = 256;
const WORK_GROUP_EDGE_FADE_HEIGHT = 12;
export const WORK_GROUP_TOGGLE_HEIGHT = THREAD_WORK_ROW_MIN_HEIGHT;
function workLogRowsHeight(
activities: ReadonlyArray,
rowHeight = WORK_ROW_HEIGHT,
): number {
return activities.length * rowHeight + Math.max(0, activities.length - 1) * WORK_ROW_GAP;
}
export function collapsedWorkLogHeight(activities: ReadonlyArray): number {
if (activities.length === 0) {
return 0;
}
const height = workLogRowsHeight(activities);
return (
WORK_LOG_BOTTOM_MARGIN +
(activities[0]?.groupedToolDetail ? Math.min(height, WORK_GROUP_MAX_HEIGHT) : height)
);
}
interface ThreadWorkLogProps {
readonly activities: ReadonlyArray;
readonly anchorKey: string;
readonly environmentId: EnvironmentId;
readonly copiedRowId: string | null;
readonly expandedRows: Readonly>;
readonly rowSizing: ReturnType;
readonly scrollPositions: Map;
readonly iconSubtleColor: ColorValue;
/** Feed background, painted as the scroll-edge fade over a long group. */
readonly edgeFadeColor: string;
readonly themeAppearance: "light" | "dark";
readonly onCopyRow: (rowId: string, value: string) => void;
readonly onToggleRow: (rowId: string, anchorKey: string) => void;
readonly renderImage: MarkdownImageRenderer;
}
export function ThreadWorkLog(props: ThreadWorkLogProps) {
const renderRow = useCallback(
(row: ThreadFeedActivity) => (
),
[
props.anchorKey,
props.copiedRowId,
props.expandedRows,
props.environmentId,
props.iconSubtleColor,
props.onCopyRow,
props.onToggleRow,
props.renderImage,
props.themeAppearance,
],
);
if (props.activities.length === 0) {
return null;
}
return (
{props.activities[0]?.groupedToolDetail ? (
) : (
{props.activities.map(renderRow)}
)}
);
}
function ThreadWorkGroupList(props: {
readonly activities: ReadonlyArray;
readonly edgeFadeColor: string;
readonly expandedRows: Readonly>;
readonly groupId: string;
readonly rowSizing: ReturnType;
readonly scrollPositions: Map;
readonly renderRow: (row: ThreadFeedActivity) => ReactNode;
}) {
const estimatedRowsHeight = workLogRowsHeight(
props.activities,
props.rowSizing.estimatedRowHeight,
);
const [initialPosition] = useState(() => {
const position = props.scrollPositions.get(props.groupId);
return props.activities.some((row) => row.id === position?.rowId) ? position : undefined;
});
const [initialScrollIndex] = useState(() =>
resolveThreadWorkGroupInitialScroll(props.activities, initialPosition),
);
const [restoringPosition, setRestoringPosition] = useState(initialScrollIndex !== undefined);
const listRef = useRef(null);
const loadedRef = useRef(false);
const userScrollingRef = useRef(false);
const pendingAppendHeightRef = useRef(null);
const previousContent = useRef({
rows: props.activities,
height: Math.max(estimatedRowsHeight, initialPosition?.contentHeight ?? 0),
expandedRows: props.expandedRows,
});
const [measuredContent, setMeasuredContent] = useState(() => ({
height: Math.max(estimatedRowsHeight, initialPosition?.contentHeight ?? 0),
rowCount: props.activities.length,
}));
const contentHeight = Math.max(
1,
measuredContent.height +
Math.max(0, props.activities.length - measuredContent.rowCount) *
(props.rowSizing.estimatedRowHeight + WORK_ROW_GAP),
);
const height = Math.min(contentHeight, WORK_GROUP_MAX_HEIGHT);
const scrollOffset = useSharedValue(initialPosition?.scrollOffset ?? 0);
const sharedValues = useMemo(() => ({ scrollOffset }), [scrollOffset]);
// Each edge fades only while content continues past it. Scroll offset stays
// on the UI thread; only content-size changes update React state.
const topFadeStyle = useAnimatedStyle(() => ({
opacity: Math.min(1, Math.max(0, scrollOffset.value) / WORK_GROUP_EDGE_FADE_HEIGHT),
}));
const bottomFadeStyle = useAnimatedStyle(() => ({
opacity: Math.min(
1,
Math.max(0, contentHeight - height - scrollOffset.value) / WORK_GROUP_EDGE_FADE_HEIGHT,
),
}));
const rememberPosition = useCallback(() => {
if (!loadedRef.current) return;
const state = listRef.current?.getState();
const position = state && resolveWorkGroupScrollAnchor(state);
if (!state || !position) return;
props.scrollPositions.set(props.groupId, {
...position,
contentHeight: state.contentLength,
});
}, [props.groupId, props.scrollPositions]);
const finishPendingAppend = useCallback(() => {
const targetHeight = pendingAppendHeightRef.current;
const state = listRef.current?.getState();
if (
targetHeight !== null &&
state &&
!userScrollingRef.current &&
Math.abs(state.scrollLength - targetHeight) <= 1
) {
pendingAppendHeightRef.current = null;
void listRef.current?.scrollToEnd({ animated: false });
}
}, []);
const onContentSizeChange = useCallback(
(nextHeight: number) => {
const previous = previousContent.current;
const detailsChanged = previous.expandedRows !== props.expandedRows;
const followAppend =
loadedRef.current &&
shouldFollowThreadWorkGroupAppend({
previousRows: previous.rows,
rows: props.activities,
previousContentHeight: previous.height,
contentHeight: nextHeight,
viewportHeight: Math.min(previous.height, WORK_GROUP_MAX_HEIGHT),
scrollOffset: scrollOffset.value,
detailsChanged,
userScrolling: userScrollingRef.current,
});
previousContent.current = {
rows: props.activities,
height: nextHeight,
expandedRows: props.expandedRows,
};
setMeasuredContent((current) =>
current.height === nextHeight && current.rowCount === props.activities.length
? current
: { height: nextHeight, rowCount: props.activities.length },
);
// Follow new calls only, never a detail toggle or a growing tool result.
if (followAppend) {
pendingAppendHeightRef.current = Math.min(nextHeight, WORK_GROUP_MAX_HEIGHT);
} else if (detailsChanged || userScrollingRef.current || previous.rows !== props.activities) {
pendingAppendHeightRef.current = null;
} else if (pendingAppendHeightRef.current !== null) {
pendingAppendHeightRef.current = Math.min(nextHeight, WORK_GROUP_MAX_HEIGHT);
}
// A short group can grow its viewport on this append. Wait for that
// layout before calculating the end offset, rather than jumping twice.
finishPendingAppend();
rememberPosition();
},
[props.activities, props.expandedRows, scrollOffset, finishPendingAppend, rememberPosition],
);
// The native ScrollView reports its content size a frame or more after
// LegendList has laid the rows out, so a detail toggle rendered the group
// at its old height while the rows below already moved. Read the size
// LegendList computes on the JS thread instead; it settles in the same
// commit as the row measurement that changed it.
const onContentSizeChangeRef = useRef(onContentSizeChange);
useLayoutEffect(() => {
onContentSizeChangeRef.current = onContentSizeChange;
}, [onContentSizeChange]);
const subscribeToContentSize = useCallback((list: LegendListRef | null) => {
listRef.current = list;
if (!list) return;
const unsubscribe = list.getState().listen("totalSize", () => {
onContentSizeChangeRef.current(list.getState().contentLength);
});
onContentSizeChangeRef.current(list.getState().contentLength);
return unsubscribe;
}, []);
const getFixedItemSize = useCallback(
(row: ThreadFeedActivity, index: number) =>
props.expandedRows[row.id] || props.rowSizing.fixedRowHeight === undefined
? undefined
: props.rowSizing.fixedRowHeight + (index < props.activities.length - 1 ? WORK_ROW_GAP : 0),
[props.activities.length, props.expandedRows, props.rowSizing.fixedRowHeight],
);
const renderItem = useCallback(
({ item, index }: { item: ThreadFeedActivity; index: number }) => (
{props.renderRow(item)}
),
[props.activities.length, props.renderRow],
);
return (
{
loadedRef.current = true;
setRestoringPosition(false);
rememberPosition();
}}
onScroll={rememberPosition}
onScrollBeginDrag={() => {
userScrollingRef.current = true;
pendingAppendHeightRef.current = null;
}}
onScrollEndDrag={() => {
userScrollingRef.current = false;
}}
onMomentumScrollBegin={() => {
userScrollingRef.current = true;
pendingAppendHeightRef.current = null;
}}
onMomentumScrollEnd={() => {
userScrollingRef.current = false;
rememberPosition();
}}
maintainVisibleContentPosition
nestedScrollEnabled
directionalLockEnabled
showsVerticalScrollIndicator
scrollsToTop={false}
bounces={false}
keyboardShouldPersistTaps="handled"
style={{ height }}
/>
);
}
/** A screen-colored gradient painted over the list edge that still has content past it. */
function EdgeFade(props: { readonly color: string; readonly direction: "up" | "down" }) {
const gradientId = `work-group-fade-${useId().replaceAll(":", "")}`;
return (
);
}
function workLogRowKey(row: ThreadFeedActivity): string {
return row.id;
}
const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
props: Omit<
ThreadWorkLogProps,
| "activities"
| "copiedRowId"
| "edgeFadeColor"
| "expandedRows"
| "rowSizing"
| "scrollPositions"
> & {
readonly row: ThreadFeedActivity;
readonly copied: boolean;
readonly expanded: boolean;
},
) {
const { row, expanded } = props;
const canExpand = row.canExpand;
const fullDetail = expanded ? row.getFullDetail() : null;
const viewedImagePath = workEntryViewedImagePath(row.workEntry);
const toolPresentation = resolveWorkEntryToolPresentation(row.workEntry);
const previewText = workEntryRowLabel(row.workEntry);
const answerPreview = row.workEntry.questionAnswer
? getQuestionAnswerPreview(row.workEntry.questionAnswer)
: null;
const accessiblePreview = [previewText, answerPreview].filter(Boolean).join(": ");
const displayText = workEntryRowLabel(row.workEntry, expanded);
const iconIsDestructive = row.icon === "alert" || row.icon === "warning";
const failed = row.status === "failure";
const toolIcon = row.workEntry.toolIcon ?? row.workEntry.toolSource?.icon;
const icon = toolPresentation?.icon ?? workRowSymbolName(row.icon);
return (
{
if (canExpand) {
void Haptics.selectionAsync();
props.onToggleRow(row.id, props.anchorKey);
}
}}
onLongPress={() => props.onCopyRow(row.id, row.getCopyText())}
className="rounded-md px-0.5 py-0 active:bg-subtle"
>
{row.live && !expanded ? (
) : (
<>
{toolIcon ? (
) : (
)}
{displayText}
{answerPreview ? (
{` ${answerPreview}`}
) : null}
>
)}
{props.copied ? (
Copied
) : null}
{failed && toolIcon !== undefined ? (
) : null}
{canExpand ? (
) : null}
{expanded && (fullDetail || viewedImagePath || row.workEntry.questionAnswer) ? (
{row.workEntry.questionAnswer ? (
) : null}
{viewedImagePath ? (
{props.renderImage({ href: viewedImagePath, alt: null, title: null })}
) : null}
{fullDetail}
) : null}
);
});
export function ThreadWorkGroupToggle(props: {
readonly environmentId: EnvironmentId;
readonly rowSizing: ReturnType;
readonly expanded: boolean;
readonly hiddenCount: number;
readonly iconSubtleColor: import("react-native").ColorValue;
readonly summary: string;
readonly summaryKind: ToolGroupSummaryKind;
readonly summaryToolIcon?: "browser" | "device" | "t3-code" | "pull-request";
readonly themeAppearance: "light" | "dark";
readonly toolSurface?: import("@t3tools/contracts").ToolActivitySurface;
readonly toolIcon?: ToolActivityIcon;
readonly hasFailure: boolean;
readonly shimmer: boolean;
readonly onToggle: () => void;
}) {
const accessibilityLabel = props.hasFailure
? `${props.summary}, tool call failed`
: props.summary;
const icon =
props.summaryToolIcon ??
(props.toolSurface
? workRowSymbolName(props.toolSurface)
: toolGroupSummarySymbolName(props.summaryKind));
return (
{
void Haptics.selectionAsync();
props.onToggle();
}}
className="min-h-8 flex-row items-center gap-1.5 rounded-md px-0.5 py-0 active:bg-subtle"
style={{ minHeight: props.rowSizing.estimatedRowHeight }}
>
{props.shimmer ? (
) : (
<>
{props.summary}
>
)}
);
}
const AGENT_SPAWN_TONE_DOT_CLASS = {
working: "bg-adaptive-sky-600-400",
completed: "bg-adaptive-emerald-600-400",
failed: "bg-adaptive-rose-600-400",
stopped: "bg-foreground-muted",
} as const satisfies Record;
/**
* A batch of spawned subagents. The status line updates in place as members
* report progress; expanding lists each member. Text nodes carry keys tied to
* the row identity only, so a progress tick re-renders the labels without
* remounting the card (see the batch key in appendActivityGroupRows).
*/
export const ThreadAgentSpawnCard = memo(function ThreadAgentSpawnCard(props: {
readonly summary: AgentSpawnSummary;
readonly expanded: boolean;
readonly iconSubtleColor: ColorValue;
readonly rowSizing: ReturnType;
readonly onToggle: () => void;
readonly onCopy: () => void;
}) {
const { summary, expanded } = props;
const working = summary.tone === "working";
const memberCount = summary.members.length;
const canExpand = memberCount > 0;
return (
{
if (!canExpand) return;
void Haptics.selectionAsync();
props.onToggle();
}}
onLongPress={props.onCopy}
className="rounded-xl border border-adaptive-neutral-200-a80-white-a8 bg-card px-2.5 py-2 active:bg-subtle"
>
{summary.title}
{working ? (
) : (
{summary.status}
)}
{canExpand ? (
) : null}
{expanded && canExpand ? (
{summary.members.map((member) => (
{member.title}
{member.status}
{member.detail ? (
{member.detail}
) : null}
))}
) : null}
);
});
export function ThreadThinkingRow(props: {
readonly rowSizing: ReturnType;
readonly iconSubtleColor: ColorValue;
}) {
return (
);
}
function ToolActivityIconView(props: {
readonly environmentId: EnvironmentId;
readonly icon?: ToolActivityIcon;
readonly fallback: WorkContentIcon;
readonly fallbackColor: ColorValue;
readonly themeAppearance: "light" | "dark";
}) {
if (!props.icon) {
return ;
}
if (props.icon._tag === "website") {
const source = toolActivityFaviconUrl(props.icon, props.themeAppearance, 32);
return source ? (
) : (
);
}
if (props.icon._tag === "themed-logo") {
const source =
props.themeAppearance === "dark"
? (props.icon.logoUrlDark ?? props.icon.logoUrl)
: props.icon.logoUrl;
return (
);
}
return (
);
}
function NativeAppToolActivityIcon(props: {
readonly environmentId: EnvironmentId;
readonly app: Extract["app"];
readonly fallback: WorkContentIcon;
readonly color: ColorValue;
readonly themeAppearance: "light" | "dark";
}) {
const source = useAssetUrl(props.environmentId, {
_tag: "native-app-icon",
app: props.app,
});
return source ? (
) : (
);
}
function ToolActivityImage(props: {
readonly source: string;
readonly fallback: WorkContentIcon;
readonly color: ColorValue;
readonly themeAppearance: "light" | "dark";
}) {
const [loaded, setLoaded] = useState(false);
const [failed, setFailed] = useState(false);
return (
{!loaded || failed ? : null}
{!failed ? (
setLoaded(true)}
onError={() => setFailed(true)}
/>
) : null}
);
}
function toolGroupSummarySymbolName(kind: ToolGroupSummaryKind): AppSymbolName {
switch (kind) {
case "pull-request":
case "link-pr":
case "unlink-pr":
case "list-prs":
return "arrow.triangle.pull";
case "read":
return { ios: "eye", android: "visibility" };
case "edit":
return { ios: "square.and.pencil", android: "edit" };
case "command":
return { ios: "terminal", android: "terminal" };
case "device":
return { ios: "iphone", android: "smartphone" };
case "browser":
case "search":
return { ios: "globe", android: "public" };
case "code-search":
return "magnifyingglass";
case "other":
return { ios: "wrench", android: "build" };
case "agent-tool":
return { ios: "sparkles", android: "auto_awesome" };
case "tone-tool":
return { ios: "bolt", android: "bolt" };
case "dynamic-tool":
case "update":
case "mixed":
return { ios: "hammer", android: "construction" };
}
}