536 lines · 19.6 KB · TypeScript
RawDownload
import {  effectiveSettled,  effectiveSnoozed,  hasQueuedTurnStart,  QUEUED_TURN_START_GRACE_MS,  resolveSnoozePresets,  snoozeWakeLabel,} from "@t3tools/client-runtime/state/thread-settled";import type {  ChangeRequestSettleSource,  SnoozePreset,} from "@t3tools/client-runtime/state/thread-settled";import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";import type { EnvironmentId, ProjectId, ThreadLinkedPullRequest } from "@t3tools/contracts"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; export { snoozeWakeLabel }; /** * Thread List v2 model, ported from the web sidebar v2 * (apps/web/src/components/Sidebar.logic.ts + SidebarV2.tsx). * * Four visual states, three colors: color is reserved for "act now" * (approval), "in motion" (working), and "broken" (failed). Ready is the * unlabeled resting state. */export type ThreadListV2Status = "approval" | "input" | "working" | "failed" | "ready";export type ThreadListV2SwipeAction = "archive" | "settle" | "unsettle" | "snooze" | "unsnooze"; export interface ThreadListV2ChangeRequestState extends ChangeRequestSettleSource {  readonly linkedPullRequestKey?: string | null;} function linkedPullRequestKey(  linkedPullRequest: ThreadLinkedPullRequest | null | undefined,): string | null {  if (linkedPullRequest == null) return null;  return JSON.stringify([    linkedPullRequest.projectId,    linkedPullRequest.repository.toLowerCase(),    linkedPullRequest.number,  ]);} /** Keep the previous linked PR state while its detail query reloads. */export function resolveThreadListV2ChangeRequestState(input: {  readonly linkedPullRequest: ThreadLinkedPullRequest | null | undefined;  readonly state: ChangeRequestSettleSource["state"] | null;  readonly updatedAt: string | null;}): ThreadListV2ChangeRequestState | null | undefined {  if (input.state === null) return input.linkedPullRequest == null ? null : undefined;  return {    state: input.state,    updatedAt: input.updatedAt,    linkedPullRequestKey: linkedPullRequestKey(input.linkedPullRequest),  };} export function resolveThreadListV2SnoozeMenuSelection(input: {  readonly event: string;  readonly displayedPresets: ReadonlyArray<SnoozePreset>;  readonly now: Date;}):  | { readonly _tag: "selected"; readonly preset: SnoozePreset }  | { readonly _tag: "expired" }  | { readonly _tag: "not-snooze" } {  if (!input.event.startsWith("snooze:")) return { _tag: "not-snooze" };   const currentPreset = resolveSnoozePresets(input.now).find(    (candidate) => input.event === `snooze:${candidate.id}`,  );  if (currentPreset) return { _tag: "selected", preset: currentPreset };   const displayedPreset = input.displayedPresets.find(    (candidate) => input.event === `snooze:${candidate.id}`,  );  if (displayedPreset && Date.parse(displayedPreset.snoozedUntil) > input.now.getTime()) {    return { _tag: "selected", preset: displayedPreset };  }  return { _tag: "expired" };} export function resolveThreadListV2SwipeActions(input: {  readonly variant: "card" | "slim";  readonly settlementSupported: boolean;  readonly snoozeSupported: boolean;  readonly snoozable: boolean;  /** Row is on the snoozed shelf. */  readonly snoozed?: boolean;}): {  readonly primary: Exclude<ThreadListV2SwipeAction, "snooze">;  readonly secondary: "snooze" | null;} {  if (input.snoozed === true) {    return { primary: "unsnooze", secondary: null };  }  const primary = input.settlementSupported    ? input.variant === "slim"      ? "unsettle"      : "settle"    : "archive";  return {    primary,    secondary: input.snoozeSupported && input.snoozable ? "snooze" : null,  };} /** * The point at which a queued-turn snooze guard expires on its own. Rows arm * a one-shot timer for this boundary so Snooze appears without waiting for an * unrelated render. User-blocked threads return null because only fresh * server data can make them snoozable. */export function resolveThreadListV2SnoozeGateExpiryMs(  thread: Pick<    EnvironmentThreadShell,    "hasPendingApprovals" | "hasPendingUserInput" | "latestUserMessageAt" | "latestTurn" | "session"  >,  options: { readonly now: string },): number | null {  if (thread.hasPendingApprovals || thread.hasPendingUserInput) return null;  if (!hasQueuedTurnStart(thread, options)) return null;  const messageAtMs = Date.parse(thread.latestUserMessageAt ?? "");  if (Number.isNaN(messageAtMs)) return null;  return messageAtMs + QUEUED_TURN_START_GRACE_MS;} // Settled-tail paging: recent history is the common lookup; the deep tail// stays behind an explicit Show more. Shared by the compact Home list and// the iPad sidebar so both page identically.export const THREAD_LIST_V2_SETTLED_INITIAL_COUNT = 10;export const THREAD_LIST_V2_SETTLED_PAGE_COUNT = 25; /** * The flat Thread List v2 is the default on every app variant; the Settings → * Legacy toggle opts a device back into the grouped legacy list. Preferences * persist as sparse patches, so `undefined` genuinely means "never chosen". * * `preferencesLoaded` guards the startup window: preferences load * asynchronously, and rendering one list before the stored choice arrives would * remount the whole thing a tick later. While loading, hold the default — that * is where every device without an explicit legacy opt-in lands anyway. */export function resolveThreadListV2Enabled(input: {  readonly legacyPreference: boolean | undefined;  readonly preferencesLoaded: boolean;}): boolean {  if (!input.preferencesLoaded) {    return true;  }  return input.legacyPreference !== true;} export function resolveThreadListV2Status(  thread: Pick<EnvironmentThreadShell, "hasPendingApprovals" | "hasPendingUserInput" | "session">,): ThreadListV2Status {  if (thread.hasPendingApprovals) {    return "approval";  }  if (thread.hasPendingUserInput) {    return "input";  }  if (thread.session?.status === "running" || thread.session?.status === "starting") {    return "working";  }  if (thread.session?.status === "error") {    return "failed";  }  return "ready";} /** NaN-safe Date.parse for sort comparators: a malformed timestamp must not    poison the whole ordering, so it sinks to the epoch instead. */function parseTimestampMs(isoDate: string): number {  const parsed = Date.parse(isoDate);  return Number.isNaN(parsed) ? 0 : parsed;} /** First VALID timestamp wins: a present-yet-malformed string falls through    to the next candidate rather than sinking the row to the epoch. */function firstValidTimestampMs(...candidates: ReadonlyArray<string | null | undefined>): number {  for (const candidate of candidates) {    if (candidate == null) continue;    const parsed = Date.parse(candidate);    if (!Number.isNaN(parsed)) return parsed;  }  return 0;} /** * v2 sort: static creation order, newest thread on top. Activity NEVER * reorders the list — a row holds its position from open until settled, so * the screen only moves at lifecycle transitions. Mirrors web's * sortThreadsForSidebarV2. */export function sortThreadsForListV2<T extends { readonly id: string; readonly createdAt: string }>(  threads: readonly T[],): T[] {  // .sort() on a copy, not .toSorted(): Hermes doesn't ship the ES2023  // change-by-copy array methods.  return [...threads].sort(    (left, right) =>      parseTimestampMs(right.createdAt) - parseTimestampMs(left.createdAt) ||      left.id.localeCompare(right.id),  );} export interface ThreadListV2Item {  readonly thread: EnvironmentThreadShell;  readonly variant: "card" | "slim";  /** Snoozed-shelf row: shows the wake countdown and offers Wake. */  readonly snoozed: boolean;  /** Pinned-block row: renders the pin glyph and offers Unpin. */  readonly pinned: boolean;  readonly isLast: boolean;} export interface ThreadListV2Layout {  readonly items: ThreadListV2Item[];  /** Settled threads beyond the render limit (behind "Show more"). */  readonly hiddenSettledCount: number;  /** Snoozed threads matching the current filters. */  readonly snoozedCount: number;  /** Index in `items` where the Snoozed shelf header belongs. The header is      still rendered when the shelf is collapsed and no snoozed rows exist. */  readonly snoozedShelfHeaderIndex: number | null;  /** Total settled threads in scope, including rows hidden by collapse/paging. */  readonly settledCount: number;  /** Index in `items` where the Settled shelf header belongs. */  readonly settledShelfHeaderIndex: number | null;  /** Soonest wake time among snoozed threads, or null. Callers arm      a timeout at this boundary so the list re-partitions the moment a      snooze expires instead of on the next minute tick. */  readonly nextSnoozeWakeAt: string | null;} export interface ThreadListV2ThreadListItem {  readonly type: "v2-thread";  readonly key: string;  readonly item: ThreadListV2Item;  /** Precomputed so recycled-list equality can see a minute-tick change. */  readonly snoozeWakeLabelText: string | undefined;} export interface ThreadListV2PendingListItem {  readonly type: "v2-pending";  readonly key: string;  readonly pendingTask: PendingNewTask;  /** First queued row after the active block draws the PENDING divider. */  readonly showPendingDivider: boolean;} export interface ThreadListV2SnoozedShelfListItem {  readonly type: "v2-snoozed-shelf";  readonly key: "v2-snoozed-shelf";  readonly count: number;  readonly expanded: boolean;} export interface ThreadListV2SettledShelfListItem {  readonly type: "v2-settled-shelf";  readonly key: "v2-settled-shelf";  readonly count: number;  readonly expanded: boolean;} export type ThreadListV2ListItem =  | ThreadListV2ThreadListItem  | ThreadListV2PendingListItem  | ThreadListV2SnoozedShelfListItem  | ThreadListV2SettledShelfListItem; /** * Builds the shared mobile order: active → pending → snoozed shelf → settled. * Pending tasks are waiting rather than asking, and parked work remains * reachable without competing with either the inbox or settled history. */export function buildThreadListV2ListItems(input: {  readonly items: ReadonlyArray<ThreadListV2Item>;  readonly pendingTasks: ReadonlyArray<PendingNewTask>;  readonly snoozedCount?: number;  readonly snoozedShelfExpanded?: boolean;  readonly snoozedShelfHeaderIndex?: number | null;  readonly settledCount?: number;  readonly settledShelfExpanded?: boolean;  readonly settledShelfHeaderIndex?: number | null;  readonly snoozeLabelNow?: string;}): ThreadListV2ListItem[] {  const threadItems = input.items.map(    (item): ThreadListV2ListItem => ({      type: "v2-thread",      key: `v2-thread:${item.thread.environmentId}:${item.thread.id}`,      item,      snoozeWakeLabelText:        item.snoozed && item.thread.snoozedUntil != null && input.snoozeLabelNow !== undefined          ? snoozeWakeLabel(item.thread.snoozedUntil, { now: input.snoozeLabelNow })          : undefined,    }),  );  const pendingItems = input.pendingTasks.map(    (pendingTask, index): ThreadListV2ListItem => ({      type: "v2-pending",      key: `v2-pending:${pendingTask.message.messageId}`,      pendingTask,      showPendingDivider: index === 0,    }),  );  const snoozedCount = input.snoozedCount ?? 0;  const snoozedShelfHeaderIndex = input.snoozedShelfHeaderIndex ?? null;  const settledCount = input.settledCount ?? 0;  const settledShelfHeaderIndex = input.settledShelfHeaderIndex ?? null;  const activeEnd = snoozedShelfHeaderIndex ?? settledShelfHeaderIndex ?? threadItems.length;  const snoozedEnd = settledShelfHeaderIndex ?? threadItems.length;  const result: ThreadListV2ListItem[] = [...threadItems.slice(0, activeEnd), ...pendingItems];  if (snoozedShelfHeaderIndex !== null && snoozedCount > 0) {    result.push({      type: "v2-snoozed-shelf",      key: "v2-snoozed-shelf",      count: snoozedCount,      expanded: input.snoozedShelfExpanded === true,    });    result.push(...threadItems.slice(snoozedShelfHeaderIndex, snoozedEnd));  }  if (settledShelfHeaderIndex !== null && settledCount > 0) {    result.push({      type: "v2-settled-shelf",      key: "v2-settled-shelf",      count: settledCount,      expanded: input.settledShelfExpanded !== false,    });    result.push(...threadItems.slice(settledShelfHeaderIndex));  }  return result;} /** * Partitions visible threads into the active card block (creation order) and * the settled recency tail, matching the web v2 list. Mobile stores these * auto-settle preferences per device. */export function buildThreadListV2Items(input: {  readonly threads: ReadonlyArray<EnvironmentThreadShell>;  readonly environmentId: EnvironmentId | null;  readonly projectRefs?: ReadonlyArray<{    readonly environmentId: EnvironmentId;    readonly projectId: ProjectId;  }> | null;  readonly searchQuery: string;  readonly matchedThreadKeys?: ReadonlySet<string>;  /** Per-row PR reported up by visible rows ("env:threadId" keys). */  readonly changeRequestByKey?: ReadonlyMap<string, ThreadListV2ChangeRequestState>;  /** Environments whose server supports thread.settle/unsettle. Threads on      other environments never classify as settled — the user could neither      un-settle nor pin them. Absent = no gating (tests). */  readonly settlementEnvironmentIds?: ReadonlySet<EnvironmentId>;  /** Environments whose server supports thread.snooze/unsnooze. Same      contract as settlementEnvironmentIds. */  readonly snoozeEnvironmentIds?: ReadonlySet<EnvironmentId>;  readonly autoSettleAfterDays?: number;  readonly autoSettleOnMerge?: boolean;  /** Max settled rows to render; the rest are counted, not built. */  readonly settledLimit?: number;  /** Injectable for tests; defaults to now. */  readonly now?: string;  /** Second-precise clock for snooze classification. Callers pass a      minute-quantized `now` for memoization; snooze wake times are      second-precise, so classifying with the floored minute would hold a      woken thread hidden for up to a minute. Defaults to `now`. */  readonly snoozeNow?: string;  /** Expands the snoozed shelf into rows. Collapsed is the default. */  readonly snoozedShelfExpanded?: boolean;  /** Expands the settled shelf into rows. Expanded is the default. */  readonly settledShelfExpanded?: boolean;  /** The selected thread remains visible on an otherwise collapsed shelf so      a split-view detail can never lose its navigation row. */  readonly selectedThreadKey?: string | null;}): ThreadListV2Layout {  const now = input.now ?? new Date().toISOString();  const snoozeNow = input.snoozeNow ?? now;  const autoSettleAfterDays = input.autoSettleAfterDays ?? 3;  const autoSettleOnMerge = input.autoSettleOnMerge ?? true;  const query = input.searchQuery.trim().toLocaleLowerCase();  const projectKeys = input.projectRefs    ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`))    : null;   const pinned: EnvironmentThreadShell[] = [];  const active: EnvironmentThreadShell[] = [];  const settled: EnvironmentThreadShell[] = [];  const snoozed: EnvironmentThreadShell[] = [];  let nextSnoozeWakeAt: string | null = null;  for (const thread of input.threads) {    // Callers pass live (unarchived) shells; settled threads are among them    // and partition into the tail via effectiveSettled.    if (input.environmentId !== null && thread.environmentId !== input.environmentId) continue;    if (projectKeys !== null && !projectKeys.has(`${thread.environmentId}:${thread.projectId}`)) {      continue;    }    if (      query.length > 0 &&      !thread.title.toLocaleLowerCase().includes(query) &&      input.matchedThreadKeys?.has(        threadSearchMatchKey({          environmentId: thread.environmentId,          threadId: thread.id,        }),      ) !== true    ) {      continue;    }    const supportsSettlement = input.settlementEnvironmentIds?.has(thread.environmentId) ?? true;    const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true;    const cachedChangeRequest =      input.changeRequestByKey?.get(`${thread.environmentId}:${thread.id}`) ?? null;    const changeRequest =      cachedChangeRequest !== null &&      (cachedChangeRequest.linkedPullRequestKey ?? null) ===        linkedPullRequestKey(thread.linkedPullRequest)        ? cachedChangeRequest        : null;    // Snooze outranks settlement and pinning until the thread wakes.    if (supportsSnooze && effectiveSnoozed(thread, { now: snoozeNow })) {      snoozed.push(thread);      if (        thread.snoozedUntil != null &&        (nextSnoozeWakeAt === null ||          parseTimestampMs(thread.snoozedUntil) < parseTimestampMs(nextSnoozeWakeAt))      ) {        nextSnoozeWakeAt = thread.snoozedUntil;      }      continue;    }    if (      supportsSettlement &&      effectiveSettled(thread, {        now,        autoSettleAfterDays,        autoSettleOnMerge,        changeRequest,      })    ) {      settled.push(thread);    } else if (thread.pinnedAt != null) {      pinned.push(thread);    } else {      active.push(thread);    }  }   const orderedActive = sortThreadsForListV2(active);  const orderedSnoozed = [...snoozed].sort(    (left, right) =>      parseTimestampMs(left.snoozedUntil ?? "") - parseTimestampMs(right.snoozedUntil ?? ""),  );  const selectedThreadKey = input.selectedThreadKey ?? null;  const visibleSnoozed =    input.snoozedShelfExpanded === true      ? orderedSnoozed      : orderedSnoozed.filter(          (thread) => `${thread.environmentId}:${thread.id}` === selectedThreadKey,        );  const orderedSettled = [...settled].sort(    (left, right) =>      firstValidTimestampMs(right.latestUserMessageAt, right.updatedAt) -      firstValidTimestampMs(left.latestUserMessageAt, left.updatedAt),  );  const settledLimit = input.settledLimit ?? Number.POSITIVE_INFINITY;  const pagedSettled =    orderedSettled.length > settledLimit ? orderedSettled.slice(0, settledLimit) : orderedSettled;  const selectedSettled = orderedSettled    .slice(pagedSettled.length)    .find((thread) => `${thread.environmentId}:${thread.id}` === selectedThreadKey);  if (selectedSettled !== undefined) pagedSettled.push(selectedSettled);  const visibleSettled =    input.settledShelfExpanded !== false      ? pagedSettled      : pagedSettled.filter(          (thread) => `${thread.environmentId}:${thread.id}` === selectedThreadKey,        );   const items: ThreadListV2Item[] = [];  for (const thread of sortPinnedThreadsByOrderKey(pinned)) {    items.push({      thread,      variant: "card",      snoozed: false,      pinned: true,      isLast: false,    });  }  for (const thread of orderedActive) {    items.push({      thread,      variant: "card",      snoozed: false,      pinned: false,      isLast: false,

Lines 1–500 of 536.