1108 lines · 42.3 KB · TypeScript
RawDownload
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";import { appAtomRegistry } from "../../state/atom-registry";import { threadArrangementOpenAtom } from "../../state/thread-order";import type { ThreadMoveDestination } from "./threadOrder";import type {  EnvironmentProject,  EnvironmentThreadShell,} from "@t3tools/client-runtime/state/shell";import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search";import type { EnvironmentMachineKind } from "@t3tools/contracts";import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled";import { resolveSettledThreadTimestamp } from "@t3tools/client-runtime/state/thread-sort";import type { MenuAction } from "@react-native-menu/menu";import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react";import { Alert, Platform, Pressable, useWindowDimensions, View } from "react-native";import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; import { SymbolView } from "../../components/AppSymbol";import { AppText as Text } from "../../components/AppText";import { ControlPillMenu } from "../../components/ControlPill";import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";import { ProjectFavicon } from "../../components/ProjectFavicon";import { ProviderInstanceIcon } from "../../components/ProviderIcon";import type { ThreadRowProviderInstance } from "./thread-provider-instance";import { cn } from "../../lib/cn";import { relativeTime } from "../../lib/time";import { useUniwindTheme } from "../../lib/useUniwindTheme";import type { PendingNewTask } from "../../state/use-pending-new-tasks";import { useThreadPr } from "../../state/use-thread-pr";import { ThreadSwipeable } from "../home/thread-swipe-actions";import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu";import {  resolveThreadListV2SnoozeMenuSelection,  resolveThreadListV2SnoozeGateExpiryMs,  resolveThreadListV2Status,  resolveThreadListV2SwipeActions,  type ThreadListV2Status,} from "./threadListV2";import { QueuedMessageIcon } from "./queued-message-icon";import { ThreadSearchMatchExcerpt } from "./thread-search-match"; /** * Thread List v2 renders one flat native list: rich edge-to-edge rows for * active work and a receded settled tail, all with native swipe and * long-press actions. State reads through colored status labels and text * hierarchy rather than card fills. */ const MONO_FONT = Platform.select({  ios: "Menlo",  android: "monospace",  default: "monospace",}); // Status hues follow the system-wide convention set by sidebar v1 and the// Live Activity/widgets (amber approval, indigo input, sky working) so a// thread reads the same color everywhere it surfaces.const STATUS_LABEL_BY_STATUS: Partial<  Record<ThreadListV2Status, { label: string; className: string }>> = {  approval: { label: "Approval", className: "text-warning-foreground" },  input: { label: "Input", className: "text-foreground-secondary" },  working: { label: "Working", className: "text-adaptive-sky-600-400" },  failed: { label: "Failed", className: "text-danger-foreground" },}; function threadTimeLabel(thread: EnvironmentThreadShell): string {  return relativeTime(thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt);} // Menus keep lifecycle and title regeneration together. Archive keeps its// own surface (thread screen / settings) rather than crowding v2 rows.const CARD_MENU_ACTIONS: MenuAction[] = [  { id: "settle", title: "Settle", image: "checkmark" },  { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },]; const SLIM_MENU_ACTIONS: MenuAction[] = [  { id: "unsettle", title: "Un-settle", image: "arrow.uturn.backward" },  { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },]; const SNOOZED_MENU_ACTIONS: MenuAction[] = [  { id: "unsnooze", title: "Wake thread", image: "clock" },  { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },]; // Pre-settlement servers: no lifecycle items, archive fills the gap.const LEGACY_MENU_ACTIONS: MenuAction[] = [  { id: "archive", title: "Archive", image: "archivebox" },  { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },]; /** Rounded-row radius shared with the v1 sidebar rows. */const SIDEBAR_V2_ROW_RADIUS = 12; /** Section label + rule: the only structure in an otherwise flat list. */export const ThreadListV2SectionDivider = memo(function ThreadListV2SectionDivider(props: {  readonly label: string;  readonly pane?: "screen" | "sidebar";}) {  return (    <View      className={cn(        "mb-1.5 mt-4 flex-row items-center gap-2.5",        props.pane === "sidebar" ? "px-3" : "px-5",      )}    >      <Text className="text-xs font-t3-medium text-foreground-tertiary">{props.label}</Text>      <View className="h-px flex-1 bg-border" />    </View>  );}); export const ThreadListV2SnoozedShelfHeader = memo(function ThreadListV2SnoozedShelfHeader(props: {  readonly count: number;  readonly disabled?: boolean;  readonly expanded: boolean;  readonly onToggle: () => void;  readonly pane?: "screen" | "sidebar";}) {  return (    <Pressable      accessibilityHint={        props.expanded ? "Collapses the snoozed threads." : "Expands the snoozed threads."      }      accessibilityLabel={props.count === 1 ? "1 snoozed thread" : `${props.count} snoozed threads`}      accessibilityRole="button"      accessibilityState={{ disabled: props.disabled, expanded: props.expanded }}      className={cn(        "mb-1.5 mt-4 flex-row items-center gap-2.5",        props.pane === "sidebar" ? "px-3" : "px-5",      )}      disabled={props.disabled}      onPress={props.onToggle}      style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}    >      <Text className="text-xs font-t3-medium text-foreground-secondary">        {props.expanded ? "Snoozed" : `Snoozed (${props.count})`}      </Text>      <View className="h-px flex-1 bg-primary/20" />      <SymbolView        name="chevron.down"        size={10}        tintColorClassName="accent-icon-muted"        type="monochrome"        style={{ transform: [{ rotate: props.expanded ? "180deg" : "0deg" }] }}      />    </Pressable>  );}); export const ThreadListV2SettledShelfHeader = memo(function ThreadListV2SettledShelfHeader(props: {  readonly count: number;  readonly disabled?: boolean;  readonly expanded: boolean;  readonly onToggle: () => void;  readonly pane?: "screen" | "sidebar";}) {  return (    <Pressable      accessibilityHint={        props.expanded ? "Collapses the settled threads." : "Expands the settled threads."      }      accessibilityLabel={props.count === 1 ? "1 settled thread" : `${props.count} settled threads`}      accessibilityRole="button"      accessibilityState={{ disabled: props.disabled, expanded: props.expanded }}      className={cn(        "mb-1.5 mt-4 flex-row items-center gap-2.5",        props.pane === "sidebar" ? "px-3" : "px-5",      )}      disabled={props.disabled}      onPress={props.onToggle}      style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}    >      <Text className="text-xs font-t3-medium text-foreground-tertiary">        {props.expanded ? "Settled" : `Settled (${props.count})`}      </Text>      <View className="h-px flex-1 bg-border" />      <SymbolView        name="chevron.down"        size={10}        tintColorClassName={"accent-foreground-muted"}        type="monochrome"        style={{ transform: [{ rotate: props.expanded ? "180deg" : "0deg" }] }}      />    </Pressable>  );}); const PENDING_TASK_MENU_ACTIONS: MenuAction[] = [  { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },]; const DRAFT_TASK_MENU_ACTIONS: MenuAction[] = [  { id: "delete", title: "Discard", image: "trash", attributes: { destructive: true } },]; /** * Unsent work, in the same idiom as an active v2 row: it is work the user * wrote, so it reads like the thread it will become. The status slot says * what happens next, not where the item sits: "Sends on reconnect" stays * uncolored because nothing is asked of the user; "Draft" takes the amber the * web sidebar uses for drafts, because this one waits on the user. */export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props: {  readonly pendingTask: PendingNewTask;  readonly project: EnvironmentProject | null;  readonly projectTitle?: string;  readonly environmentLabel: string | null;  /** Drawn beside the label; ignored while the label is null. */  readonly environmentMachine?: EnvironmentMachineKind;  readonly pane?: "screen" | "sidebar";  /** Draws the "Unsent" divider above the first draft or queued row. */  readonly showPendingDivider: boolean;  /** Keeps row hairlines inside a section; section headers draw their own rule. */  readonly showTrailingDivider?: boolean;  readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void;  readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void;}) {  const { pendingTask, onSelectPendingTask, onDeletePendingTask } = props;  const sidebarPane = props.pane === "sidebar";  const isDraft = pendingTask.kind === "draft";  const projectTitle = props.projectTitle ?? props.project?.title ?? pendingTask.projectTitle ?? "";  const branch = pendingTask.branch;   const handleMenuAction = useCallback(    ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => {      if (nativeEvent.event === "delete") onDeletePendingTask(pendingTask);    },    [onDeletePendingTask, pendingTask],  );   const rowContent = (    <>      <View className="flex-row items-center gap-1.5">        {props.project ? (          <ProjectFavicon            environmentId={pendingTask.environmentId}            faviconPath={props.project.faviconPath}            size={15}            projectTitle={projectTitle}            workspaceRoot={props.project.workspaceRoot}          />        ) : null}        <Text className="flex-1 text-sm font-t3-medium text-foreground-muted" numberOfLines={1}>          {projectTitle}        </Text>        {isDraft ? (          <View className="flex-row items-center gap-1">            <SymbolView              name="square.and.pencil"              size={10}              tintColorClassName="accent-adaptive-amber-700-300"              type="monochrome"            />            <Text className="text-xs text-adaptive-amber-700-300">Draft</Text>          </View>        ) : (          <Text className="text-xs text-foreground-tertiary">Sends on reconnect</Text>        )}      </View>      {/* One line, unlike the two an active row allows: a queued title is          derived from the whole prompt rather than written as a title, so the          second line is usually a stray word or emoji rather than meaning. */}      <Text className="mt-1 text-base font-t3-medium text-foreground" numberOfLines={1}>        {pendingTask.title}      </Text>      {branch || props.environmentLabel ? (        <View className="mt-1 flex-row items-center gap-1">          <Text className="shrink text-xs text-foreground-muted" numberOfLines={1}>            {branch ? (              <Text className="text-xs text-foreground-muted" style={{ fontFamily: MONO_FONT }}>                {branch}              </Text>            ) : null}            {branch && props.environmentLabel ? "  ·  " : null}            {props.environmentLabel ? (              <Text className="text-xs text-foreground-tertiary">{props.environmentLabel}</Text>            ) : null}          </Text>          {props.environmentLabel && props.environmentMachine ? (            <EnvironmentMachineSymbol              kind={props.environmentMachine}              size={11}              tintColorClassName="accent-foreground-tertiary"            />          ) : null}        </View>      ) : null}    </>  );   return (    <>      {props.showPendingDivider ? (        <ThreadListV2SectionDivider label="Unsent" pane={props.pane} />      ) : null}      <ControlPillMenu        actions={isDraft ? DRAFT_TASK_MENU_ACTIONS : PENDING_TASK_MENU_ACTIONS}        onPressAction={handleMenuAction}        shouldOpenOnLongPress      >        <Pressable          accessibilityHint={            isDraft              ? "Opens the draft in the new task composer"              : "Sends when the environment reconnects. Opens the task for editing"          }          accessibilityLabel={pendingTask.title}          accessibilityRole="button"          className={sidebarPane ? "bg-drawer active:bg-subtle" : undefined}          onPress={() => onSelectPendingTask(pendingTask)}          style={            sidebarPane              ? {                  borderRadius: SIDEBAR_V2_ROW_RADIUS,                  paddingHorizontal: 12,                  paddingVertical: 10,                }              : ({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })          }        >          {sidebarPane ? (            rowContent          ) : (            <View className="bg-screen">              <View className="px-5 py-2.5">{rowContent}</View>              {props.showTrailingDivider !== false ? (                <View className="ml-5 h-px bg-border-subtle" />              ) : null}            </View>          )}        </Pressable>      </ControlPillMenu>    </>  );}); export const ThreadListV2Row = memo(function ThreadListV2Row(props: {  readonly thread: EnvironmentThreadShell;  readonly variant: "card" | "slim";  /** A message for this thread is waiting in the outbox. */  readonly hasQueuedMessages?: boolean;  /** Snoozed-shelf row: shows its wake time and offers Wake. */  readonly snoozed?: boolean;  /** Pinned-block row: shows the pin glyph and offers Unpin. */  readonly pinned?: boolean;  /** Preformatted against the parent minute tick so this memoized row's      countdown keeps moving. */  readonly snoozeWakeLabelText?: string;  /** Parent minute tick passed as a prop so this memoized row refreshes its      native snooze menu while mounted. */  readonly snoozePresetMinute: string;  readonly project: EnvironmentProject | null;  readonly projectTitle?: string;  readonly providerInstance: ThreadRowProviderInstance | null;  /** Which machine hosts the thread. Null when only one environment is      connected — repeating the same label on every row is noise. Mirrors      the web sidebar's remote-environment cloud icon, but as text since      phones have no hover tooltips. */  readonly environmentLabel: string | null;  /** Drawn after the label so the machine reads at a glance; ignored while      the label is null. */  readonly environmentMachine?: EnvironmentMachineKind;  /** Hosting surface. "screen" (default) renders the compact Home idiom:      flat edge-to-edge rows on the screen background with inset hairlines.      "sidebar" renders the iPad split-view idiom: rounded rows blending      into the drawer surface, selection filled with the accent color —      matching the v1 sidebar rows. */  readonly pane?: "screen" | "sidebar";  /** Keeps row hairlines inside a section; section headers draw their own rule. */  readonly showTrailingDivider?: boolean;  /** Highlights the thread open in the detail pane (iPad split view). The      compact Home list never sets it — phones navigate away on select. */  readonly selected?: boolean;  /** Override for narrow panes (iPad sidebar); defaults to window width. */  readonly fullSwipeWidth?: number;  readonly onSelectThread: (thread: EnvironmentThreadShell) => void;  readonly onDeleteThread: (thread: EnvironmentThreadShell) => void;  readonly onNewThreadOnBranch: (thread: EnvironmentThreadShell) => void;  readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => void;  readonly onSettleThread: (thread: EnvironmentThreadShell) => Promise<boolean>;  readonly onSnoozeThread: (thread: EnvironmentThreadShell, snoozedUntil: string) => void;  readonly onUnsnoozeThread: (thread: EnvironmentThreadShell) => void;  readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void;  readonly onArchiveThread: (thread: EnvironmentThreadShell) => void;  readonly onPinThread: (thread: EnvironmentThreadShell) => void;  readonly onUnpinThread: (thread: EnvironmentThreadShell) => void;  /** False on environments whose server predates thread.settle/unsettle:      swipe + menu fall back to Archive instead of failing on use. */  readonly settlementSupported: boolean;  /** False on servers that predate thread.snooze/unsnooze. */  readonly snoozeSupported: boolean;  /** False on servers that predate thread.pin/unpin. */  readonly pinningSupported: boolean;  /** False on servers that predate thread title regeneration. */  readonly titleRegenerationSupported: boolean;  /** Server supports reordering this card's section. */  readonly reorderSupported?: boolean;  readonly onMoveThread?: (    thread: EnvironmentThreadShell,    direction: ThreadMoveDestination,  ) => void;  /** Position flags for the card's section so the menu disables the move that      would fall off the end of the list. */  readonly canMoveUp?: boolean;  readonly canMoveDown?: boolean;  readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void;  readonly onSwipeableClose: (methods: SwipeableMethods) => void;  readonly searchMatch?: EnvironmentThreadSearchMatch;  readonly searchQuery?: string;  readonly simultaneousSwipeGesture?: ComponentProps<    typeof ThreadSwipeable  >["simultaneousWithExternalGesture"];}) {  const { width: windowWidth } = useWindowDimensions();  const {    thread,    variant,    onSelectThread,    onDeleteThread,    onRegenerateThreadTitle,    onNewThreadOnBranch,    onSettleThread,    onSnoozeThread,    onUnsnoozeThread,    onUnsettleThread,    onArchiveThread,    onPinThread,    onUnpinThread,    onMoveThread,  } = props;  const snoozedRow = props.snoozed === true;  const pinnedRow = props.pinned === true;   const pr = useThreadPr(thread);   const { materialYouStyleLayoutActive } = useAppearancePreferences();  const theme = useUniwindTheme();  const screenColor = theme["--color-screen"];  const drawerColor = theme["--color-drawer"];  const pressedBackgroundColor = theme["--color-subtle"];  const selectedBackgroundColor =    theme[materialYouStyleLayoutActive ? "--color-thread-selected" : "--color-user-bubble"];  const sidebarPane = props.pane === "sidebar";  const selected = props.selected === true;  // The provider badge's border blends into the row's own surface, which  // differs by pane and (for the sidebar pane) selection: the sidebar row  // background becomes the selected fill or the drawer surface, while the  // flat "screen" pane rows always sit on the screen background.  const providerIconSurfaceColor = sidebarPane    ? selected      ? selectedBackgroundColor      : drawerColor    : screenColor;   const status = resolveThreadListV2Status(thread);  const statusLabel = STATUS_LABEL_BY_STATUS[status];  // Settled rows label by the same stamp they sort by, so order and label  // can't disagree. updatedAt is always present, so the resolver never  // returns null here.  const settledTimestamp =    variant === "slim" && !snoozedRow ? resolveSettledThreadTimestamp(thread) : null;  const timeLabel =    settledTimestamp !== null ? relativeTime(settledTimestamp) : threadTimeLabel(thread);   const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]);  const handleRegenerateTitle = useCallback(    () => onRegenerateThreadTitle(thread),    [onRegenerateThreadTitle, thread],  );  const handleSettle = useCallback(() => onSettleThread(thread), [onSettleThread, thread]);  const handleSnooze = useCallback(    (snoozedUntil: string) => onSnoozeThread(thread, snoozedUntil),    [onSnoozeThread, thread],  );  const handleUnsnooze = useCallback(() => onUnsnoozeThread(thread), [onUnsnoozeThread, thread]);  const handleUnsettle = useCallback(() => onUnsettleThread(thread), [onUnsettleThread, thread]);  const handlePin = useCallback(() => onPinThread(thread), [onPinThread, thread]);  const handleUnpin = useCallback(() => onUnpinThread(thread), [onUnpinThread, thread]);  const handleMoveUp = useCallback(() => onMoveThread?.(thread, "up"), [onMoveThread, thread]);  const handleMoveDown = useCallback(() => onMoveThread?.(thread, "down"), [onMoveThread, thread]);  const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]);   // Swipe: the v2 primary action is the lifecycle transition. Un-settling a  // settled row keeps it active until new activity clears the user override.  const canUnsettle = variant === "slim";  const [snoozeGateTick, bumpSnoozeGateTick] = useState(0);  const snoozeGateExpiryMs = props.snoozeSupported    ? resolveThreadListV2SnoozeGateExpiryMs(thread, { now: new Date().toISOString() })    : null;  useEffect(() => {    if (snoozeGateExpiryMs === null) return;    const delayMs = Math.min(Math.max(0, snoozeGateExpiryMs - Date.now()) + 50, 2_147_483_647);    const id = setTimeout(() => bumpSnoozeGateTick((tick) => tick + 1), delayMs);    return () => clearTimeout(id);  }, [snoozeGateExpiryMs, snoozeGateTick]);  const swipeActions = resolveThreadListV2SwipeActions({    variant,

Lines 1–500 of 1108.