diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 15cb4cfa51364..f3f660af604fc 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,25 +1,14 @@ import { useThreadRouteTarget } from "../hooks/useThreadRouteTarget"; import { - type EnvironmentId, type ScopedProjectRef, AuthOrchestrationOperateScope, - type EnvironmentMachineKind, ProjectId, type ScopedThreadRef, type ThreadId, } from "@t3tools/contracts"; -import { ContextPreviewHover, type ContextPreviewPlacement } from "./contextPreviewRegistry"; +import { ContextPreviewHover } from "./contextPreviewRegistry"; import { CollapsibleSectionHeader } from "./ui/collapsible-section-header"; -import { - ThreadTerminalStatusButton, - ThreadPullRequestBadgeControl, - ThreadWorktreeIndicator, - prStatusIndicator, - resolveThreadPullRequestBadge, - terminalStatusFromRunningIds, - useLinkedThreadPullRequest, - ThreadSearchPullRequestNumber, -} from "./ThreadStatusIndicators"; +import { terminalStatusFromRunningIds } from "./ThreadStatusIndicators"; import { ThreadContextDragGhost } from "./chat/ThreadContextDragGhost"; import { dropThreadContext, @@ -27,14 +16,8 @@ import { moveThreadContextDrag as moveThreadContextDragGhost, } from "./chat/threadContextDrag"; import { discardComposerDraft } from "../lib/discardComposerDraft"; -import { useThreadMessageDrafts } from "../state/messageDrafts"; import { requestCustomSnooze } from "./CustomSnoozeDialog"; -import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; -import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests"; import { useAtomValue } from "@effect/atom-react"; -import { assistantCitationsToLabels } from "@t3tools/shared/assistantCitations"; -import { composerFileMentionsToLabels } from "@t3tools/shared/composerInlineTokens"; -import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences"; import * as Schema from "effect/Schema"; import { closestCenter, @@ -47,7 +30,7 @@ import { type DragStartEvent, type Modifier, } from "@dnd-kit/core"; -import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; +import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { restrictToFirstScrollableAncestor, restrictToParentElement, @@ -57,22 +40,12 @@ import { CSS } from "@dnd-kit/utilities"; import { canSnooze, effectiveSnoozed, - hasQueuedTurnStart, - QUEUED_TURN_START_GRACE_MS, threadWokeAt, } from "@t3tools/client-runtime/state/thread-settled"; import { createInboxReturnTracker } from "@t3tools/client-runtime/state/thread-inbox"; -import { formatDiffCount } from "@t3tools/client-runtime/diff-count"; -import { - resolveSettledThreadTimestamp, - sortSettledThreads, -} from "@t3tools/client-runtime/state/thread-sort"; +import { sortSettledThreads } from "@t3tools/client-runtime/state/thread-sort"; +import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import { - threadSearchMatchKey, - type EnvironmentThreadSearchMatch, -} from "@t3tools/client-runtime/state/thread-search"; -import { - resolveThreadProviderStack, threadRuntimeCanArchive, type EnvironmentThreadShell, } from "@t3tools/client-runtime/state/models"; @@ -84,41 +57,24 @@ import { } from "@t3tools/client-runtime/thread-tabs"; import { useHiddenTabThreads } from "./sidebar/useHiddenTabThreads"; import { resolveThreadTabTarget, useThreadTabRecencyStore } from "../threadTabRecencyStore"; - import { parseScopedThreadKey, scopeProjectRef, scopeThreadRef, scopedThreadKey, } from "@t3tools/client-runtime/environment"; - import { threadGroupsShownInThreads, type SidebarTabSortDirection, type SidebarTabSortOrder, - type TimestampFormat, type ThreadGroup, } from "@t3tools/contracts/settings"; import { - AlarmClockIcon, - AlarmClockOffIcon, - EyeIcon, PencilIcon, - BotIcon, - CheckIcon, ChevronDownIcon, ChevronUpIcon, - CircleCheckIcon, - ClockIcon, - Columns2Icon, - FileTextIcon, - LayersIcon, - PinIcon, - PinOffIcon, PlusIcon, - SquarePenIcon, TerminalIcon, - Undo2Icon, XIcon, } from "lucide-react"; import { @@ -131,12 +87,9 @@ import { useState, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, - type PointerEvent as ReactPointerEvent, type ReactNode, } from "react"; -import { flushSync } from "react-dom"; import { useRouter } from "@tanstack/react-router"; -import { useRightPanelStore } from "../rightPanelStore"; import { isAtomCommandInterrupted, settlePromise, @@ -157,7 +110,7 @@ import { isTerminalFocused } from "../lib/terminalFocus"; import { isModelPickerOpen } from "../modelPickerVisibility"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { isMacPlatform, cn } from "~/lib/utils"; -import { useOpenPrLink } from "../lib/openPullRequestLink"; +import { textDirection } from "~/textDirection"; import { readLocalApi } from "../localApi"; import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore"; import { selectProjectGroupingSettings } from "../logicalProject"; @@ -192,7 +145,6 @@ import { import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; import { useLocalStorage } from "../hooks/useLocalStorage"; import { useNowMinute } from "../hooks/useNowMinute"; -import { useNowSecond } from "../hooks/useNowSecond"; import { useEnvironmentIdentities, useConnectedEnvironmentIds, @@ -218,25 +170,12 @@ import { threadEnvironment } from "../state/threads"; import { useThreadSearch } from "../state/queries"; import { useOrchestrationCommand } from "../state/use-orchestration-command"; import { runtime } from "../lib/runtime"; -import { - readPreparedConnection, - readEnvironmentScope, - useEnvironmentScope, -} from "../state/session"; +import { readPreparedConnection, readEnvironmentScope } from "../state/session"; import { buildThreadRouteParams, resolveActiveThreadRouteRef } from "../threadRoutes"; -import { formatRelativeTimeLabel, parseTimestampDate } from "../timestampFormat"; import { resolveSidebarTopStatus, SidebarTopStatusIcon } from "./sidebar/SidebarTopStatus"; -import { - compactSidebarTimeLabel, - SidebarTabSummary, - tabSortTimeLabel, - threadTimeLabel, -} from "./sidebar/SidebarTabSummary"; +import { SidebarTabSummary, tabSortTimeLabel, threadTimeLabel } from "./sidebar/SidebarTabSummary"; import type { SidebarThreadSummary } from "../types"; import { isScratchProject, threadMoveTargets } from "@t3tools/client-runtime/state/projects"; -import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; - -import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { ProjectEnvironmentBadge } from "./ProjectEnvironmentBadge"; import { buildDraftActionMenuItems, @@ -255,13 +194,11 @@ import { openSlackMessagePicker } from "./chat/SlackMessagePicker"; import { openDiscordMessagePicker } from "./chat/DiscordMessagePicker"; import { openTranscriptExportDialog } from "./TranscriptExportDialog"; import { - animateSidebarLayoutChanges, applySidebarThreadDrop, filterSidebarV2VisibleThreads, buildBulkTitleRegenerationContextMenuItem, buildBulkUnpinContextMenuItem, deleteSelectedThreadEntries, - formatWorkingDurationLabel, firstValidTimestampMs, foldedSidebarTabThreads, groupSidebarTabThreads, @@ -274,7 +211,6 @@ import { sortSidebarTabs, resolveSidebarTabPairTarget, sidebarTabToggleCount, - sidebarTabToggleLabel, moveSidebarTab, sidebarTabGroupRowKey, sidebarTabNeighbourKey, @@ -290,14 +226,11 @@ import { resolveSidebarDropTarget, resolveSidebarDropVerb, resolveSidebarRowAccessibility, - type SidebarDropVerb, resolveSidebarThreadStatus, - resolveThreadLastVisitedAt, searchSidebarThreads, shouldCreateNewThreadInCurrentProject, shouldNavigateAfterThreadPark, shouldRecedeSidebarThread, - resolveWorkingStartedAt, availableSidebarPages, buildOrganisationOptions, buildSidebarFilterFacets, @@ -310,7 +243,6 @@ import { sidebarPagesAllowDragging, sidebarGroupPage, sidebarListItemId, - sidebarMarkerId, type SidebarPage, sidebarThreadKeyAtY, sortInboxThreadsByReturn, @@ -322,10 +254,8 @@ import { sortThreadsForSidebar, sortWorkingThreadsBySend, useThreadJumpHintVisibility, - useSidebarRowSubscriptionLease, withSidebarTabThreads, type SidebarListItem, - type SidebarListMarker, type SidebarSection, } from "./Sidebar.logic"; import { @@ -334,11 +264,8 @@ import { restrictBelowSidebarLabel, } from "./Sidebar.drag"; import { SidebarDragLifecycle, SidebarPointerSensor } from "./Sidebar.pointer"; -import { animateSidebarDisclosure, createSidebarListMotion } from "./Sidebar.motion"; - +import { createSidebarListMotion } from "./Sidebar.motion"; import { resolveSnoozePresets, snoozeWakeLabel, type SnoozePreset } from "./Sidebar.snooze"; -import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon"; -import { ThreadSearchMatchExcerpt } from "./ThreadSearchMatch"; import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop"; import { useThreadTabActions } from "./chat/ThreadTabs"; import { @@ -354,7 +281,6 @@ import { shouldShowInstanceBadge, type ProviderInstanceEntry } from "../provider import { useThreadRunningTerminalIds } from "../state/terminalSessions"; import { useStableCallback } from "../hooks/useStableCallback"; import { stackedThreadToast, toastManager } from "./ui/toast"; -import { InlineButton } from "./ui/button"; import { SidebarContent, SidebarGroup, useSidebar } from "./ui/sidebar"; import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; import { SidebarAttentionInbox } from "./sidebar/SidebarAttentionInbox"; @@ -368,7 +294,6 @@ import { import { requestThreadGroup } from "./ThreadGroupDialog"; import { ThreadGroupIcon } from "./sidebar/ThreadGroupIcon"; import { projectIconColorClassName } from "../projectIconColors"; - import { useThreadGroups } from "../hooks/useThreadGroups"; import { useOrganisations } from "../hooks/useOrganisations"; import { @@ -376,26 +301,35 @@ import { sidebarTabSortDirectionLabel, SidebarTabsMenu, } from "./sidebar/SidebarTabsMenu"; -import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuShortcut, MenuTrigger } from "./ui/menu"; import { PreviewCard, PreviewCardPopup, PreviewCardTrigger } from "./ui/preview-card"; -import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; -import { MiddleTruncate } from "./ui/middle-truncate"; +import { TooltipProvider } from "./ui/tooltip"; import { composerDraftHasUserContent, DraftId, useComposerDraftStore, useComposerDraftActiveModelSelection, - useThreadHasUnsentDraft, - type ComposerThreadDraftState, - type DraftSessionState, } from "../composerDraftStore"; +import { + SIDEBAR_THREAD_PREVIEW_PLACEMENT, + JumpHintBadge, + terminalProcessLabel, + type SortableThreadRowBag, + SortableTabThreadRow, + SortableThreadRow, + type SidebarSweepAction, + SidebarSplitIndicator, + SidebarRenameInput, + SidebarSearchResultRow, +} from "./sidebar/SidebarThreadRow"; +import { SidebarDraftBlock } from "./sidebar/SidebarDraftBlock"; +import { + SortableSidebarMarker, + SidebarSectionPlaceholder, + SidebarDragBoundary, + SidebarSectionHeader, +} from "./sidebar/SidebarListMarkers"; const EMPTY_PROVIDER_ENTRIES: ReadonlyMap = new Map(); -const SIDEBAR_THREAD_PREVIEW_PLACEMENT: ContextPreviewPlacement = { - side: "right", - align: "start", - sideOffset: 4, -}; // Collapsed shelves share one empty list so a route change alone does not // give the sidebar list a new identity. const EMPTY_THREADS: readonly EnvironmentThreadShell[] = []; @@ -447,334 +381,12 @@ function checkThreadOperations( }); return false; } -function settledTimeLabel(thread: SidebarThreadSummary): string { - const timestamp = resolveSettledThreadTimestamp(thread); - return timestamp === null ? "" : compactSidebarTimeLabel(formatRelativeTimeLabel(timestamp)); -} - -// Subscribes to the minute tick itself, so only the label re-renders each -// minute. The minute is passed as the clock so it is the formatter's input. -function RelativeTimeLabel(props: { timestamp: string | null }) { - const nowMinute = useNowMinute(); - return props.timestamp === null - ? null - : compactSidebarTimeLabel( - formatRelativeTimeLabel(props.timestamp, Date.parse(`${nowMinute}:00.000Z`)), - ); -} - -function threadTimeTimestamp(thread: SidebarThreadSummary): string { - return thread.latestUserMessageAt ?? thread.updatedAt; -} -function JumpHintBadge(props: { label: string }) { - return ( - - {props.label} - - ); -} - -function WorkingDuration(props: { startedAt: string | null }) { - const startedMs = props.startedAt !== null ? Date.parse(props.startedAt) : Number.NaN; - if (Number.isNaN(startedMs)) return null; - return ; -} - -// Self-ticking so only this span re-renders each second, not the whole row; -// the shared tick lands every working row's update in the same frame. -function WorkingDurationLabel(props: { startedMs: number }) { - const nowMs = useNowSecond(); - return ( - {formatWorkingDurationLabel(nowMs - props.startedMs)} - ); -} const EMPTY_TABS_BY_ROW: ReadonlyMap = new Map(); function sidebarThreadKey(thread: EnvironmentThreadShell): string { return scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); } -function terminalProcessLabel(count: number): string { - return `${count} terminal ${count === 1 ? "process" : "processes"} running`; -} -function SidebarProviderStack(props: { - thread: SidebarThreadSummary; - /** An unsent composer pick, which becomes the current provider before the next turn. */ - draftModelSelection?: ReturnType; - providerEntryByInstanceId: ReadonlyMap; -}) { - const stack = resolveThreadProviderStack( - props.draftModelSelection - ? { - ...props.thread, - runtime: null, - modelSelection: props.draftModelSelection, - } - : props.thread, - ); - const currentInstanceId = stack[stack.length - 1]!; - const currentEntry = props.providerEntryByInstanceId.get(currentInstanceId) ?? null; - if (currentEntry === null) return null; - const showInstanceBadge = shouldShowInstanceBadge( - currentEntry, - props.providerEntryByInstanceId.values(), - ); - const current = ( - - ); - if (stack.length === 1) { - return {current}; - } - return ( - - {stack.slice(0, -1).map((instanceId) => { - const entry = props.providerEntryByInstanceId.get(instanceId); - if (entry === undefined) return null; - return ( - - ); - })} - {current} - - ); -} -function SnoozeMenuButton(props: { - open: boolean; - onOpenChange: (open: boolean) => void; - onSnooze: (preset: Pick) => void; - timestampFormat: TimestampFormat; -}) { - const { open, onOpenChange, onSnooze, timestampFormat } = props; - const presets = open ? resolveSnoozePresets(new Date(), timestampFormat) : []; - return ( - - - event.stopPropagation()} - onDoubleClick={(event) => event.stopPropagation()} - className="inline-flex h-full cursor-pointer items-center gap-0.5 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground hover:text-foreground" - /> - } - /> - } - > - - - Snooze thread - - - {presets.map((preset) => ( - { - event.stopPropagation(); - onSnooze(preset); - }} - > - {preset.label} - {preset.whenLabel} - - ))} - - { - event.stopPropagation(); - const choice = await requestCustomSnooze(); - if (choice) onSnooze(choice); - }} - > - Custom… - - - - ); -} -type SortableThreadRowBag = Pick< - ReturnType, - "listeners" | "setNodeRef" | "transform" | "transition" | "isDragging" ->; -// dnd-kit keys memoization on the disabled object identity. -const SIDEBAR_SORTABLE_DRAG_DISABLED = { draggable: true } as const; -const SIDEBAR_SORTABLE_DRAG_ENABLED = { draggable: false } as const; - -function SortableTabThreadRow(props: { - id: string; - disabled: boolean; - contextDrag: boolean; - environmentId: EnvironmentId; - children: (bag: SortableThreadRowBag) => ReactNode; -}) { - const canOperateThread = useEnvironmentScope(props.environmentId, AuthOrchestrationOperateScope); - const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({ - id: props.id, - disabled: - props.disabled || !canOperateThread - ? SIDEBAR_SORTABLE_DRAG_DISABLED - : SIDEBAR_SORTABLE_DRAG_ENABLED, - animateLayoutChanges: animateSidebarLayoutChanges, - }); - // dnd-kit memoizes each field but not the bag, so the memoized row would - // rerender on every shell update without this. - const bag = { - listeners, - setNodeRef, - transform: props.contextDrag ? null : transform, - transition: props.contextDrag && isDragging ? "transform 150ms ease-out" : transition, - isDragging: isDragging && !props.contextDrag, - }; - return props.children(bag); -} -function SortableThreadRow( - props: Omit & { - sortableId: string; - sortableDisabled: boolean; - contextDrag: boolean; - }, -) { - const { sortableId, sortableDisabled, contextDrag, ...rowProps } = props; - const canOperateThread = useEnvironmentScope( - rowProps.thread.environmentId, - AuthOrchestrationOperateScope, - ); - const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({ - id: sortableId, - disabled: - sortableDisabled || !canOperateThread - ? SIDEBAR_SORTABLE_DRAG_DISABLED - : SIDEBAR_SORTABLE_DRAG_ENABLED, - animateLayoutChanges: animateSidebarLayoutChanges, - }); - // dnd-kit memoizes each field but not the bag, so the memoized row would - // rerender on every shell update without this. - const bag = { - listeners, - setNodeRef, - transform: contextDrag ? null : transform, - transition: contextDrag && isDragging ? "transform 150ms ease-out" : transition, - isDragging: isDragging && !contextDrag, - }; - // Keyed by variant so a card/slim switch remounts the row. - return ; -} - -const draftSurfaceClassName = "bg-warning/4 hover:bg-warning/8"; -const draftPenClassName = "size-3 shrink-0 text-warning-foreground"; -function SortableSidebarMarker(props: { - marker: SidebarListMarker; - /** Overrides the marker's id when several items share one marker kind. */ - id?: string; - className?: string; - children?: ReactNode; - "data-testid"?: string; -}) { - const { setNodeRef, transform, transition } = useSortable({ - id: props.id ?? sidebarMarkerId(props.marker), - disabled: SIDEBAR_SORTABLE_DRAG_DISABLED, - animateLayoutChanges: animateSidebarLayoutChanges, - }); - return ( -
  • - {props.children} -
  • - ); -} -function SidebarSectionPlaceholder(props: { - marker: "active-placeholder" | "settled-placeholder"; - label: string; - showHint: boolean; - isDropTarget: boolean; -}) { - return ( - - {props.showHint ? ( -
    - {props.label} -
    - ) : null} -
    - ); -} const SIDEBAR_DRAG_DISTANCE = 6; -type SidebarSweepAction = "settle" | "unsettle" | "unsnooze"; -// Unhide is a row action but not a sweep: hidden rows are few and deliberate. -type SidebarRowAction = SidebarSweepAction | "unhide"; const SIDEBAR_DRAG_LABEL_HEIGHT = 24; -function SidebarDragBoundary(props: { - marker: "pinned-header" | "pinned-divider"; - label: string; - visible: boolean; - isDropTarget: boolean; -}) { - return ( - - {props.visible ? ( -
    - - {props.label} - - -
    - ) : null} -
    - ); -} // Titles a user-made group in its accent. Click the title to collapse it; click the pencil, or // right-click, to rename or restyle. function SidebarGroupHeader(props: { @@ -834,1626 +446,6 @@ function SidebarGroupHeader(props: { ); } -// The Working shelf header stays visible and keeps its measured height while dragging. -function SidebarSectionHeader(props: { - marker: "working-header"; - label: string; - className?: string; - toggle: { - expanded: boolean; - onToggle: () => void; - }; -}) { - return ( - - - {props.label} - - - ); -} -const SidebarDraftRow = memo(function SidebarDraftRow(props: { - draftId: DraftId; - composer: ComposerThreadDraftState; - project: ProjectFaviconProject | null; - projectDisplayName: string | null; - isActive: boolean; - onNavigate: (draftId: DraftId) => void; - onDiscard: (draftId: DraftId) => void; - onContextMenu: ( - draftId: DraftId, - position: { - x: number; - y: number; - }, - ) => void; -}) { - const { composer, draftId, onContextMenu, onDiscard, onNavigate } = props; - const promptPreview = - composerFileMentionsToLabels( - assistantCitationsToLabels( - replaceComposerContextReferences(composer.prompt, (occurrence) => occurrence.label), - ), - ) - .trim() - .split("\n", 1)[0] ?? ""; - // images mirrors persistedAttachments once rehydration finishes; before - // that only the persisted list is populated, hence max not sum. - const attachmentCount = - Math.max(composer.images.length, composer.persistedAttachments.length) + - composer.files.length + - composer.terminalContexts.length + - composer.previewAnnotations.length + - composer.reviewComments.length; - const preview = - promptPreview.length > 0 - ? promptPreview - : `${attachmentCount} attachment${attachmentCount === 1 ? "" : "s"}`; - const accessibility = resolveSidebarRowAccessibility({ - title: preview, - statusLabel: "Unsent draft", - projectDisplayName: props.projectDisplayName, - isActive: props.isActive, - }); - const handleActivate = () => onNavigate(draftId); - const handleKeyDown = (event: ReactKeyboardEvent) => { - // Keys targeting the nested discard button belong to the button: - // preventDefault here would swallow Space's synthesized click and - // navigate instead of discarding. - if ((event.target as HTMLElement).closest("button")) return; - if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) { - event.preventDefault(); - const rect = event.currentTarget.getBoundingClientRect(); - onContextMenu(draftId, { x: rect.left, y: rect.bottom }); - } else if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - onNavigate(draftId); - } - }; - const handleContextMenu = (event: ReactMouseEvent) => { - event.preventDefault(); - onContextMenu(draftId, { x: event.clientX, y: event.clientY }); - }; - const handleDiscard = (event: ReactMouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - onDiscard(draftId); - }; - return ( -
  • -
    - {preview} -
    -
    - - {props.project ? ( - - ) : null} - - {props.projectDisplayName} - - - - - - - } - /> - Discard draft - - -
    -
    - {preview} -
    -
    -
    -
  • - ); -}); -interface SidebarDraftRowData { - draftId: DraftId; - session: DraftSessionState; - composer: ComposerThreadDraftState; -} -const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { - projectByKey: ReadonlyMap; - projectDisplayNameByKey: ReadonlyMap; - scopedProjectKeys: ReadonlySet | null; - routeDraftId: string | null; - onNavigateToDraft: (draftId: DraftId) => void; - onDraftContextMenu: ( - draftId: DraftId, - position: { - x: number; - y: number; - }, - ) => void; -}) { - const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey); - const draftsByThreadKey = useComposerDraftStore((store) => store.draftsByThreadKey); - const [frozenActive, setFrozenActive] = useState<{ - routeDraftId: string | null; - row: SidebarDraftRowData | null; - }>({ routeDraftId: null, row: null }); - if (frozenActive.routeDraftId !== props.routeDraftId) { - let row: SidebarDraftRowData | null = null; - if (props.routeDraftId !== null) { - const draftId = DraftId.make(props.routeDraftId); - const session = draftThreadsByThreadKey[draftId]; - const composer = draftsByThreadKey[draftId]; - row = - session && session.promotedTo == null && composer && composerDraftHasUserContent(composer) - ? { draftId, session, composer } - : null; - } - setFrozenActive({ routeDraftId: props.routeDraftId, row }); - } - const drafts = (() => { - const rows: SidebarDraftRowData[] = []; - for (const [draftKey, session] of Object.entries(draftThreadsByThreadKey)) { - if (session.promotedTo != null) { - continue; - } - if ( - props.scopedProjectKeys !== null && - !props.scopedProjectKeys.has(`${session.environmentId}:${session.projectId}`) - ) { - continue; - } - if (draftKey === props.routeDraftId) { - if (frozenActive.routeDraftId === draftKey && frozenActive.row !== null) { - rows.push(frozenActive.row); - } - continue; - } - const composer = draftsByThreadKey[draftKey]; - if (!composer || !composerDraftHasUserContent(composer)) { - continue; - } - rows.push({ draftId: DraftId.make(draftKey), session, composer }); - } - rows.sort((left, right) => right.session.createdAt.localeCompare(left.session.createdAt)); - return rows; - })(); - if (drafts.length === 0) { - return null; - } - return ( - <> - {drafts.map(({ composer, draftId, session }) => { - const projectKey = `${session.environmentId}:${session.projectId}`; - return ( - - ); - })} -
  • - - ); -}); -const dropVerbBadge: Record = { - pin: ( - <> - - Pin - - ), - unpin: ( - <> - - Unpin - - ), - settle: ( - <> - - Settle - - ), - unsettle: ( - <> - - Un-settle - - ), - wake: ( - <> - - Wake - - ), -}; -/** Marks a row whose chat is one side of the split view on screen. */ -function SidebarSplitIndicator() { - return ( - - - - ); -} -type SidebarThreadRowProps = { - thread: SidebarThreadSummary; - /** The tab the hidden-tabs row opens; the group thread still owns ordering and selection. */ - displayThread: SidebarThreadSummary; - tabGroupName?: string | undefined; - variant: "card" | "slim"; - // The category determines the action independently of the row layout. - variantAction: SidebarRowAction; - settlementSupported: boolean; - snoozeSupported: boolean; - pinningSupported: boolean; - isPinned: boolean; - sortable?: SortableThreadRowBag | undefined; - dropVerb: SidebarDropVerb | null; - dragOverPinned: boolean; - sweepAction: SidebarSweepAction | null; - snoozeWakeLabelText: string | null; - wokeAt: string | null; - isActive: boolean; - // One side of the split view on screen: the routed chat or the one beside it. - inSplit: boolean; - isRouteThread: boolean; - // A sibling tab is the open route. The header stays present, but the active - // pill belongs to that tab row. - groupFocused?: boolean; - openPullRequestsInRightPanel: boolean; - jumpLabel: string | null; - currentEnvironmentId: string | null; - environmentLabel: string | null; - environmentMachine: EnvironmentMachineKind; - scratchMachineLabel: string | null; - project: EnvironmentProject | null; - projectDisplayName: string | null; - providerEntryByInstanceId: ReadonlyMap; - timestampFormat: TimestampFormat; - onThreadClick: ( - event: ReactMouseEvent, - threadRef: ScopedThreadRef, - keepOpenGroupTab?: boolean, - ) => void; - onThreadActivate: (threadRef: ScopedThreadRef, keepOpenGroupTab?: boolean) => void; - onStartRename: (threadRef: ScopedThreadRef, title: string) => void; - onNameTabGroup: ((threadRef: ScopedThreadRef) => void) | undefined; - onRenameTitleChange: (title: string) => void; - onCommitRename: (threadRef: ScopedThreadRef, title: string, originalTitle: string) => void; - onCancelRename: () => void; - isRenaming: boolean; - renamingTitle: string; - onContextMenu: ( - threadRef: ScopedThreadRef, - position: { - x: number; - y: number; - }, - ) => void; - onSettle: (threadRef: ScopedThreadRef) => void; - onActionSweepStart: ( - threadRef: ScopedThreadRef, - action: SidebarSweepAction, - event: PointerEvent, - ) => void; - onUnsettle: (threadRef: ScopedThreadRef) => void; - onSnooze: (threadRef: ScopedThreadRef, preset: Pick) => void; - onUnsnooze: (threadRef: ScopedThreadRef) => void; - onUnhide: (threadRef: ScopedThreadRef) => void; - onUnpin: (threadRef: ScopedThreadRef) => void; - onPin: (threadRef: ScopedThreadRef) => void; - onAcknowledgeWoke: (threadRef: ScopedThreadRef, visitedAt: string) => void; - onFileDropThreads?: ((threadRef: ScopedThreadRef, files: File[]) => void) | undefined; - /** Adds a chat tab to this row's group. Absent where the environment has no tabs. */ - onNewTab?: ((threadRef: ScopedThreadRef) => void) | undefined; - /** Tabs in this row's group; the badge shows only above one. */ - tabCount: number; - /** Tabs the count control hides or reveals, after the show-up-to limit. */ - tabToggleCount: number; - /** The group's tabs. Mounted under the row while `tabsOpen`, and while it animates closed. */ - tabs: ReactNode; - tabsOpen: boolean; - /** Lists the group's tabs when hovering the folded count badge, so one opens in place. */ - tabPreview?: ReactNode; - /** Opens or folds this group's tabs. Absent where the row has no tabs. */ - onToggleTabs?: ((rowThreadKey: string) => void) | undefined; - /** A tab list finished opening or closing, so rows below it have moved. */ - onTabsResized?: (() => void) | undefined; -}; -const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { - const { - isRenaming, - onCancelRename, - onCommitRename, - onContextMenu, - onAcknowledgeWoke, - onFileDropThreads, - onNewTab, - onRenameTitleChange, - onSettle, - onActionSweepStart, - onSnooze, - onStartRename, - onThreadActivate, - onThreadClick, - onUnsettle, - onUnsnooze, - onUnhide, - onUnpin, - onPin, - openPullRequestsInRightPanel, - renamingTitle, - displayThread: thread, - variant, - variantAction, - } = props; - const threadRef = scopeThreadRef(thread.environmentId, thread.id); - const rowThreadRef = scopeThreadRef(props.thread.environmentId, props.thread.id); - const rowThreadKey = scopedThreadKey(rowThreadRef); - const threadKey = scopedThreadKey(threadRef); - const canOperateThread = useEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope); - useEffect(() => { - if (!canOperateThread && isRenaming) onCancelRename(); - }, [canOperateThread, isRenaming, onCancelRename]); - const { leaseLiveStatus, rowRef } = useSidebarRowSubscriptionLease(props.isActive); - const isRegeneratingTitle = thread.titleRegeneration != null; - const localLastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); - const lastVisitedAt = resolveThreadLastVisitedAt(thread.lastVisitedAt, localLastVisitedAt); - // Woke belongs to the group, so it reads the row thread's visit, not the shown tab's. - const localRowLastVisitedAt = useUiStateStore( - (state) => state.threadLastVisitedAtById[rowThreadKey], - ); - const rowLastVisitedAt = resolveThreadLastVisitedAt( - props.thread.lastVisitedAt, - localRowLastVisitedAt, - ); - const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(rowThreadKey)); - const openPrLink = useOpenPrLink(); - const runningTerminalIds = useThreadRunningTerminalIds({ - environmentId: thread.environmentId, - threadId: thread.id, - }); - const hasUnsentDraft = useThreadHasUnsentDraft(threadRef) && !props.isRouteThread; - const savedDraftCount = useThreadMessageDrafts(threadRef).length; - const handleDiscardDraftClick = (event: ReactMouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - discardComposerDraft(threadRef); - }; - const linkedPullRequestStatus = useLinkedThreadPullRequest( - thread.environmentId, - thread.linkedPullRequest, - leaseLiveStatus, - thread.pullRequests, - thread.branchPullRequest, - ); - const pr = linkedPullRequestStatus?.pr ?? null; - const supportsMultiplePullRequests = useSupportsMultiplePullRequests(thread.environmentId); - const currentLinkedPr = supportsMultiplePullRequests - ? resolveThreadCurrentPullRequestLink(thread.pullRequests) - : null; - const isUnread = hasUnseenCompletion({ ...thread, lastVisitedAt }); - const status = resolveSidebarThreadStatus(thread); - const lastVisitedDate = - rowLastVisitedAt === undefined ? null : parseTimestampDate(rowLastVisitedAt); - const wokeAtDate = props.wokeAt === null ? null : parseTimestampDate(props.wokeAt); - const isWoke = - wokeAtDate !== null && - (lastVisitedDate === null || lastVisitedDate < wokeAtDate) && - props.thread.settledOverride !== "settled"; - const shouldRecede = shouldRecedeSidebarThread({ - status, - isUnread, - isWoke, - isActive: props.isActive || props.groupFocused === true, - isSelected, - }); - const resolvedTopStatus = resolveSidebarTopStatus(status, isWoke, isUnread); - // A native /goal keeps the agent going across turns until it is met. - const topStatus = - resolvedTopStatus !== null && status === "working" && thread.goal?.status === "active" - ? { ...resolvedTopStatus, label: "Goal" } - : resolvedTopStatus; - const isWokeStatus = topStatus?.icon === "woke"; - const prStatus = prStatusIndicator(pr, linkedPullRequestStatus?.sourceControlProvider); - // An unsent composer pick wins, so the icon follows the composer before the next turn. - const draftModelSelection = useComposerDraftActiveModelSelection(threadRef); - const isRemote = thread.environmentId !== props.currentEnvironmentId; - const showsScratchMachine = !thread.branch && props.scratchMachineLabel !== null; - const handleClick = (event: ReactMouseEvent) => { - // The group header stands for every tab. A tab already open in it stays open. - onThreadClick(event, rowThreadRef, true); - }; - const handleAcknowledgeWokeClick = (event: ReactMouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - if (props.wokeAt === null) return; - onAcknowledgeWoke(rowThreadRef, props.wokeAt); - }; - const handleContextMenu = (event: ReactMouseEvent) => { - event.preventDefault(); - onContextMenu(threadRef, { x: event.clientX, y: event.clientY }); - }; - const handleKeyDown = (event: ReactKeyboardEvent) => { - if (event.target !== event.currentTarget) return; - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - onThreadActivate(rowThreadRef, true); - }; - const handleDoubleClick = (event: ReactMouseEvent) => { - if ( - !readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope) || - isRenaming || - event.metaKey || - event.ctrlKey || - event.shiftKey || - event.altKey - ) { - return; - } - if ((event.target as HTMLElement).closest("button, a, input")) return; - event.preventDefault(); - if (props.onNameTabGroup) props.onNameTabGroup(threadRef); - else onStartRename(threadRef, thread.title); - }; - const [isFileDragOver, setIsFileDragOver] = useState(false); - const fileDropHandlers = onFileDropThreads - ? makeWorkspaceFileDropHandlers({ - setDragActive: setIsFileDragOver, - addFiles: (files) => { - onFileDropThreads(threadRef, files); - }, - addFolders: () => {}, - }) - : null; - useEffect(() => { - if (!isFileDragOver) return; - const clearFileDrag = () => setIsFileDragOver(false); - window.addEventListener("dragend", clearFileDrag); - return () => window.removeEventListener("dragend", clearFileDrag); - }, [isFileDragOver]); - const handleSettleClick = (event: ReactMouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - onSettle(rowThreadRef); - }; - const handleNewTabClick = (event: ReactMouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - onNewTab?.(threadRef); - }; - const handleActionPointerDown = (event: ReactPointerEvent) => { - if (!event.isPrimary || event.button !== 0) return; - event.stopPropagation(); - if (variantAction === "unhide") return; - onActionSweepStart(threadRef, variantAction, event.nativeEvent); - }; - const handleUnsettleClick = (event: ReactMouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - onUnsettle(rowThreadRef); - }; - const handleUnsnoozeClick = (event: ReactMouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - onUnsnooze(rowThreadRef); - }; - const handleUnhideClick = (event: ReactMouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - onUnhide(rowThreadRef); - }; - const rowAction = - variantAction === "unhide" - ? { - label: "Unhide thread", - text: "Unhide", - Icon: EyeIcon, - onClick: handleUnhideClick, - supported: true, - } - : variantAction === "unsnooze" - ? { - label: "Wake thread now", - text: "Wake", - Icon: AlarmClockOffIcon, - onClick: handleUnsnoozeClick, - supported: props.snoozeSupported, - } - : variantAction === "unsettle" - ? { - label: "Un-settle thread", - text: "Un-settle", - Icon: Undo2Icon, - onClick: handleUnsettleClick, - supported: props.settlementSupported, - } - : { - label: "Settle thread", - text: "Settle", - Icon: CheckIcon, - onClick: handleSettleClick, - supported: props.settlementSupported, - }; - const RowActionIcon = rowAction.Icon; - const handleUnpinClick = (event: ReactMouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - onUnpin(rowThreadRef); - }; - const handlePinClick = (event: ReactMouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - onPin(rowThreadRef); - }; - const handleSnoozePreset = (preset: Pick) => { - onSnooze(rowThreadRef, preset); - }; - const [snoozeMenuOpenRaw, setSnoozeMenuOpen] = useState(false); - // The React Compiler caches render results on their inputs, so a clock read - // here would stay frozen until `thread` changes and a queued-turn gate would - // never reopen. The gate's clock is state, reset with the thread. - const [snoozeGateClock, setSnoozeGateClock] = useState(() => ({ - thread, - now: new Date().toISOString(), - })); - if (snoozeGateClock.thread !== thread) { - setSnoozeGateClock({ thread, now: new Date().toISOString() }); - } - const snoozeGateNow = snoozeGateClock.now; - const snoozeGateExpiryMs = hasQueuedTurnStart(thread, { now: snoozeGateNow }) - ? Date.parse(thread.latestUserMessageAt ?? "") + QUEUED_TURN_START_GRACE_MS - : Number.NaN; - useEffect(() => { - // A gate still closed past its expiry is held by runtime status, not time, - // so no clock tick can open it. - if (!Number.isFinite(snoozeGateExpiryMs) || snoozeGateExpiryMs <= Date.parse(snoozeGateNow)) { - return; - } - const id = window.setTimeout( - () => setSnoozeGateClock({ thread, now: new Date().toISOString() }), - Math.min(Math.max(0, snoozeGateExpiryMs - Date.now()) + 50, 2_147_483_647), - ); - return () => window.clearTimeout(id); - }, [snoozeGateExpiryMs, snoozeGateNow, thread]); - const showSnoozeButton = - canOperateThread && props.snoozeSupported && canSnooze(thread, { now: snoozeGateNow }); - // Closing here, not just masking, keeps the menu from reopening when the button returns. - if (snoozeMenuOpenRaw && !showSnoozeButton) setSnoozeMenuOpen(false); - const showHoverActions = - (canOperateThread && rowAction.supported) || showSnoozeButton || hasUnsentDraft; - const snoozeMenuOpen = snoozeMenuOpenRaw && showSnoozeButton; - const handlePrClick = (event: ReactMouseEvent, targetUrl?: string) => { - const url = targetUrl ?? pr?.url ?? currentLinkedPr?.url; - if (!url) return; - const showInRightPanel = targetUrl !== undefined || openPullRequestsInRightPanel; - const openedInRightPanel = openPrLink(event, url, showInRightPanel ? threadRef : undefined); - if (openedInRightPanel && showInRightPanel && !props.isRouteThread) { - onThreadActivate(threadRef); - } - }; - // An open tab list includes this thread as its first row, so the card is only - // the group header. The active tab carries the highlight. - const unifyTabs = variant === "card" && props.tabsOpen && props.tabs != null; - const rowActive = props.isActive && !unifyTabs; - // The chat beside the routed one in a split gets a lighter version of the active surface. - const rowSplitPartner = props.inSplit && !props.isActive && !unifyTabs; - // Each listed tab shows its own status, so the header would only repeat the - // first tab's and read as the whole group's. Woke belongs to the group. - const headerStatus = unifyTabs && !isWokeStatus ? null : topStatus; - const rowSurfaceClassName = cn( - "group/sidebar-row relative w-full cursor-pointer overflow-hidden rounded-md text-left outline-none select-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring", - variantAction === "unsettle" && "[&:not(:hover):not(:focus-within)_*]:text-secondary-label/70", - rowActive - ? "bg-sidebar-row-active text-sidebar-foreground" - : rowSplitPartner - ? "bg-sidebar-row-active/50 text-sidebar-foreground" - : isSelected || props.sweepAction !== null - ? "bg-sidebar-row-selected text-sidebar-foreground" - : hasUnsentDraft - ? cn(draftSurfaceClassName, "text-sidebar-foreground") - : shouldRecede - ? "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground" - : "bg-transparent text-sidebar-foreground hover:bg-sidebar-row-hover", - // Background work fades as a whole row, status label included, so it - // takes less attention than rows that need a human (input, approval). - shouldRecede && - status === "working" && - "opacity-70 transition-opacity hover:opacity-100 focus-within:opacity-100 motion-reduce:transition-none", - isFileDragOver && "ring-1 ring-inset ring-primary/70", - // The hover tint must not clobber an active/selected row's own surface. - isFileDragOver && !rowActive && !isSelected && "bg-sidebar-row-hover", - // The lifted row is an opaque card so the rows beneath it never show - // through. The row tint is translucent in dark themes and the pointer - // keeps the hover color applied, so both the tint and the solid sidebar - // color are stacked as background images. - props.sortable?.isDragging && - "bg-sidebar bg-linear-to-b from-sidebar-row-active to-sidebar-row-active text-sidebar-foreground opacity-100 shadow-lg", - ); - const sortable = props.sortable; - const sortableRootProps = sortable - ? { - ref: sortable.setNodeRef, - style: { - transform: CSS.Translate.toString(sortable.transform), - transition: sortable.transition, - // A zero-height boundary also makes dnd-kit scale the source to - // zero. Only projected peers use scaleY as a visibility sentinel. - visibility: - !sortable.isDragging && sortable.transform?.scaleY === 0 - ? ("hidden" as const) - : undefined, - }, - ...sortable.listeners, - } - : {}; - const destinationVerb = sortable?.isDragging - ? props.dropVerb - : props.sweepAction === "unsnooze" - ? "wake" - : props.sweepAction; - const dragDestination = - destinationVerb !== null ? ( - - {dropVerbBadge[destinationVerb]} - - ) : null; - const accessibility = resolveSidebarRowAccessibility({ - title: props.tabGroupName ?? thread.title, - statusLabel: headerStatus?.label ?? null, - projectDisplayName: props.projectDisplayName, - isActive: rowActive, - startedByAgent: thread.startedBy !== null, - }); - const title = - isRenaming && canOperateThread ? ( - - ) : ( - - {props.tabGroupName ?? thread.title} - - ); - const accessibleTitle = isRenaming ? null : ( - {props.tabGroupName ?? thread.title} - ); - const prBadgeShape = supportsMultiplePullRequests - ? resolveThreadPullRequestBadge(thread.pullRequests) - : null; - const handlePrListClick = () => { - useRightPanelStore.getState().open(threadRef, "pull-requests"); - if (!props.isRouteThread) onThreadActivate(threadRef); - }; - const prBadge = - prBadgeShape?.kind === "stack" || pr || currentLinkedPr ? ( - } - badge={prBadgeShape} - pullRequests={thread.pullRequests} - number={pr?.number ?? currentLinkedPr?.number} - url={pr?.url ?? currentLinkedPr?.url} - status={prStatus} - onOpenList={handlePrListClick} - onOpenPullRequest={handlePrClick} - /> - ) : null; - const terminalStatusIcon = ( - - ); - const draftIndicator = hasUnsentDraft ? ( - - - } - > - - - Unsent draft - - ) : null; - const savedDraftsLabel = `${savedDraftCount} draft${savedDraftCount === 1 ? "" : "s"}`; - const savedDraftsIndicator = - savedDraftCount > 0 ? ( - - - } - > - - {savedDraftCount} - - {savedDraftsLabel} - - ) : null; - const onToggleTabs = props.onToggleTabs; - const handleToggleTabsClick = (event: ReactMouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - onToggleTabs?.(rowThreadKey); - }; - // The row's own tooltip would cover the badge's tab list, so it stays shut while that is open. - const [tabPreviewOpenRaw, setTabPreviewOpen] = useState(false); - const tabPreviewOpen = tabPreviewOpenRaw && !props.tabsOpen && props.tabCount > 1; - const tabCountBadge = - props.tabCount > 1 ? ( - - ) : null; - const tabList = - props.tabs != null ? ( - - {props.tabs} - - ) : null; - const showPin = - props.isPinned && (!sortable?.isDragging || (props.dragOverPinned && props.dropVerb === null)); - // Unpinned rows offer pin beside the other hover actions. Pinned rows keep - // a trailing marker instead, so the two never show at once. - const showUnpinnedPinAction = props.pinningSupported && !props.isPinned; - const pinIndicator = showPin ? ( - props.pinningSupported && canOperateThread && !sortable?.isDragging ? ( - - - } - > - {/* Pin marks the pinned state at rest; hover and focus swap in pin-off so the - icon reads as the action the button performs. */} - - - - Unpin thread - - ) : ( - - ) - ) : null; - // An agent started this thread. The row's label speaks it, the hover card - // and chat header say which agent. - const agentIndicator = - thread.startedBy !== null ? ( - - ) : null; - if (variant === "slim") { - return ( -
  • - - {accessibleTitle} - - {props.project ? ( - - ) : null} - - {draftIndicator} - {title} - {tabCountBadge} - {agentIndicator} - {pinIndicator} - {props.inSplit ? : null} - {terminalStatusIcon} - {savedDraftsIndicator} - {isRegeneratingTitle ? ( - - Regenerating title - - ) : null} - {prBadge} - {sortable?.isDragging ? ( - dragDestination - ) : ( - - - {variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? ( - - {props.snoozeWakeLabelText} - - ) : isWoke ? ( - - - - Woke - - } - /> - Dismiss Woke notification - - ) : ( - - - - )} - - {variantAction === "unhide" ? ( - - - } - > - - - Unhide thread - - ) : variantAction === "unsnooze" ? ( - !props.snoozeSupported ? null : ( - - ) - ) : !canOperateThread || !props.settlementSupported ? null : variantAction === - "unsettle" ? ( - - - } - > - - - Un-settle thread - - ) : ( - - )} - - )} - {props.sweepAction !== null ? dragDestination : null} - {props.jumpLabel ? : null} - - } - /> - {tabList} -
  • - ); - } - const diff = latestRunDiff(thread); - return ( -
  • - - {accessibleTitle} - {/* Sized by content (78px with the title) so the title line can - animate away when the group's tabs open. */} -
    -
    - {draftIndicator} - {props.project ? ( - - ) : null} - {props.projectDisplayName ? ( - - {props.projectDisplayName} - - ) : ( - - )} - {agentIndicator} - {/* Status at rest, hover actions once they fit. The pin is the - trailing item, outside that swap, so it cannot slide sideways. */} - {sortable?.isDragging ? ( - <> - {dragDestination} - {pinIndicator} - - ) : ( - - - {/* Read-only status labels yield to the hover actions. Woke is - itself an action, so it stays pointer-enabled and visible - while the other controls appear beside it. */} - - {headerStatus ? ( - isWokeStatus ? ( - - - - {headerStatus.label} - - } - /> - Dismiss Woke notification - - ) : ( - - - {/* The label alone is the live region: a role="status" - wrapper around the ticking duration would make - screen readers announce every second. */} - {headerStatus.label} - {status === "working" ? ( - - - - ) : status === "waiting" ? ( - - - - ) : null} - - ) - ) : unifyTabs ? null : variantAction === "unsnooze" && - props.snoozeWakeLabelText !== null ? ( - props.snoozeWakeLabelText - ) : variantAction === "unsettle" ? ( - settledTimeLabel(thread) - ) : ( - threadTimeLabel(thread) - )} - - {showHoverActions || onNewTab || showUnpinnedPinAction ? ( - - {hasUnsentDraft ? ( - - - } - > - - - Discard draft - - ) : null} - {onNewTab ? ( - - - } - > - - - New tab - - ) : null} - {showSnoozeButton ? ( - - ) : null} - {canOperateThread && rowAction.supported ? ( - - - } - > - - {rowAction.text} - - {rowAction.label} - - ) : null} - {showUnpinnedPinAction ? ( - - - } - > - - - Pin thread - - ) : null} - - ) : null} - - {pinIndicator} - - )} - {props.sweepAction !== null ? dragDestination : null} -
    - {/* An open tab list starts with this thread, so its title moves there. */} - {props.tabs != null ? ( - -
    {title}
    -
    - ) : ( -
    {title}
    - )} - {isRegeneratingTitle ? ( - - Regenerating title - - ) : null} -
    - {/* Always the branch. The plan step used to take this slot while - working, but it truncated to a half-sentence and dropped the - branch, so the row lost its most stable identifier. */} - {thread.branch ? ( - <> - - - - - - ) : showsScratchMachine ? ( - <> - - - {props.scratchMachineLabel} - - - ) : ( - - )} - {tabCountBadge} - {props.inSplit && !unifyTabs ? : null} - {unifyTabs ? null : terminalStatusIcon} - {savedDraftsIndicator} - {prBadge} - {diff ? ( - - - +{formatDiffCount(diff.insertions)} - {" "} - - −{formatDiffCount(diff.deletions)} - - - ) : null} - - {isRemote && !showsScratchMachine ? ( - - - - ) : null} - {unifyTabs ? null : ( - - )} - -
    -
    - {unifyTabs || !props.jumpLabel ? null : } - - } - /> - {tabList} -
  • - ); -}); -/** Inline title editor, mounted only while a row is being renamed. */ -function SidebarRenameInput(props: { - threadRef: ScopedThreadRef; - originalTitle: string; - value: string; - onChange: (title: string) => void; - onCommit: (threadRef: ScopedThreadRef, title: string, originalTitle: string) => void; - onCancel: () => void; -}) { - // Enter and Escape unmount the field, which blurs it; the blur must not commit a second time. - const finishedRef = useRef(false); - return ( - props.onChange(event.target.value)} - onFocus={(event) => event.currentTarget.select()} - onKeyDown={(event) => { - event.stopPropagation(); - if (event.nativeEvent.isComposing || event.keyCode === 229) return; - if (event.key === "Enter") { - event.preventDefault(); - finishedRef.current = true; - props.onCommit(props.threadRef, props.value, props.originalTitle); - } else if (event.key === "Escape") { - event.preventDefault(); - finishedRef.current = true; - props.onCancel(); - } - }} - onBlur={() => { - if (!finishedRef.current) props.onCommit(props.threadRef, props.value, props.originalTitle); - }} - onClick={(event) => event.stopPropagation()} - onDoubleClick={(event) => event.stopPropagation()} - className="min-w-0 flex-1 rounded-sm border border-input bg-card px-1 text-sm font-medium text-card-foreground outline-none focus:border-foreground" - /> - ); -} -/** - * The group's tab count. With `onToggle`, it opens and folds that group's tabs. While folded, - * hovering it shows `preview`, a list to switch tabs from without opening the group. - */ -function SidebarTabCountBadge(props: { - count: number; - toggleCount: number; - open: boolean; - onToggle?: ((event: ReactMouseEvent) => void) | undefined; - preview?: ReactNode; - onPreviewOpenChange?: ((open: boolean) => void) | undefined; -}) { - const { onPreviewOpenChange } = props; - const [previewOpen, setPreviewOpenState] = useState(false); - const setPreviewOpen = (open: boolean) => { - setPreviewOpenState(open); - onPreviewOpenChange?.(open); - }; - const className = - "inline-flex shrink-0 items-center gap-0.5 text-xs tabular-nums text-muted-foreground/70"; - if (!props.onToggle) { - return props.open ? null : ( - - - {props.count} - tabs - - ); - } - const label = sidebarTabToggleLabel(props.open, props.toggleCount); - const button = ( - - } - /> - - ); -}); export default function Sidebar() { const projects = useProjects(); const threads = useQuietThreadShells(); diff --git a/apps/web/src/components/sidebar/SidebarDraftBlock.tsx b/apps/web/src/components/sidebar/SidebarDraftBlock.tsx index dcb436fc03028..6c99be08c332b 100644 --- a/apps/web/src/components/sidebar/SidebarDraftBlock.tsx +++ b/apps/web/src/components/sidebar/SidebarDraftBlock.tsx @@ -1,4 +1,6 @@ import { discardComposerDraft } from "../../lib/discardComposerDraft"; +import { assistantCitationsToLabels } from "@t3tools/shared/assistantCitations"; +import { composerFileMentionsToLabels } from "@t3tools/shared/composerInlineTokens"; import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences"; import { SquarePenIcon, XIcon } from "lucide-react"; import { @@ -7,8 +9,8 @@ import { type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, } from "react"; -import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { cn } from "~/lib/utils"; +import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { resolveSidebarRowAccessibility } from "../Sidebar.logic"; import { ProjectFavicon, type ProjectFaviconProject } from "../ProjectFavicon"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; @@ -19,7 +21,7 @@ import { type ComposerThreadDraftState, type DraftSessionState, } from "../../composerDraftStore"; -import { draftPenClassName, draftSurfaceClassName } from "./SidebarThreadRow"; +import { draftSurfaceClassName, draftPenClassName } from "./SidebarThreadRow"; const SidebarDraftRow = memo(function SidebarDraftRow(props: { draftId: DraftId; @@ -29,11 +31,21 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { isActive: boolean; onNavigate: (draftId: DraftId) => void; onDiscard: (draftId: DraftId) => void; - onContextMenu: (draftId: DraftId, position: { x: number; y: number }) => void; + onContextMenu: ( + draftId: DraftId, + position: { + x: number; + y: number; + }, + ) => void; }) { const { composer, draftId, onContextMenu, onDiscard, onNavigate } = props; const promptPreview = - replaceComposerContextReferences(composer.prompt, (occurrence) => occurrence.label) + composerFileMentionsToLabels( + assistantCitationsToLabels( + replaceComposerContextReferences(composer.prompt, (occurrence) => occurrence.label), + ), + ) .trim() .split("\n", 1)[0] ?? ""; // images mirrors persistedAttachments once rehydration finishes; before @@ -130,20 +142,24 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { ); }); - interface SidebarDraftRowData { draftId: DraftId; session: DraftSessionState; composer: ComposerThreadDraftState; } - export const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { projectByKey: ReadonlyMap; projectDisplayNameByKey: ReadonlyMap; scopedProjectKeys: ReadonlySet | null; routeDraftId: string | null; onNavigateToDraft: (draftId: DraftId) => void; - onDraftContextMenu: (draftId: DraftId, position: { x: number; y: number }) => void; + onDraftContextMenu: ( + draftId: DraftId, + position: { + x: number; + y: number; + }, + ) => void; }) { const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey); const draftsByThreadKey = useComposerDraftStore((store) => store.draftsByThreadKey); diff --git a/apps/web/src/components/sidebar/SidebarListMarkers.tsx b/apps/web/src/components/sidebar/SidebarListMarkers.tsx index 0bfee0f6bfa86..540bbab137eda 100644 --- a/apps/web/src/components/sidebar/SidebarListMarkers.tsx +++ b/apps/web/src/components/sidebar/SidebarListMarkers.tsx @@ -10,14 +10,16 @@ import { } from "../Sidebar.logic"; import { SIDEBAR_SORTABLE_DRAG_DISABLED } from "./SidebarThreadRow"; -function SortableSidebarMarker(props: { +export function SortableSidebarMarker(props: { marker: SidebarListMarker; + /** Overrides the marker's id when several items share one marker kind. */ + id?: string; className?: string; children?: ReactNode; "data-testid"?: string; }) { const { setNodeRef, transform, transition } = useSortable({ - id: sidebarMarkerId(props.marker), + id: props.id ?? sidebarMarkerId(props.marker), disabled: SIDEBAR_SORTABLE_DRAG_DISABLED, animateLayoutChanges: animateSidebarLayoutChanges, }); @@ -37,7 +39,6 @@ function SortableSidebarMarker(props: { ); } - export function SidebarSectionPlaceholder(props: { marker: "active-placeholder" | "settled-placeholder"; label: string; @@ -63,7 +64,6 @@ export function SidebarSectionPlaceholder(props: { ); } - export function SidebarDragBoundary(props: { marker: "pinned-header" | "pinned-divider"; label: string; @@ -98,22 +98,16 @@ export function SidebarDragBoundary(props: { ); } - +// The Working shelf header stays visible and keeps its measured height while dragging. export function SidebarSectionHeader(props: { - marker: "working-header" | "snoozed-header" | "settled-header"; + marker: "working-header"; label: string; className?: string; - dragging?: boolean; - isDropTarget?: boolean; - toggle: { expanded: boolean; onToggle: () => void }; + toggle: { + expanded: boolean; + onToggle: () => void; + }; }) { - const shelf = - props.marker === "working-header" - ? "working" - : props.marker === "snoozed-header" - ? "snoozed" - : "settled"; - const snoozed = shelf === "snoozed"; return ( {props.label} diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index bff3c2c78aeb3..866e48202c93a 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -1,6 +1,22 @@ -import { ThreadHoverCard, ThreadHoverCardPopup } from "../ThreadHoverCard"; -import { ThreadTerminalStatusButton } from "../ThreadStatusIndicators"; +import { + type EnvironmentId, + AuthOrchestrationOperateScope, + type EnvironmentMachineKind, + type ScopedThreadRef, +} from "@t3tools/contracts"; +import { useThreadShell } from "../../state/entities"; +import { ContextPreviewHover, type ContextPreviewPlacement } from "../contextPreviewRegistry"; +import { + ThreadTerminalStatusButton, + ThreadPullRequestBadgeControl, + ThreadWorktreeIndicator, + prStatusIndicator, + resolveThreadPullRequestBadge, + useLinkedThreadPullRequest, + ThreadSearchPullRequestNumber, +} from "../ThreadStatusIndicators"; import { discardComposerDraft } from "../../lib/discardComposerDraft"; +import { useThreadMessageDrafts } from "../../state/messageDrafts"; import { requestCustomSnooze } from "../CustomSnoozeDialog"; import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests"; @@ -14,41 +30,32 @@ import { import { formatDiffCount } from "@t3tools/client-runtime/diff-count"; import { resolveSettledThreadTimestamp } from "@t3tools/client-runtime/state/thread-sort"; import { type EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; -import { - formatWorkingDurationLabel, - resolveThreadProviderStack, -} from "@t3tools/client-runtime/state/models"; +import { resolveThreadProviderStack } from "@t3tools/client-runtime/state/models"; import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment"; -import { - AuthOrchestrationOperateScope, - type EnvironmentMachineKind, - type ScopedThreadRef, -} from "@t3tools/contracts"; - -import type { TimestampFormat } from "@t3tools/contracts/settings"; +import { type TimestampFormat } from "@t3tools/contracts/settings"; import { AlarmClockIcon, AlarmClockOffIcon, - ArrowRightLeftIcon, + EyeIcon, + BotIcon, CheckIcon, - CircleAlertIcon, + ChevronDownIcon, CircleCheckIcon, - CircleDashedIcon, - CircleDotDashedIcon, ClockIcon, - GitBranchIcon, - MessageCircleQuestionIcon, + Columns2Icon, + FileTextIcon, + LayersIcon, PinIcon, PinOffIcon, - ShieldQuestionIcon, + PlusIcon, SquarePenIcon, - TerminalIcon, Undo2Icon, XIcon, } from "lucide-react"; import { memo, useEffect, + useLayoutEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, @@ -56,80 +63,63 @@ import { type PointerEvent as ReactPointerEvent, type ReactNode, } from "react"; - +import { flushSync } from "react-dom"; import { useRightPanelStore } from "../../rightPanelStore"; +import { cn } from "~/lib/utils"; +import { textDirection } from "~/textDirection"; import { useOpenPrLink } from "../../lib/openPullRequestLink"; import { useUiStateStore } from "../../uiStateStore"; import { useThreadSelectionStore } from "../../threadSelectionStore"; import { useNowMinute } from "../../hooks/useNowMinute"; import { useNowSecond } from "../../hooks/useNowSecond"; -import { vcsEnvironment } from "../../state/vcs"; -import { useEnvironmentQuery } from "../../state/query"; +import { type ProviderEntrySummary } from "../../state/providerEntries"; import { readEnvironmentScope, useEnvironmentScope } from "../../state/session"; import { formatRelativeTimeLabel, parseTimestampDate } from "../../timestampFormat"; +import { resolveSidebarTopStatus, SidebarTopStatusIcon } from "./SidebarTopStatus"; +import { compactSidebarTimeLabel, threadTimeLabel } from "./SidebarTabSummary"; import type { SidebarThreadSummary } from "../../types"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; -import { cn } from "~/lib/utils"; -import { textDirection } from "~/textDirection"; import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; import { animateSidebarLayoutChanges, + formatWorkingDurationLabel, hasUnseenCompletion, + sidebarTabToggleLabel, resolveSidebarRowAccessibility, type SidebarDropVerb, resolveSidebarThreadStatus, resolveThreadLastVisitedAt, shouldRecedeSidebarThread, resolveWorkingStartedAt, - useRetainedValue, useSidebarRowSubscriptionLease, } from "../Sidebar.logic"; -import { resolveLocalCheckoutBranchMismatch } from "../BranchToolbar.logic"; -import { - ThreadPullRequestBadgeControl, - ThreadPullRequestsMiniList, - ThreadWorktreeIndicator, - prStatusIndicator, - resolveThreadPullRequestBadge, - terminalStatusFromRunningIds, - type TerminalStatusIndicator, - useLinkedThreadPullRequest, - ThreadSearchPullRequestNumber, -} from "../ThreadStatusIndicators"; +import { animateSidebarDisclosure } from "../Sidebar.motion"; import { resolveSnoozePresets, type SnoozePreset } from "../Sidebar.snooze"; -import { useThreadShell } from "../../state/entities"; -import { ProjectFavicon, type ProjectFaviconProject } from "../ProjectFavicon"; +import { ProjectFavicon } from "../ProjectFavicon"; import { ThreadSearchMatchExcerpt } from "../ThreadSearchMatch"; import { makeWorkspaceFileDropHandlers } from "../chat/workspaceFileDrop"; import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; -import { getTriggerDisplayModelLabel } from "../chat/providerIconUtils"; -import { shouldShowInstanceBadge, type ProviderInstanceEntry } from "../../providerInstances"; +import { shouldShowInstanceBadge } from "../../providerInstances"; import { useThreadRunningTerminalIds } from "../../state/terminalSessions"; import { InlineButton } from "../ui/button"; import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuShortcut, MenuTrigger } from "../ui/menu"; +import { PreviewCard, PreviewCardPopup, PreviewCardTrigger } from "../ui/preview-card"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { MiddleTruncate } from "../ui/middle-truncate"; -import { useThreadHasUnsentDraft } from "../../composerDraftStore"; +import { + useComposerDraftActiveModelSelection, + useThreadHasUnsentDraft, +} from "../../composerDraftStore"; -// The fields thread rows show. Provider status pushes refresh `checkedAt`, -// status and auth on every provider; leaving those out keeps each -// environment's map, and every memoized row, stable across them. -export type SidebarProviderEntry = Pick< - ProviderInstanceEntry, - | "instanceId" - | "driverKind" - | "enabled" - | "displayName" - | "accentColor" - | "acpRegistryAgentId" - | "acpRegistryIconUrl" - | "models" ->; -function compactSidebarTimeLabel(label: string): string { - if (label === "just now") return "now"; - return label.endsWith(" ago") ? label.slice(0, -4) : label; +export const SIDEBAR_THREAD_PREVIEW_PLACEMENT: ContextPreviewPlacement = { + side: "right", + align: "start", + sideOffset: 4, +}; +function settledTimeLabel(thread: SidebarThreadSummary): string { + const timestamp = resolveSettledThreadTimestamp(thread); + return timestamp === null ? "" : compactSidebarTimeLabel(formatRelativeTimeLabel(timestamp)); } - // Subscribes to the minute tick itself, so only the label re-renders each // minute. The minute is passed as the clock so it is the formatter's input. function RelativeTimeLabel(props: { timestamp: string | null }) { @@ -140,12 +130,10 @@ function RelativeTimeLabel(props: { timestamp: string | null }) { formatRelativeTimeLabel(props.timestamp, Date.parse(`${nowMinute}:00.000Z`)), ); } - function threadTimeTimestamp(thread: SidebarThreadSummary): string { return thread.latestUserMessageAt ?? thread.updatedAt; } - -function JumpHintBadge(props: { label: string }) { +export function JumpHintBadge(props: { label: string }) { return ( ); } - function WorkingDuration(props: { startedAt: string | null }) { const startedMs = props.startedAt !== null ? Date.parse(props.startedAt) : Number.NaN; if (Number.isNaN(startedMs)) return null; return ; } - // Self-ticking so only this span re-renders each second, not the whole row; // the shared tick lands every working row's update in the same frame. function WorkingDurationLabel(props: { startedMs: number }) { @@ -170,16 +156,24 @@ function WorkingDurationLabel(props: { startedMs: number }) { {formatWorkingDurationLabel(nowMs - props.startedMs)} ); } - -function terminalProcessLabel(count: number): string { +export function terminalProcessLabel(count: number): string { return `${count} terminal ${count === 1 ? "process" : "processes"} running`; } - function SidebarProviderStack(props: { thread: SidebarThreadSummary; - providerEntryByInstanceId: ReadonlyMap; + /** An unsent composer pick, which becomes the current provider before the next turn. */ + draftModelSelection?: ReturnType; + providerEntryByInstanceId: ReadonlyMap; }) { - const stack = resolveThreadProviderStack(props.thread); + const stack = resolveThreadProviderStack( + props.draftModelSelection + ? { + ...props.thread, + runtime: null, + modelSelection: props.draftModelSelection, + } + : props.thread, + ); const currentInstanceId = stack[stack.length - 1]!; const currentEntry = props.providerEntryByInstanceId.get(currentInstanceId) ?? null; if (currentEntry === null) return null; @@ -222,146 +216,6 @@ function SidebarProviderStack(props: { ); } - -function SidebarThreadTooltip({ - thread, - project, - projectDisplayName, - environmentLabel, - environmentMachine, - providerEntry, - providerEntryByInstanceId, - showInstanceBadge, - modelInstanceId, - modelLabel, - branchMismatch, - terminalStatus, - terminalProcessCount, -}: { - thread: SidebarThreadSummary; - project: ProjectFaviconProject | null; - projectDisplayName: string | null; - environmentLabel: string | null; - environmentMachine: EnvironmentMachineKind; - providerEntry: SidebarProviderEntry | null; - providerEntryByInstanceId: ReadonlyMap; - showInstanceBadge: boolean; - modelInstanceId: string; - modelLabel: string; - branchMismatch: { - threadBranch: string; - currentBranch: string; - } | null; - terminalStatus: TerminalStatusIndicator | null; - terminalProcessCount: number; -}) { - const driverKind = providerEntry?.driverKind ?? null; - const previousProviderNames = thread.providerInstanceHistory - .filter((instanceId) => instanceId !== modelInstanceId) - .map((instanceId) => providerEntryByInstanceId.get(instanceId)?.displayName ?? instanceId); - const supportsMultiplePullRequests = useSupportsMultiplePullRequests(thread.environmentId); - return ( - - 0 ? ( -
    - -
    - ) : null - } - > - {projectDisplayName ? ( -
    - {project ? : null} -
    {projectDisplayName}
    -
    - ) : null} - {environmentLabel ? ( -
    - -
    {environmentLabel}
    -
    - ) : null} - {thread.branch ? ( -
    - - -
    - ) : null} - {branchMismatch ? ( -
    - -
    - You're currently checked out on another branch. -
    -
    - ) : null} - {driverKind ? ( -
    - -
    - {showInstanceBadge && providerEntry - ? `${modelLabel} · ${providerEntry.displayName}` - : modelLabel} -
    -
    - ) : null} - {previousProviderNames.length > 0 ? ( -
    - -
    - Handed off from {previousProviderNames.join(", ")} -
    -
    - ) : null} - {terminalStatus ? ( -
    - -
    - {terminalProcessLabel(terminalProcessCount)} -
    -
    - ) : null} - {thread.runtime?.lastError ? ( -
    - -
    - {thread.runtime.lastErrorClass === "usage_limit" - ? "Usage limit reached" - : "Error occurred"} -
    -
    - ) : null} -
    -
    - ); -} - function SnoozeMenuButton(props: { open: boolean; onOpenChange: (open: boolean) => void; @@ -419,23 +273,40 @@ function SnoozeMenuButton(props: { ); } - -type SortableThreadRowBag = Pick< +export type SortableThreadRowBag = Pick< ReturnType, "listeners" | "setNodeRef" | "transform" | "transition" | "isDragging" >; - -export const draftSurfaceClassName = "bg-warning/4 hover:bg-warning/8"; -export const draftPenClassName = "size-3 shrink-0 text-warning-foreground"; - -// useSortable memoizes on its `disabled` object, so a fresh one per render -// re-renders every row and marker on each sidebar update. +// dnd-kit keys memoization on the disabled object identity. export const SIDEBAR_SORTABLE_DRAG_DISABLED = { draggable: true } as const; const SIDEBAR_SORTABLE_DRAG_ENABLED = { draggable: false } as const; - -// Takes the row's props rather than a render function: a closure from the -// sidebar body is new on every sidebar render, which rebuilt the bag and -// re-rendered every memoized row with it. +export function SortableTabThreadRow(props: { + id: string; + disabled: boolean; + contextDrag: boolean; + environmentId: EnvironmentId; + children: (bag: SortableThreadRowBag) => ReactNode; +}) { + const canOperateThread = useEnvironmentScope(props.environmentId, AuthOrchestrationOperateScope); + const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: props.id, + disabled: + props.disabled || !canOperateThread + ? SIDEBAR_SORTABLE_DRAG_DISABLED + : SIDEBAR_SORTABLE_DRAG_ENABLED, + animateLayoutChanges: animateSidebarLayoutChanges, + }); + // dnd-kit memoizes each field but not the bag, so the memoized row would + // rerender on every shell update without this. + const bag = { + listeners, + setNodeRef, + transform: props.contextDrag ? null : transform, + transition: props.contextDrag && isDragging ? "transform 150ms ease-out" : transition, + isDragging: isDragging && !props.contextDrag, + }; + return props.children(bag); +} export function SortableThreadRow( props: Omit & { sortableId: string; @@ -466,14 +337,30 @@ export function SortableThreadRow( isDragging: isDragging && !contextDrag, }; // The sidebar arranges rows from quiet shells, which hold still while a - // thread streams; reading the live shell here re-renders only this row. + // thread streams; reading the live shells here re-renders only this row. const thread = useThreadShell(scopeThreadRef(rowProps.thread.environmentId, rowProps.thread.id)) ?? rowProps.thread; + const displayThread = + useThreadShell( + scopeThreadRef(rowProps.displayThread.environmentId, rowProps.displayThread.id), + ) ?? rowProps.displayThread; // Keyed by variant so a card/slim switch remounts the row. - return ; + return ( + + ); } +export const draftSurfaceClassName = "bg-warning/4 hover:bg-warning/8"; +export const draftPenClassName = "size-3 shrink-0 text-warning-foreground"; export type SidebarSweepAction = "settle" | "unsettle" | "unsnooze"; +// Unhide is a row action but not a sweep: hidden rows are few and deliberate. +type SidebarRowAction = SidebarSweepAction | "unhide"; const dropVerbBadge: Record = { pin: ( <> @@ -506,11 +393,26 @@ const dropVerbBadge: Record = { ), }; - +/** Marks a row whose chat is one side of the split view on screen. */ +export function SidebarSplitIndicator() { + return ( + + + + ); +} type SidebarThreadRowProps = { thread: SidebarThreadSummary; + /** The tab the hidden-tabs row opens; the group thread still owns ordering and selection. */ + displayThread: SidebarThreadSummary; + tabGroupName?: string | undefined; variant: "card" | "slim"; - variantAction: SidebarSweepAction; + // The category determines the action independently of the row layout. + variantAction: SidebarRowAction; settlementSupported: boolean; snoozeSupported: boolean; pinningSupported: boolean; @@ -522,9 +424,12 @@ type SidebarThreadRowProps = { snoozeWakeLabelText: string | null; wokeAt: string | null; isActive: boolean; - // Unlike isActive, never true for the ancestor standing in for an open - // subagent: drafts and PR clicks care about which composer is on screen. + // One side of the split view on screen: the routed chat or the one beside it. + inSplit: boolean; isRouteThread: boolean; + // A sibling tab is the open route. The header stays present, but the active + // pill belongs to that tab row. + groupFocused?: boolean; openPullRequestsInRightPanel: boolean; jumpLabel: string | null; currentEnvironmentId: string | null; @@ -533,17 +438,28 @@ type SidebarThreadRowProps = { scratchMachineLabel: string | null; project: EnvironmentProject | null; projectDisplayName: string | null; - providerEntryByInstanceId: ReadonlyMap; + providerEntryByInstanceId: ReadonlyMap; timestampFormat: TimestampFormat; - onThreadClick: (event: ReactMouseEvent, threadRef: ScopedThreadRef) => void; - onThreadActivate: (threadRef: ScopedThreadRef) => void; + onThreadClick: ( + event: ReactMouseEvent, + threadRef: ScopedThreadRef, + keepOpenGroupTab?: boolean, + ) => void; + onThreadActivate: (threadRef: ScopedThreadRef, keepOpenGroupTab?: boolean) => void; onStartRename: (threadRef: ScopedThreadRef, title: string) => void; + onNameTabGroup: ((threadRef: ScopedThreadRef) => void) | undefined; onRenameTitleChange: (title: string) => void; onCommitRename: (threadRef: ScopedThreadRef, title: string, originalTitle: string) => void; onCancelRename: () => void; isRenaming: boolean; renamingTitle: string; - onContextMenu: (threadRef: ScopedThreadRef, position: { x: number; y: number }) => void; + onContextMenu: ( + threadRef: ScopedThreadRef, + position: { + x: number; + y: number; + }, + ) => void; onSettle: (threadRef: ScopedThreadRef) => void; onActionSweepStart: ( threadRef: ScopedThreadRef, @@ -553,11 +469,27 @@ type SidebarThreadRowProps = { onUnsettle: (threadRef: ScopedThreadRef) => void; onSnooze: (threadRef: ScopedThreadRef, preset: Pick) => void; onUnsnooze: (threadRef: ScopedThreadRef) => void; + onUnhide: (threadRef: ScopedThreadRef) => void; onUnpin: (threadRef: ScopedThreadRef) => void; + onPin: (threadRef: ScopedThreadRef) => void; onAcknowledgeWoke: (threadRef: ScopedThreadRef, visitedAt: string) => void; onFileDropThreads?: ((threadRef: ScopedThreadRef, files: File[]) => void) | undefined; + /** Adds a chat tab to this row's group. Absent where the environment has no tabs. */ + onNewTab?: ((threadRef: ScopedThreadRef) => void) | undefined; + /** Tabs in this row's group; the badge shows only above one. */ + tabCount: number; + /** Tabs the count control hides or reveals, after the show-up-to limit. */ + tabToggleCount: number; + /** The group's tabs. Mounted under the row while `tabsOpen`, and while it animates closed. */ + tabs: ReactNode; + tabsOpen: boolean; + /** Lists the group's tabs when hovering the folded count badge, so one opens in place. */ + tabPreview?: ReactNode; + /** Opens or folds this group's tabs. Absent where the row has no tabs. */ + onToggleTabs?: ((rowThreadKey: string) => void) | undefined; + /** A tab list finished opening or closing, so rows below it have moved. */ + onTabsResized?: (() => void) | undefined; }; - const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { const { isRenaming, @@ -566,6 +498,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP onContextMenu, onAcknowledgeWoke, onFileDropThreads, + onNewTab, onRenameTitleChange, onSettle, onActionSweepStart, @@ -575,14 +508,18 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP onThreadClick, onUnsettle, onUnsnooze, + onUnhide, onUnpin, + onPin, openPullRequestsInRightPanel, renamingTitle, - thread, + displayThread: thread, variant, variantAction, } = props; const threadRef = scopeThreadRef(thread.environmentId, thread.id); + const rowThreadRef = scopeThreadRef(props.thread.environmentId, props.thread.id); + const rowThreadKey = scopedThreadKey(rowThreadRef); const threadKey = scopedThreadKey(threadRef); const canOperateThread = useEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope); useEffect(() => { @@ -592,22 +529,27 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP const isRegeneratingTitle = thread.titleRegeneration != null; const localLastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); const lastVisitedAt = resolveThreadLastVisitedAt(thread.lastVisitedAt, localLastVisitedAt); - const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); + // Woke belongs to the group, so it reads the row thread's visit, not the shown tab's. + const localRowLastVisitedAt = useUiStateStore( + (state) => state.threadLastVisitedAtById[rowThreadKey], + ); + const rowLastVisitedAt = resolveThreadLastVisitedAt( + props.thread.lastVisitedAt, + localRowLastVisitedAt, + ); + const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(rowThreadKey)); const openPrLink = useOpenPrLink(); const runningTerminalIds = useThreadRunningTerminalIds({ environmentId: thread.environmentId, threadId: thread.id, }); - const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); - const terminalProcessCount = runningTerminalIds.length; const hasUnsentDraft = useThreadHasUnsentDraft(threadRef) && !props.isRouteThread; + const savedDraftCount = useThreadMessageDrafts(threadRef).length; const handleDiscardDraftClick = (event: ReactMouseEvent) => { event.preventDefault(); event.stopPropagation(); discardComposerDraft(threadRef); }; - - const gitCwd = thread.worktreePath ?? props.project?.workspaceRoot ?? null; const linkedPullRequestStatus = useLinkedThreadPullRequest( thread.environmentId, thread.linkedPullRequest, @@ -615,140 +557,48 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP thread.pullRequests, thread.branchPullRequest, ); - const gitStatus = useEnvironmentQuery( - leaseLiveStatus && (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null - ? vcsEnvironment.status({ - environmentId: thread.environmentId, - input: { cwd: gitCwd, includeRemote: false }, - }) - : null, - ); - const visibleGitStatus = useRetainedValue( - JSON.stringify([thread.environmentId, gitCwd]), - gitStatus.data, - ); const pr = linkedPullRequestStatus?.pr ?? null; const supportsMultiplePullRequests = useSupportsMultiplePullRequests(thread.environmentId); const currentLinkedPr = supportsMultiplePullRequests ? resolveThreadCurrentPullRequestLink(thread.pullRequests) : null; - const isUnread = hasUnseenCompletion({ ...thread, lastVisitedAt }); const status = resolveSidebarThreadStatus(thread); - const lastVisitedDate = lastVisitedAt === undefined ? null : parseTimestampDate(lastVisitedAt); + const lastVisitedDate = + rowLastVisitedAt === undefined ? null : parseTimestampDate(rowLastVisitedAt); const wokeAtDate = props.wokeAt === null ? null : parseTimestampDate(props.wokeAt); const isWoke = wokeAtDate !== null && (lastVisitedDate === null || lastVisitedDate < wokeAtDate) && - thread.settledOverride !== "settled"; + props.thread.settledOverride !== "settled"; const shouldRecede = shouldRecedeSidebarThread({ status, isUnread, isWoke, - isActive: props.isActive, + isActive: props.isActive || props.groupFocused === true, isSelected, }); + const resolvedTopStatus = resolveSidebarTopStatus(status, isWoke, isUnread); + // A native /goal keeps the agent going across turns until it is met. const topStatus = - status === "working" - ? { - label: thread.goal?.status === "active" ? "Goal" : "Working", - icon: "working" as const, - className: "text-info", - } - : status === "waiting" - ? { - label: "Waiting", - icon: "waiting" as const, - className: "text-muted-foreground", - } - : status === "approval" - ? { - label: "Approval", - icon: "approval" as const, - className: "text-warning-foreground", - } - : status === "input" - ? { - label: "Input", - icon: "input" as const, - className: "text-indigo-600 dark:text-indigo-300", - } - : status === "limited" - ? { - label: "Limited", - icon: "failed" as const, - className: "text-warning", - } - : status === "failed" - ? { - label: "Failed", - icon: "failed" as const, - className: "text-error", - } - : isWoke - ? { - label: "Woke", - icon: "woke" as const, - className: "text-warning", - } - : isUnread - ? { - label: "Done", - icon: "done" as const, - className: "text-success", - } - : null; + resolvedTopStatus !== null && status === "working" && thread.goal?.status === "active" + ? { ...resolvedTopStatus, label: "Goal" } + : resolvedTopStatus; const isWokeStatus = topStatus?.icon === "woke"; - - const branchMismatch = resolveLocalCheckoutBranchMismatch({ - effectiveEnvMode: thread.worktreePath === null ? "local" : "worktree", - activeWorktreePath: thread.worktreePath, - activeThreadBranch: thread.branch, - currentGitBranch: visibleGitStatus?.refName ?? null, - }); const prStatus = prStatusIndicator(pr, linkedPullRequestStatus?.sourceControlProvider); - - const modelInstanceId = thread.runtime?.providerInstanceId ?? thread.modelSelection.instanceId; - const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; - const showInstanceBadge = - providerEntry !== null && - shouldShowInstanceBadge(providerEntry, props.providerEntryByInstanceId.values()); - const selectedModel = providerEntry?.models.find( - (model) => model.slug === thread.modelSelection.model, - ); - const modelLabel = selectedModel - ? getTriggerDisplayModelLabel(selectedModel) - : thread.modelSelection.model; - + // An unsent composer pick wins, so the icon follows the composer before the next turn. + const draftModelSelection = useComposerDraftActiveModelSelection(threadRef); const isRemote = thread.environmentId !== props.currentEnvironmentId; const showsScratchMachine = !thread.branch && props.scratchMachineLabel !== null; - - const detailsTooltip = ( - - ); - const handleClick = (event: ReactMouseEvent) => { - onThreadClick(event, threadRef); + // The group header stands for every tab. A tab already open in it stays open. + onThreadClick(event, rowThreadRef, true); }; const handleAcknowledgeWokeClick = (event: ReactMouseEvent) => { event.preventDefault(); event.stopPropagation(); if (props.wokeAt === null) return; - onAcknowledgeWoke(threadRef, props.wokeAt); + onAcknowledgeWoke(rowThreadRef, props.wokeAt); }; const handleContextMenu = (event: ReactMouseEvent) => { event.preventDefault(); @@ -758,7 +608,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP if (event.target !== event.currentTarget) return; if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); - onThreadActivate(threadRef); + onThreadActivate(rowThreadRef, true); }; const handleDoubleClick = (event: ReactMouseEvent) => { if ( @@ -773,7 +623,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP } if ((event.target as HTMLElement).closest("button, a, input")) return; event.preventDefault(); - onStartRename(threadRef, thread.title); + if (props.onNameTabGroup) props.onNameTabGroup(threadRef); + else onStartRename(threadRef, thread.title); }; const [isFileDragOver, setIsFileDragOver] = useState(false); const fileDropHandlers = onFileDropThreads @@ -791,55 +642,82 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP window.addEventListener("dragend", clearFileDrag); return () => window.removeEventListener("dragend", clearFileDrag); }, [isFileDragOver]); - const renameCommittedRef = useRef(false); - useEffect(() => { - if (isRenaming) renameCommittedRef.current = false; - }, [isRenaming]); - const handleRenameKeyDown = (event: ReactKeyboardEvent) => { + const handleSettleClick = (event: ReactMouseEvent) => { + event.preventDefault(); event.stopPropagation(); - if (event.nativeEvent.isComposing || event.keyCode === 229) return; - if (event.key === "Enter") { - event.preventDefault(); - renameCommittedRef.current = true; - onCommitRename(threadRef, renamingTitle, thread.title); - } else if (event.key === "Escape") { - event.preventDefault(); - renameCommittedRef.current = true; - onCancelRename(); - } + onSettle(rowThreadRef); }; - const handleRenameBlur = () => { - if (!renameCommittedRef.current) { - onCommitRename(threadRef, renamingTitle, thread.title); - } - }; - const handleSettleClick = (event: ReactMouseEvent) => { + const handleNewTabClick = (event: ReactMouseEvent) => { event.preventDefault(); event.stopPropagation(); - onSettle(threadRef); + onNewTab?.(threadRef); }; const handleActionPointerDown = (event: ReactPointerEvent) => { if (!event.isPrimary || event.button !== 0) return; event.stopPropagation(); + if (variantAction === "unhide") return; onActionSweepStart(threadRef, variantAction, event.nativeEvent); }; const handleUnsettleClick = (event: ReactMouseEvent) => { event.preventDefault(); event.stopPropagation(); - onUnsettle(threadRef); + onUnsettle(rowThreadRef); }; const handleUnsnoozeClick = (event: ReactMouseEvent) => { event.preventDefault(); event.stopPropagation(); - onUnsnooze(threadRef); + onUnsnooze(rowThreadRef); + }; + const handleUnhideClick = (event: ReactMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + onUnhide(rowThreadRef); }; + const rowAction = + variantAction === "unhide" + ? { + label: "Unhide thread", + text: "Unhide", + Icon: EyeIcon, + onClick: handleUnhideClick, + supported: true, + } + : variantAction === "unsnooze" + ? { + label: "Wake thread now", + text: "Wake", + Icon: AlarmClockOffIcon, + onClick: handleUnsnoozeClick, + supported: props.snoozeSupported, + } + : variantAction === "unsettle" + ? { + label: "Un-settle thread", + text: "Un-settle", + Icon: Undo2Icon, + onClick: handleUnsettleClick, + supported: props.settlementSupported, + } + : { + label: "Settle thread", + text: "Settle", + Icon: CheckIcon, + onClick: handleSettleClick, + supported: props.settlementSupported, + }; + const RowActionIcon = rowAction.Icon; const handleUnpinClick = (event: ReactMouseEvent) => { event.preventDefault(); event.stopPropagation(); - onUnpin(threadRef); + onUnpin(rowThreadRef); + }; + const handlePinClick = (event: ReactMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + onPin(rowThreadRef); }; const handleSnoozePreset = (preset: Pick) => { - onSnooze(threadRef, preset); + onSnooze(rowThreadRef, preset); }; const [snoozeMenuOpenRaw, setSnoozeMenuOpen] = useState(false); // The React Compiler caches render results on their inputs, so a clock read @@ -872,6 +750,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP canOperateThread && props.snoozeSupported && canSnooze(thread, { now: snoozeGateNow }); // Closing here, not just masking, keeps the menu from reopening when the button returns. if (snoozeMenuOpenRaw && !showSnoozeButton) setSnoozeMenuOpen(false); + const showHoverActions = + (canOperateThread && rowAction.supported) || showSnoozeButton || hasUnsentDraft; const snoozeMenuOpen = snoozeMenuOpenRaw && showSnoozeButton; const handlePrClick = (event: ReactMouseEvent, targetUrl?: string) => { const url = targetUrl ?? pr?.url ?? currentLinkedPr?.url; @@ -882,24 +762,41 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP onThreadActivate(threadRef); } }; - + // An open tab list includes this thread as its first row, so the card is only + // the group header. The active tab carries the highlight. + const unifyTabs = variant === "card" && props.tabsOpen && props.tabs != null; + const rowActive = props.isActive && !unifyTabs; + // The chat beside the routed one in a split gets a lighter version of the active surface. + const rowSplitPartner = props.inSplit && !props.isActive && !unifyTabs; + // Each listed tab shows its own status, so the header would only repeat the + // first tab's and read as the whole group's. Woke belongs to the group. + const headerStatus = unifyTabs && !isWokeStatus ? null : topStatus; const rowSurfaceClassName = cn( "group/sidebar-row relative w-full cursor-pointer overflow-hidden rounded-md text-left outline-none select-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring", variantAction === "unsettle" && "[&:not(:hover):not(:focus-within)_*]:text-secondary-label/70", - props.isActive + rowActive ? "bg-sidebar-row-active text-sidebar-foreground" - : isSelected || props.sweepAction !== null - ? "bg-sidebar-row-selected text-sidebar-foreground" - : hasUnsentDraft - ? cn(draftSurfaceClassName, "text-sidebar-foreground") - : shouldRecede - ? "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground" - : "bg-transparent text-sidebar-foreground hover:bg-sidebar-row-hover", + : rowSplitPartner + ? "bg-sidebar-row-active/50 text-sidebar-foreground" + : isSelected || props.sweepAction !== null + ? "bg-sidebar-row-selected text-sidebar-foreground" + : hasUnsentDraft + ? cn(draftSurfaceClassName, "text-sidebar-foreground") + : shouldRecede + ? "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground" + : "bg-transparent text-sidebar-foreground hover:bg-sidebar-row-hover", + // Background work fades as a whole row, status label included, so it + // takes less attention than rows that need a human (input, approval). shouldRecede && status === "working" && "opacity-70 transition-opacity hover:opacity-100 focus-within:opacity-100 motion-reduce:transition-none", isFileDragOver && "ring-1 ring-inset ring-primary/70", - isFileDragOver && !props.isActive && !isSelected && "bg-sidebar-row-hover", + // The hover tint must not clobber an active/selected row's own surface. + isFileDragOver && !rowActive && !isSelected && "bg-sidebar-row-hover", + // The lifted row is an opaque card so the rows beneath it never show + // through. The row tint is translucent in dark themes and the pointer + // keeps the hover color applied, so both the tint and the solid sidebar + // color are stacked as background images. props.sortable?.isDragging && "bg-sidebar bg-linear-to-b from-sidebar-row-active to-sidebar-row-active text-sidebar-foreground opacity-100 shadow-lg", ); @@ -934,28 +831,22 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP {dropVerbBadge[destinationVerb]} ) : null; - const accessibility = resolveSidebarRowAccessibility({ - title: thread.title, - statusLabel: topStatus?.label ?? null, + title: props.tabGroupName ?? thread.title, + statusLabel: headerStatus?.label ?? null, projectDisplayName: props.projectDisplayName, - isActive: props.isActive, + isActive: rowActive, + startedByAgent: thread.startedBy !== null, }); - const title = isRenaming && canOperateThread ? ( - onRenameTitleChange(event.target.value)} - onFocus={(event) => event.currentTarget.select()} - onKeyDown={handleRenameKeyDown} - onBlur={handleRenameBlur} - onClick={(event) => event.stopPropagation()} - onDoubleClick={(event) => event.stopPropagation()} - className="min-w-0 flex-1 rounded-sm border border-input bg-card px-1 text-sm font-medium text-card-foreground outline-none focus:border-foreground" + onChange={onRenameTitleChange} + onCommit={onCommitRename} + onCancel={onCancelRename} /> ) : ( - {thread.title} + {props.tabGroupName ?? thread.title} ); - const accessibleTitle = isRenaming ? null : {thread.title}; - + const accessibleTitle = isRenaming ? null : ( + {props.tabGroupName ?? thread.title} + ); const prBadgeShape = supportsMultiplePullRequests ? resolveThreadPullRequestBadge(thread.pullRequests) : null; @@ -1037,8 +929,56 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP Unsent draft ) : null; + const savedDraftsLabel = `${savedDraftCount} draft${savedDraftCount === 1 ? "" : "s"}`; + const savedDraftsIndicator = + savedDraftCount > 0 ? ( + + + } + > + + {savedDraftCount} + + {savedDraftsLabel} + + ) : null; + const onToggleTabs = props.onToggleTabs; + const handleToggleTabsClick = (event: ReactMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + onToggleTabs?.(rowThreadKey); + }; + // The row's own tooltip would cover the badge's tab list, so it stays shut while that is open. + const [tabPreviewOpenRaw, setTabPreviewOpen] = useState(false); + const tabPreviewOpen = tabPreviewOpenRaw && !props.tabsOpen && props.tabCount > 1; + const tabCountBadge = + props.tabCount > 1 ? ( + + ) : null; + const tabList = + props.tabs != null ? ( + + {props.tabs} + + ) : null; const showPin = props.isPinned && (!sortable?.isDragging || (props.dragOverPinned && props.dropVerb === null)); + // Unpinned rows offer pin beside the other hover actions. Pinned rows keep + // a trailing marker instead, so the two never show at once. + const showUnpinnedPinAction = props.pinningSupported && !props.isPinned; const pinIndicator = showPin ? ( props.pinningSupported && canOperateThread && !sortable?.isDragging ? ( @@ -1048,10 +988,15 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP type="button" aria-label="Unpin thread" onClick={handleUnpinClick} - className="group/unpin inline-flex cursor-pointer items-center rounded-sm text-muted-foreground/65 outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring" + className={cn( + "group/unpin inline-flex cursor-pointer items-center rounded-sm text-muted-foreground/65 outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring", + variant === "card" && "-mr-1 self-center px-1.5", + )} /> } > + {/* Pin marks the pinned state at rest; hover and focus swap in pin-off so the + icon reads as the action the button performs. */} ) ) : null; - + // An agent started this thread. The row's label speaks it, the hover card + // and chat header say which agent. + const agentIndicator = + thread.startedBy !== null ? ( + + ) : null; if (variant === "slim") { return (
  • - - - } - > - {accessibleTitle} - - {props.project ? : null} - - {draftIndicator} - {title} - {pinIndicator} - {terminalStatusIcon} - {isRegeneratingTitle ? ( - - Regenerating title - - ) : null} - {prBadge} - {sortable?.isDragging ? ( - dragDestination - ) : ( + {accessibleTitle} + {props.project ? ( + + ) : null} + + {draftIndicator} + {title} + {tabCountBadge} + {agentIndicator} + {pinIndicator} + {props.inSplit ? : null} + {terminalStatusIcon} + {savedDraftsIndicator} + {isRegeneratingTitle ? ( + + Regenerating title + + ) : null} + {prBadge} + {sortable?.isDragging ? ( + dragDestination + ) : ( - {variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? ( - - {props.snoozeWakeLabelText} - - ) : isWoke ? ( + + {variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? ( + + {props.snoozeWakeLabelText} + + ) : isWoke ? ( + + + + Woke + + } + /> + Dismiss Woke notification + + ) : ( + + + + )} + + {variantAction === "unhide" ? ( - - Woke - + aria-label="Unhide thread" + onClick={handleUnhideClick} + className={cn( + "pointer-events-none absolute inset-y-0 right-0 -mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-any-hover/sidebar-row:pointer-events-auto group-any-hover/sidebar-row:opacity-100", + isWoke && "group-any-hover/sidebar-row:static", + )} + /> } - /> - Dismiss Woke notification + > + + + Unhide thread - ) : ( - - + + + ) + ) : !canOperateThread || !props.settlementSupported ? null : variantAction === + "unsettle" ? ( + + } - /> - - )} - - {variantAction === "unsnooze" ? ( - !props.snoozeSupported ? null : ( + > + + + Un-settle thread + + ) : ( - ) - ) : !props.settlementSupported ? null : variantAction === "unsettle" ? ( - - - } - > - - - Un-settle thread - - ) : ( - - )} - - )} - {props.sweepAction !== null ? dragDestination : null} - {props.jumpLabel ? : null} - - {detailsTooltip} - + )} + + )} + {props.sweepAction !== null ? dragDestination : null} + {props.jumpLabel ? : null} + + } + /> + {tabList}
  • ); } - const diff = latestRunDiff(thread); - return (
  • - - - } - > - {accessibleTitle} -
    -
    - {draftIndicator} - {props.project ? ( - - ) : null} - {/* Left of the status slot, the pin would slide when hover swaps the - label for the wider row controls. */} - {pinIndicator} - {props.projectDisplayName ? ( - - {props.projectDisplayName} - - ) : ( - - )} - {sortable?.isDragging ? ( - dragDestination - ) : ( - + + {accessibleTitle} + {/* Sized by content (78px with the title) so the title line can + animate away when the group's tabs open. */} +
    +
    + {draftIndicator} + {props.project ? ( + + ) : null} + {props.projectDisplayName ? ( - {topStatus ? ( - isWokeStatus ? ( - - - - {topStatus.label} - - } - /> - Dismiss Woke notification - - ) : ( + {props.projectDisplayName} + + ) : ( + + )} + {agentIndicator} + {/* Status at rest, hover actions once they fit. The pin is the + trailing item, outside that swap, so it cannot slide sideways. */} + {sortable?.isDragging ? ( + <> + {dragDestination} + {pinIndicator} + + ) : ( + + + {/* Read-only status labels yield to the hover actions. Woke is + itself an action, so it stays pointer-enabled and visible + while the other controls appear beside it. */} + + {headerStatus ? ( + isWokeStatus ? ( + + + + {headerStatus.label} + + } + /> + Dismiss Woke notification + + ) : ( + + + {/* The label alone is the live region: a role="status" + wrapper around the ticking duration would make + screen readers announce every second. */} + {headerStatus.label} + {status === "working" ? ( + + + + ) : status === "waiting" ? ( + + + + ) : null} + + ) + ) : unifyTabs ? null : variantAction === "unsnooze" && + props.snoozeWakeLabelText !== null ? ( + props.snoozeWakeLabelText + ) : variantAction === "unsettle" ? ( + settledTimeLabel(thread) + ) : ( + threadTimeLabel(thread) + )} + + {showHoverActions || onNewTab || showUnpinnedPinAction ? ( - {topStatus.icon === "working" ? ( - - ) : topStatus.icon === "waiting" ? ( - - ) : topStatus.icon === "input" ? ( - - ) : topStatus.icon === "approval" ? ( - - ) : topStatus.icon === "failed" ? ( - - ) : topStatus.icon === "done" ? ( - + {hasUnsentDraft ? ( + + + } + > + + + Discard draft + ) : null} - {/* The label alone is the live region: a role="status" - wrapper around the ticking duration would make - screen readers announce every second. */} - {topStatus.label} - {status === "working" ? ( - - - - ) : status === "waiting" ? ( - - - + {onNewTab ? ( + + + } + > + + + New tab + + ) : null} + {showSnoozeButton ? ( + + ) : null} + {canOperateThread && rowAction.supported ? ( + + + } + > + + {rowAction.text} + + {rowAction.label} + + ) : null} + {showUnpinnedPinAction ? ( + + + } + > + + + Pin thread + ) : null} - ) - ) : ( - - )} - - {props.settlementSupported || showSnoozeButton || hasUnsentDraft ? ( - - {hasUnsentDraft ? ( - - - } - > - - - Discard draft - - ) : null} - {showSnoozeButton ? ( - - ) : null} - {props.settlementSupported ? ( - - - } - > - - Settle - - Settle thread - ) : null} - ) : null} - + {pinIndicator} + + )} + {props.sweepAction !== null ? dragDestination : null} +
    + {/* An open tab list starts with this thread, so its title moves there. */} + {props.tabs != null ? ( + +
    {title}
    +
    + ) : ( +
    {title}
    )} - {props.sweepAction !== null ? dragDestination : null} -
    -
    - {title} {isRegeneratingTitle ? ( Regenerating title ) : null} -
    -
    - {thread.branch ? ( - <> - - - - - - ) : showsScratchMachine ? ( - <> - - - {props.scratchMachineLabel} - - - ) : ( - - )} - {terminalStatusIcon} - {prBadge} - {diff ? ( - - - +{formatDiffCount(diff.insertions)} - {" "} - - −{formatDiffCount(diff.deletions)} - - - ) : null} - - {isRemote && !showsScratchMachine ? ( - + {/* Always the branch. The plan step used to take this slot while + working, but it truncated to a half-sentence and dropped the + branch, so the row lost its most stable identifier. */} + {thread.branch ? ( + <> + + + + + + ) : showsScratchMachine ? ( + <> + + {props.scratchMachineLabel} + + + ) : ( + + )} + {tabCountBadge} + {props.inSplit && !unifyTabs ? : null} + {unifyTabs ? null : terminalStatusIcon} + {savedDraftsIndicator} + {prBadge} + {diff ? ( + + + +{formatDiffCount(diff.insertions)} + {" "} + + −{formatDiffCount(diff.deletions)} + ) : null} - - + + {isRemote && !showsScratchMachine ? ( + + + + ) : null} + {unifyTabs ? null : ( + + )} + +
    + {unifyTabs || !props.jumpLabel ? null : }
    - {props.jumpLabel ? : null} -
    - {detailsTooltip} -
    + } + /> + {tabList}
  • ); }); - -function latestRunDiff( - thread: SidebarThreadSummary, -): { insertions: number; deletions: number } | null { +/** Inline title editor, mounted only while a row is being renamed. */ +export function SidebarRenameInput(props: { + threadRef: ScopedThreadRef; + originalTitle: string; + value: string; + onChange: (title: string) => void; + onCommit: (threadRef: ScopedThreadRef, title: string, originalTitle: string) => void; + onCancel: () => void; +}) { + // Enter and Escape unmount the field, which blurs it; the blur must not commit a second time. + const finishedRef = useRef(false); + return ( + props.onChange(event.target.value)} + onFocus={(event) => event.currentTarget.select()} + onKeyDown={(event) => { + event.stopPropagation(); + if (event.nativeEvent.isComposing || event.keyCode === 229) return; + if (event.key === "Enter") { + event.preventDefault(); + finishedRef.current = true; + props.onCommit(props.threadRef, props.value, props.originalTitle); + } else if (event.key === "Escape") { + event.preventDefault(); + finishedRef.current = true; + props.onCancel(); + } + }} + onBlur={() => { + if (!finishedRef.current) props.onCommit(props.threadRef, props.value, props.originalTitle); + }} + onClick={(event) => event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + className="min-w-0 flex-1 rounded-sm border border-input bg-card px-1 text-sm font-medium text-card-foreground outline-none focus:border-foreground" + /> + ); +} +/** + * The group's tab count. With `onToggle`, it opens and folds that group's tabs. While folded, + * hovering it shows `preview`, a list to switch tabs from without opening the group. + */ +function SidebarTabCountBadge(props: { + count: number; + toggleCount: number; + open: boolean; + onToggle?: ((event: ReactMouseEvent) => void) | undefined; + preview?: ReactNode; + onPreviewOpenChange?: ((open: boolean) => void) | undefined; +}) { + const { onPreviewOpenChange } = props; + const [previewOpen, setPreviewOpenState] = useState(false); + const setPreviewOpen = (open: boolean) => { + setPreviewOpenState(open); + onPreviewOpenChange?.(open); + }; + const className = + "inline-flex shrink-0 items-center gap-0.5 text-xs tabular-nums text-muted-foreground/70"; + if (!props.onToggle) { + return props.open ? null : ( + + + {props.count} + tabs + + ); + } + const label = sidebarTabToggleLabel(props.open, props.toggleCount); + const button = ( + + } + /> ); }); diff --git a/docs/fork-differences.md b/docs/fork-differences.md index 7a93203f01374..d985c67643522 100644 --- a/docs/fork-differences.md +++ b/docs/fork-differences.md @@ -552,7 +552,7 @@ editable. Names are stored by the [ThreadTabs service](../apps/server/src/thread it while one of its tabs is already open leaves that tab selected (`threadTabGroupHeaderTarget` in `packages/client-runtime/src/threadTabs.ts`, `apps/web/src/threadTabRecencyStore.ts`). With tabs hidden on web and desktop, the row shows - that tab’s title, model, and details (`apps/web/src/components/Sidebar.tsx`). On web and desktop, **Settings → General → Tabs in + that tab’s title, model, and details (`apps/web/src/components/sidebar/SidebarThreadRow.tsx`). On web and desktop, **Settings → General → Tabs in sidebar**, the sidebar header's tabs button (click to toggle, right-click for a menu; `sidebar/SidebarTabsMenu.tsx`), the command palette, and `Cmd+Option+T` on macOS or `Ctrl+Alt+T` on Windows and Linux list each tab under that row: