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