1290 lines · 44.7 KB · TypeScript
RawDownload
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 (      <T3Wordmark height={10} {...(colorClassName ? { colorClassName } : { color: props.color })} />    );  }  return (    <SymbolView      name={        props.icon === "pull-request"          ? "arrow.triangle.pull"          : props.icon === "browser"            ? { ios: "globe", android: "public" }            : props.icon === "device"              ? { ios: "iphone", android: "smartphone" }              : props.icon      }      size={14}      weight="medium"      {...(colorClassName ? { tintColorClassName: colorClassName } : { tintColor: props.color })}      type="monochrome"    />  );} 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 (    <Animated.View      accessible={false}      accessibilityElementsHidden      importantForAccessibility="no-hide-descendants"      pointerEvents="none"      style={[{ width: props.size, height: props.size }, rotationStyle]}    >      <SymbolView        name={props.collapsedDirection === "right" ? "chevron.right" : "chevron.down"}        size={props.size}        tintColor={props.tintColor}        type="monochrome"      />    </Animated.View>  );} 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<typeof Text>["onTextLayout"];  readonly showIcon: boolean;  readonly themeAppearance?: "light" | "dark";  readonly toolIcon?: ToolActivityIcon;}) {  return (    <View className="flex-row items-center gap-1.5">      {props.showIcon ? (        <View className="h-6 w-6 shrink-0 items-center justify-center">          {props.toolIcon && props.environmentId ? (            <ToolActivityIconView              environmentId={props.environmentId}              icon={props.toolIcon}              fallback={props.icon}              fallbackColor={props.iconSubtleColor}              themeAppearance={props.themeAppearance ?? "light"}            />          ) : (            <WorkLogIcon              icon={props.icon}              color={props.iconSubtleColor}              highlighted={props.highlighted}            />          )}        </View>      ) : null}      <Text        className={cn(          "min-w-0 shrink",          props.compact ? "text-xs" : "text-sm",          props.highlighted ? "text-foreground" : "text-foreground-muted",          props.textClassName,        )}        numberOfLines={1}        onTextLayout={props.onTextLayout}      >        {props.label}      </Text>    </View>  );} 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 (    <View      className={cn("min-w-0 flex-1 overflow-hidden", props.className)}      onLayout={(event) => setAvailableWidth(event.nativeEvent.layout.width)}    >      <ShimmerWorkContent        textClassName={props.textClassName}        compact={props.compact}        environmentId={props.environmentId}        highlighted={false}        icon={props.icon}        iconSubtleColor={props.iconSubtleColor}        label={props.label}        showIcon={props.showIcon}        themeAppearance={props.themeAppearance}        toolIcon={props.toolIcon}        onTextLayout={(event) => setTextWidth(event.nativeEvent.lines[0]?.width ?? 0)}      />      {!reducedMotion && appIsActive && screenIsFocused && contentWidth > 0 ? (        <Animated.View          className="absolute inset-y-0 left-0 overflow-hidden"          pointerEvents="none"          accessible={false}          accessibilityElementsHidden          importantForAccessibility="no-hide-descendants"          style={[{ width: SHIMMER_WIDTH }, sweepStyle]}        >          <MaskedView            style={StyleSheet.absoluteFill}            maskElement={              <Svg width="100%" height="100%">                <Defs>                  <LinearGradient id={gradientId} x1="0%" x2="100%" y1="0%" y2="0%">                    <Stop offset="0" stopColor="white" stopOpacity={0} />                    <Stop offset="0.15" stopColor="white" stopOpacity={0.12} />                    <Stop offset="0.35" stopColor="white" stopOpacity={0.55} />                    <Stop offset="0.5" stopColor="white" stopOpacity={1} />                    <Stop offset="0.65" stopColor="white" stopOpacity={0.55} />                    <Stop offset="0.85" stopColor="white" stopOpacity={0.12} />                    <Stop offset="1" stopColor="white" stopOpacity={0} />                  </LinearGradient>                </Defs>                <Rect width="100%" height="100%" fill={`url(#${gradientId})`} />              </Svg>            }          >            <Animated.View style={[{ width: availableWidth }, counterSweepStyle]}>              <ShimmerWorkContent                textClassName={props.textClassName}                compact={props.compact}                environmentId={props.environmentId}                highlighted                icon={props.icon}                iconSubtleColor={props.iconSubtleColor}                label={props.label}                showIcon={props.showIcon}                themeAppearance={props.themeAppearance}                toolIcon={props.toolIcon}              />            </Animated.View>          </MaskedView>        </Animated.View>      ) : null}    </View>  );} 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-pxconst WORK_LOG_BOTTOM_MARGIN = 3.5; // mb-1 with the mobile 14px remconst 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<ThreadFeedActivity>,  rowHeight = WORK_ROW_HEIGHT,): number {  return activities.length * rowHeight + Math.max(0, activities.length - 1) * WORK_ROW_GAP;} export function collapsedWorkLogHeight(activities: ReadonlyArray<ThreadFeedActivity>): 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<ThreadFeedActivity>;  readonly anchorKey: string;  readonly environmentId: EnvironmentId;  readonly copiedRowId: string | null;  readonly expandedRows: Readonly<Record<string, boolean>>;  readonly rowSizing: ReturnType<typeof deriveThreadWorkLogSizing>;  readonly scrollPositions: Map<string, ThreadWorkGroupScrollPosition>;  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) => (      <ThreadWorkLogRow        key={row.id}        row={row}        anchorKey={props.anchorKey}        copied={props.copiedRowId === row.id}        expanded={props.expandedRows[row.id] ?? false}        environmentId={props.environmentId}        iconSubtleColor={props.iconSubtleColor}        onCopyRow={props.onCopyRow}        onToggleRow={props.onToggleRow}        renderImage={props.renderImage}        themeAppearance={props.themeAppearance}      />    ),    [      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 (    <View className="-mx-1 mb-1 px-1 py-0">      {props.activities[0]?.groupedToolDetail ? (        <ThreadWorkGroupList          activities={props.activities}          edgeFadeColor={props.edgeFadeColor}          expandedRows={props.expandedRows}          groupId={props.anchorKey}          rowSizing={props.rowSizing}          scrollPositions={props.scrollPositions}          renderRow={renderRow}        />      ) : (        <View className="gap-px">{props.activities.map(renderRow)}</View>      )}    </View>  );} function ThreadWorkGroupList(props: {  readonly activities: ReadonlyArray<ThreadFeedActivity>;  readonly edgeFadeColor: string;  readonly expandedRows: Readonly<Record<string, boolean>>;  readonly groupId: string;  readonly rowSizing: ReturnType<typeof deriveThreadWorkLogSizing>;  readonly scrollPositions: Map<string, ThreadWorkGroupScrollPosition>;  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(() =>

Lines 1–500 of 1290.