1237 lines · 41.2 KB · TypeScript
RawDownload
import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests";import * as React from "react";import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit/sortable";import {  isAtomCommandInterrupted,  type AtomCommandResult,} from "@t3tools/client-runtime/state/runtime";import type { ContextMenuItem } from "@t3tools/contracts";import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings";import type { AsyncResult } from "effect/unstable/reactivity";import { planPinnedReorder } from "@t3tools/client-runtime/state/thread-sort";import {  getThreadSortTimestamp,  resolveSettledThreadTimestamp,  sortThreads,  toSortableTimestamp,  type SettledThreadTimestampInput,  type ThreadSortInput,} from "../lib/threadSort";import type { SidebarThreadSummary, Thread } from "../types";import { cn } from "../lib/utils";import { isLatestTurnSettled } from "../session-logic"; const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]";export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 200;// Visible sidebar rows are prewarmed into the thread-detail cache so opening a// nearby thread usually reuses an already-hot subscription. Each prewarmed// thread holds a live, fully hydrated detail subscription (all messages and// activities, growing as agents work) for as long as the row stays visible,// so this limit is a direct renderer-heap and server-load multiplier — keep// it small; cold opens still render instantly from the cached snapshot.const SIDEBAR_THREAD_PREWARM_LIMIT = 3;// A small buffer keeps the next few rows warm without leasing every row that// content-visibility leaves mounted below the scroll viewport.const SIDEBAR_ROW_SUBSCRIPTION_OVERSCAN_PX = 160; export function useSidebarRowSubscriptionLease(isActive: boolean): {  readonly leaseLiveStatus: boolean;  readonly rowRef: React.Dispatch<React.SetStateAction<HTMLElement | null>>;} {  const [row, setRow] = React.useState<HTMLElement | null>(null);  const [isNearViewport, setIsNearViewport] = React.useState(isActive);   React.useEffect(() => {    if (isActive) {      setIsNearViewport(true);      return;    }    if (row === null) return;    if (typeof IntersectionObserver === "undefined") {      setIsNearViewport(true);      return;    }     const scrollRoot = row.closest<HTMLElement>('[data-slot="scroll-area-viewport"]');    const observer = new IntersectionObserver(      ([entry]) => setIsNearViewport(entry?.isIntersecting === true),      {        root: scrollRoot,        rootMargin: `${SIDEBAR_ROW_SUBSCRIPTION_OVERSCAN_PX}px 0px`,      },    );    observer.observe(row);    return () => observer.disconnect();  }, [isActive, row]);   return {    leaseLiveStatus: isActive || isNearViewport,    rowRef: setRow,  };} // A row keeps the last live value it rendered so a released lease never// blanks its badge. The value is bound to `key`, so a different worktree or// linked pull request cannot reuse the previous one.export function useRetainedValue<T>(key: string | null, value: T | null): T | null {  const retained = React.useRef<{ readonly key: string; readonly value: T } | null>(null);  if (key !== null && value !== null) {    retained.current = { key, value };  }  if (value !== null) return value;  return key !== null && retained.current?.key === key ? retained.current.value : null;} // Sidebar.motion handles ordinary section changes. Sortable transforms own// dragging; replaying their committed DOM order would animate the drop twice.export const animateSidebarLayoutChanges: AnimateLayoutChanges = (args) =>  args.isSorting ? defaultAnimateLayoutChanges(args) : false; // Rows and section markers share one sortable list. The separators resolve// the lifecycle action; Sidebar.drag previews the resulting layout. Pinned// and active threads keep the dragged position; settled threads use time// order. Snoozed rows can leave the shelf, but dropping into it is not// supported because snoozing requires a wake time. export type SidebarSection = "pinned" | "active" | "snoozed" | "settled"; /** Sortable ids: thread rows use their scoped key; structural items use a    colon-free prefix: scoped thread keys always contain a colon. */const SIDEBAR_MARKER_PREFIX = "sidebar-marker-"; export type SidebarListMarker =  /** The top boundary is also a landing target when there are no pins. */  | "pinned-header"  /** Stand-in rows so an empty section has somewhere for the gap to open. */  | "active-placeholder"  | "settled-placeholder"  /** The boundary between pinned and active rows. */  | "pinned-divider"  | "snoozed-header"  | "settled-header"; export function sidebarMarkerId(marker: SidebarListMarker): string {  return `${SIDEBAR_MARKER_PREFIX}${marker}`;} export type SidebarListItem =  | { readonly kind: "thread"; readonly key: string; readonly section: SidebarSection }  | { readonly kind: "marker"; readonly marker: SidebarListMarker }; export function sidebarListItemId(item: SidebarListItem): string {  return item.kind === "thread" ? item.key : sidebarMarkerId(item.marker);} /** The section a slot belongs to, read off the markers around it: from    the top down, everything before the pinned divider is pinned, then the    inbox until the snoozed header, the shelf until the settled header,    then settled. */function sectionAtSidebarSlot(items: readonly SidebarListItem[], index: number): SidebarSection {  let section: SidebarSection = "pinned";  for (let i = 0; i < index && i < items.length; i += 1) {    const item = items[i]!;    if (item.kind !== "marker") continue;    if (item.marker === "pinned-divider") section = "active";    else if (item.marker === "snoozed-header") section = "snoozed";    else if (item.marker === "settled-header") section = "settled";  }  return section;} /** Resolve the destination section and manual order from an arrayMove across * the separators. The snoozed shelf is never a destination. */export type SidebarDropTarget = {  readonly section: "pinned" | "active" | "settled";  readonly pinnedOrder: readonly string[];  readonly activeOrder: readonly string[];}; export function resolveSidebarDropTarget(  items: readonly SidebarListItem[],  activeKey: string,  overId: string,): SidebarDropTarget | null {  const activeIndex = items.findIndex((item) => sidebarListItemId(item) === activeKey);  const overIndex = items.findIndex((item) => sidebarListItemId(item) === overId);  if (activeIndex === -1 || overIndex === -1 || items[activeIndex]?.kind !== "thread") return null;  const moved = items.filter((_, index) => index !== activeIndex);  moved.splice(overIndex, 0, items[activeIndex]!);  const section = sectionAtSidebarSlot(moved, overIndex);  if (section === "snoozed") return null;  const pinnedOrder: string[] = [];  const activeOrder: string[] = [];  let currentSection: SidebarSection = "pinned";  for (const item of moved) {    if (item.kind === "marker") {      if (item.marker === "pinned-divider") currentSection = "active";      else if (item.marker === "snoozed-header" || item.marker === "settled-header") break;    } else if (currentSection === "pinned") pinnedOrder.push(item.key);    else activeOrder.push(item.key);  }  return { section, pinnedOrder, activeOrder };} export type SidebarThreadDropPlan =  | { readonly kind: "none" }  /** Within the pinned block: the existing key writes. */  | {      readonly kind: "reorder-pinned";      readonly order: readonly string[];      readonly assignments: ReadonlyArray<{ readonly id: string; readonly orderKey: string }>;    }  /** From another section into the pinned block. Fresh pins take `orderKey`      on the pin command. `extraAssignments` land afterward, including the      moved row when it was already pinned beneath a snooze. */  | {      readonly kind: "pin";      readonly order: readonly string[];      readonly orderKey: string | undefined;      readonly extraAssignments: ReadonlyArray<{ readonly id: string; readonly orderKey: string }>;    }  | {      readonly kind: "move-active";      readonly order: readonly string[];      readonly assignments: ReadonlyArray<{ readonly id: string; readonly orderKey: string }>;      readonly unpin: boolean;      readonly unsettle: boolean;      readonly unsnooze: boolean;    }  | { readonly kind: "settle" }; /** What dropping in `to` does to a thread lifted from `from`, for the badge    on the lifted row. Null while reordering inside one section and for the    snoozed shelf, which cannot be a drop target. */export type SidebarDropVerb = "pin" | "unpin" | "settle" | "unsettle" | "wake"; export function resolveSidebarDropVerb(  from: SidebarSection,  to: SidebarSection | null,): SidebarDropVerb | null {  if (to === null || to === from || to === "snoozed") return null;  if (to === "pinned") return "pin";  if (to === "settled") return "settle";  if (from === "pinned") return "unpin";  if (from === "settled") return "unsettle";  return "wake";} export function planSidebarThreadDrop(input: {  readonly activeKey: string;  readonly activeSection: SidebarSection;  /** Snoozed threads can retain pinning and settlement beneath the shelf. */  readonly activePinned?: boolean;  readonly activeSettled?: boolean;  readonly supportsSettlement?: boolean;  readonly target: SidebarDropTarget;  /** All pinned keys in displayed order before the drop. */  readonly pinnedOrder: readonly string[];  readonly pinnedKeysById: ReadonlyMap<string, string | null | undefined>;  readonly reorderableKeys?: ReadonlySet<string>;  readonly activeOrder: readonly string[];  readonly activeKeysById: ReadonlyMap<string, string | null | undefined>;  readonly activeReorderableKeys?: ReadonlySet<string>;}): SidebarThreadDropPlan {  const {    activeKey,    activeSection,    activePinned = activeSection === "pinned",    activeSettled = activeSection === "settled",    target,    pinnedOrder,    pinnedKeysById,    reorderableKeys,    activeOrder,    activeKeysById,    activeReorderableKeys,  } = input;  if (input.supportsSettlement === false && (target.section === "settled" || activeSettled)) {    return { kind: "none" };  }  switch (target.section) {    case "active": {      const order = target.activeOrder;      if (        activeSection === "active" &&        order.length === activeOrder.length &&        order.every((key, index) => key === activeOrder[index])      ) {        return { kind: "none" };      }      const assignments = planPinnedReorder({        orderedIds: order,        keysById: activeKeysById,        movedId: activeKey,      });      if (activeReorderableKeys && assignments.some(({ id }) => !activeReorderableKeys.has(id))) {        return { kind: "none" };      }      return {        kind: "move-active",        order,        assignments,        unpin: activePinned,        unsettle: activeSettled,        unsnooze: activeSection === "snoozed",      };    }    case "settled":      return activeSection === "settled" ? { kind: "none" } : { kind: "settle" };    case "pinned": {      const order = target.pinnedOrder;      // Dropped back where it started: nothing to write.      if (        activeSection === "pinned" &&        order.length === pinnedOrder.length &&        order.every((key, index) => key === pinnedOrder[index])      ) {        return { kind: "none" };      }      const assignments = planPinnedReorder({        orderedIds: order,        keysById: pinnedKeysById,        movedId: activeKey,      });      if (reorderableKeys && assignments.some(({ id }) => !reorderableKeys.has(id))) {        return { kind: "none" };      }      if (activeSection === "pinned") {        return assignments.length === 0          ? { kind: "none" }          : { kind: "reorder-pinned", order, assignments };      }      return {        kind: "pin",        order,        orderKey: assignments.find((assignment) => assignment.id === activeKey)?.orderKey,        extraAssignments: activePinned          ? assignments          : assignments.filter((assignment) => assignment.id !== activeKey),      };    }  }} /** Project a drop's lifecycle fields before sorting its destination. Reusing    the server's re-entry rules keeps the preview in place when events arrive. */export function applySidebarThreadDrop<  T extends Pick<    SidebarThreadSummary,    | "pinnedAt"    | "pinOrderKey"    | "activeOrderKey"    | "snoozedAt"    | "snoozedUntil"    | "settledAt"    | "settledOverride"    | "unsettledAt"  >,>(thread: T, section: "pinned" | "active" | "settled", now: string, orderKey?: string): T {  const wasSettled = thread.settledOverride === "settled";  const awake = { ...thread, snoozedAt: null, snoozedUntil: null };  if (section === "settled") {    return {      ...awake,      pinnedAt: null,      pinOrderKey: null,      activeOrderKey: null,      settledOverride: "settled",      settledAt: wasSettled ? (thread.settledAt ?? now) : now,      unsettledAt: null,    };  }  const resumed = wasSettled    ? { ...awake, settledOverride: "active" as const, settledAt: null, unsettledAt: now }    : awake;  return {    ...resumed,    pinnedAt: section === "pinned" ? (thread.pinnedAt ?? now) : null,    pinOrderKey: section === "pinned" ? (orderKey ?? thread.pinOrderKey) : null,    ...(section === "active" && orderKey !== undefined ? { activeOrderKey: orderKey } : {}),  };} type SidebarProject = {  id: string;  title: string;  createdAt?: string | undefined;  updatedAt?: string | undefined;}; type ScopedSidebarProject = SidebarProject & {  environmentId: string;}; type ScopedSidebarThread = ThreadSortInput & {  environmentId: string;  projectId: string;  archivedAt: string | null;}; type LogicalSidebarProject = SidebarProject & {  projectKey: string;  memberProjectRefs: readonly {    environmentId: string;    projectId: string;  }[];}; export type ThreadTraversalDirection = "previous" | "next"; /** * Shared-worktree checks must exclude only successful deletions, never the * whole batch. A null result skips an entry that the caller can no longer find. */export async function deleteSelectedThreadEntries<  TEntry extends { readonly threadKey: string },>(input: {  entries: readonly TEntry[];  delete: (    entry: TEntry,    deletedThreadKeys: ReadonlySet<string>,  ) => Promise<AtomCommandResult<unknown, unknown> | null>;}) {  const deletedThreadKeys = new Set<string>();  let firstFailure: AsyncResult.Failure<unknown, unknown> | null = null;   for (const entry of input.entries) {    const result = await input.delete(entry, deletedThreadKeys);    if (result === null) continue;    if (result._tag === "Failure") {      if (isAtomCommandInterrupted(result)) break;      firstFailure ??= result;      continue;    }    deletedThreadKeys.add(entry.threadKey);  }   return { deletedThreadKeys, firstFailure };} export async function archiveSelectedThreadEntries<  TEntry extends { readonly threadKey: string },  TResult extends { readonly _tag: "Success" | "Failure" },>(input: {  entries: readonly TEntry[];  archive: (entry: TEntry, onArchived: () => void) => Promise<TResult>;}): Promise<{  archivedThreadKeys: readonly string[];  mutationFailure: Extract<TResult, { readonly _tag: "Failure" }> | null;  followupFailures: readonly Extract<TResult, { readonly _tag: "Failure" }>[];}> {  const archivedThreadKeys: string[] = [];  const followupFailures: Extract<TResult, { readonly _tag: "Failure" }>[] = [];   for (const entry of input.entries) {    let didArchive = false;    const result = await input.archive(entry, () => {      didArchive = true;    });    if (didArchive || result._tag === "Success") {      archivedThreadKeys.push(entry.threadKey);    }    if (result._tag === "Success") continue;    const failure = result as Extract<TResult, { readonly _tag: "Failure" }>;    if (didArchive) {      followupFailures.push(failure);      continue;    }    return { archivedThreadKeys, mutationFailure: failure, followupFailures };  }   return { archivedThreadKeys, mutationFailure: null, followupFailures };} export function buildMultiSelectThreadContextMenuItems(input: {  count: number;  hasRunningThread: boolean;}): readonly ContextMenuItem<"mark-unread" | "archive" | "delete">[] {  return [    { id: "mark-unread", label: `Mark unread (${input.count})` },    {      id: "archive",      label: `Archive (${input.count})`,      disabled: input.hasRunningThread,    },    { id: "delete", label: `Delete (${input.count})`, destructive: true },  ];} export function buildBulkTitleRegenerationContextMenuItem(input: {  supportedCount: number;  actionableCount: number;}): ContextMenuItem<"regenerate-title"> | null {  if (input.supportedCount === 0) return null;  if (input.actionableCount === 0) {    return {      id: "regenerate-title",      label: `Regenerating… (${input.supportedCount})`,      disabled: true,    };  }  return {    id: "regenerate-title",    label: `Regenerate titles (${input.actionableCount})`,  };} /** * Bulk unpin follows the same "count only what the action will touch" rule * as title regeneration: on a mixed selection the label counts the pinned * rows alone, and the item disappears when nothing selected is pinned. */export function buildBulkUnpinContextMenuItem(input: {  pinnedCount: number;}): ContextMenuItem<"unpin"> | null {  if (input.pinnedCount === 0) return null;  return { id: "unpin", label: `Unpin (${input.pinnedCount})` };} export interface ThreadStatusPill {  label:    | "Working"    | "Monitoring"    | "Connecting"    | "Completed"    | "Pending Approval"    | "Awaiting Input"    | "Plan Ready";  colorClass: string;  dotClass: string;  pulse: boolean;

Lines 1–500 of 1237.