1163 lines · 37.2 KB · TypeScript
RawDownload
import * as React from "react";import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit/sortable";import type { ContextMenuItem } from "@t3tools/contracts";import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings";import {  activeThreadAnchorTimestampMs,  getThreadSortTimestamp,  resolveSettledThreadTimestamp,  sortThreads,  toSortableTimestamp,  type SettledThreadTimestampInput,  type ThreadSortInput,} from "../lib/threadSort";import type { SidebarThreadSummary, Thread } from "../types";import type { ThreadRouteTarget } from "../threadRoutes";import { cn } from "../lib/utils";import { isLatestRunSettled } from "../session-logic";import { resolveServerBackedAppStageLabel } from "../branding.logic"; export 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.export 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.export 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;} // The list already reaches its destination through sortable transforms while// the pointer is down. dnd-kit's default also animates the committed DOM order// after release, replaying the same movement across every affected row.export const animatePinnedLayoutChanges: AnimateLayoutChanges = (args) =>  args.isSorting ? defaultAnimateLayoutChanges(args) : false; 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"; 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 isSidebarSubagentThread(thread: Pick<SidebarThreadSummary, "lineage">): boolean {  return thread.lineage.relationshipToParent === "subagent";} export function filterSidebarV2VisibleThreads<  T extends Pick<SidebarThreadSummary, "archivedAt" | "lineage"> & {    environmentId: string;    projectId: string;  },>(threads: readonly T[], scopedProjectKeys: ReadonlySet<string> | null): T[] {  return threads.filter(    (thread) =>      thread.archivedAt === null &&      !isSidebarSubagentThread(thread) &&      (scopedProjectKeys === null ||        scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)),  );} export function getSidebarForkParentThreadId(  thread: Pick<SidebarThreadSummary, "forkedFrom" | "lineage">,) {  if (thread.lineage.relationshipToParent !== "fork") {    return null;  }  return thread.forkedFrom?.type === "run"    ? thread.forkedFrom.threadId    : thread.lineage.parentThreadId;} 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"    | "Connecting"    | "Completed"    | "Pending Approval"    | "Awaiting Input"    | "Waiting"    | "Plan Ready";  colorClass: string;  dotClass: string;  pulse: boolean;} const THREAD_STATUS_PRIORITY: Record<ThreadStatusPill["label"], number> = {  "Pending Approval": 5,  "Awaiting Input": 4,  Working: 3,  Connecting: 3,  Waiting: 2.5,  "Plan Ready": 2,  Completed: 1,}; type ThreadStatusInput = Pick<  SidebarThreadSummary,  | "hasActionableProposedPlan"  | "hasPendingApprovals"  | "hasPendingUserInput"  | "interactionMode"  | "latestRun"  | "runtime"> & {  lastVisitedAt?: string | null | undefined;  pendingBackgroundTasks?: SidebarThreadSummary["pendingBackgroundTasks"] | undefined;}; export interface ThreadJumpHintVisibilityController {  sync: (shouldShow: boolean) => void;  dispose: () => void;} export function resolveSidebarStageBadgeLabel(input: {  primaryServerVersion: string | null | undefined;  fallbackStageLabel: string;}): string {  return resolveServerBackedAppStageLabel(input);} export function createThreadJumpHintVisibilityController(input: {  delayMs: number;  onVisibilityChange: (visible: boolean) => void;  setTimeoutFn?: typeof globalThis.setTimeout;  clearTimeoutFn?: typeof globalThis.clearTimeout;}): ThreadJumpHintVisibilityController {  const setTimeoutFn = input.setTimeoutFn ?? globalThis.setTimeout;  const clearTimeoutFn = input.clearTimeoutFn ?? globalThis.clearTimeout;  let isVisible = false;  let timeoutId: NodeJS.Timeout | null = null;   const clearPendingShow = () => {    if (timeoutId === null) {      return;    }    clearTimeoutFn(timeoutId);    timeoutId = null;  };   return {    sync: (shouldShow) => {      if (!shouldShow) {        clearPendingShow();        if (isVisible) {          isVisible = false;          input.onVisibilityChange(false);        }        return;      }       if (isVisible || timeoutId !== null) {        return;      }       timeoutId = setTimeoutFn(() => {        timeoutId = null;        isVisible = true;        input.onVisibilityChange(true);      }, input.delayMs);    },    dispose: () => {      clearPendingShow();    },  };} export function useThreadJumpHintVisibility(): {  showThreadJumpHints: boolean;  updateThreadJumpHintsVisibility: (shouldShow: boolean) => void;} {  const [showThreadJumpHints, setShowThreadJumpHints] = React.useState(false);  const controllerRef = React.useRef<ThreadJumpHintVisibilityController | null>(null);   React.useEffect(() => {    const controller = createThreadJumpHintVisibilityController({      delayMs: THREAD_JUMP_HINT_SHOW_DELAY_MS,      onVisibilityChange: (visible) => {        setShowThreadJumpHints(visible);      },      setTimeoutFn: window.setTimeout.bind(window),      clearTimeoutFn: window.clearTimeout.bind(window),    });    controllerRef.current = controller;     return () => {      controller.dispose();      controllerRef.current = null;    };  }, []);   const updateThreadJumpHintsVisibility = React.useCallback((shouldShow: boolean) => {    controllerRef.current?.sync(shouldShow);  }, []);   return {    showThreadJumpHints,    updateThreadJumpHintsVisibility,  };} /** * Effective visited watermark for a thread. Servers with visited tracking * project `lastVisitedAt` on the shell and are authoritative — that value is * shared across every device connected to the environment. Pre-tracking * servers omit the field, and the browser's locally persisted watermark keeps * working as before. */export function resolveThreadLastVisitedAt(  serverLastVisitedAt: string | null | undefined,  localLastVisitedAt: string | undefined,): string | undefined {  // When the server tracks visits it is authoritative — including explicit  // rewinds from mark-unread, which a newer browser-local watermark must not  // mask. The local value only carries servers without visited tracking.  if (serverLastVisitedAt === undefined) return localLastVisitedAt;  return serverLastVisitedAt ?? undefined;} export function hasUnseenCompletion(thread: ThreadStatusInput): boolean {  if (!thread.latestRun?.completedAt) return false;  const completedAt = Date.parse(thread.latestRun.completedAt);  if (Number.isNaN(completedAt)) return false;  if (!thread.lastVisitedAt) return false;   const lastVisitedAt = Date.parse(thread.lastVisitedAt);  if (Number.isNaN(lastVisitedAt)) return true;  return completedAt > lastVisitedAt;} export function shouldClearThreadSelectionOnMouseDown(target: HTMLElement | null): boolean {  if (target === null) return true;  return !target.closest(THREAD_SELECTION_SAFE_SELECTOR);} // A double-click dispatches two `click` events before `dblclick`: the first has// `detail === 1`, the second `detail === 2`. The second click must not run the// row's single-click navigation, otherwise double-click-to-rename would also// navigate. `MouseEvent.detail` is 0 for synthetic/keyboard activations, which// still count as a normal single activation.export function isTrailingDoubleClick(detail: number): boolean {  return detail > 1;} function nodeClosest(node: object | null, selector: string): unknown {  if (node === null || !("closest" in node) || typeof node.closest !== "function") return null;  return node.closest(selector);} /** Clicks on a nested link keep the link's meaning. The row must not treat them as multi-select. */export function isSidebarNestedLinkClick(target: EventTarget | null): boolean {  if (target == null || typeof target !== "object") return false;  if (nodeClosest(target, "a[href]") !== null) return true;  const parent =    "parentElement" in target &&    target.parentElement !== null &&    typeof target.parentElement === "object"      ? target.parentElement      : null;  return nodeClosest(parent, "a[href]") !== null;} // Shift+click on the new thread button creates directly in the current// project, skipping the command palette's project picker. With a single// project there is nothing to pick, so a plain click already creates// immediately and the modifier changes nothing.export function shouldCreateNewThreadInCurrentProject(  shiftKey: boolean,  projectGroupCount: number,): boolean {  return shiftKey || projectGroupCount <= 1;} export function orderItemsByPreferredIds<TItem, TId>(input: {  items: readonly TItem[];  preferredIds: readonly TId[];  getId: (item: TItem) => TId;  getPreferenceIds?: (item: TItem) => readonly TId[];}): TItem[] {  const { getId, getPreferenceIds, items, preferredIds } = input;  if (preferredIds.length === 0) {    return [...items];  }   const indexesByPreferenceId = new Map<TId, number[]>();  for (const [index, item] of items.entries()) {    const preferenceIds = getPreferenceIds?.(item) ?? [getId(item)];    for (const preferenceId of new Set(preferenceIds)) {      const indexes = indexesByPreferenceId.get(preferenceId);      if (indexes) {        indexes.push(index);      } else {        indexesByPreferenceId.set(preferenceId, [index]);      }    }  }   const emittedIndexes = new Set<number>();  const ordered = preferredIds.flatMap((id) => {    const index = indexesByPreferenceId      .get(id)      ?.find((candidate) => !emittedIndexes.has(candidate));    if (index === undefined) {      return [];    }    emittedIndexes.add(index);    return [items[index]!];  });  const remaining = items.filter((_, index) => !emittedIndexes.has(index));  return [...ordered, ...remaining];} export function getVisibleSidebarThreadIds<TThreadId>(  renderedProjects: readonly {    shouldShowThreadPanel?: boolean;    renderedThreadIds: readonly TThreadId[];  }[],): TThreadId[] {  return renderedProjects.flatMap((renderedProject) =>    renderedProject.shouldShowThreadPanel === false ? [] : renderedProject.renderedThreadIds,  );} export function getSidebarThreadIdsToPrewarm<TThreadId>(  visibleThreadIds: readonly TThreadId[],  limit = SIDEBAR_THREAD_PREWARM_LIMIT,): TThreadId[] {  return visibleThreadIds.slice(0, Math.max(0, limit));} export function resolveAdjacentThreadId<T>(input: {  threadIds: readonly T[];  currentThreadId: T | null;  direction: ThreadTraversalDirection;}): T | null {  const { currentThreadId, direction, threadIds } = input;   if (threadIds.length === 0) {    return null;  }   if (currentThreadId === null) {    return direction === "previous" ? (threadIds.at(-1) ?? null) : (threadIds[0] ?? null);  }   const currentIndex = threadIds.indexOf(currentThreadId);  if (currentIndex === -1) {    return null;  }   if (direction === "previous") {    return currentIndex > 0 ? (threadIds[currentIndex - 1] ?? null) : null;

Lines 1–500 of 1163.