);
}
/**
* 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 pinned card's root
. Listeners go
// on the whole card (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 card body already
// carries its own button semantics.
type SortablePinnedRowBag = Pick<
ReturnType,
"listeners" | "setNodeRef" | "transform" | "transition" | "isDragging"
>;
function SortablePinnedThreadRow(props: {
id: string;
children: (bag: SortablePinnedRowBag) => ReactNode;
}) {
const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: props.id,
animateLayoutChanges: animatePinnedLayoutChanges,
});
return props.children({ listeners, setNodeRef, transform, transition, isDragging });
}
// 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;
projectTitle: string | null;
projectCwd: string | null;
projectFaviconPath: 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.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 (
{props.projectTitle}
}
/>
Discard draft
{preview}
);
});
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: {
projectDisplayNameByKey: ReadonlyMap;
projectCwdByKey: ReadonlyMap;
projectFaviconPathByKey: 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 (
);
})}
>
);
});
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;
autoSettleOnMerge: 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 only on pinned cards whose server supports reordering: dnd-kit
// sortable bag applied to the card root so the whole card drags (the
// pointer sensor's distance constraint keeps plain clicks working).
sortable?: SortablePinnedRowBag | undefined;
// 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;
projectCwd: string | null;
projectFaviconPath: string | null;
projectTitle: 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;
changeRequestSnapshot: ThreadChangeRequestSnapshot | null;
onChangeRequestSnapshot: (
threadKey: string,
snapshot: ThreadChangeRequestSnapshot | null,
) => void;
}) {
const {
isRenaming,
changeRequestSnapshot,
onChangeRequestSnapshot,
onCancelRename,
onCommitRename,
onContextMenu,
onAcknowledgeWoke,
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 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;
const gitCwd = thread.worktreePath ?? props.projectCwd;
const linkedPullRequestStatus = useLinkedThreadPullRequest(
thread.environmentId,
thread.linkedPullRequest,
);
const gitStatus = useEnvironmentQuery(
(thread.branch != null || thread.worktreePath !== null) && gitCwd !== null
? vcsEnvironment.status({
environmentId: thread.environmentId,
input: { cwd: gitCwd },
})
: null,
);
const retainTerminalOnBranchMismatch = thread.worktreePath === null;
const pr = resolveDisplayedThreadPr({
threadBranch: thread.branch,
gitStatus: gitStatus.data,
snapshot: changeRequestSnapshot,
retainTerminalOnBranchMismatch,
linkedPullRequest: thread.linkedPullRequest,
linkedPullRequestStatus,
});
// 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);
// 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) &&
!changeRequestAutoSettles(pr, {
autoSettleOnMerge: props.autoSettleOnMerge,
thread,
});
// In-flight rows (working, or waiting on approval/input) fade as a whole:
// there is nothing for the user to do yet, so prominence is reserved for
// rows that need a human — done (unread), read-but-unsettled, failed, and
// freshly woken. The status label keeps its hue, so waiting rows stay
// findable. In-flight rows recede the same as read-ready ones (inbox-zero:
// working threads aren't your problem yet) — only the colored status label
// stands out.
const isInFlight =
status === "working" || status === "monitoring" || status === "approval" || status === "input";
const shouldRecede =
(status === "ready" || isInFlight) && !isUnread && !isWoke && !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: gitStatus.data?.refName ?? null,
});
const prProvider = resolveDisplayedThreadPrProvider({
threadBranch: thread.branch,
gitStatus: gitStatus.data,
snapshot: changeRequestSnapshot,
retainTerminalOnBranchMismatch,
linkedPullRequest: thread.linkedPullRequest,
linkedPullRequestStatus,
});
const prStatus = prStatusIndicator(pr, prProvider);
const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state) : undefined;
useEffect(() => {
const nextSnapshot = nextThreadChangeRequestSnapshot({
threadBranch: thread.branch,
gitStatus: gitStatus.data,
snapshot: changeRequestSnapshot,
retainTerminalOnBranchMismatch,
linkedPullRequest: thread.linkedPullRequest,
linkedPullRequestStatus,
});
if (nextSnapshot === undefined) return;
onChangeRequestSnapshot(threadKey, nextSnapshot);
}, [
changeRequestSnapshot,
gitStatus.data,
linkedPullRequestStatus,
onChangeRequestSnapshot,
retainTerminalOnBranchMismatch,
thread.branch,
thread.linkedPullRequest,
threadKey,
]);
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;
const isRemote =
props.currentEnvironmentId !== null && 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 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.
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) => {
if (!pr?.url) return;
const openedInRightPanel = openPrLink(
event,
pr.url,
openPullRequestsInRightPanel ? threadRef : undefined,
);
if (openedInRightPanel && openPullRequestsInRightPanel && !props.isActive) {
onThreadActivate(threadRef);
}
},
[onThreadActivate, openPrLink, openPullRequestsInRightPanel, pr, 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",
props.isActive
? "bg-sidebar-row-active text-sidebar-foreground"
: isSelected
? "bg-sidebar-row-selected 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",
);
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}
);
// A real link so cmd/ctrl+click and middle-click open the host in the
// browser. A plain click still opens T3's pull request view.
const prBadge =
prStatus && pr ? (
event.stopPropagation()}
onClick={handlePrClick}
className={cn(
// Sidebar chrome follows the interface font; tabular digits keep the
// number from reflowing as PR states stream in.
"shrink-0 text-xs tabular-nums hover:underline",
variant === "slim" && variantAction === "unsettle"
? props.isActive
? "text-secondary-label"
: cn("text-secondary-label transition-colors", settledPrHoverClass)
: prStatus.colorClass,
)}
aria-label={prStatus.tooltip}
>
#{pr.number}
) : null;
const terminalStatusIcon = terminalStatus ? (
) : null;
const pinIndicator = props.isPinned ? (
props.pinningSupported ? (
}
>
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. */}
{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}
{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}
{props.projectTitle ? (
{props.projectTitle}
) : (
)}
{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. */}
{/* 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 ? (
{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}
{(() => {
const renderThreadRow = (
thread: EnvironmentThreadShell,
section: "pinned" | "active" | "snoozed" | "settled",
sortable?: SortablePinnedRowBag,
) => {
const threadKey = scopedThreadKey(
scopeThreadRef(thread.environmentId, thread.id),
);
// Settled and snoozed are the ONLY things that collapse a
// row: every other thread is a full card. Density comes
// from users (or the auto rules) actually parking work,
// not from the sidebar second-guessing what still matters.
const isCard = section === "active" || section === "pinned";
const rowVariant = isCard ? "card" : "slim";
return (
);
};
// Draft block above everything, then the pinned block:
// full cards above the inbox, closed by a thin divider (the
// pin glyphs carry the meaning, so no header text). Both
// vanish entirely at count 0.
// Pinned rows render in the one shared pinned order; only
// reorder-capable rows register as sortable (legacy-server
// pins render in place as plain rows).
const items: ReactNode[] = [
,
pinnedThreads.length > 0 ? (
) : null,
];
if (pinnedThreads.length > 0) {
items.push(
,
);
}
for (const thread of activeThreads) {
items.push(renderThreadRow(thread, "active"));
}
// Snoozed shelf: between the inbox and Settled — out of the
// way, never gone. The header always renders while anything
// is snoozed (the count is the whole footprint when
// collapsed); rows only when expanded. Vanishes entirely at
// count 0.
if (snoozedThreads.length > 0) {
items.push(
,
);
for (const thread of visibleSnoozedThreads) {
items.push(renderThreadRow(thread, "snoozed"));
}
}
if (settledThreads.length > 0) {
items.push(