diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 7e7e895e7a69..6a619c42f6af 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -38,8 +38,6 @@ import { CheckIcon, ChevronDownIcon, CircleAlertIcon, - CircleCheckIcon, - CircleDashedIcon, ClockIcon, FolderIcon, FolderPlusIcon, @@ -222,6 +220,81 @@ function JumpHintBadge(props: { label: string }) { ); } +// The two status glyphs share one geometry: both fill the full 24-unit box +// (the spinner's r=10 ring + 4-unit stroke reaches r=12; the check ring is +// r=11 + 2-unit stroke = r=12), so at the same rendered size their outer +// edges coincide and Working ↔ Done reads as the same mark changing state. +// lucide's CircleCheck (r=10, edge 11) sat visibly inside the spinner. +// +// The spinner is Material's transform-only construction (see .working-spinner +// in index.css): a static track ring, then two half-ring SVGs whose rotation +// inside clipped halves makes the arc grow and reel in without animating any +// paint property. Half ring = full circumference dash, offset by half. +const SPINNER_RING_CIRCUMFERENCE = 2 * Math.PI * 10; + +function SpinnerHalfRing() { + return ( + + + + ); +} + +function WorkingSpinnerIcon(props: { className?: string }) { + return ( + + + + + + + + + + + + + + + + + + + ); +} + +function DoneCircleCheckIcon(props: { className?: string }) { + return ( + + + + + ); +} + // Self-ticking so only this span re-renders each second, not the whole row. function WorkingDuration(props: { startedAt: string | null }) { const startedMs = props.startedAt !== null ? Date.parse(props.startedAt) : Number.NaN; @@ -232,11 +305,7 @@ function WorkingDuration(props: { startedAt: string | null }) { return () => window.clearInterval(id); }, [startedMs]); if (Number.isNaN(startedMs)) return null; - return ( - - {formatWorkingDurationLabel(Date.now() - startedMs)} - - ); + return {formatWorkingDurationLabel(Date.now() - startedMs)}; } function terminalProcessLabel(count: number): string { @@ -787,13 +856,14 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { wokeAtDate !== null && (lastVisitedDate === null || lastVisitedDate < wokeAtDate) && !changeRequestAutoSettles(prState, props.autoSettleOnMerge); - // 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. + // In-flight rows (working, or waiting on approval/input) recede exactly the + // same as read-ready ones — same title/label/branch treatment, no extra + // row-level fade (which stacked on the per-element dimming and made a live + // thread's branch visibly darker than a stale one's). There is nothing for + // the user to do yet, so prominence is reserved for rows that need a human — + // done (unread), failed, and freshly woken — and only the colored status + // label marks a row as in flight (inbox-zero: working threads aren't your + // problem yet). const isInFlight = status === "working" || status === "monitoring" || status === "approval" || status === "input"; const shouldRecede = @@ -808,10 +878,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { 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"), + // No dimming either: a receded working row is otherwise identical to + // a read one, so this label is the only thing marking it in flight + // and stays at full hue like Done and Woke do. (An earlier fade here + // left it the faintest colored text in the sidebar.) + className: "text-sky-600 dark:text-sky-400", } : status === "monitoring" ? { @@ -1046,10 +1117,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { : 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 ? ( @@ -1069,14 +1136,24 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { } > -
+
{props.projectTitle ? ( - + {props.projectTitle} ) : ( @@ -1380,9 +1452,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { )} > {topStatus.icon === "working" ? ( - + ) : topStatus.icon === "done" ? ( - + ) : null} {/* The label alone is the live region: a role="status" wrapper around the ticking duration would make @@ -1442,14 +1514,16 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ) : 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} + + {thread.branch} + ) : ( diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 5d37c8f97b3a..9bf1e02efdc1 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1269,7 +1269,10 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --sidebar-muted-foreground: var(--muted-foreground); --sidebar-control-surface: var(--muted); --sidebar-row-hover: color-mix(in srgb, var(--foreground) 8%, transparent); - --sidebar-row-active: color-mix(in srgb, var(--foreground) 11%, transparent); + /* Active sits only a step above hover on purpose: the open thread's + semibold, full-brightness title carries the selection, so the surface + just needs to anchor it rather than shout. */ + --sidebar-row-active: color-mix(in srgb, var(--foreground) 9%, transparent); --sidebar-row-selected: color-mix(in srgb, var(--foreground) 7%, transparent); --sidebar-border: var(--border); /* The stage-channel header art must ramp to THIS panel's surface, not the @@ -2216,6 +2219,170 @@ label:has(> select#reasoning-effort) select { } } +/* Sidebar "Working" spinner: Material's indeterminate ring, transform-only. + The arc's grow-and-reel-in is faked by two half-ring SVGs, one per clipped + half, counter-rotating with ease-in-out inside a stepped-rotation layer + inside a linearly rotating container (MDC's construction and timings). Every + animated property is a transform on an HTML element or a root , so the + compositor drives all of it and the main thread never repaints a frame — + the same bar the stepped status indicators hold themselves to. A stroke-dash + animation would look identical but repaint on the main thread every vsync. */ +.working-spinner { + position: relative; + display: block; +} + +.working-spinner__track, +.working-spinner__container, +.working-spinner__layer { + position: absolute; + inset: 0; + display: block; +} + +.working-spinner__track { + opacity: 0.25; +} + +.working-spinner__container { + opacity: 0.75; + animation: working-spinner-container-rotate 1568ms linear infinite; +} + +.working-spinner__layer { + animation: working-spinner-layer-rotate 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both; +} + +.working-spinner__clipper { + position: absolute; + top: 0; + display: block; + width: 50%; + height: 100%; + overflow: hidden; +} + +.working-spinner__clipper--left { + left: 0; +} + +.working-spinner__clipper--right { + left: 50%; +} + +.working-spinner__clipper > svg { + position: absolute; + top: 0; + left: 0; + width: 200%; + height: 100%; +} + +.working-spinner__clipper--right > svg { + left: -100%; +} + +.working-spinner__clipper--left > svg { + animation: working-spinner-left-spin 1333ms cubic-bezier(0.4, 0, 0.2, 1) infinite both; +} + +.working-spinner__clipper--right > svg { + animation: working-spinner-right-spin 1333ms cubic-bezier(0.4, 0, 0.2, 1) infinite both; +} + +/* A hairline sliver of ring parked over the 12 o'clock seam where the two + halves meet (the arc always spans it), so antialiasing never shows a gap. */ +.working-spinner__patch { + position: absolute; + top: 0; + left: 46%; + display: block; + width: 8%; + height: 100%; + overflow: hidden; +} + +.working-spinner__patch > svg { + position: absolute; + top: 0; + left: -575%; + width: 1250%; + height: 100%; + transform: rotate(180deg); +} + +@keyframes working-spinner-container-rotate { + to { + transform: rotate(360deg); + } +} + +@keyframes working-spinner-layer-rotate { + 12.5% { + transform: rotate(135deg); + } + 25% { + transform: rotate(270deg); + } + 37.5% { + transform: rotate(405deg); + } + 50% { + transform: rotate(540deg); + } + 62.5% { + transform: rotate(675deg); + } + 75% { + transform: rotate(810deg); + } + 87.5% { + transform: rotate(945deg); + } + 100% { + transform: rotate(1080deg); + } +} + +@keyframes working-spinner-left-spin { + from { + transform: rotate(265deg); + } + 50% { + transform: rotate(130deg); + } + to { + transform: rotate(265deg); + } +} + +@keyframes working-spinner-right-spin { + from { + transform: rotate(-265deg); + } + 50% { + transform: rotate(-130deg); + } + to { + transform: rotate(-265deg); + } +} + +@media (prefers-reduced-motion: reduce) { + .working-spinner__container, + .working-spinner__layer, + .working-spinner__clipper > svg { + animation: none; + } + /* Static ~125° arc, the footprint the old fixed arc had. */ + .working-spinner__clipper--left > svg { + transform: rotate(208deg); + } + .working-spinner__clipper--right > svg { + transform: rotate(-208deg); + } +} + @keyframes provider-update-pill-countdown { from { transform: scaleX(1);