- {backgroundRuns.map((run) => {
- const metaItems = backgroundRunMetaItems(run);
+ {/* Runs are rows on their own spine, drawn the same way as the task
+ steps above so the popover reads as one surface. Every run is live,
+ so each row carries the halo node. */}
+
+ {/* Whole-pixel geometry: the 1px line sits at x=7 (no transform, so the
+ browser cannot snap it to a neighbouring column) and the node is
+ centred in a 15px box, i.e. on x=7.5, the line's own centre. Odd node
+ sizes then land every edge on a whole pixel. */}
+
{connectTop ? (
) : null}
{connectBottom ? (
) : null}
{/* Centring the node in a box of twice the offset puts its centre on the
offset without taking the row out of flow. */}
{node}
@@ -155,15 +159,16 @@ function SpineRow({
type SpineNodeKind = "done" | "running" | "warning" | "error" | "group" | "pending";
const TONE_SPINE_DOT_CLASS_NAME = {
- warning: "size-[6px] rounded-full bg-warning",
- error: "size-[6px] rounded-full bg-destructive",
+ warning: "size-[5px] rounded-full bg-warning",
+ error: "size-[5px] rounded-full bg-destructive",
} as const satisfies Record<"warning" | "error", string>;
/** The glyph that sits on a spine for one row. The accent halo is reserved for
* the surface's single live node; a still-running step gets a small accent
- * tick, settled steps are quiet solid dots, warnings/errors are compact tone
- * dots, a collapsed group of steps is a hollow ring (same family, reads as
- * "openable"), and a step not yet started is a fainter hollow ring. */
+ * tick, settled steps are solid muted dots (filled in, unlike the hollow ring
+ * of a step not yet started), warnings/errors are compact tone dots, and a
+ * collapsed group of steps is a hollow ring (same family, reads as
+ * "openable"). Sizes stay odd so nodes centre on the spine's half-pixel. */
function SpineNode({ kind }: { kind: SpineNodeKind }) {
if (kind === "running") {
return (
@@ -190,25 +195,38 @@ function SpineNode({ kind }: { kind: SpineNodeKind }) {
return (
);
}
/**
- * Accent fade for the connectors approaching a live terminus: the spine warms
- * toward accent as it nears the row where work is happening now, and settles
- * to the hairline colour a row and a half away.
+ * Accent fade for the connectors approaching a live node: the spine warms
+ * toward accent as it comes down to the row where work is happening now, and
+ * settles to the hairline colour a row and a half above it. The line past the
+ * live node stays hairline, so colour only ever covers ground already walked.
*
- * @param distanceFromLive Rows between this row and the live terminus.
+ * @param rowsBeforeLive Rows between this row and the live one; 0 is the live
+ * row itself, negative rows come after it.
*/
-function spineAccentRowStyle(distanceFromLive: number): React.CSSProperties {
+function spineAccentRowStyle(rowsBeforeLive: number): React.CSSProperties {
+ if (rowsBeforeLive < 0) {
+ return SPINE_HAIRLINE_ROW_STYLE;
+ }
return {
- ["--spine-top"]: spineAccentSegment(distanceFromLive + 0.5, distanceFromLive),
- ["--spine-bottom"]: spineAccentSegment(distanceFromLive, Math.max(0, distanceFromLive - 0.5)),
+ ["--spine-top"]: spineAccentSegment(rowsBeforeLive + 0.5, rowsBeforeLive),
+ ["--spine-bottom"]:
+ rowsBeforeLive === 0
+ ? "var(--spine, var(--border))"
+ : spineAccentSegment(rowsBeforeLive, rowsBeforeLive - 0.5),
} as React.CSSProperties;
}
+const SPINE_HAIRLINE_ROW_STYLE = {
+ ["--spine-top"]: "var(--spine, var(--border))",
+ ["--spine-bottom"]: "var(--spine, var(--border))",
+} as React.CSSProperties;
+
function spineAccentColor(distanceFromLive: number): string {
if (distanceFromLive >= 1.5) {
return "var(--border)";
diff --git a/apps/web/src/planPanelState.test.ts b/apps/web/src/planPanelState.test.ts
new file mode 100644
index 00000000..86a1ded5
--- /dev/null
+++ b/apps/web/src/planPanelState.test.ts
@@ -0,0 +1,38 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { derivePlanTaskBadge } from "./planPanelState";
+import type { ActivePlanState } from "./session-logic";
+
+function plan(
+ statuses: ReadonlyArray,
+): ActivePlanState {
+ return {
+ createdAt: "2026-09-06T08:00:00.000Z",
+ turnId: null,
+ steps: statuses.map((status, index) => ({ step: `Step ${index + 1}`, status })),
+ };
+}
+
+describe("derivePlanTaskBadge", () => {
+ it("counts finished steps, not the step being worked on", () => {
+ const badge = derivePlanTaskBadge({
+ activePlan: plan(["completed", "inProgress", "pending"]),
+ activeProposedPlan: null,
+ });
+
+ expect(badge).toMatchObject({ label: "1/3", tone: "active", pulse: true });
+ expect(badge?.ariaLabel).toBe("Tasks, 1 of 3 done, working on step 2");
+ });
+
+ it("shows the queued count before anything starts and goes green when everything is done", () => {
+ expect(
+ derivePlanTaskBadge({ activePlan: plan(["pending", "pending"]), activeProposedPlan: null }),
+ ).toMatchObject({ label: "2", tone: "ready", pulse: false });
+ expect(
+ derivePlanTaskBadge({
+ activePlan: plan(["completed", "completed"]),
+ activeProposedPlan: null,
+ }),
+ ).toMatchObject({ label: "2/2", tone: "complete", pulse: false });
+ });
+});
diff --git a/apps/web/src/planPanelState.ts b/apps/web/src/planPanelState.ts
index fdb7d77f..24beba5e 100644
--- a/apps/web/src/planPanelState.ts
+++ b/apps/web/src/planPanelState.ts
@@ -138,14 +138,11 @@ export function derivePlanTaskBadge(input: {
const total = activePlan.steps.length;
const activeStepIndex = activePlan.steps.findIndex((step) => step.status === "inProgress");
const completedCount = activePlan.steps.filter((step) => step.status === "completed").length;
- // Before anything starts, "0/N" reads like stalled progress — show the
- // queued step count instead and switch to n/m once work begins.
- const label =
- activeStepIndex >= 0
- ? `${activeStepIndex + 1}/${total}`
- : completedCount > 0
- ? `${completedCount}/${total}`
- : `${total}`;
+ // The count is finished work over total, so it matches the filled-in dots
+ // in the popover; the pulse says a step is running. Before anything
+ // starts, "0/N" reads like stalled progress, so show the queued count.
+ const started = activeStepIndex >= 0 || completedCount > 0;
+ const label = started ? `${completedCount}/${total}` : `${total}`;
const tone =
completedCount === total
? "complete"
@@ -159,11 +156,11 @@ export function derivePlanTaskBadge(input: {
label,
ariaLabel:
activeStepIndex >= 0
- ? `Tasks, working on step ${activeStepIndex + 1} of ${total}`
+ ? `Tasks, ${completedCount} of ${total} done, working on step ${activeStepIndex + 1}`
: completedCount === total
? `Tasks complete, ${completedCount} of ${total}`
: completedCount > 0
- ? `Tasks, ${completedCount} of ${total} complete`
+ ? `Tasks, ${completedCount} of ${total} done`
: `Tasks, ${total} steps queued`,
tone,
pulse: activeStepIndex >= 0,