From 18ab1d3f34344e411f7a5a1b56c1ec6e595d290a Mon Sep 17 00:00:00 2001 From: OceanLi <122793010+ohdearquant@users.noreply.github.com> Date: Wed, 3 Jun 2026 14:07:04 -0400 Subject: [PATCH 1/3] feat(studio/frontend): four Studio UX fixes (#1177 #1178 #1179 #1135) #1177: Action panel auto-synthesizes a next action from failed plays (names, exit codes, remediation) instead of rendering "no blockers" when failures exist. (app/shows/[topic]/page.tsx) #1178: Runs page filter chips show per-status counts derived from loaded runs; active chip filled vs outlined. (app/runs/page.tsx) #1179: Play graph gets status-based node color, clickable nodes (inline expand), zoom + fit-to-view, and critical-path highlight; ReactFlow kept. (app/shows/[topic]/components/PlayDag.tsx) #1135: jsx-a11y ESLint perf baseline documented; production bundle byte-identical with/without plugin, eslint config restored. (PERF.md) Gates (verified by tester op-5 and re-run by critic op-6): pnpm lint 0 errors, pnpm typecheck 0 errors, pnpm build green (18/18 pages). Co-Authored-By: Claude Opus 4.8 --- apps/studio/frontend/PERF.md | 56 +++++++++ apps/studio/frontend/app/runs/page.tsx | 29 ++++- .../app/shows/[topic]/components/PlayDag.tsx | 117 +++++++++++++++++- .../frontend/app/shows/[topic]/page.tsx | 26 +++- 4 files changed, 220 insertions(+), 8 deletions(-) create mode 100644 apps/studio/frontend/PERF.md diff --git a/apps/studio/frontend/PERF.md b/apps/studio/frontend/PERF.md new file mode 100644 index 000000000..d6c3c4ec2 --- /dev/null +++ b/apps/studio/frontend/PERF.md @@ -0,0 +1,56 @@ +# jsx-a11y ESLint Performance Baseline (#1135) + +Date: 2026-06-03 + +## Methodology + +- Worktree: `show/lionagi-sweep/studio-frontend` +- Directory: `apps/studio/frontend` +- Runtime: Node `v25.6.0`, pnpm `10.15.0` +- Commands: + - `pnpm lint` (`eslint .`) + - `pnpm build` (`next build`) +- Timing method: `/usr/bin/time -p`, reporting wall-clock `real` seconds. +- Repetitions: `n=3` per condition. +- Build method: removed `.next` before each build; reused installed dependencies and toolchain caches. +- Bundle-size metric: summed bytes for `.next/static` JavaScript and CSS files after each successful build. +- jsx-a11y enabled condition: shipped `eslint.config.mjs`. +- jsx-a11y disabled condition: temporary config removed the real `eslint-plugin-jsx-a11y` import and stripped active `jsx-a11y/*` rules from `eslint-config-next`. An inert local namespace was added only so existing `eslint-disable` comments for jsx-a11y rule IDs would parse; `eslint --print-config app/runs/page.tsx` showed no active `jsx-a11y/*` rules. +- Restoration check: `eslint.config.mjs` SHA-256 before and after measurement was `0bc03ee3ef328ccf35124318873816b2351c7ee94e4cd54ac55c388c5dff1ea5`. + +## Lint Time + +| Condition | Runs, seconds | Mean | Stddev | CV | Result | +| ----------------- | ---------------: | ---: | -----: | ----: | --------------------------- | +| jsx-a11y enabled | 4.15, 6.33, 5.00 | 5.16 | 1.10 | 21.3% | pass, 0 errors / 3 warnings | +| jsx-a11y disabled | 3.90, 3.97, 3.94 | 3.94 | 0.04 | 0.9% | pass, 0 errors / 7 warnings | + +Observed lint delta: enabled mean minus disabled mean = `+1.22s`. The enabled lint sample is noisy (`CV=21.3%`), so treat this as a local baseline rather than a statistically stable estimate. + +The disabled condition reports more warnings because existing `eslint-disable` comments for jsx-a11y rules become unused when those rules are inactive. + +## Build Time + +| Condition | Runs, seconds | Mean | Stddev | CV | Result | +| --------------------------- | ------------------: | ----: | -----: | ----: | ------ | +| jsx-a11y enabled | 13.80, 13.22, 21.13 | 16.05 | 4.41 | 27.5% | pass | +| jsx-a11y disabled | 5.71, 5.65, 5.90 | 5.75 | 0.13 | 2.3% | pass | +| post-restore enabled sanity | 6.09 | 6.09 | n/a | n/a | pass | + +The first enabled build group shows warmup/cache noise (`CV=27.5%`). Because `next build` does not execute the ESLint rule set here, the post-restore enabled sanity build is the better check for whether jsx-a11y affects build execution. It remained comparable to the disabled build timings. + +## Production Bundle Size + +| Condition | Static JS/CSS files | Static JS/CSS bytes | Delta vs enabled | +| --------------------------- | ------------------: | ------------------: | ---------------: | +| jsx-a11y enabled | 45 | 1,482,391 | 0 | +| jsx-a11y disabled | 45 | 1,482,391 | 0 | +| post-restore enabled sanity | 45 | 1,482,391 | 0 | + +## Conclusion + +`eslint-plugin-jsx-a11y` has zero production-bundle impact in this frontend: the built `.next/static` JavaScript/CSS payload is byte-identical with and without active jsx-a11y ESLint rules. + +The only measured effect is on `pnpm lint`, where the local three-run mean was `5.16s` with jsx-a11y enabled versus `3.94s` without active jsx-a11y rules. Build-time differences are attributable to build cache/warmup variance, not the ESLint plugin. + +Config restoration: confirmed. `eslint.config.mjs` was restored to the original SHA-256 hash and has no git diff after measurement. diff --git a/apps/studio/frontend/app/runs/page.tsx b/apps/studio/frontend/app/runs/page.tsx index c5fc88c1a..f0afe3f87 100644 --- a/apps/studio/frontend/app/runs/page.tsx +++ b/apps/studio/frontend/app/runs/page.tsx @@ -112,15 +112,25 @@ function provenanceLabel(run: RunSummary): "fs" | "db" { function StatusFilterChip({ value, + count, active, onClick, }: { value: string; + count?: number; active: boolean; onClick: () => void; }) { return ( - ); @@ -327,6 +337,22 @@ function RunsPageInner() { [runs], ); + // Per-status counts derived from the currently loaded runs. + // "completed" is the canonical DB value; "done" is the ADR-0025 backward-compat alias. + const statusCounts = useMemo>(() => { + const counts: Record = {}; + for (const s of STATUS_FILTERS) counts[s] = 0; + for (const run of runs) { + const st = run.status; + if (st === "completed") { + counts["done"] = (counts["done"] ?? 0) + 1; + } else if (st in counts) { + counts[st] = (counts[st] ?? 0) + 1; + } + } + return counts; + }, [runs]); + return (
toggleStatus(s)} /> diff --git a/apps/studio/frontend/app/shows/[topic]/components/PlayDag.tsx b/apps/studio/frontend/app/shows/[topic]/components/PlayDag.tsx index 1116b70e4..7bd840719 100644 --- a/apps/studio/frontend/app/shows/[topic]/components/PlayDag.tsx +++ b/apps/studio/frontend/app/shows/[topic]/components/PlayDag.tsx @@ -1,8 +1,8 @@ "use client"; -import { useMemo } from "react"; +import { useCallback, useMemo } from "react"; import ReactFlow, { Background, Controls, MarkerType } from "reactflow"; -import type { Edge, Node } from "reactflow"; +import type { Edge, Node, NodeMouseHandler } from "reactflow"; import "reactflow/dist/style.css"; import { getLayoutedElements } from "@/components/canvas/useLayout"; import type { ShowDetail } from "@/lib/types"; @@ -12,6 +12,7 @@ type Play = ShowDetail["plays"][number]; interface PlayDagProps { plays: Play[]; showMd?: string | null; + onNodeClick?: (playName: string) => void; } function parseShowMdDeps(showMd: string | null | undefined): Map { @@ -39,6 +40,23 @@ function parseShowMdDeps(showMd: string | null | undefined): Map { + const outgoing = new Map(); + const inDegree = new Map(); + for (const n of nodes) { + outgoing.set(n.id, []); + inDegree.set(n.id, 0); + } + for (const e of edges) { + outgoing.get(e.source)?.push(e.target); + inDegree.set(e.target, (inDegree.get(e.target) ?? 0) + 1); + } + + // Kahn's topological sort + const queue: string[] = []; + const deg = new Map(inDegree); + deg.forEach((d, id) => { + if (d === 0) queue.push(id); + }); + const topo: string[] = []; + while (queue.length > 0) { + const u = queue.shift()!; + topo.push(u); + for (const v of outgoing.get(u) ?? []) { + const nd = (deg.get(v) ?? 0) - 1; + deg.set(v, nd); + if (nd === 0) queue.push(v); + } + } + + // DP: longest path distance and predecessor tracking + const dist = new Map(nodes.map((n) => [n.id, 0])); + const prev = new Map(nodes.map((n) => [n.id, null])); + for (const u of topo) { + for (const v of outgoing.get(u) ?? []) { + if ((dist.get(u) ?? 0) + 1 > (dist.get(v) ?? 0)) { + dist.set(v, (dist.get(u) ?? 0) + 1); + prev.set(v, u); + } + } + } + + // Find end of longest path + let maxDist = -1; + let endNode = ""; + dist.forEach((d, id) => { + if (d > maxDist) { + maxDist = d; + endNode = id; + } + }); + + // Map "source→target" to edge id for fast lookup + const edgeByKey = new Map(edges.map((e) => [`${e.source}→${e.target}`, e.id])); + + // Trace back from endNode to collect critical edge IDs + const critical = new Set(); + let cur = endNode; + let p = prev.get(cur); + while (p) { + const eid = edgeByKey.get(`${p}→${cur}`); + if (eid) critical.add(eid); + cur = p; + p = prev.get(cur); + } + return critical; +} + +export default function PlayDag({ plays, showMd, onNodeClick }: PlayDagProps) { const { nodes, edges } = useMemo(() => { const depsMap = parseShowMdDeps(showMd); const playNames = new Set(plays.map((p) => p.name)); @@ -66,7 +152,7 @@ export default function PlayDag({ plays, showMd }: PlayDagProps) { position: { x: 0, y: 0 }, data: { label: play.name }, style: { - background: "var(--surface-raised)", + background: statusBackground(play.meta.status), border: `1px solid ${statusColor(play.meta.status)}`, color: "var(--content-primary)", fontSize: 10, @@ -107,11 +193,29 @@ export default function PlayDag({ plays, showMd }: PlayDagProps) { })); } - return getLayoutedElements(rawNodes, rawEdges, "LR"); + // Highlight the critical (longest) path with a distinct edge style + const criticalIds = criticalPathEdgeIds(rawNodes, rawEdges); + const highlightedEdges = rawEdges.map((e) => + criticalIds.has(e.id) + ? { ...e, style: { ...e.style, stroke: "var(--status-running)", strokeWidth: 2 } } + : e, + ); + + return getLayoutedElements(rawNodes, highlightedEdges, "LR"); }, [plays, showMd]); + const handleNodeClick: NodeMouseHandler = useCallback( + (_event, node) => { + onNodeClick?.(node.id); + }, + [onNodeClick], + ); + return ( -
+
diff --git a/apps/studio/frontend/app/shows/[topic]/page.tsx b/apps/studio/frontend/app/shows/[topic]/page.tsx index ceb7e40f4..92e0fbf36 100644 --- a/apps/studio/frontend/app/shows/[topic]/page.tsx +++ b/apps/studio/frontend/app/shows/[topic]/page.tsx @@ -468,7 +468,11 @@ export default function ShowDetailPage({ params }: { params: Promise<{ topic: st {show.plays.length > 0 && (

Play graph

- + setExpanded(playName)} + />
)} @@ -570,6 +574,26 @@ function ShowSummaryPanel({ {hasBlockers && } {hasNext && nextValue && } + ) : rollup && rollup.failed.length > 0 ? ( + // Synthesized from failed plays when no explicit blocker/next is declared +
+

+ Failed plays — suggested actions +

+
    + {rollup.failed.map((play) => ( +
  • + {play.name} + + exit {play.meta.exit_code ?? "—"} —{" "} + {play.meta.exit_code === 124 + ? "rerun with timeout override" + : "inspect logs for details"} + +
  • + ))} +
+
) : (

No blockers or next action declared in plan. From 8bbf76d2207a46a367694a7b17aee10f9781215f Mon Sep 17 00:00:00 2001 From: OceanLi <122793010+ohdearquant@users.noreply.github.com> Date: Sat, 6 Jun 2026 08:52:25 -0400 Subject: [PATCH 2/3] chore: add sweep summary Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/studio/frontend/SUMMARY.md | 89 +++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 apps/studio/frontend/SUMMARY.md diff --git a/apps/studio/frontend/SUMMARY.md b/apps/studio/frontend/SUMMARY.md new file mode 100644 index 000000000..c7329a582 --- /dev/null +++ b/apps/studio/frontend/SUMMARY.md @@ -0,0 +1,89 @@ +# Studio Frontend — Four UX Fixes Summary + +**Branch**: `show/lionagi-sweep/studio-frontend` (based on `main`) +**Commit**: `18ab1d3f3` — `feat(studio/frontend): four Studio UX fixes (#1177 #1178 #1179 #1135)` +**Status**: committed locally only — **NOT pushed, no PR opened**. +**Critic verdict** (op-6): **APPROVE** — `CRIT:0 | MAJ:0 | MIN:2` (both MINOR are inherent-design +notes that conform to the contracts; neither blocks). + +## Gate results (tester op-5, re-run and confirmed by critic op-6) + +| Gate | Command | Result | +| --------- | --------------------------------- | -------------------------------------------------------------------------- | +| Lint | `pnpm lint` | **PASS** — exit 0, 0 errors (3 pre-existing warnings, none from this work) | +| Typecheck | `pnpm typecheck` (`tsc --noEmit`) | **PASS** — exit 0, 0 type errors | +| Build | `pnpm build` (`next build`) | **PASS** — ✓ Compiled successfully, 18/18 static pages | + +> Pre-commit prettier hook reformatted the three `.tsx` files + `PERF.md` on first commit attempt; +> changes are formatting-only, were re-staged, and the eslint/prettier hooks both passed on the +> final commit. + +--- + +## #1177 — Action panel auto-synthesize from failed plays + +**What changed**: On the Show-detail ACTION panel, when no explicit blocker/next-step is declared +but failed plays exist (`rollup.failed.length > 0`), the panel now synthesizes a concrete next +action — each failed play named with its exit code (`play.meta.exit_code`) and a remediation hint +(exit 124 → "rerun with timeout override", otherwise → "inspect logs for details"). The literal +"No blockers or next action declared in plan." is now only reachable when there are zero failed +plays, so the misleading render is impossible. + +**Files touched**: `app/shows/[topic]/page.tsx` (lines ~577–599). + +**Gate**: lint 0 errors / typecheck clean / build green. + +--- + +## #1178 — Filter chips show per-status counts + +**What changed**: Runs page filter chips now display per-status counts derived from the already- +loaded runs via a `statusCounts` memo (recomputes on `[runs]`, seeds all `STATUS_FILTERS` to 0, +maps canonical DB `"completed"` → `"done"` chip per ADR-0025). Counts render through `Button`'s +existing `trailing` slot and are hidden during skeleton load. Active vs inactive distinction is +provided by the existing `Button variant="toggle"` (filled green vs outlined). + +**Files touched**: `app/runs/page.tsx` (`statusCounts` memo ~342–356; chip render + call site). + +**Gate**: lint 0 errors / typecheck clean / build green. + +--- + +## #1179 — Play graph: color, click, zoom, critical path + +**What changed** (ReactFlow extended, **not** swapped): + +- Node color by status — `statusBackground()`: failed/error = red, running = blue, + merged/completed/done = green, pending/blocked = neutral gray (all 5 CSS vars confirmed present + in `globals.css`). +- Clickable nodes → inline expand of the matching play's table row (`onNodeClick` → `setExpanded`). +- Zoom controls + fit-to-view via `` and `fitView`/`fitViewOptions`; height 220→300px. +- Critical-path highlight — Kahn topo-sort + DP longest-path (`criticalPathEdgeIds()`), critical + edges restyled (running-blue stroke, width 2). +- `for...of` over Map replaced with `.forEach` for `target: es5`. + +**Files touched**: `app/shows/[topic]/components/PlayDag.tsx`. + +**Gate**: lint 0 errors / typecheck clean / build green; no graph regression. + +--- + +## #1135 — jsx-a11y ESLint perf baseline + +**What changed**: Measurement + doc only (no code behavior change). One-shot n=3 audit of +`pnpm lint` and `pnpm build` with/without the jsx-a11y plugin. Lint overhead ~+1.22s +(enabled 5.16s vs disabled 3.94s); production bundle **byte-identical** at 1,482,391 bytes / 45 +files across all conditions (eslint is dev-only). eslint config restored to original +SHA-256 `0bc03ee3ef…` (empty `git diff`). + +**Files touched**: `apps/studio/frontend/PERF.md` (new). **See `PERF.md` for the full #1135 +baseline numbers, methodology, and raw timings.** + +**Gate**: PERF.md present; config restored; lint pass. + +--- + +## Disposition + +Four fixes implemented, committed to `show/lionagi-sweep/studio-frontend` as `18ab1d3f3`. +All gates green. **Nothing pushed; no PR opened.** From bac1b09087fd7d26087ab5e692797d06411c782c Mon Sep 17 00:00:00 2001 From: OceanLi <122793010+ohdearquant@users.noreply.github.com> Date: Wed, 10 Jun 2026 19:20:37 -0400 Subject: [PATCH 3/3] =?UTF-8?q?fix(studio):=20panel/filters=20review=20rou?= =?UTF-8?q?nd=201=20=E2=80=94=20memo=20stability,=20honest=20filter=20coun?= =?UTF-8?q?ts,=20drop=20sweep=20artifact?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - runs gets a stable memoized reference (the [] fallback was a fresh reference every render, re-running both downstream memos and tripping react-hooks/exhaustive-deps twice). Clears the lint warnings entirely. - Filter chip counts hide when a status filter is active: counts derive from the loaded result set, so the other chips' true counts are unknowable client-side — hiding beats showing misleading zeros. - SUMMARY.md removed: agent sweep artifact, not repo material. Co-Authored-By: Claude Fable 5 --- apps/studio/frontend/SUMMARY.md | 89 -------------------------- apps/studio/frontend/app/runs/page.tsx | 9 ++- 2 files changed, 7 insertions(+), 91 deletions(-) delete mode 100644 apps/studio/frontend/SUMMARY.md diff --git a/apps/studio/frontend/SUMMARY.md b/apps/studio/frontend/SUMMARY.md deleted file mode 100644 index c7329a582..000000000 --- a/apps/studio/frontend/SUMMARY.md +++ /dev/null @@ -1,89 +0,0 @@ -# Studio Frontend — Four UX Fixes Summary - -**Branch**: `show/lionagi-sweep/studio-frontend` (based on `main`) -**Commit**: `18ab1d3f3` — `feat(studio/frontend): four Studio UX fixes (#1177 #1178 #1179 #1135)` -**Status**: committed locally only — **NOT pushed, no PR opened**. -**Critic verdict** (op-6): **APPROVE** — `CRIT:0 | MAJ:0 | MIN:2` (both MINOR are inherent-design -notes that conform to the contracts; neither blocks). - -## Gate results (tester op-5, re-run and confirmed by critic op-6) - -| Gate | Command | Result | -| --------- | --------------------------------- | -------------------------------------------------------------------------- | -| Lint | `pnpm lint` | **PASS** — exit 0, 0 errors (3 pre-existing warnings, none from this work) | -| Typecheck | `pnpm typecheck` (`tsc --noEmit`) | **PASS** — exit 0, 0 type errors | -| Build | `pnpm build` (`next build`) | **PASS** — ✓ Compiled successfully, 18/18 static pages | - -> Pre-commit prettier hook reformatted the three `.tsx` files + `PERF.md` on first commit attempt; -> changes are formatting-only, were re-staged, and the eslint/prettier hooks both passed on the -> final commit. - ---- - -## #1177 — Action panel auto-synthesize from failed plays - -**What changed**: On the Show-detail ACTION panel, when no explicit blocker/next-step is declared -but failed plays exist (`rollup.failed.length > 0`), the panel now synthesizes a concrete next -action — each failed play named with its exit code (`play.meta.exit_code`) and a remediation hint -(exit 124 → "rerun with timeout override", otherwise → "inspect logs for details"). The literal -"No blockers or next action declared in plan." is now only reachable when there are zero failed -plays, so the misleading render is impossible. - -**Files touched**: `app/shows/[topic]/page.tsx` (lines ~577–599). - -**Gate**: lint 0 errors / typecheck clean / build green. - ---- - -## #1178 — Filter chips show per-status counts - -**What changed**: Runs page filter chips now display per-status counts derived from the already- -loaded runs via a `statusCounts` memo (recomputes on `[runs]`, seeds all `STATUS_FILTERS` to 0, -maps canonical DB `"completed"` → `"done"` chip per ADR-0025). Counts render through `Button`'s -existing `trailing` slot and are hidden during skeleton load. Active vs inactive distinction is -provided by the existing `Button variant="toggle"` (filled green vs outlined). - -**Files touched**: `app/runs/page.tsx` (`statusCounts` memo ~342–356; chip render + call site). - -**Gate**: lint 0 errors / typecheck clean / build green. - ---- - -## #1179 — Play graph: color, click, zoom, critical path - -**What changed** (ReactFlow extended, **not** swapped): - -- Node color by status — `statusBackground()`: failed/error = red, running = blue, - merged/completed/done = green, pending/blocked = neutral gray (all 5 CSS vars confirmed present - in `globals.css`). -- Clickable nodes → inline expand of the matching play's table row (`onNodeClick` → `setExpanded`). -- Zoom controls + fit-to-view via `` and `fitView`/`fitViewOptions`; height 220→300px. -- Critical-path highlight — Kahn topo-sort + DP longest-path (`criticalPathEdgeIds()`), critical - edges restyled (running-blue stroke, width 2). -- `for...of` over Map replaced with `.forEach` for `target: es5`. - -**Files touched**: `app/shows/[topic]/components/PlayDag.tsx`. - -**Gate**: lint 0 errors / typecheck clean / build green; no graph regression. - ---- - -## #1135 — jsx-a11y ESLint perf baseline - -**What changed**: Measurement + doc only (no code behavior change). One-shot n=3 audit of -`pnpm lint` and `pnpm build` with/without the jsx-a11y plugin. Lint overhead ~+1.22s -(enabled 5.16s vs disabled 3.94s); production bundle **byte-identical** at 1,482,391 bytes / 45 -files across all conditions (eslint is dev-only). eslint config restored to original -SHA-256 `0bc03ee3ef…` (empty `git diff`). - -**Files touched**: `apps/studio/frontend/PERF.md` (new). **See `PERF.md` for the full #1135 -baseline numbers, methodology, and raw timings.** - -**Gate**: PERF.md present; config restored; lint pass. - ---- - -## Disposition - -Four fixes implemented, committed to `show/lionagi-sweep/studio-frontend` as `18ab1d3f3`. -All gates green. **Nothing pushed; no PR opened.** diff --git a/apps/studio/frontend/app/runs/page.tsx b/apps/studio/frontend/app/runs/page.tsx index f0afe3f87..47642ef77 100644 --- a/apps/studio/frontend/app/runs/page.tsx +++ b/apps/studio/frontend/app/runs/page.tsx @@ -328,7 +328,9 @@ function RunsPageInner() { }); } - const runs = data?.runs ?? []; + // Memoized so the [] fallback keeps a stable reference while loading — + // otherwise every render re-runs the memos hanging off `runs`. + const runs = useMemo(() => data?.runs ?? [], [data]); const total = data?.total ?? 0; const totalPages = data?.total_pages ?? 1; @@ -445,7 +447,10 @@ function RunsPageInner() { 0 ? undefined : statusCounts[s]} active={statuses.includes(s)} onClick={() => toggleStatus(s)} />