);
}
/**
* Hover entry point for snooze: a clock button opening the preset menu.
* Controlled by the row (which also uses the open state to pin its hover
* actions while the menu is up).
*/
function SnoozePopoverButton(props: {
open: boolean;
onOpenChange: (open: boolean) => void;
onSnooze: (preset: SnoozePreset) => void;
timestampFormat: TimestampFormat;
}) {
const { open, onOpenChange, onSnooze, timestampFormat } = props;
// Presets resolve at open time so "In 1 hour" is relative to the click,
// not to when the row mounted.
const presets = useMemo(
() => (open ? resolveSnoozePresets(new Date(), timestampFormat) : []),
[open, 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) => (
))}
);
}
// Subset of useSortable applied to a thread row's root
. Listeners go
// on the whole row (no dedicated handle): the pointer sensor's distance
// constraint keeps plain clicks working, and we skip dnd-kit's aria
// attributes since there is no keyboard sensor and the row body already
// carries its own button semantics.
type SortableThreadRowBag = Pick<
ReturnType,
"listeners" | "setNodeRef" | "transform" | "transition" | "isDragging"
>;
function SortableThreadRow(props: {
id: string;
disabled: boolean;
children: (bag: SortableThreadRowBag) => ReactNode;
}) {
const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: props.id,
disabled: { draggable: props.disabled },
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 = useMemo(
() => ({ listeners, setNodeRef, transform, transition, isDragging }),
[listeners, setNodeRef, transform, transition, isDragging],
);
return props.children(bag);
}
// Unsent work shares one look: the new-thread draft rows and thread rows
// with unsent composer text both use this tint and pen so they read alike.
const draftSurfaceClassName = "bg-amber-400/[0.04] hover:bg-amber-400/[0.08]";
const draftPenClassName = "size-3 shrink-0 text-amber-600 dark:text-amber-300/80";
// Structural list items — the section headers and the
// empty-section placeholders — take part in the sortable list so they shift
// with the rows and the gap can open on either side of them. They can't be
// picked up, and a marker is the sortable `over` when the pointer is on it,
// which resolveSidebarDropTarget turns into the section the gap sits in.
function SortableSidebarMarker(props: {
marker: SidebarListMarker;
className?: string;
children?: ReactNode;
"data-testid"?: string;
}) {
const { setNodeRef, transform, transition } = useSortable({
id: sidebarMarkerId(props.marker),
disabled: { draggable: true },
animateLayoutChanges: animateSidebarLayoutChanges,
});
return (
{props.children}
);
}
// Empty targets stay measurable without reserving space at rest. The sorting
// strategy opens their hint space during a drag.
function SidebarSectionPlaceholder(props: {
marker: "active-placeholder" | "settled-placeholder";
label: string;
showHint: boolean;
isDropTarget: boolean;
}) {
return (
{props.showHint ? (
{props.label}
) : null}
);
}
// Zero-height markers reserve no label space at rest. During a drag the
// sorting strategy opens 24px for a 16px label with 4px clearance on each side.
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}
);
}
// Shelf headers stay visible and keep their measured height while dragging.
function SidebarSectionHeader(props: {
marker: "snoozed-header" | "settled-header";
label: string;
// While dragging, the settled header reads at full strength and takes the
// accent while the lifted row is over it.
dragging?: boolean;
isDropTarget?: boolean;
toggle: { expanded: boolean; onToggle: () => void };
}) {
const snoozed = props.marker === "snoozed-header";
const className = cn(
"flex h-full w-full items-center gap-2 px-2 text-left text-xs font-medium",
snoozed ? "text-blue-600 dark:text-blue-400" : "text-sidebar-muted-foreground/60",
props.dragging && "text-sidebar-foreground/80",
props.isDropTarget && "text-primary",
);
const content = (
<>
{props.label}
>
);
return (
);
}
// One unsent draft session the user has invested content in. Two lines,
// nothing else: project name, then the typed prompt. All the draft's
// settings (model, env mode, branch, worktree) still travel with it —
// clicking is a plain navigation to /draft/$draftId, which touches nothing.
// While the draft is open the row renders a frozen snapshot (see
// SidebarDraftBlock); memoized so per-keystroke block re-renders skip it
// entirely.
const SidebarDraftRow = memo(function SidebarDraftRow(props: {
draftId: DraftId;
session: DraftSessionState;
composer: ComposerThreadDraftState;
project: ProjectFaviconProject | null;
projectDisplayName: string | null;
isActive: boolean;
onNavigate: (draftId: DraftId) => void;
onDiscard: (draftId: DraftId) => void;
}) {
const { composer, draftId, onDiscard, onNavigate, session } = props;
const promptPreview = composer.prompt.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.elementContexts.length +
composer.previewAnnotations.length +
composer.reviewComments.length;
const preview =
promptPreview.length > 0
? promptPreview
: `${attachmentCount} attachment${attachmentCount === 1 ? "" : "s"}`;
const handleActivate = useCallback(() => onNavigate(draftId), [draftId, onNavigate]);
const handleKeyDown = useCallback(
(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 === "Enter" || event.key === " ") {
event.preventDefault();
onNavigate(draftId);
}
},
[draftId, onNavigate],
);
const handleDiscard = useCallback(
(event: ReactMouseEvent) => {
event.preventDefault();
event.stopPropagation();
onDiscard(draftId);
},
[draftId, onDiscard],
);
return (
);
});
interface SidebarDraftRowData {
draftId: DraftId;
session: DraftSessionState;
composer: ComposerThreadDraftState;
}
// Draft sessions with user content, surfaced above the pinned block so an
// interrupted "new thread" stays one click away. Self-contained (own store
// subscription + closing divider) so per-keystroke composer updates
// re-render only this block, never the whole sidebar. Vanishes at count 0.
const SidebarDraftBlock = memo(function SidebarDraftBlock(props: {
projectByKey: ReadonlyMap;
projectDisplayNameByKey: ReadonlyMap;
scopedProjectKeys: ReadonlySet | null;
routeDraftId: string | null;
onNavigateToDraft: (draftId: DraftId) => void;
}) {
const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey);
const draftsByThreadKey = useComposerDraftStore((store) => store.draftsByThreadKey);
const clearDraftThread = useComposerDraftStore((store) => store.clearDraftThread);
// The open draft's row is FROZEN at the moment the draft became the route:
// it stays visible (like a thread row) but never repaints while the user
// types. A draft that was never navigated away from has no snapshot to
// freeze, so a fresh typing session shows no row at all. Captured
// synchronously on route change (setState-during-render derived state) so
// the row never flickers out for a frame between route change and capture.
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 store = useComposerDraftStore.getState();
const session = store.getDraftSession(draftId);
const composer = store.getComposerDraft(draftId);
row =
session && session.promotedTo == null && composer && composerDraftHasUserContent(composer)
? { draftId, session, composer }
: null;
}
setFrozenActive({ routeDraftId: props.routeDraftId, row });
}
const drafts = useMemo(() => {
const rows: SidebarDraftRowData[] = [];
// Every non-promoted session with content gets a row, mapped or not:
// new-thread surfaces mint fresh drafts and leave invested ones behind
// unmapped, so the mapping only knows about the latest per project.
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) {
// Open draft: render the frozen entry snapshot, or nothing for a
// draft that has never been left. Gated on the LIVE session above so
// send/discard still removes the row immediately.
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;
}, [
draftThreadsByThreadKey,
draftsByThreadKey,
frozenActive,
props.routeDraftId,
props.scopedProjectKeys,
]);
const handleDiscard = useCallback(
(draftId: DraftId) => {
// The /draft/$draftId route redirects home on its own when the draft
// it renders disappears, so discarding the open draft needs no
// special-casing here.
releaseComposerDraftUploads(draftId);
clearDraftThread(draftId);
},
[clearDraftThread],
);
if (drafts.length === 0) {
return null;
}
return (
<>
{drafts.map(({ composer, draftId, session }) => {
const projectKey = `${session.environmentId}:${session.projectId}`;
return (
);
})}
>
);
});
// Verb and icon on the lifted row while it hovers over another section. Uses
// the same icons as the row actions and context menu so the drop reads as the
// action it performs.
const dropVerbBadge: Record = {
pin: (
<>
Pin
>
),
unpin: (
<>
Unpin
>
),
settle: (
<>
Settle
>
),
unsettle: (
<>
Un-settle
>
),
wake: (
<>
Wake
>
),
};
const SidebarThreadRow = memo(function SidebarThreadRow(props: {
thread: SidebarThreadSummary;
variant: "card" | "slim";
// Slim rows are either settled (action: un-settle) or merely quiet
// (seen Ready threads — action: settle).
variantAction: "settle" | "unsettle" | "unsnooze";
// False on environments whose server predates thread.settle/unsettle:
// the lifecycle affordances hide entirely rather than fail on click.
settlementSupported: boolean;
// Same contract for thread.snooze/unsnooze.
snoozeSupported: boolean;
// Pinned threads show the same pin marker in active, settled, and snoozed
// rows. The marker can unpin the thread when the server supports pinning.
pinningSupported: boolean;
isPinned: boolean;
// Present on rows whose server supports every drop outcome: dnd-kit
// sortable bag applied to the row root so the whole row drags (the
// pointer sensor's distance constraint keeps plain clicks working).
sortable?: SortableThreadRowBag | undefined;
dropVerb: SidebarDropVerb | null;
// While dragging, the pin marker stays only for a pinned thread still over
// the pinned section. Any other position shows the verb badge instead, and
// the badge carries its own icon.
dragOverPinned: boolean;
// Compact wake countdown ("2h") for rows in the snoozed shelf.
snoozeWakeLabelText: string | null;
// When a snooze ended (timer or early wake); drives the Woke pill until
// the user visits the thread.
wokeAt: string | null;
isActive: boolean;
openPullRequestsInRightPanel: boolean;
jumpLabel: string | null;
currentEnvironmentId: string | null;
environmentLabel: string | null;
environmentMachine: EnvironmentMachineKind;
project: EnvironmentProject | null;
projectDisplayName: string | null;
providerEntryByInstanceId: ReadonlyMap;
timestampFormat: TimestampFormat;
onThreadClick: (event: ReactMouseEvent, threadRef: ScopedThreadRef) => void;
onThreadActivate: (threadRef: ScopedThreadRef) => void;
onStartRename: (threadRef: ScopedThreadRef, title: string) => void;
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;
onUnsettle: (threadRef: ScopedThreadRef) => void;
onSnooze: (threadRef: ScopedThreadRef, preset: SnoozePreset) => void;
onUnsnooze: (threadRef: ScopedThreadRef) => void;
onUnpin: (threadRef: ScopedThreadRef) => void;
onAcknowledgeWoke: (threadRef: ScopedThreadRef, visitedAt: string) => void;
/**
* External files dropped onto this row. The row highlights while the drag
* is over it; the callback opens the thread and hands the files to its
* composer. Absent when the sidebar cannot open server threads.
*/
onFileDropThreads?: ((threadRef: ScopedThreadRef, files: File[]) => void) | undefined;
}) {
const {
isRenaming,
onCancelRename,
onCommitRename,
onContextMenu,
onAcknowledgeWoke,
onFileDropThreads,
onRenameTitleChange,
onSettle,
onSnooze,
onStartRename,
onThreadActivate,
onThreadClick,
onUnsettle,
onUnsnooze,
onUnpin,
openPullRequestsInRightPanel,
renamingTitle,
thread,
variant,
variantAction,
} = props;
const threadRef = useMemo(
() => scopeThreadRef(thread.environmentId, thread.id),
[thread.environmentId, thread.id],
);
const threadKey = scopedThreadKey(threadRef);
const { leaseLiveStatus, rowRef } = useSidebarRowSubscriptionLease(props.isActive);
const isRegeneratingTitle = thread.titleRegeneration != null;
const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]);
const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey));
const openPrLink = useOpenPrLink();
const runningTerminalIds = useThreadRunningTerminalIds({
environmentId: thread.environmentId,
threadId: thread.id,
});
const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds);
const terminalProcessCount = runningTerminalIds.length;
// Unsent composer text on this thread. The open thread shows its own
// composer, so the marker only decorates rows you have navigated away from.
const hasUnsentDraft = useThreadHasUnsentDraft(threadRef) && !props.isActive;
const clearComposerContent = useComposerDraftStore((store) => store.clearComposerContent);
const handleDiscardDraftClick = useCallback(
(event: ReactMouseEvent) => {
event.preventDefault();
event.stopPropagation();
releaseComposerDraftUploads(threadRef);
clearComposerContent(threadRef);
},
[clearComposerContent, threadRef],
);
const gitCwd = thread.worktreePath ?? props.project?.workspaceRoot ?? null;
const linkedPullRequestStatus = useLinkedThreadPullRequest(
thread.environmentId,
thread.linkedPullRequest,
leaseLiveStatus,
thread.pullRequests,
thread.branchPullRequest,
);
const gitStatus = useEnvironmentQuery(
leaseLiveStatus && (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null
? vcsEnvironment.status({
environmentId: thread.environmentId,
input: { cwd: gitCwd },
})
: 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;
// Same semantics as the legacy sidebar (never-visited counts as read):
// switching sidebars must not light up every historical thread as unread.
const isUnread = hasUnseenCompletion({ ...thread, lastVisitedAt });
const status = resolveSidebarThreadStatus(thread);
const isInFlight =
status === "working" || status === "monitoring" || status === "approval" || status === "input";
// A woken thread reappears at its original position (the sort is
// deliberately static), so the pill has to carry the weight. Snoozing is
// an explicit act, so the pill clears only when the user re-engages:
// reading a completion-triggered wake, clicking the pill, sending a
// message, settling, archiving, or a change request state that settles the
// thread. Timer wakes survive a mere visit. An unparseable visit timestamp
// counts as never-visited, so corrupt local data cannot eat the wake signal.
const lastVisitedDate = lastVisitedAt === undefined ? null : parseTimestampDate(lastVisitedAt);
const wokeAtDate = props.wokeAt === null ? null : parseTimestampDate(props.wokeAt);
const isWoke =
wokeAtDate !== null &&
(lastVisitedDate === null || lastVisitedDate < wokeAtDate) &&
thread.settledOverride !== "settled";
// Background work always recedes when it is not selected: an unread parent
// completion must not pull a still-working thread back into the foreground.
// Ready and action-required rows keep their unread and wake prominence.
const shouldRecede = shouldRecedeSidebarThread({
status,
isUnread,
isWoke,
isActive: props.isActive,
isSelected,
});
// Status hues follow the system-wide convention set by sidebar v1 and the
// mobile Live Activity/widgets (amber approval, indigo input, sky working)
// so a thread reads the same color everywhere it surfaces.
const topStatus =
status === "working"
? {
label: "Working",
icon: "working" as const,
// No shimmer: a label that animates forever is noise in a sidebar
// full of them (and repaints every vsync on high-refresh displays).
// Working is a background state, so it rests at the dim end of what
// the old pulse cycled through; only the thread you have open gets
// the label at full strength.
className: cn("text-sky-600 dark:text-sky-400", !props.isActive && "opacity-75"),
}
: status === "monitoring"
? {
// Monitoring is calm background presence, not active progress
// (monitoring-pill D6), so it keeps the label at full strength.
label: "Monitoring",
icon: null,
className: "text-sky-600 dark:text-sky-400",
}
: status === "approval"
? {
label: "Approval",
icon: null,
className: "text-amber-700 dark:text-amber-300",
}
: status === "input"
? {
label: "Input",
icon: null,
className: "text-indigo-600 dark:text-indigo-300",
}
: status === "failed"
? {
label: "Failed",
icon: null,
className: "text-red-700 dark:text-red-300",
}
: isWoke
? {
label: "Woke",
icon: "woke" as const,
className: "text-amber-700 dark:text-amber-300",
}
: isUnread
? {
label: "Done",
icon: "done" as const,
className: "text-emerald-700 dark:text-emerald-300",
}
: null;
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.session?.providerInstanceId ?? thread.modelSelection.instanceId;
const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null;
const driverKind = providerEntry?.driverKind ?? 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;
// The local environment is "this machine" and needs no marker; every other
// one gets its machine glyph. With no local environment (the hosted app)
// that is every thread, which is the point: the glyph is what tells rows on
// different machines apart.
const isRemote = thread.environmentId !== props.currentEnvironmentId;
const detailsTooltip = (
);
const handleClick = useCallback(
(event: ReactMouseEvent) => {
onThreadClick(event, threadRef);
},
[onThreadClick, threadRef],
);
const handleAcknowledgeWokeClick = useCallback(
(event: ReactMouseEvent) => {
event.preventDefault();
event.stopPropagation();
if (props.wokeAt === null) return;
onAcknowledgeWoke(threadRef, props.wokeAt);
},
[onAcknowledgeWoke, props.wokeAt, threadRef],
);
const handleContextMenu = useCallback(
(event: ReactMouseEvent) => {
event.preventDefault();
onContextMenu(threadRef, { x: event.clientX, y: event.clientY });
},
[onContextMenu, threadRef],
);
const handleKeyDown = useCallback(
(event: ReactKeyboardEvent) => {
if (event.target !== event.currentTarget) return;
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
onThreadActivate(threadRef);
},
[onThreadActivate, threadRef],
);
const handleDoubleClick = useCallback(
(event: ReactMouseEvent) => {
if (isRenaming || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
return;
}
if ((event.target as HTMLElement).closest("button, a, input")) return;
event.preventDefault();
onStartRename(threadRef, thread.title);
},
[isRenaming, onStartRename, thread.title, threadRef],
);
const [isFileDragOver, setIsFileDragOver] = useState(false);
const fileDropHandlers = useMemo(
() =>
onFileDropThreads
? makeWorkspaceFileDropHandlers({
setDragActive: setIsFileDragOver,
addFiles: (files) => {
onFileDropThreads(threadRef, files);
},
})
: null,
[onFileDropThreads, threadRef],
);
// A drop lands on a child or outside the window entirely, so dragend is
// the reset of last resort for the row's highlight.
useEffect(() => {
if (!isFileDragOver) return;
const clearFileDrag = () => setIsFileDragOver(false);
window.addEventListener("dragend", clearFileDrag);
return () => window.removeEventListener("dragend", clearFileDrag);
}, [isFileDragOver]);
const renameCommittedRef = useRef(false);
useEffect(() => {
if (isRenaming) renameCommittedRef.current = false;
}, [isRenaming]);
const handleRenameKeyDown = useCallback(
(event: ReactKeyboardEvent) => {
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();
}
},
[onCancelRename, onCommitRename, renamingTitle, thread.title, threadRef],
);
const handleRenameBlur = useCallback(() => {
if (!renameCommittedRef.current) {
onCommitRename(threadRef, renamingTitle, thread.title);
}
}, [onCommitRename, renamingTitle, thread.title, threadRef]);
const handleSettleClick = useCallback(
(event: ReactMouseEvent) => {
event.preventDefault();
event.stopPropagation();
onSettle(threadRef);
},
[onSettle, threadRef],
);
const handleUnsettleClick = useCallback(
(event: ReactMouseEvent) => {
event.preventDefault();
event.stopPropagation();
onUnsettle(threadRef);
},
[onUnsettle, threadRef],
);
const handleUnsnoozeClick = useCallback(
(event: ReactMouseEvent) => {
event.preventDefault();
event.stopPropagation();
onUnsnooze(threadRef);
},
[onUnsnooze, threadRef],
);
const handleUnpinClick = useCallback(
(event: ReactMouseEvent) => {
event.preventDefault();
event.stopPropagation();
onUnpin(threadRef);
},
[onUnpin, threadRef],
);
const handleSnoozePreset = useCallback(
(preset: SnoozePreset) => {
onSnooze(threadRef, preset);
},
[onSnooze, threadRef],
);
// While the snooze popover is open the pointer leaves the row, which
// would fade the hover actions out from under the open menu. Pin them and
// suppress the row tooltip so its portal cannot overlap the popover.
const [snoozeMenuOpenRaw, setSnoozeMenuOpen] = useState(false);
// Snooze is offered only where it can succeed: capability-gated and never
// on blocked-on-you work or queued turns (the server rejects both).
const showSnoozeButton =
props.snoozeSupported && canSnooze(thread, { now: new Date().toISOString() });
// If the thread becomes blocked while the popover is open, the button
// unmounts without firing onOpenChange(false). Deriving the flag keeps a
// stale true from permanently hiding the status label / pinning the
// hover actions, and the effect clears the raw state so the popover
// doesn't resurrect if the button later remounts.
const snoozeMenuOpen = snoozeMenuOpenRaw && showSnoozeButton;
useEffect(() => {
if (!showSnoozeButton) setSnoozeMenuOpen(false);
}, [showSnoozeButton]);
const handlePrClick = useCallback(
(event: ReactMouseEvent) => {
const url = pr?.url ?? currentLinkedPr?.url;
if (!url) return;
const openedInRightPanel = openPrLink(
event,
url,
openPullRequestsInRightPanel ? threadRef : undefined,
);
if (openedInRightPanel && openPullRequestsInRightPanel && !props.isActive) {
onThreadActivate(threadRef);
}
},
[
onThreadActivate,
openPrLink,
openPullRequestsInRightPanel,
pr,
currentLinkedPr,
props.isActive,
threadRef,
],
);
// All sidebar rows share one surface model. Live threads used to look
// like elevated cards while settled threads were plain rows, leaving neither
// a useful hierarchy nor a reliable hover cue. Status now lives in the row
// content; surface is reserved for interaction (hover, multi-select, route).
const rowSurfaceClassName = cn(
"group/sidebar-row relative w-full cursor-pointer overflow-hidden rounded-md text-left outline-none select-none",
variantAction === "unsettle" && "[&:not(:hover):not(:focus-within)_*]:text-secondary-label/70",
props.isActive
? "bg-sidebar-row-active text-sidebar-foreground"
: isSelected
? "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",
isInFlight &&
!props.isActive &&
!isSelected &&
"opacity-70 transition-opacity hover:opacity-100",
isFileDragOver && "ring-1 ring-inset ring-primary/70",
// The hover tint must not clobber an active/selected row's own surface.
isFileDragOver && !props.isActive && !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-[linear-gradient(var(--sidebar-row-active),var(--sidebar-row-active)),linear-gradient(var(--sidebar),var(--sidebar))] text-sidebar-foreground opacity-100 shadow-lg",
);
// dnd-kit props for the row root. Same bag on both variants: every row in
// the list translates around the gap as the drag passes it.
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 dragDestination =
sortable?.isDragging && props.dropVerb !== null ? (
{dropVerbBadge[props.dropVerb]}
) : null;
const title = isRenaming ? (
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"
/>
) : (
{thread.title}
);
// Stacks show their layer count; multiple unrelated links show their total count.
// Plain clicks open T3; individual PR links also support opening the host in a new tab.
const prBadgeShape = supportsMultiplePullRequests
? resolveThreadPullRequestBadge(thread.pullRequests)
: null;
const handlePrStackClick = useCallback(() => {
useRightPanelStore.getState().open(threadRef, "pull-requests");
if (!props.isActive) onThreadActivate(threadRef);
}, [onThreadActivate, props.isActive, threadRef]);
const prBadge =
prBadgeShape?.kind === "stack" || pr || currentLinkedPr ? (
) : null;
const terminalStatusIcon = terminalStatus ? (
) : null;
// Same pen the new-thread draft rows lead with, so both kinds of unsent
// work read the same way in the list.
const draftIndicator = hasUnsentDraft ? (
}
>
Unsent draft
) : null;
const showPin =
props.isPinned && (!sortable?.isDragging || (props.dragOverPinned && props.dropVerb === null));
const pinIndicator = showPin ? (
props.pinningSupported && !sortable?.isDragging ? (
}
>
Unpin thread
) : (
)
) : null;
if (variant === "slim") {
return (
}
>
{/* Settled history recedes: dimmed favicon at rest, restored on
hover so the tail stays scannable when you're hunting. */}
{props.project ? : null}
{draftIndicator}
{title}
{pinIndicator}
{terminalStatusIcon}
{isRegeneratingTitle ? (
Regenerating title
) : null}
{/* The PR badge stays outside the hover-fading slot: it must
remain visible AND clickable while the row is hovered. Only
the time/jump label yields to the settle affordance. */}
{prBadge}
{sortable?.isDragging ? (
dragDestination
) : (
{variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? (
// Snoozed rows show when they come BACK, not when they were
// last touched — the return ticket is the row's whole story.
{props.snoozeWakeLabelText}
) : isWoke ? (
// A wake can land straight in the settled tail (e.g. PR
// merged while snoozed); the signal must survive the trip.
Woke
}
/>
Dismiss Woke notification
) : (
{variantAction === "unsettle"
? settledTimeLabel(thread)
: threadTimeLabel(thread)}
)}
{variantAction === "unsnooze" ? (
!props.snoozeSupported ? null : (
)
) : !props.settlementSupported ? null : variantAction === "unsettle" ? (
}
>
Un-settle thread
) : (
)}
)}
{props.jumpLabel ? : null}
{detailsTooltip}
{draftIndicator}
{props.project ? (
) : null}
{props.projectDisplayName ? (
{props.projectDisplayName}
) : (
)}
{pinIndicator}
{/* The visible state owns this slot's width: status at rest,
actions on hover/keyboard focus or while the popover is open. Keeping
the hidden state out of flow lets the project label reclaim
space without either state overlapping it. */}
{sortable?.isDragging ? (
dragDestination
) : (
{/* 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. */}
{topStatus ? (
isWokeStatus ? (
{topStatus.label}
}
/>
Dismiss Woke notification
) : (
{topStatus.icon === "working" ? (
) : topStatus.icon === "done" ? (
) : 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" ? (
) : null}
)
) : (
threadTimeLabel(thread)
)}
{props.settlementSupported || showSnoozeButton || hasUnsentDraft ? (
{hasUnsentDraft ? (
}
>
Discard draft
) : null}
{showSnoozeButton ? (
) : null}
{props.settlementSupported ? (
}
>
Settle
Settle thread
) : null}
) : null}
)}
{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 ? (
<>
{thread.branch}
>
) : (
)}
{terminalStatusIcon}
{prBadge}
{diff ? (
+{diff.insertions}{" "}
−{diff.deletions}
) : null}
{isRemote ? (
) : null}
{driverKind ? (
) : null}