From af16601a398d8a799dcfde3fdefafaf6429dff72 Mon Sep 17 00:00:00 2001 From: Adam Higgins Date: Thu, 22 Oct 2026 13:19:20 +0100 Subject: [PATCH] fix(web): the fork's sidebar rows live in upstream's row files again The fork kept its thread row, draft rows and list markers inline in Sidebar.tsx after upstream moved them to sidebar/SidebarThreadRow.tsx, SidebarDraftBlock.tsx and SidebarListMarkers.tsx. Those files went unused, so upstream fixes to them merged cleanly into dead code and never reached the fork. Move the fork's versions into those files, unchanged, and port the two upstream fixes that had landed only there: - Rows read their live thread shell over the sidebar's quiet list, so a streaming thread re-renders only its own row (pingdotgg/t3code#22730). - Thread titles, tab titles and the rename field read right to left for Arabic and Hebrew (pingdotgg/t3code#22777). Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/Sidebar.tsx | 2202 +---------------- .../components/sidebar/SidebarDraftBlock.tsx | 30 +- .../components/sidebar/SidebarListMarkers.tsx | 32 +- .../components/sidebar/SidebarThreadRow.tsx | 1963 ++++++++------- docs/fork-differences.md | 2 +- 5 files changed, 1149 insertions(+), 3080 deletions(-) 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: