From 8532dcab9c1726475e0b247e3948d0f3268d90cf Mon Sep 17 00:00:00 2001
From: badcuban <108198679+badcuban@users.noreply.github.com>
Date: Sun, 6 Sep 2026 04:44:55 -0400
Subject: [PATCH 1/2] fix(web): activity popover task dots sit on the line and
count finished steps
The header activity popover's task list had four small defects: the spine
dots rendered a pixel left of the line because the line sat on a half-pixel
and snapped differently from the dots; the badge counted the step being
worked on (2/3 with one step done) instead of finished work; done dots were
so faint they read like not-started; and the accent glow faded symmetrically
around the current step, so the line toward the next step lit up too.
Put the spine on whole pixels (line at x=7, node box 15px wide so odd-sized
dots centre on the line), make done dots solid, and only warm the line down
to the live row. The badge now shows finished/total and pulses while a step
runs. Background runs become rows on their own spine like the task steps
instead of filled cards, with a quiet Stop text button.
---
.../components/chat/ThreadActivityPopover.tsx | 209 ++++++++----------
apps/web/src/components/ui/threadline.tsx | 52 +++--
apps/web/src/planPanelState.test.ts | 38 ++++
apps/web/src/planPanelState.ts | 17 +-
4 files changed, 177 insertions(+), 139 deletions(-)
create mode 100644 apps/web/src/planPanelState.test.ts
diff --git a/apps/web/src/components/chat/ThreadActivityPopover.tsx b/apps/web/src/components/chat/ThreadActivityPopover.tsx
index 0237c1e9c..5304aa4ad 100644
--- a/apps/web/src/components/chat/ThreadActivityPopover.tsx
+++ b/apps/web/src/components/chat/ThreadActivityPopover.tsx
@@ -10,11 +10,9 @@ import {
} from "react";
import {
ChevronDownIcon,
- ClockIcon,
ExternalLinkIcon,
FileTextIcon,
ListTodoIcon,
- LoaderIcon,
RadarIcon,
SquareIcon,
TerminalSquareIcon,
@@ -28,7 +26,13 @@ import { cn } from "~/lib/utils";
import { useHorizontalOverflow } from "../../hooks/useHorizontalOverflow";
import { Button } from "../ui/button";
import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
-import { SpineNode, SpineRow, spineAccentRowStyle, type SpineNodeKind } from "../ui/threadline";
+import {
+ LiveNode,
+ SpineNode,
+ SpineRow,
+ spineAccentRowStyle,
+ type SpineNodeKind,
+} from "../ui/threadline";
import { Tooltip, TooltipPopup, TooltipTrigger, TooltipWrapper } from "../ui/tooltip";
import {
backgroundRunCommandText,
@@ -530,11 +534,7 @@ function TaskSection({
nodeOffset={TASK_STEP_NODE_OFFSET_PX}
connectTop={index > 0}
connectBottom={index < visiblePlanStepRows.length - 1}
- style={
- liveStepIndex >= 0
- ? spineAccentRowStyle(Math.abs(index - liveStepIndex))
- : undefined
- }
+ style={liveStepIndex >= 0 ? spineAccentRowStyle(liveStepIndex - index) : undefined}
>
{backgroundRuns.length}
-
- {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 000000000..86a1ded5b
--- /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 fdb7d77f3..24beba5ef 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,
From 4781c8c2e3e7f4b79bd889486d6f89665a1177e1 Mon Sep 17 00:00:00 2001
From: badcuban <108198679+badcuban@users.noreply.github.com>
Date: Sun, 6 Sep 2026 04:58:12 -0400
Subject: [PATCH 2/2] test(web): timeline warning and error spine dots are 5px
---
apps/web/src/components/chat/MessagesTimeline.test.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx
index 7462ac0a2..c92c71f1d 100644
--- a/apps/web/src/components/chat/MessagesTimeline.test.tsx
+++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx
@@ -791,8 +791,8 @@ describe("MessagesTimeline", () => {
expect(markup).toContain("Claude API connection issue");
expect(markup).toContain("Runtime error");
- expect(markup).toContain("size-[6px] rounded-full bg-warning");
- expect(markup).toContain("size-[6px] rounded-full bg-destructive");
+ expect(markup).toContain("size-[5px] rounded-full bg-warning");
+ expect(markup).toContain("size-[5px] rounded-full bg-destructive");
expect(markup).not.toContain("border-warning/65");
expect(markup).not.toContain("border-destructive/70");
expect(markup).not.toContain("lucide-circle-alert");