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. Load more
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;