497 lines · 18.2 KB · TypeScript
RawDownload
import {  effectiveSnoozed,  hasQueuedTurnStart,  QUEUED_TURN_START_GRACE_MS,  resolveSnoozePresets,  snoozeWakeLabel,} from "@t3tools/client-runtime/state/thread-settled";import type { 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 {  activeThreadAnchorTimestampMs,  resolveSettledThreadTimestamp,  sortPinnedThreadsByOrderKey,} from "@t3tools/client-runtime/state/thread-sort";import type { EnvironmentId, ProjectId } 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). * * Six visual states. Color distinguishes approval, input, active work, and * failures. Ready is the unlabeled resting state; waiting (runtime status "idle") is the agent * parked on open background tasks, grey like working rather than a false Done. * The orchestrator v2 presentation bridge parks runtime at idle when the * post-settlement background roster is nonempty. */export type ThreadListV2Status = "approval" | "input" | "working" | "waiting" | "failed" | "ready";export type ThreadListV2SwipeAction = "archive" | "settle" | "unsettle" | "snooze" | "unsnooze"; 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" | "latestRun" | "latestUserMessageAt" | "runtime"  >,  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;} /** * Completed-but-not-yet-seen, mirroring the web sidebar's * hasUnseenCompletion. The visited watermark is server state * (thread.lastVisitedAt), so the marker agrees across web and mobile. * Never-visited threads count as read — a fresh environment must not light * up its whole history — and pre-tracking servers (field absent) never * report unread. */export function threadHasUnseenCompletion(  thread: Pick<EnvironmentThreadShell, "latestRun" | "lastVisitedAt">,): boolean {  const completedAt = thread.latestRun?.completedAt;  if (!completedAt) return false;  const completedAtMs = Date.parse(completedAt);  if (Number.isNaN(completedAtMs)) return false;  if (!thread.lastVisitedAt) return false;  const lastVisitedAtMs = Date.parse(thread.lastVisitedAt);  if (Number.isNaN(lastVisitedAtMs)) return true;  return completedAtMs > lastVisitedAtMs;} export function resolveThreadListV2Status(  thread: Pick<EnvironmentThreadShell, "hasPendingApprovals" | "hasPendingUserInput" | "runtime">,): ThreadListV2Status {  if (thread.hasPendingApprovals) {    return "approval";  }  if (thread.hasPendingUserInput) {    return "input";  }  if (    thread.runtime !== null &&    ["preparing", "queued", "starting", "running", "waiting"].includes(thread.runtime.status)  ) {    return "working";  }  if (thread.runtime?.status === "idle") {    return "waiting";  }  if (thread.runtime?.status === "failed") {    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;} /** * v2 sort: static order, newest anchor on top. Activity NEVER reorders the * list — a row holds its position between lifecycle transitions. The anchor * is creation time until an un-settle re-anchors it (see * activeThreadAnchorTimestampMs), so an un-settled thread surfaces at the * top instead of sinking back to its creation-order slot. Mirrors web's * sortThreadsForSidebar. */export function sortThreadsForListV2<  T extends {    readonly id: string;    readonly createdAt: string;    readonly unsettledAt?: string | null | undefined;  },>(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) =>      activeThreadAnchorTimestampMs(right) - activeThreadAnchorTimestampMs(left) ||      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. */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>;  /** 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>;  /** Max settled rows to render; the rest are counted, not built. */  readonly settledLimit?: number;  /** Second-precise clock used for time-based classification. */  readonly now: 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;  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 shells. The server stamps settledOverride for the tail.    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;    // Snooze outranks settlement and pinning until the thread wakes.    if (supportsSnooze && effectiveSnoozed(thread, { now })) {      snoozed.push(thread);      if (        thread.snoozedUntil != null &&        (nextSnoozeWakeAt === null ||          parseTimestampMs(thread.snoozedUntil) < parseTimestampMs(nextSnoozeWakeAt))      ) {        nextSnoozeWakeAt = thread.snoozedUntil;      }      continue;    }    if (supportsSettlement && thread.settledOverride === "settled") {      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) =>      parseTimestampMs(resolveSettledThreadTimestamp(right) ?? "") -      parseTimestampMs(resolveSettledThreadTimestamp(left) ?? ""),  );  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,    });  }  const snoozedShelfHeaderIndex = orderedSnoozed.length > 0 ? items.length : null;  for (const thread of visibleSnoozed) {    items.push({      thread,      variant: "slim",      snoozed: true,      pinned: false,      isLast: false,    });  }  const settledShelfHeaderIndex = orderedSettled.length > 0 ? items.length : null;  for (const thread of visibleSettled) {    items.push({      thread,      variant: "slim",      snoozed: false,      pinned: false,      isLast: false,    });  }  const last = items.at(-1);  if (last) {    items[items.length - 1] = { ...last, isLast: true };  }  return {    items,    hiddenSettledCount: orderedSettled.length - pagedSettled.length,    snoozedCount: orderedSnoozed.length,    snoozedShelfHeaderIndex,    settledCount: orderedSettled.length,    settledShelfHeaderIndex,    nextSnoozeWakeAt,  };}