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/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.** 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.