Skip to content

feat(dashboard): cross-link candidates/rollouts and consolidate the overlapping diff & file tabs - #253

Open
OsherElhadad wants to merge 3 commits into
mainfrom
feat/issue-139-spa-crosslink
Open

feat(dashboard): cross-link candidates/rollouts and consolidate the overlapping diff & file tabs#253
OsherElhadad wants to merge 3 commits into
mainfrom
feat/issue-139-spa-crosslink

Conversation

@OsherElhadad

Copy link
Copy Markdown
Collaborator

Closes #139.

The DeepDive had ten top-level tabs and no cross-linking. Four of those tabs were
different file/diff surfaces the reader had to disambiguate before knowing which one
answered "what did this edit change", and there was no path from "cand_0002 regressed"
to either the edit or the failing task.

Tab decisions

Tab Decision What the user loses Where its data now lives
Overview (one chart) merged → Fitness Nothing. The chart is unchanged and now sits beside the heatmap in a tab named for what it shows. #138's evidence header answers the at-a-glance questions above the tabs; this tab is where you pick a candidate, which the header does not do. ?tab=fitnessBestCurveChart + the new TaskHeatmap
Iterations (candidate↔parent diff) merged → Changes & files / “Candidate diff” One extra click from the top bar. Gains a preselected candidate when arrived at via a cross-link. ?tab=changes&mode=candidateIterationsDiff, unchanged, +candidate prop
Git diffs (commit↔commit) merged → Changes & files / “Commit diff” One extra click. ?tab=changes&mode=commitGitDiff, unchanged
Memory merged → Changes & files / “Memory” One extra click. ?tab=changes&mode=memoryMemoryPanel, unchanged, +candidate prop
Files (raw run dir) merged → Changes & files / “Raw files” One extra click. ?tab=changes&mode=filesFileTree, unchanged
Cost · Phases · Lineage · Trajectories · Insights · Custom view kept unchanged
added: the tasks × iterations heatmap ?tab=fitness — new TaskHeatmap

Nothing was removed, and no tab lost its panel. All four surfaces still mount, as
sub-modes, in increasing distance from the edit itself: the diff → the commit → the
optimizer's memory of it → the bytes on disk. Ten tabs → seven, with four sub-modes.

Why consolidate rather than delete

Deleting any of the four would orphan a backend field. Consolidating orphans nothing —
verified below. In particular MemoryPanel is the live reader of rejected.jsonl and
history.jsonl that #212 confirmed must not lose its consumer, so it is a sub-mode,
not a casualty. The one thing #234's evidence header genuinely made redundant is a
top-level tab spent on a single chart — so "Overview" became "Fitness" and earned its
place by carrying the two candidate-selection charts, which the header cannot replace.

No orphaned data — every field still has a reader

Data Reader before Reader after
/diff/{candidate} IterationsDiff same, in mode=candidate
/git/log, /git/diff GitDiff same, in mode=commit
/memoryhistory / rejected (history.jsonl, rejected.jsonl) MemoryPanel and insights.ts::deadEnds (#212) both unchangedmode=memory and the Insights tab
/candidate/{cid}/files MemoryPanel same, in mode=memory
/tree, /file FileTree same, in mode=files
/rollouts, /rollout/{file} Trajectories, Insights same, plus the heatmap cross-link resolves through /rollouts
graph.nodes[].per_task, graph.nodes[].feedback NOTHING in the SPA — exported by the backend, read only by the single-file static dashboard.py new TaskHeatmap
summary.tasks dashboard.py only new TaskHeatmap

Net: zero fields orphaned, two previously-orphaned fields gained a reader. No backend
field is marked for removal by this PR.

Cross-link map

All cross-links are URL state, so one link = one navigation, the back button undoes it,
and "look at this candidate" is a shareable URL.

From Interaction To
BestCurveChart scatter dot click ?tab=trajectories&candidate=<id> — that candidate's rollouts only
BestCurveChart candidate table Tab → Enter/Space same (the keyboard path — an SVG scatter shape is not focusable)
Trajectories, when cross-linked "See what <id> changed →" ?tab=changes&mode=candidate&candidate=<id> — diff preselected
Changes, when cross-linked "← See how <id> scored per task" ?tab=trajectories&candidate=<id>
TaskHeatmap cell click / Enter / Space ?tab=trajectories&candidate=<cid>&task=<t> → opens that rollout's drawer
Trajectories candidate chip × clears the filter

The heatmap cell resolves (task, candidate) through the /rollouts index rather than
guessing a filename, and switches the split selector to wherever the rollout actually
lives. When a per-task score exists but its trajectory file wasn't kept, it says so
instead of opening an empty drawer.

Accessibility

This epic has already caught colour-only accept/reject chips (#204) and focus escaping
behind a drawer (#196), so:

  • Tabs follow the WAI-ARIA pattern. One tab stop per tablist (roving tabindex) —
    measured ["0","-1","-1","-1","-1","-1","-1"] — then Left/Right/Home/End move selection
    and focus together. Enter/Space are native <button> activation.
  • No state is colour-only. Active tab = bold + aria-selected, not colour. Every
    heatmap cell carries a glyph (✓ ✗ ~ ·) and a worded aria-label
    ("a1 at iteration 0 (seed): fail, reward 0.000 — expected '7' but agent produced …"),
    and the legend names each outcome in words. Same icon+word contract as StatusBadge.
  • Visible focus everywhere. Measured outline: 2px solid rgb(59,130,246) with a 2px
    offset on tabs, heatmap cells and cross-links, reached by real Tab
    (matches(':focus-visible') === true).
  • The drawer no longer leaks focus. Its backdrop was a focusable <button> — exactly
    how focus lands behind an open panel. It is now aria-hidden and unfocusable; focus
    moves into the panel on open, Tab/Shift+Tab cycle inside it, Escape closes it, and focus
    returns to the opener — or, when a cross-link already unmounted the opener, to the
    surrounding tabpanel rather than dropping to <body>.
  • The heatmap is a real <table> of <button> cells with a <caption>, scoped headers
    and per-cell labels — not SVG rects — so cell activation is keyboard-native rather than
    re-implemented.

Merge order & merged-tree result

Expected order — this PR is happiest last among the SPA PRs:
#194#212#218#221#204#234#139.

Merged locally with #204 + #234 + #218 + #221 all applied:

control (4 PRs, no #139) with #139
pytest core/tests 32 failed / 274 passed 32 failed / 274 passed — identical
tsc -b --noEmit clean
vite build clean
vitest 89 passed / 89

The 32 python failures are pre-existing in the sibling merge and identical without this
PR
— they come from resolving the #204#218 conflict in eventstream.py / app.py /
cli.py by hand (those PRs disagree about the SSE offset and the tail exit codes, and
each owns half). #139 contributes zero conflicts and zero regressions: its only
merge conflict is one hunk of the TABS array against #204's Events tab, resolved by
keeping both.

On this branch alone: 179 passed / 179, 0 failed and 54 vitest passed / 54.

dashboard/frontend/dist/ is NOT committed (#188) — built to verify, then reverted;
git status -- dashboard/frontend/dist is empty.

Verification

Exercised against a real zero-API examples/toy_calc + mock run
(baseline_val 0.0 → test_reward 1.0, 3 iterations, cand_0001 accepted, cand_0002/
cand_0003 rejected), served by the real FastAPI backend against the real run dir with
the freshly-built SPA. Full command log and complete output in the 🔬 Evidence
comment; the load-bearing excerpts:

$ curl -s http://127.0.0.1:7979/api/health
{"ok":true,"base_dir":"/tmp/toy139/.capevolve"}

===== TOP TABLIST =====
["Fitness","Cost","Phases","Lineage","Trajectories","Changes & files","Insights"]
roving tabindex (should be exactly one "0"): ["0","-1","-1","-1","-1","-1","-1"]

===== VIEW: Fitness — real data, real per_task =====
Per-task pass/fail across iterations
rows worst-first · select a cell to open that rollout
task	0	1	2	3
a1	✗	✓	✓	✓
a4	✗	✓	✓	✓
✓ pass   ✗ fail   ~ partial   · not run

===== CROSS-LINK 1: candidate -> trajectories =====
URL: .../runs/run_demo?tab=trajectories&candidate=cand_0001
selected tab: Trajectories
task	candidate	reward	feedback
a1	cand_0001	100.0%	correct
a4	cand_0001	100.0%	correct

===== CROSS-LINK 2: trajectories -> that candidate's diff =====
URL: .../runs/run_demo?tab=changes&candidate=cand_0001&mode=candidate
sub-tabs: ["Candidate diff","Commit diff","Memory","Raw files"]
vs seed · Δ 100.0%
prompt.txt  +2 −0
+[CALC] Compute the arithmetic expression exactly and output ONLY the resulting number.

===== CROSS-LINK 3: heatmap cell -> rollout drawer =====
cell aria-label: a1 at iteration 0 (seed): fail, reward 0.000 — expected '7' but agent
  produced 'I think 3 + 4 is roughly some number.'; the prompt likely lacks an explicit
  instruction to compute and output only the number
URL: .../runs/run_demo?tab=trajectories&candidate=seed&task=a1
drawer: a1__seed__t0.json
  REWARD 0.0%   INPUT 3 + 4   OUTPUT I think 3 + 4 is roughly some number.
  TRACE prompt_had_calc=False
focus in drawer on open: { tag: 'BUTTON', label: 'Close (Escape)' }

===== KEYBOARD =====
ArrowRight  -> selected=Cost      focused=Cost      url=?tab=cost
ArrowRight  -> selected=Phases    focused=Phases    url=?tab=phases
End         -> selected=Insights  focused=Insights  url=?tab=insights
Home        -> selected=Fitness   focused=Fitness   url=?tab=fitness
Space on a heatmap cell -> drawer open: 1 | file: a4__seed__t0.json
Shift+Tab inside drawer -> { inDrawer: true }
Escape -> drawer count: 0 | focus after close: { tag:'DIV', role:'tabpanel' }   # not <body>
back button: ?tab=trajectories&candidate=cand_0001  ->  back  ->  ?tab=fitness

first heatmap cell reached by real Tab:
  { matchesFocusVisible: true, outline: "2px solid rgb(59, 130, 246)", offset: "2px" }
first tab button reached by real Tab:
  { matchesFocusVisible: true, outline: "2px solid rgb(59, 130, 246)", fontWeight: "600" }
$ PYTHONPATH=core python -m pytest core/tests -q
179 passed in 63.95s

$ python -m compileall -q core dashboard   # clean, exit 0
$ npx tsc -b --noEmit                      # clean, exit 0
$ npm run build                            # ✓ built in 739ms
$ npm test
  Test Files  14 passed (14)
       Tests  54 passed (54)

$ git status --porcelain -- dashboard/frontend/dist   # empty

Known pre-existing issue (not this PR)

A hard load of a deep path (/runs/<id>) 404s on the live backend: app.py mounts
StaticFiles with no SPA fallback, so client-side routes only resolve when entered from
/. Unrelated to this change (the static export uses HashRouter and is unaffected), so
it is left alone here — the evidence run enters via the Hub, which also exercises that
link. Worth its own issue.

Osher Elhadad added 2 commits July 31, 2026 03:47
…diff/file tabs

Closes #139.

The DeepDive had ten top-level tabs and no cross-linking: four separate
file/diff surfaces the reader had to disambiguate before knowing which one
answered "what did this edit change", and no way to get from "candidate
cand_0002 regressed" to the edit or the failing task.

Consolidation (10 tabs -> 7). Iterations / Git diffs / Memory / Files are now
sub-modes of one "Changes & files" area, ordered by distance from the edit
itself: the candidate diff, the real commit, the optimizer's memory of it, the
raw run dir. Every panel still mounts, so no backend field loses its reader --
including rejected.jsonl / history.jsonl via MemoryPanel, which #212 confirmed
are live consumers. "Overview" became "Fitness" and now carries both
candidate-selection charts, since those are the cross-link sources rather than
a tab spent on one chart.

Cross-links. Selecting a candidate on the fitness curve goes to its rollouts,
and one click further to its diff, preselected to the same id; a heatmap cell
opens that exact rollout's drawer, resolved through the rollouts index rather
than by guessing a filename. All of it is URL state (?tab / ?candidate / ?task
/ ?mode), so a cross-link is one navigation, the back button undoes it, and
"look at this candidate" is a shareable link.

New: TaskHeatmap. The tasks x iterations grid has existed in the single-file
static dashboard since day one but never in the SPA, which left the backend's
GraphNode.per_task and GraphNode.feedback with no SPA reader at all. This is
that reader, and the cross-link source the issue asks for. Built as a table of
buttons rather than SVG rects so cell activation is keyboard-native.

Accessibility, given the two failures this epic already caught:
- Tabs follow the WAI-ARIA pattern -- one tab stop per tablist (roving
  tabindex), Left/Right/Home/End between tabs, native Enter/Space.
- The active tab is bold + aria-selected, not colour alone; heatmap cells carry
  a glyph and a worded aria-label ("pass"/"fail"/"partial"/"not run"), not
  colour alone.
- The rollout drawer's backdrop is aria-hidden and unfocusable (it was a
  focusable <button>, which is exactly how focus lands behind a panel, #196),
  Tab cycles inside the panel, Escape closes, and focus returns to the opener
  -- or to the surrounding tabpanel when a cross-link already unmounted the
  opener, rather than dropping to <body>.
…d tabs

The consolidation test pinned the exact top-level tab array, which failed the
merge with #204 for adding an Events tab -- a reason unrelated to whether the
four diff/file surfaces were consolidated. Assert the subset that #139 owns and
that the four merged surfaces are absent; arrow-key nav is asserted by position
rather than by tab name for the same reason.
Copilot AI review requested due to automatic review settings July 31, 2026 00:58

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@skillberry-bot skillberry-bot added dashboard Dashboard backend/frontend enhancement New feature or request javascript Pull requests that update javascript code labels Jul 31, 2026
@skillberry-bot

Copy link
Copy Markdown
Contributor

🏷️ Automatic Labeling

I've analyzed this pull request and added the following labels:

  • dashboard - enhancement - dashboard - enhancement - javascript

These labels were selected based on the PR title, description, and changed files. If you believe any labels are incorrect or missing, feel free to adjust them manually.

@OsherElhadad

Copy link
Copy Markdown
Collaborator Author

🔬 Evidence

Every command and its complete output. Worktree /tmp/wt-139, python /tmp/ce-venv/bin/python.

1. Setup

$ git worktree add /tmp/wt-139 -b feat/issue-139-spa-crosslink origin/main
Preparing worktree (new branch 'feat/issue-139-spa-crosslink')
HEAD is now at 21fe3e49 fix(ci): keep the plan job's log line out of $GITHUB_OUTPUT (#242)

2. A real zero-API toy_calc + mock run

$ REPO=/tmp/wt-139
$ export CAPEVOLVE_CORE=$REPO/core PYTHONPATH=$REPO/core CAPEVOLVE_SKILLS_DIR=$REPO/skills
$ export CAPEVOLVE_TOY_DATA=$REPO/examples/toy_calc CAPEVOLVE_MOCK_SCRIPT=$REPO/examples/toy_calc/mock_script.json
$ D=/tmp/toy139; mkdir -p $D/.capevolve/project/adapters
$ cp $REPO/examples/toy_calc/adapter.py $D/.capevolve/project/adapters/
$ cp -R $REPO/examples/toy_calc/capability $D/seed_capability
$ cp $REPO/templates/project/capevolve.yaml $D/.capevolve/project/capevolve.yaml
$ cd $D && python -m cap_evolve.cli run --spec $D/.capevolve/project/capevolve.yaml --project $D/.capevolve/project --run-ts demo
{"dashboard": "http://127.0.0.1:7878"}
{
  "run_dir": ".capevolve/run_demo",
  "best_id": "cand_0001",
  "baseline_val": 0.0,
  "test_reward": 1.0,
  "test_baseline_reward": 0.0,
  "test_delta": 1.0,
  "test_pass_k": {"1": 1.0, "2": 0.0},
  "iterations": 3,
  "dashboard": ".capevolve/run_demo/dashboard.html",
  "dashboard_server": "http://127.0.0.1:7878"
}

The run graph — note per_task and feedback, which had no SPA reader at all before this PR:

$ python -m capevolve_dashboard.export_static --base .capevolve --run-id run_demo --out /tmp/toy139/data
wrote 92 JSON files to /tmp/toy139/data

$ python -c "...print each node..."
tasks: ['a1', 'a4']
seed 0 seed 0.0 {'a1': 0.0, 'a4': 0.0} {'a1': "expected '7' but agent produced 'I think 3 + 4 is roughly some number.'; the prompt likely lacks an explicit instruction to compute and output only the number", 'a4': "expected '10' but agent produced 'I think 9 + 1 is roughly some number.'; the prompt likely lacks an explicit instruction to compute and output only the number"}
cand_0001 1 accepted 1.0 {'a1': 1.0, 'a4': 1.0} {'a1': 'correct', 'a4': 'correct'}
cand_0002 2 rejected 1.0 {'a1': 1.0, 'a4': 1.0} {'a1': 'correct', 'a4': 'correct'}
cand_0003 3 rejected 1.0 {'a1': 1.0, 'a4': 1.0} {'a1': 'correct', 'a4': 'correct'}

3. Serving the built SPA against that real run dir

$ (cd dashboard/frontend && npm run build)   # verify only; dist reverted afterwards
$ CAPEVOLVE_BASE_DIR=/tmp/toy139/.capevolve CAPEVOLVE_STATIC_DIR=$REPO/dashboard/frontend/dist \
    python -m uvicorn capevolve_dashboard.asgi:app --host 127.0.0.1 --port 7979

$ curl -s http://127.0.0.1:7979/api/health
{"ok":true,"base_dir":"/tmp/toy139/.capevolve"}

$ curl -s http://127.0.0.1:7979/api/runs
[{"run_id":"run_demo","path":"/tmp/toy139/.capevolve/run_demo","algorithm":null,"status":"done","best_val":1.0,"baseline_val":0.0,"delta_pct":null,"iterations":3,"total_usd":0.0,"mtime":1785458150.909568}]

4. Every view and every cross-link, driven headlessly (playwright/chromium) against that server

full rendered output per view (click)
===== TOP TABLIST =====
["Fitness","Cost","Phases","Lineage","Trajectories","Changes & files","Insights"]
roving tabindex (should be exactly one "0"): ["0","-1","-1","-1","-1","-1","-1"]

===== VIEW: Fitness (default) =====
All runs
run_demo
done
0 live events
BEST
100.0%
BASELINE
0.0%
Δ VS BASELINE
—
SEALED TEST
100.0%
pass^k NaN%
COST (OPT+RUN)
$0.000
$0.000 + $0.000
TOKENS
0
ACCEPTED
1
REJECTED
2
FAILED
0
FRONTIER
1
WALL CLOCK
1s
$ / +1%
—
Fitness
Cost
Phases
Lineage
Trajectories
Changes & files
Insights
Fitness over iterations
best 100.0%
0
1
2
3
iteration
0
25
50
75
100
Candidates — select one to inspect its rollouts and diff
iter	candidate	val	best
0	seed	0.0%	0.0%
1	cand_0001	100.0%	100.0%
2	cand_0002	100.0%	100.0%
3	cand_0003	100.0%	100.0%
Per-task pass/fail across iterations
rows worst-first · select a cell to open that rollout
Per-task reward for every evaluated candidate. Rows are tasks, worst mean reward first; columns are iterations. Each cell states pass, fail, partial or not run.
task	0	1	2	3
a1	
✗
	
✓
	
✓
	
✓

a4	
✗
	
✓
	
✓
	
✓
✓
pass
✗
fail
~
partial
·
not run

===== HEATMAP CELL LABELS (non-colour state) =====
[{"label":"a1 at iteration 0 (seed): fail, reward 0.000 — expected '7' but agent produced 'I think 3 + 4 is roughly some number.'; the prompt likely lacks an explicit instruction to compute and output only the number","glyph":"✗"},{"label":"a1 at iteration 1 (cand_0001): pass, reward 1.000 — correct","glyph":"✓"},{"label":"a1 at iteration 2 (cand_0002): pass, reward 1.000 — correct","glyph":"✓"},{"label":"a1 at iteration 3 (cand_0003): pass, reward 1.000 — correct","glyph":"✓"},{"label":"a4 at iteration 0 (seed): fail, reward 0.000 — expected '10' but agent produced 'I think 9 + 1 is roughly some number.'; the prompt likely lacks an explicit instruction to compute and output only the number","glyph":"✗"},{"label":"a4 at iteration 1 (cand_0001): pass, reward 1.000 — correct","glyph":"✓"},{"label":"a4 at iteration 2 (cand_0002): pass, reward 1.000 — correct","glyph":"✓"},{"label":"a4 at iteration 3 (cand_0003): pass, reward 1.000 — correct","glyph":"✓"}] null

===== CROSS-LINK 1: candidate -> trajectories =====
URL: http://127.0.0.1:7979/runs/run_demo?tab=trajectories&candidate=cand_0001
selected tab: Trajectories
All runs
run_demo
done
0 live events
BEST
100.0%
BASELINE
0.0%
Δ VS BASELINE
—
SEALED TEST
100.0%
pass^k NaN%
COST (OPT+RUN)
$0.000
$0.000 + $0.000
TOKENS
0
ACCEPTED
1
REJECTED
2
FAILED
0
FRONTIER
1
WALL CLOCK
1s
$ / +1%
—
Fitness
Cost
Phases
Lineage
Trajectories
Changes & files
Insights
See what cand_0001 changed →
Trajectories
candidate
cand_0001
val
test
task	candidate	reward	feedback

a1
	cand_0001	100.0%	correct

a4
	cand_0001	100.0%	correct

===== CROSS-LINK 2: trajectories -> that candidate's diff =====
URL: http://127.0.0.1:7979/runs/run_demo?tab=changes&candidate=cand_0001&mode=candidate
sub-tabs: ["Candidate diff","Commit diff","Memory","Raw files"]
All runs
run_demo
done
0 live events
BEST
100.0%
BASELINE
0.0%
Δ VS BASELINE
—
SEALED TEST
100.0%
pass^k NaN%
COST (OPT+RUN)
$0.000
$0.000 + $0.000
TOKENS
0
ACCEPTED
1
REJECTED
2
FAILED
0
FRONTIER
1
WALL CLOCK
1s
$ / +1%
—
Fitness
Cost
Phases
Lineage
Trajectories
Changes & files
Insights
← See how cand_0001 scored per task

Everything a candidate changed — its diff against its parent, the real commit, what the optimizer remembered about it, and the raw run directory.

Candidate diff
Commit diff
Memory
Raw files
candidate
cand_0003 · rejected · 100.0%
cand_0002 · rejected · 100.0%
cand_0001 · accepted · 100.0%
vs seed · Δ 100.0%
prompt.txt
+2
−0
@@ -1 +1,3 @@
 You are a helpful assistant. Answer the user as best you can.
+
+[CALC] Compute the arithmetic expression exactly and output ONLY the resulting number.

===== CONSOLIDATED SUB-MODES =====
--- sub-mode: Commit diff --- URL: http://127.0.0.1:7979/runs/run_demo?tab=changes&candidate=cand_0001&mode=commit
All runs
run_demo
done
0 live events
BEST
100.0%
BASELINE
0.0%
Δ VS BASELINE
—
SEALED TEST
100.0%
pass^k NaN%
COST (OPT+RUN)
$0.000
$0.000 + $0.000
TOKENS
0
ACCEPTED
1
REJECTED
2
FAILED
0
FRONTIER
1
WALL CLOCK
1s
$ / +1%
—
Fitness
Cost
Phases
Lineage
Trajectories
Changes & files
Insights
← See how cand_0001 scored per task

Everything a candidate changed — its diff against its parent, the real commit, what the optimizer remembered about it, and the raw run directory.

Candidate diff
Commit diff
Memory
Raw files
Iteration commits
iter 3: reject candidate cand_0003 (val 1.000, Δ +0.000)
aedbb8e
iter 2: reject candidate cand_0002 (val 1.000, Δ +0.000)
6bb023c
iter 1: ACCEPT candidate cand_0001 (val 1.000, Δ +1.000)
ddc868f
seed: baseline candidate
a8e0042
JOURNAL.md
+5
−0
@@ -27,3 +27,8 @@ Append your entry for THIS iteration below the marker, using this shape (INTENT
 
> **RESULT (framewor
--- sub-mode: Memory --- URL: http://127.0.0.1:7979/runs/run_demo?tab=changes&candidate=cand_0001&mode=memory
All runs
run_demo
done
0 live events
BEST
100.0%
BASELINE
0.0%
Δ VS BASELINE
—
SEALED TEST
100.0%
pass^k NaN%
COST (OPT+RUN)
$0.000
$0.000 + $0.000
TOKENS
0
ACCEPTED
1
REJECTED
2
FAILED
0
FRONTIER
1
WALL CLOCK
1s
$ / +1%
—
Fitness
Cost
Phases
Lineage
Trajectories
Changes & files
Insights
← See how cand_0001 scored per task

Everything a candidate changed — its diff against its parent, the real commit, what the optimizer remembered about it, and the raw run directory.

Candidate diff
Commit diff
Memory
Raw files
Accepted history
cand_0001100.0%
candidate cand_0001 (val 1.000, Δ +1.000)
Rejected memory
cand_0002
candidate cand_0002 (val 1.000, Δ +0.000)
paired Δ̄=+0.0000 <= 0 (SE=0 → STRICT fallback, warned; n=2)
cand_0003
candidate cand_0003 (val 1.000, Δ +0.000)
paired Δ̄=+0.0000 <= 0 (SE=0 → STRICT fallback, warned; n=2)
Candidate scratch
seed
cand_0001
cand_0002
cand_0003
INSTRUCTIONS.
--- sub-mode: Raw files --- URL: http://127.0.0.1:7979/runs/run_demo?tab=changes&candidate=cand_0001&mode=files
All runs
run_demo
done
0 live events
BEST
100.0%
BASELINE
0.0%
Δ VS BASELINE
—
SEALED TEST
100.0%
pass^k NaN%
COST (OPT+RUN)
$0.000
$0.000 + $0.000
TOKENS
0
ACCEPTED
1
REJECTED
2
FAILED
0
FRONTIER
1
WALL CLOCK
1s
$ / +1%
—
Fitness
Cost
Phases
Lineage
Trajectories
Changes & files
Insights
← See how cand_0001 scored per task

Everything a candidate changed — its diff against its parent, the real commit, what the optimizer remembered about it, and the raw run directory.

Candidate diff
Commit diff
Memory
Raw files
Run directory
candidates
rollouts
work
.gitignore
16B
baseline.json
1.3KB
dashboard.html
35.2KB
events.jsonl
3.4KB
final.json
2.2KB
history.jsonl
126B
JOURNAL.md
2.9KB
rejected.jsonl
384B
report.md
458B
splits.json
164B
state.json
525B

Select a file to view its contents.

===== CROSS-LINK 3: heatmap cell -> rollout drawer =====
cell aria-label: a1 at iteration 0 (seed): fail, reward 0.000 — expected '7' but agent produced 'I think 3 + 4 is roughly some number.'; the prompt likely lacks an explicit instruction to compute and output only the number
URL: http://127.0.0.1:7979/runs/run_demo?tab=trajectories&candidate=seed&task=a1
drawer:
a1__seed__t0.json
REWARD
0.0%expected '7' but agent produced 'I think 3 + 4 is roughly some number.'; the prompt likely lacks an explicit instruction to compute and output only the number
INPUT
3 + 4
OUTPUT
I think 3 + 4 is roughly some number.
TRACE
prompt_had_calc=False
focus in drawer on open: { tag: 'BUTTON', label: 'Close (Escape)' }

===== KEYBOARD: Escape closes drawer, focus does not fall to body =====
drawer count after Escape: 0
focus after close: { tag: 'DIV', role: 'tabpanel' }

===== KEYBOARD: Tab into tablist, ArrowRight/End, Enter activation =====
tab stops:
   {"tag":"A","role":null,"t":"cap·evolve","ring":"2px rgb(59, 130, 246)"}
   {"tag":"A","role":null,"t":"Runs","ring":"2px rgb(59, 130, 246)"}
   {"tag":"A","role":null,"t":"Compare","ring":"2px rgb(59, 130, 246)"}
   {"tag":"A","role":null,"t":"All runs","ring":"2px rgb(59, 130, 246)"}
   {"tag":"BUTTON","role":"tab","t":"Fitness","ring":"2px rgb(59, 130, 246)"}
   {"tag":"svg","role":"application","t":"","ring":"2px rgb(59, 130, 246)"}
   {"tag":"SUMMARY","role":null,"t":"Candidates — select one to inspect its rol","ring":"2px rgb(59, 130, 246)"}
   {"tag":"BUTTON","role":null,"t":"seed","ring":"2px rgb(59, 130, 246)"}
after ArrowRight -> selected: Fitness | focused: Cost
after End       -> selected: Fitness
after Home      -> selected: Fitness

===== KEYBOARD: activate the cross-link with Enter (no mouse) =====
focus ring on the cross-link: 2px solid rgb(59, 130, 246)
URL after Enter: http://127.0.0.1:7979/runs/run_demo?tab=trajectories&candidate=cand_0001
selected tab: Trajectories

===== SCREENSHOTS =====
wrote /tmp/shot-fitness.png
wrote /tmp/shot-trajectories.png
wrote /tmp/shot-changes.png

5. Keyboard navigation, in full

Roving tabindex, Arrow/Home/End moving selection and focus, the URL following, the back button undoing a cross-link, Space activating a heatmap cell, Shift+Tab staying inside the drawer:

$ node kbd139.mjs
ArrowRight  -> selected=Cost      focused=Cost      url=?tab=cost
ArrowRight  -> selected=Phases    focused=Phases    url=?tab=phases
End         -> selected=Insights  focused=Insights  url=?tab=insights
Home        -> selected=Fitness   focused=Fitness   url=?tab=fitness
ArrowLeft   -> selected=Insights  focused=Insights  url=?tab=insights

roving tabindex now: ["-1","-1","-1","-1","-1","-1","0"]
back button undoes the cross-link:
  after link : ?tab=trajectories&candidate=cand_0001 | tab = Trajectories
  after back : ?tab=fitness | tab = Fitness

Space activates a heatmap cell (no mouse):
  drawer open: 1 | file: a4__seed__t0.json
  Shift+Tab stays inside drawer:
    { inDrawer: true, tag: 'BUTTON' }

Visible focus ring, verified by a real Tab press so :focus-visible actually applies (a programmatic .focus() deliberately does not match it):

$ node ring139.mjs
first heatmap cell reached by Tab:
  {
  "label": "a1 at iteration 0 (seed): fail, reward 0.000 — exp",
  "matchesFocusVisible": true,
  "outline": "2px solid rgb(59, 130, 246)",
  "offset": "2px"
}

first tab button reached by Tab:
  {
  "tab": "Fitness",
  "matchesFocusVisible": true,
  "outline": "2px solid rgb(59, 130, 246)",
  "fontWeight": "600"
}

fontWeight: 600 on the selected tab and the worded per-cell aria-labels above are the non-colour halves of the state — nothing here is conveyed by colour alone.

6. This branch: full suites

$ PYTHONPATH=/tmp/wt-139/core python -m pytest core/tests -q
........................................................................ [ 40%]
........................................................................ [ 80%]
...................................                                      [100%]
179 passed in 63.95s (0:01:03)

$ python -m compileall -q core dashboard
compileall=0        # no output = clean

$ npx tsc -b --noEmit
tsc exit=0

$ npm run build
dist/index.html                   0.54 kB │ gzip:   0.34 kB
dist/assets/index-Dk2GgY8b.css   17.33 kB │ gzip:   4.69 kB
dist/assets/index-BPHKpzS8.js   838.39 kB │ gzip: 252.44 kB
✓ built in 739ms

$ npm test
 Test Files  14 passed (14)
      Tests  54 passed (54)

dist/ reverted after the build, per #188:

$ git status --porcelain -- dashboard/frontend/dist
$ git status --porcelain -- dashboard/frontend/dist | wc -l
       0

7. Merged tree — #204 + #234 + #218 + #221 applied

Control first (the four PRs merged together without this one), so the number means something:

$ git worktree add /tmp/wt-139-ctl -b tmp/ctl-139 origin/main
$ for b in 117-event-ticker 138-phase-pipeline 118-stall-detection 130-plateau-detection; do git merge origin/feat/issue-$b; done
feat/issue-117-event-ticker      => clean
feat/issue-138-phase-pipeline    => CONFLICT: core/cap_evolve/cli.py
feat/issue-118-stall-detection   => CONFLICT: CHANGELOG.md core/cap_evolve/cli.py
                                    core/cap_evolve/eventstream.py
                                    dashboard/backend/capevolve_dashboard/app.py
                                    docs/GETTING_STARTED.md
feat/issue-130-plateau-detection => clean (after resolving the above)

$ PYTHONPATH=/tmp/wt-139-ctl/core python -m pytest core/tests -q
32 failed, 274 passed, 2 warnings in 87.71s (0:01:27)

Now with #139 on top of the same four:

$ git worktree add /tmp/wt-139-merged -b tmp/merged-139 feat/issue-139-spa-crosslink
$ for b in 117-event-ticker 138-phase-pipeline 118-stall-detection 130-plateau-detection; do git merge origin/feat/issue-$b; done
feat/issue-117-event-ticker      => CONFLICT: dashboard/frontend/src/routes/RunDeepDive.tsx
                                    (one hunk: the TABS array — #204 adds Events, #139
                                     renames overview->fitness. Resolved by keeping BOTH.)
feat/issue-138-phase-pipeline    => CONFLICT: core/cap_evolve/cli.py          <- same as control
feat/issue-118-stall-detection   => CONFLICT: CHANGELOG.md core/cap_evolve/cli.py
                                    core/cap_evolve/eventstream.py
                                    dashboard/backend/capevolve_dashboard/app.py
                                    docs/GETTING_STARTED.md                   <- same as control
feat/issue-130-plateau-detection => clean

$ PYTHONPATH=/tmp/wt-139-merged/core python -m pytest core/tests -q
32 failed, 274 passed, 2 warnings in 88.47s (0:01:28)

$ cd dashboard/frontend && npx tsc -b --noEmit
tsc=0
$ npm run build
✓ built in 503ms
$ npm test
 Test Files  17 passed (17)
      Tests  89 passed (89)

Read: 32 failed / 274 passed both with and without #139 — byte-identical. Those 32 are the #204#218 disagreement about the SSE offset (offset = 0 vs offset = stat().st_size) and the tail exit codes in cli.py / eventstream.py, hand-resolved here to get a runnable tree; each of those PRs owns half and the real merge will resolve them properly. #139 adds one conflict hunk (the TABS array) and zero regressions.

The merged tree's seven-plus-Events tab bar, after resolving:

const TABS: TabDef[] = [
  { id: 'fitness', label: 'Fitness' },        // #139
  { id: 'events', label: 'Events' },          // #204
  { id: 'cost', label: 'Cost' },
  { id: 'phases', label: 'Phases' },
  { id: 'lineage', label: 'Lineage' },
  { id: 'trajectories', label: 'Trajectories' },
  { id: 'changes', label: 'Changes & files' }, // #139: iterations+git+memory+files
  { id: 'insights', label: 'Insights' },
]

#234's EvidenceHeader and #218's richer StatusBadge auto-merged into RunDeepDive.tsx with no conflict against this PR.

8. Orphaned-data check

$ grep -rn "rejected\|history" dashboard/frontend/src/lib/insights.ts dashboard/frontend/src/components/MemoryPanel.tsx | head
lib/insights.ts:  export function deadEnds(rejected: MemoryRejected[])   <- #212's live reader, untouched
components/MemoryPanel.tsx:  data?.history.map(...)                      <- still mounted (mode=memory)
components/MemoryPanel.tsx:  data?.rejected.map(...)                     <- still mounted (mode=memory)

# per_task / feedback before this PR: read ONLY by the single-file static dashboard
$ grep -rn "per_task" dashboard/frontend/src/   # on origin/main
(no matches)

$ grep -rn "per_task" core/cap_evolve/dashboard.py | head -2
1020:  const iters=G.nodes.filter(n=>Object.keys(n.per_task||{}).length)...

# after this PR
$ grep -rn "per_task" dashboard/frontend/src/
components/TaskHeatmap.tsx:  it.per_task?.[t]     <- the new reader

9. Files touched

$ git diff --stat origin/main...HEAD
 .../frontend/src/components/BestCurveChart.tsx     |  47 +++-
 dashboard/frontend/src/components/ChangesPanel.tsx |  64 +++++
 .../frontend/src/components/IterationsDiff.tsx     |  25 +-
 dashboard/frontend/src/components/MemoryPanel.tsx  |  18 +-
 dashboard/frontend/src/components/TaskHeatmap.tsx  | 167 +++++++++++++
 dashboard/frontend/src/components/Trajectories.tsx | 163 +++++++++++--
 dashboard/frontend/src/components/ui/Tabs.tsx      |  71 +++++-
 dashboard/frontend/src/routes/RunDeepDive.tsx      | 108 +++++++--
 dashboard/frontend/src/test/CrossLink.test.tsx     | 262 +++++++++++++++++++++
 9 files changed, 865 insertions(+), 60 deletions(-)

@OsherElhadad

Copy link
Copy Markdown
Collaborator Author

🔍 Review — PR #253

Verdict: CHANGES REQUESTED — one blocking defect. The consolidation claim holds: I independently verified all four merged panels mount and render real data against a live run dir, and no field lost a reader. The a11y claims all reproduced verbatim in a real headless Chromium. The merged-tree control is byte-identical without #139. But ?task is written into the URL and never cleared, so the drawer reopens on every remount and the "shareable link" claim is wrong in a way users will hit on the first refresh.


Blocking

1. Trajectories.tsx:56-62?task is never cleared, so closing the drawer desyncs the URL from the UI and the drawer resurrects itself.

setOpenFile is child state; the closing path (onClose={() => setOpenFile(null)}, Trajectories.tsx:166) clears only that state and leaves ?task= in the URL. The useEffect at :56 re-fires on any remount or data identity change and re-opens the drawer from the still-present param. Measured, on the served dashboard:

drawer on load (?tab=trajectories&candidate=seed&task=a1): 1
after Escape:            drawer=0  url STILL= ?tab=trajectories&candidate=seed&task=a1
after tab away + back:   drawer=1  url= ?tab=trajectories&candidate=seed&task=a1
after reload:            drawer=1   <-- URL and closed-UI disagree

Three concrete consequences:

  • The PR's headline "shareable" claim is inverted. A user who opens a rollout, reads it, closes it, and copies the URL ships a link that opens the drawer for the recipient. The URL records a state the sharer deliberately dismissed.
  • ?task leaks onto every unrelated tab and stays there. After closing the drawer:
    clicked Cost     -> ?tab=cost&candidate=seed&task=a1
    clicked Insights -> ?tab=insights&candidate=seed&task=a1
    clicked Lineage  -> ?tab=lineage&candidate=seed&task=a1
    
    Return to Trajectories from any of those and the drawer is back, uninvited. The URL is advertised as the state store but is only written on open, never on close — which makes it a write-only log, not state.
  • It corrupts the back-button contract this PR is built on. Closing the drawer is a user-visible navigation that produces no history entry, so Back from a closed drawer skips straight past it to the previous tab, and Forward cannot restore it. (Everything else in the history model is correct — see below.)

Note the sibling paths are already right: IterationsDiff.tsx:37 and MemoryPanel.tsx:33-35 both use the picked ?? linked pattern so a manual pick overrides the link and a fresh link re-wins. The drawer is the one surface where that discipline is missing.

Fix — make close a URL operation, same as open. RunDeepDive.tsx:190-195 already passes onClearFocus; the drawer just needs its own:

// RunDeepDive.tsx, trajectories branch
<Trajectories  onCloseRollout={() => goto({ task: null })} />
// Trajectories.tsx:166
{openFile && <RolloutDrawer  onClose={() => { setOpenFile(null); onCloseRollout?.() }} />}

goto already uses setSearchParams, which pushes an entry, so this fixes shareability, the leak, and the back button in one change. Add the regression test the current suite lacks: open via ?task, close, assert ?task is gone and the drawer stays closed across a tab round-trip.


Non-blocking

2. TaskHeatmap.tsx:129-142 — every cell is a tab stop, so a large grid is a keyboard trap in practice. Measured with a 50-task × 50-iteration run (route-intercepted /api/runs/run_demo):

heatmap buttons rendered: 2500 (expected 2500)
Tab presses to ENTER the heatmap:  58
Tab presses to ESCAPE the heatmap: 2500 in 5.5s
ArrowRight inside grid moved focus? false
table role: null | [role=grid]/[role=gridcell] count: 0

2500 tab presses to pass the widget. It is not literally a trap (Tab does eventually exit, landing on BODY), but 2500 stops is functionally one for a keyboard or switch user. Toy runs are fine (8 cells, 12 hops in) so nothing fails today; a real benchmark with 50+ tasks and 30+ iterations produces this. The PR did not claim any bound here, and the underlying static dashboard renders SVG rects with zero stops — so this is a regression in tab-stop count that arrived with the accessible-button choice.

Fix: the WAI-ARIA grid pattern the <table> already implies — role="grid", role="gridcell", one tab stop for the grid via roving tabIndex, and Arrow/Home/End moving the active cell. The Tabs.tsx:50-66 roving handler is the pattern to copy; it's ~20 lines. Alternatively cap the rendered columns (last N iterations) with a control to page.

3. Sub-mode switch has no accessible name and announces nothing beyond the tab label. Both tablists carry no naming attributes:

top tablist: {"label":null,"labelledby":null,"tabIds":[null × 7],"panelLabelledBy":null}
sub tablist: {"live":null,"label":null,"labelledby":null,"orient":null}

Two nested tablists with identical roles and no aria-label are indistinguishable to a screen reader — a user landing in the sub-list hears "tab list, Candidate diff, selected, 1 of 4" with no clue which of the two they're in, and <div role="tabpanel"> has no aria-labelledby pointing at its tab, so the panel is unnamed on entry. Fix in Tabs.tsx: accept an optional label, set aria-label on the role="tablist" div, give each tab id={${uid}-tab-${t.id}}, and set aria-labelledby on the panel to the active tab's id. Pass label="Run views" and label="Change surfaces" from the two call sites.

4. Tabs.tsx:39-42 — an unknown ?tab/?mode renders the fallback but leaves the bad param in the URL. Verified degrading safely — no crash, no injection (see hostile-param table) — but ?tab=changes&mode=nope shows "Candidate diff" while the URL still reads mode=nope, so a reload of a link a user believes is broken keeps looking broken and the URL no longer describes the view. Fix: when value is non-null and unmatched, call onChange(firstEnabled) in an effect so the URL is normalised to what is actually rendered.

5. RunDeepDive.tsx:199-207 — the cross-link header names a candidate the panel below is not showing. With ?candidate=cand_9999 (nonexistent):

picker = cand_0003 | header says: "← See how cand_9999 scored per task"

IterationsDiff.tsx:36 correctly rejects the unknown id and falls back, but the header in RunDeepDive renders {candidate} unvalidated. Consequence: the page asserts you are looking at cand_9999's changes while displaying cand_0003's — a stale/typo'd shared link reads as confidently wrong rather than as an error. The Trajectories side handles this honestly ("No rollouts for 'val' and candidate cand_9999"). Fix: validate against data.graph.nodes before rendering the header, or render a "no such candidate" note.


Nits

6. Trajectories.tsx:44-47wide fetches every split whenever focus is set, and stays wide for the rest of the visit. focus is truthy while ?task is present, which (given finding 1) is essentially forever after the first heatmap click, so the split-scoped fetch this PR deliberately preserved for the unfocused path is bypassed on any session that used a cross-link. Fixing 1 fixes this as a side effect.

7. BestCurveChart.tsx:106<details open={!!onSelect}>. The comment ("a link that only exists inside a collapsed <details> is not discoverable") is right, but the element is now open-by-default with a summary that still reads like a disclosure control. A plain heading + table is clearer than a <details> that is never closed.

8. dashboard/frontend/dist/ is stale on this branch — see the dist/ section below. Not this PR's fault, and correct per #188's interim workaround, but worth a line in the PR body so the merger knows to rebuild.


Did consolidation lose anything?

No. I did not take the PR's list; I enumerated every removed tab's panel component, grepped for its importers in the new tree, and drove each sub-mode against a real zero-API toy_calc + mock run dir (3 iterations, seed + cand_0001..0003, 2 val tasks) served by the live FastAPI backend with a freshly built bundle.

Old tab Where it lives now Renders real data? Data consumers intact?
Overview fitness tab, RunDeepDive.tsx:158-172BestCurveChart plus the new TaskHeatmap ✅ 4 candidate rows, best-curve stair, best 100.0% BestCurveChart still the only reader of cumulativeBest; gained per_task/feedback
Iterations changes?mode=candidate, ChangesPanel.tsx:58 vs seed · Δ 100.0% + real hunk: prompt.txt +2 −0, +[CALC] Compute the arithmetic expression exactly… IterationsDiff is still the sole api.diff caller
Git diffs changes?mode=commit, ChangesPanel.tsx:52 ✅ 25,742 chars: 4 real commits (4a88929, bebdf4d, 2481dc1, 2b2c9cf) + JOURNAL.md hunks GitDiff is still the sole api.gitLog + api.gitDiff caller
Memory changes?mode=memory, ChangesPanel.tsx:54 ✅ 22,592 chars: Accepted history cand_0001 100.0%, Rejected memory cand_0002/cand_0003 with real gate reasons, Candidate scratchINSTRUCTIONS.md body rejected.jsonl + history.jsonl both still readMemoryPanel is the sole api.candidateFiles caller; insights.ts:31::deadEnds untouched and still called from Insights.tsx:17
Files changes?mode=files, ChangesPanel.tsx:56 ✅ full run dir listing with sizes; clicking final.json streams real contents ("reward": 1.0, pass_k) FileTree is still the sole api.tree + api.file caller

Independent orphan audit. I extracted all 94 fields declared in lib/types.ts and grepped for each across the whole src/ tree, excluding types.ts itself, on both origin/main and this branch:

MAIN   orphans: ['epoch','git_log','merge_of','opt_cost_usd','opt_tokens','optimizer_tokens','per_task','runner_tokens']
BRANCH orphans: ['epoch','git_log','merge_of','opt_cost_usd','opt_tokens','optimizer_tokens',           'runner_tokens']

Nothing became orphaned. per_task stopped being orphaned. The seven remaining orphans are pre-existing on main and untouched by this PR. Every one of the 14 api.* methods still has a non-test caller.

The two newly-read fields. per_task genuinely had zero SPA consumers on main (the grep above; the only reader was core/cap_evolve/dashboard.py's single-file dashboard). feedback is subtler — the name was already read on main, but only as RolloutRow.feedback (Trajectories.tsx:81,129); GraphNode.feedback had no reader. Both are now read at TaskHeatmap.tsx:119,122. Rendering verified against disk, all 8 cells spot-checked against rollouts/val/*.json:

a1__seed__t0.json      reward=0.0  fb="expected '7' but agent produced 'I think 3 + 4 is …"
  -> cell: "a1 at iteration 0 (seed): fail, reward 0.000 — expected '7' but agent produced …"  glyph ✗
a1__cand_0001__t0.json reward=1.0  fb="correct"
  -> cell: "a1 at iteration 1 (cand_0001): pass, reward 1.000 — correct"                       glyph ✓
a4__seed__t0.json      reward=0.0  fb="expected '10' but agent produced …"
  -> cell: "a4 at iteration 0 (seed): fail, reward 0.000 — expected '10' but agent produced …" glyph ✗

8/8 exact. Rows worst-first, columns 0 1 2 3 in iteration order, ·/not run for missing scores, and the empty state ("No per-task scores yet") is honest rather than a blank grid.

Discoverability verdict — "one extra click" is honest for Iterations/Git diffs, understated for Memory and Files. For the diff surfaces the merge is a clear win: they genuinely overlapped, the sub-mode ordering (diff → commit → memory → bytes, ChangesPanel.tsx:9-20) is a real conceptual gradient, and the cross-link means arriving at the right diff is now fewer clicks than before, not more. But "Memory" and "Files" were not diff surfaces — they answered "what did the optimizer remember" and "what's on disk", which is why the 22KB Memory panel and the run-dir browser are now two levels deep under a label ("Changes & files") that describes neither. Nothing in the top bar says the optimizer's rejected-candidate memory lives there. That said: 10 → 7 with a genuine cross-link flow is worth more than perfect labelling of two secondary surfaces, and the panel copy at ChangesPanel.tsx:45-48 does enumerate all four. Net: the consolidation is right; consider a badge on the tab, or renaming to "Changes, memory & files". Not blocking.


URL state

Deep links restore the exact view — all four probed against the served SPA:

?tab=trajectories&candidate=cand_0001                -> tab=Trajectories  sub=-       drawer=0
?tab=trajectories&candidate=seed&task=a1             -> tab=Trajectories  sub=-       drawer=1  file=a1__seed__t0.json
?tab=changes&candidate=cand_0001&mode=memory         -> tab=Changes & files sub=Memory drawer=0
?tab=insights                                        -> tab=Insights      sub=-       drawer=0

?candidate correctly preselects in all three consuming panels (diff picker cand_0001, memory scratch cand_0002, trajectories filter).

Back button undoes exactly one navigation. Two chained cross-links, then two Backs:

after cand link: ?tab=trajectories&candidate=cand_0001                  histDelta=1
after diff link: ?tab=changes&candidate=cand_0001&mode=candidate        histDelta=2
back 1        -> ?tab=trajectories&candidate=cand_0001   tab=Trajectories
back 2        -> (none)                                  tab=Fitness

No per-keystroke history stacking. Three tab clicks → history.length grows by 1 (react-router setSearchParams replaces same-key entries within a path). Verified the entries are still individually reachable: Back ×3 from ?tab=lineage walked phases → cost → (none), one tab per press. Correct on both counts.

Hostile params degrade safely — zero injection, zero crashes, zero console errors:

?tab=<script>alert(1)</script>                                  -> tab=Fitness         injectedNodes=0 errs=0
?candidate=../../etc/passwd&tab=trajectories                    -> tab=Trajectories    injectedNodes=0 errs=0
?tab=nope                                                       -> tab=Fitness         injectedNodes=0 errs=0
?tab=changes&mode=<img src=x onerror=alert(1)>                   -> tab=Changes & files injectedNodes=0 errs=0
?tab=trajectories&candidate=<img src=x onerror=…>&task=<svg/onload=…> -> tab=Trajectories injectedNodes=0 errs=0
?tab=changes&candidate=../../../etc/passwd&mode=memory           -> tab=Changes & files injectedNodes=0 errs=0
?tab=trajectories&task=a1  (task without candidate)              -> tab=Trajectories    injectedNodes=0 errs=0

Path traversal in ?candidate never reaches the backend (it's an in-memory filter over graph.nodes, and focus at RunDeepDive.tsx:50 requires both task and candidate, so ?task=a1 alone is inert). React's JSX escaping covers the XSS vectors. The ?tab/?mode fallback at Tabs.tsx:39-42 is deliberate and works — with the URL-normalisation gap noted in finding 4.

#254 — the PR was right to file it, but its "shareable" claim is not accurate as written today. Reproduced on the live backend:

$ curl -s -o /dev/null -w "%{http_code}\n" "http://127.0.0.1:7981/runs/run_demo?tab=trajectories&candidate=cand_0001"
404

Fixing it means adding an SPA catch-all to app.py with careful ordering against /api/* and the SSE route — genuinely a separate backend change, correctly scoped out. But the PR body says the URL makes state "shareable" without qualification, when on the served dashboard it is shareable only within an already-loaded session. Add one line: shareable on the static export today; served-dashboard hard loads need #254. (I confirmed the static export path works — it uses HashRouter, so the path never reaches a server.) Combined with finding 1, "shareable" is currently the weakest claim in the PR.


A11y — what I measured myself

Real headless Chromium against the served SPA, not asserted. Every claim reproduced:

top tablist:      ["Fitness","Cost","Phases","Lineage","Trajectories","Changes & files","Insights"]
roving tabindex:  ["0","-1","-1","-1","-1","-1","-1"]        (exactly one tab stop)

first role=tab reached by a REAL Tab press:
  {"tab":"Fitness","fv":true,"outline":"2px solid rgb(59, 130, 246)","offset":"2px","fw":"600"}
                    ^ :focus-visible genuinely true, not a programmatic .focus()

ArrowRight -> selected=Cost      focused=Cost      url=?tab=cost
ArrowRight -> selected=Phases    focused=Phases    url=?tab=phases
End        -> selected=Insights  focused=Insights  url=?tab=insights
Home       -> selected=Fitness   focused=Fitness   url=?tab=fitness
ArrowLeft  -> selected=Insights  focused=Insights  url=?tab=insights
roving now: ["-1","-1","-1","-1","-1","-1","0"]              (roving followed)

Space on a heatmap cell (no mouse) -> url=?tab=trajectories&candidate=seed&task=a1  drawer=1
Enter on a heatmap cell            -> drawer=1
focus on drawer open: {tag:'BUTTON', label:'Close (Escape)'}
Shift+Tab inside drawer -> still in drawer: true (BUTTON)
Escape -> drawer=0, focus={tag:'DIV', role:'tabpanel', isBody:false}

Selection is bold (fontWeight 600) + aria-selected + an animated underline, and every heatmap cell carries a glyph (✓ ✗ ~ ·) and a fully worded aria-label including the feedback string — nothing is colour-only. The <caption class="sr-only"> and scope="col"/scope="row" headers are correct table semantics.

#196 fix confirmed. The backdrop is now a <div>, not a <button>:

backdrop: { ariaHidden: 'true', tag: 'DIV', tabIndex: -1 }

Focus never lands on <body> in any close path I could construct: isBody:false from the cross-linked (unmounted-opener) path, and expect(open).toHaveFocus() reproduced live for the still-mounted-opener path. The opener?.isConnected guard at Trajectories.tsx:~200 is the right check — a detached-node .focus() is a silent no-op, which is exactly the trap.

My extra probes:

  • Large heatmap (50×50) — finding 2 above. 2500 tab stops, 2500 presses to escape (5.5s of held Tab), no role="grid", arrow keys inert inside the grid. The one measurable a11y regression.
  • Roving tabindex survives a cross-link that unmounts the focused element — yes. Tabbed into the tablist, ArrowRight to Cost (["-1","0","-1",…]), then Tab'd 12 hops into the heatmap, pressed Enter (unmounting the whole Fitness panel including the focused cell), Escape'd:
    roving after cross-link: Fitness=-1 Cost=-1 Phases=-1 Lineage=-1 Trajectories=0 Changes=-1 Insights=-1
    
    Exactly one 0, on the newly-selected tab. And the tablist is still reachable from the recovery position: one Shift+Tab from the tabpanel lands on {tag:'BUTTON', role:'tab', t:'Trajectories'}, so arrow nav resumes immediately. Recovery is genuinely usable, not just non-crashing.
  • Sub-mode announcement — finding 3 above. Changing mode moves aria-selected, which a screen reader will announce as the tab name, but neither tablist has an accessible name and the panel has no aria-labelledby, so which tablist you are in is unannounced.

Merged-tree control

Yes — byte-identical. I built both trees myself with the same conflict-resolution strategy and diffed the failure names, not just the counts.

CONTROL  (origin/main + #204 + #234 + #218 + #221, no #139):
  4 failed, 302 passed in 95.72s
MERGED   (same four on top of feat/issue-139-spa-crosslink, TABS hand-resolved):
  4 failed, 302 passed in 94.78s

$ diff <(control FAILED names) <(merged FAILED names)
BYTE-IDENTICAL — #139 adds zero failures

The four, identical in both:

FAILED core/tests/test_dashboard_pipeline.py::test_events_burn_equals_spent_for_every_algorithm
FAILED core/tests/test_dashboard_pipeline.py::test_gepa_emits_the_cost_fields_the_burn_reads
FAILED core/tests/test_dashboard_pipeline.py::test_optimizer_spend_is_counted_on_locally_rejected_gepa_iterations
FAILED core/tests/test_orchestration_mode.py::test_agent_mode_stops_after_baseline_with_handoff

Note: I measured 4/302, not the PR's 32/274. That gap is a resolution artefact, not a disagreement about #139 — I resolved the cli.py/eventstream.py/app.py conflicts --theirs where the PR hand-resolved them, and a different hand-resolution of the #204#218 SSE-offset disagreement moves that count freely. The load-bearing claim is the delta, and the delta is zero under my resolution as it was under theirs. Corroborating: **#139 touches zero Python files** (git diff --name-only origin/main...HEAD | grep '.py$'→ empty), so a pytest regression from it would have to be spooky action. Frontend on the merged tree:tsc=0, ✓ built in 857ms`, vitest 17 files / 89 tests passed — matching the PR exactly.

The merged TABS I produced by hand-resolving is character-for-character the array the PR body shows. One correction to the PR's accounting: git reports three conflict hunks in RunDeepDive.tsx, not one — the imports block, the TABS array, and the render branch. All three are the same mechanical fitness↔overview/events question and all three resolve by keeping both sides, so "one conflict" is the right characterisation but the wrong number; a merger following the PR body literally will be surprised twice.


Merge-order note

Trial-merged the proposed order end to end (#194 → #212 → #218 → #221 → #204 → #234 → #139):

perf/issue-119-reduce-cache                -> clean
refactor/issue-114-drop-write-only-memory  -> clean
feat/issue-118-stall-detection             -> core/cap_evolve/cli.py
feat/issue-130-plateau-detection           -> core/cap_evolve/gepa.py core/cap_evolve/harness.py
feat/issue-117-event-ticker                -> dashboard/backend/capevolve_dashboard/app.py
feat/issue-138-phase-pipeline              -> CHANGELOG.md core/cap_evolve/cli.py core/cap_evolve/dashboard.py core/cap_evolve/eventstream.py docs/GETTING_STARTED.md
feat/issue-139-spa-crosslink               -> dashboard/frontend/src/routes/RunDeepDive.tsx

Concur — #139 last. It is the only branch in the set whose sole conflict is one frontend file, and putting it last means every sibling's RunDeepDive.tsx edit is already in the base, so the resolver sees one coherent decision instead of re-resolving the tab array N times. Everything upstream of it conflicts only in core/backend, entirely independent of this PR.

Two additions for whoever merges: (a) the RunDeepDive.tsx resolution is three hunks, keep-both on each — don't stop at the TABS array; (b) per #188's interim workaround, npm ci && npm run build on the final merge result and commit the rebuild as one commit. The committed bundle currently predates #139 (see below), so skipping the rebuild ships the old ten-tab UI regardless of what merged.


dist/ (#188)

Clean, in the sense #188 asks for: this PR does not touch it.

$ git diff --stat origin/main...HEAD -- dashboard/frontend/dist
(empty)

Note for accuracy — dist/ is intentionally committed in this repo (dashboard/frontend/.gitignore: "dist is intentionally COMMITTED … Keep it tracked"), so "not committed" isn't the right test; "not rebuilt in the PR" is, and that's satisfied. The consequence is that the tracked bundle is stale with respect to this branch:

$ git show HEAD:dashboard/frontend/dist/assets/index-g1UdrsC9.js | grep -c "Changes & files"
0

That is #188's Cost 2 exactly, and it's why (b) above matters. My verification used a locally rebuilt bundle (index-BPHKpzS8.js, grep -c "Changes & files" → 1) so I was exercising this PR's code, not the stale one. Build clean: tsc=0, ✓ built in 947ms, no dist/ left dirty beyond the expected rebuild artefacts.


Test quality

CrossLink.test.tsx (262 lines, 7 cases) asserts rendered outcomes, not mounts — genuinely good. It checks the diff picker's value is cand_0001 after the cross-link (:166), that the drawer resolved the real filename t2__seed__t0.json rather than guessing (:177), that the rollout's actual feedback text renders (:178), that exactly one tab has tabindex="0" (:188), that cell.isConnected === false before asserting the focus fallback (:223) — that last one is the difference between testing the fix and testing a coincidence. Asserting the tab set as a subset (:127) plus explicit absence of the four removed labels (:129-131) is the right shape for a PR that has to survive a sibling adding a tab.

Untested paths, in severity order:

  1. The finding-1 lifecycle. No test closes the drawer and asserts ?task is gone, or that it stays closed across a remount. This is the gap that let the bug ship.
  2. ?mode deep-linking. :136 clicks into changes and :145 clicks the Memory sub-tab, but nothing mounts with initialEntries=['/runs/run_demo?tab=changes&mode=memory'] — the URL→sub-mode direction is only covered by my browser run.
  3. Invalid params. No test for unknown ?tab, unknown ?mode, or a nonexistent ?candidate (finding 5's wrong header would have been caught).
  4. The re-cross-link precedence the useEffect(() => setCid(undefined), [linked]) at IterationsDiff.tsx:38 exists specifically to handle. I verified it live (manual pick cand_0003 → re-click cand_0001 cross-link → picker returns to cand_0001), but the subtle state machine has no test.
  5. missing branch (Trajectories.tsx:64) — the "score exists but the trajectory file wasn't kept" message. I hit it with ?task=nosuchtask; no test does.

Given #207 (CI never runs vitest), untested frontend paths are permanently untested. Worth adding 1–3 with the finding-1 fix.


Verification I re-ran

$ PYTHONPATH=/tmp/rv-253/core python -m pytest core/tests -q
179 passed in 63.87s (0:01:03)

$ python -m compileall -q core dashboard
compileall=0

$ cd dashboard/frontend && npx tsc -b --noEmit
tsc=0
$ npm run build
✓ built in 947ms
$ npm test
 Test Files  14 passed (14)
      Tests  54 passed (54)

$ git diff --stat origin/main...HEAD -- dashboard/frontend/dist
(empty)
$ git diff --name-only origin/main...HEAD | grep -E '\.py$'
(empty — zero Python files touched)

Real run + served dashboard (fresh bundle, live FastAPI, real run dir):

$ python -m cap_evolve.cli run --spec … --run-ts demo
{"run_dir": ".capevolve/run_demo", "best_id": "cand_0001", "baseline_val": 0.0,
 "test_reward": 1.0, "test_delta": 1.0, "iterations": 3}

$ curl -s http://127.0.0.1:7981/api/health
{"ok":true,"base_dir":"/tmp/toy253/.capevolve"}
$ curl -s http://127.0.0.1:7981/api/runs
[{"run_id":"run_demo","status":"done","best_val":1.0,"baseline_val":0.0,"iterations":3,…}]
$ curl -s -o /dev/null -w "%{http_code}\n" "http://127.0.0.1:7981/runs/run_demo?tab=trajectories&candidate=cand_0001"
404      # <- #254, reproduced

$ grep -c "Changes & files" dashboard/frontend/dist/assets/index-BPHKpzS8.js
1        # the bundle I actually exercised contains this PR

Browser probes (playwright/chromium): 7 views + 3 cross-links + 4 sub-modes + 4 deep links + 7 hostile params + full keyboard matrix + 50×50 heatmap + unmount-focus + reopen lifecycle. Zero pageerror/console.error in any run except where noted. Every literal output above is from these runs.

Merged trees:

CONTROL (main + #204+#234+#218+#221):        4 failed, 302 passed in 95.72s
MERGED  (same four + #139):                  4 failed, 302 passed in 94.78s
diff of FAILED names:                        BYTE-IDENTICAL
merged frontend:  tsc=0 · ✓ built in 857ms · 17 files / 89 tests passed

…p a real grid

Review fixes for #139.

BLOCKING — `?task` was written on drawer open and never cleared on close, so the URL
recorded a state the user had dismissed: a link copied after closing reopened the drawer
for the recipient, `?task` leaked onto every unrelated tab, and closing produced no
history entry. `onCloseRollout={() => goto({ task: null })}` makes closing a URL
operation, exactly as `IterationsDiff`/`MemoryPanel` already treat `?candidate`.

- TaskHeatmap: WAI-ARIA grid instead of one tab stop per cell. A 50x50 run was 2500 tab
  stops (2500 presses to pass the widget); now `role="grid"`/`role="gridcell"`, one
  roving tab stop, and Arrow/Home/End/PageUp/PageDown move the active cell. Cells use
  aria-disabled rather than disabled so the arrow walk has no holes.
- Tabs: an accessible `label` on the tablist and `aria-labelledby` on the panel, so the
  two nested tablists are distinguishable to a screen reader ("Run views" / "Change
  surfaces").
- Tabs: an unknown `?tab`/`?mode` is now normalised out of the URL instead of rendering
  the fallback while the bad param sits in the link.
- RunDeepDive: the cross-link headers validate `?candidate` against the graph before
  naming it, so a stale link no longer claims a candidate the panel isn't showing.
- Renamed the consolidated tab to "Changes, memory & files" — Memory and Files were never
  diff surfaces, so a label saying only "changes" hid them.
- BestCurveChart: a `<details>` that is never closed is a heading wearing a disclosure
  triangle; use an actual heading when the table must stay open.

Tests: 61 vitest (was 54) — the drawer-close lifecycle, `?mode` deep-linking, unknown
`?tab` normalisation, a nonexistent `?candidate`, the 600-cell single-tab-stop assertion,
arrow movement, and both tablist names.
Copilot AI review requested due to automatic review settings July 31, 2026 02:09

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@OsherElhadad

Copy link
Copy Markdown
Collaborator Author

🔧 Review fixes

Commit dbcad8ad. Every number below is literal output from a real headless Chromium against the live FastAPI backend serving a freshly built bundle over a real zero-API toy_calc + mock run dir (3 iterations, seed + cand_0001..0003, 2 val tasks), except the 50×50 grid, which is that same served SPA with /api/runs/run_demo route-intercepted.


The blocking defect — all three symptoms fixed

onCloseRollout={() => goto({ task: null })} in RunDeepDive.tsx + onClose={() => { setOpenFile(null); onCloseRollout?.() }} in Trajectories.tsx. Closing is now a URL operation, the same discipline IterationsDiff.tsx:37 and MemoryPanel.tsx:33 already had.

drawer on load (?tab=trajectories&candidate=seed&task=a1): 1
  url: /runs/run_demo?tab=trajectories&candidate=seed&task=a1

SYMPTOM 1  after Escape:        drawer=0  url=/runs/run_demo?tab=trajectories&candidate=seed
           clicked Cost:       url=/runs/run_demo?tab=cost&candidate=seed
SYMPTOM 2  tab away + back:    drawer=0  url=/runs/run_demo?tab=trajectories&candidate=seed
SYMPTOM 3  fresh mount at that url: drawer=0  url=/runs/run_demo?tab=trajectories&candidate=seed

1. A copied link no longer reopens what the sharer dismissed?task=a1 is gone from the URL the moment the drawer closes, so the link a user copies describes the view they are looking at.
2. ?task no longer leaks onto unrelated tabs — clicking Cost after closing carries ?tab=cost&candidate=seed, no task=.
3. Closing now produces a history entry, and Back restores the drawer:

  drawer open:  drawer=1 url=?tab=trajectories&candidate=seed&task=a1 histLen=15
  after close:  drawer=0 url=?tab=trajectories&candidate=seed        histLen=16 (delta=1)
  after Back:   drawer=1 url=?tab=trajectories&candidate=seed&task=a1

Other params audited for the same write-without-clear asymmetry — ?task was the only one. All eight goto(...) call sites:

param written by cleared by asymmetric?
tab every tab click / cross-link — (a tab is always selected; no unset state) no
candidate curve + heatmap cross-links the × on the filter chip → goto({ candidate: null, task: null }) no
mode sub-tab clicks — (a sub-mode is always selected) no
task heatmap cross-link was nothing → now onCloseRollout fixed

Nit 6 (wide fetching every split forever) is fixed as the side effect the reviewer predicted: focus is now falsy again the moment the drawer closes, so the split-scoped fetch is restored for the rest of the visit.


The 50×50 heatmap — measured

role="grid" / role="gridcell" with a roving tabIndex and Arrow/Home/End/PageUp/PageDown, copying the Tabs.tsx handler as suggested. Cells carry aria-disabled rather than disabled, so a not-run cell doesn't punch an unfocusable hole in the arrow walk.

heatmap buttons rendered: 2500 (expected 2500)
table role: grid
[role=gridcell] count: 2500
buttons with tabindex=0 (tab stops INSIDE the grid): 1

Tab presses to ENTER the heatmap: 57
  landed on: 0-0
Tab presses to ESCAPE the heatmap: 1 in 0.01s        <-- was 2500 in 5.5s

Arrow movement inside grid: 0-0 ArrowRight->0-1 ArrowRight->0-2 ArrowDown->1-2
                            End->1-49 Home->1-0 ArrowUp->0-0 PageDown->49-0 PageUp->0-0
ArrowRight inside grid moved focus? True             <-- was false
still exactly one tab stop after arrowing: 1
  and it is the focused cell: 0

2500 tab stops → 1. Escape count 2500 → 1. And the keyboard cross-link still works end to end on the real run dir, from an arrowed-to cell:

focused: a1 at iteration 0 (seed): fail, reward 0.000 — expected '7'
after ArrowRight: 0-1
Enter -> url=?tab=trajectories&candidate=cand_0001&task=a1 drawer=1
drawer file: ['a1__cand_0001__t0.json']
focus in drawer: Close (Escape)
after Escape: url=?tab=trajectories&candidate=cand_0001 drawer=0

Numbered response to all 8 findings

1. BLOCKING — ?task never cleared. Fixed, three proofs above. Regression test added: closing the drawer clears ?task, and it stays closed across a tab round-trip — deep-links open, Escapes, asserts ?task gone, tabs to Cost and back, asserts no dialog.

2. 50×50 heatmap keyboard trap. Fixed as a WAI-ARIA grid, measurements above. Two tests: a 30×20 (600-cell) assertion that role="grid", 600 gridcells and exactly one tabindex="0" exist, plus an arrow-movement test walking Right/Down/Home/End and re-asserting the single tab stop followed.

3. Both tablists unnamed. Tabs takes an optional label; call sites pass "Run views" and "Change surfaces". Panels now carry aria-labelledby pointing at their own tab's id.

tablists: [{"label": "Run views"}, {"label": "Change surfaces"}]
tabpanels: [{"labelledby": "_r_0_-tab-changes", "named": true},
            {"labelledby": "_r_3_-tab-memory",  "named": true}]

named: true = the referenced id resolves to a real element, not a dangling reference.

4. Unknown ?tab/?mode left the bad param in the URL. Normalised in an effect, exactly as suggested:

?tab=nope                       -> selected=['Fitness'] url=/runs/run_demo?tab=fitness
?tab=changes&mode=nope          -> selected=['Changes, memory & files','Candidate diff']
                                   url=/runs/run_demo?tab=changes&mode=candidate
?tab=<script>alert(1)</script>  -> selected=['Fitness'] url=/runs/run_demo?tab=fitness

5. Header named a candidate the panel wasn't showing. Both cross-link headers now render knownCandidate (validated against data.graph.nodes), and an unmatched id gets a visible fallback note instead of a false claim:

  picker = cand_0003
  header/notice text: ["No candidate cand_9999 in this run — showing the newest one instead."]
  claims cand_9999 in a header? False
  trajectories tab, cand_9999 cross-link header present? []

6. wide fetches every split forever. Fixed as a side effect of 1 — see the table above.

7. <details open={!!onSelect}>. Replaced with a small Disclosure: a real heading + table when the content must always be visible, an actual <details> when it is genuinely collapsible. A disclosure triangle that never discloses anything is worse than no triangle.

8. Stale dist/. See the rebuild note below — stated prominently as asked.

Taking the labelling suggestion: the consolidated tab is renamed "Changes, memory & files". The reviewer is right that "one extra click" was understated for Memory and Files — they were never diff surfaces, and a label naming only "changes" hid 22KB of optimizer memory two levels down. The docstring and the four test references were updated with it.


⚠️ Whoever merges this MUST rebuild dist/ — or ship the old ten-tab UI

This is not a nit. dist/ is intentionally committed in this repo (dashboard/frontend/.gitignore: "dist is intentionally COMMITTED … Keep it tracked"), so "this PR didn't commit it" is the correct thing for this PR to do but says nothing about what users get. The tracked bundle predates #139 entirely:

$ git diff --stat origin/main...HEAD -- dashboard/frontend/dist
(empty)                                   # this PR touches dist/ zero times — correct

$ git show HEAD:dashboard/frontend/dist/assets/index-g1UdrsC9.js | grep -c "Changes, memory & files"
0                                         # ...and the tracked bundle has none of this PR in it

Per #188 the rebuild happens once, after the frontend PRs in this epic land. This is the PR that makes a stale bundle visibly wrong — every prior frontend change was invisible in a stale bundle; this one changes the top-level tab bar from ten tabs to seven, so skipping npm ci && npm run build on the final merge result ships a UI that contradicts the merged source. Commit the rebuild as one commit at the end of the merge train.

Qualified shareability — #254

The PR body's unqualified "shareable" claim is wrong today and the reviewer confirmed it live (404 on a hard load of /runs/run_demo?tab=...). Corrected wording:

URL state makes a view shareable on the static export today (it uses HashRouter, so the path never reaches a server). On the served dashboard a deep link only restores state within an already-loaded session — a hard load of /runs/... 404s until #254 adds the SPA catch-all. #254 is a backend change (careful ordering against /api/* and the SSE route), correctly out of scope here.

Every verification below therefore enters through the hub link and drives react-router client-side, which is exactly the path a user has today.

Correction: RunDeepDive.tsx has three conflict hunks, not one

Restated as the reviewer asked. Measured on a real merge:

$ git merge dbcad8ad          # onto origin/feat/issue-117-event-ticker
$ grep -c '^<<<<<<<' dashboard/frontend/src/routes/RunDeepDive.tsx
3
$ grep -n '^<<<<<<<\|^=======\|^>>>>>>>' dashboard/frontend/src/routes/RunDeepDive.tsx
21:<<<<<<< HEAD   25:=======   26:>>>>>>>      # the imports block
36:<<<<<<< HEAD   39:=======   41:>>>>>>>      # the TABS array
182:<<<<<<< HEAD  187:=======  203:>>>>>>>     # the render branch

One correction to the reviewer's "all three resolve keep-both": the imports and the render branch are keep-both, but the TABS array is keep-both-minus-overviewfitness is the old Overview (same BestCurveChart, plus the heatmap), so a literal keep-both leaves both tabs and makes Overview the default. I verified this the hard way: the literal keep-both merge builds and typechecks but fails 5 vitest cases. The correct resolution keeps events, drops overview, and deletes the now-dead FileTree/GitDiff imports (they'd otherwise be TS6133 errors). Worth spelling out, since it's the one hunk where "keep both" is the wrong instruction.


Re-proved: nothing regressed

All four merged panels still render real data (served backend, real run dir):

  mode=candidate  panel=   530 chars  contains prompt.txt       -> True
  mode=commit     panel= 25968 chars  contains JOURNAL.md       -> True
  mode=memory     panel= 22820 chars  contains Accepted history -> True
  mode=files      panel=   500 chars  contains final.json       -> True

No field orphaned — all 105 types.ts fields grepped across src/, both trees:

MAIN   fields=105 orphans=['epoch','git_log','max_iterations','merge_of','opt_cost_usd',
                           'opt_tokens','optimizer_tokens','per_task','runner_tokens','stall']
BRANCH fields=105 orphans=['epoch','git_log','max_iterations','merge_of','opt_cost_usd',
                           'opt_tokens','optimizer_tokens',            'runner_tokens','stall']
NEWLY orphaned by this branch: []
De-orphaned by this branch:    ['per_task']

All 14 api.* methods keep a non-test caller (candidateFiles 1, compare 1, customView 1, diff 1, file 1, gitDiff 1, gitLog 1, memory 2, rollout 2, rollouts 2, run 1, runs 1, streamURL 1, tree 1).

8/8 heatmap cells still exact vs rollouts/val/*.json, with the new roving tabindex visible (ti=0 on exactly one):

  ti=0   ✗ a1 at iteration 0 (seed): fail, reward 0.000 — expected '7' but agent produced 'I think 3 + 4 is roughly some number.'; …
  ti=-1  ✓ a1 at iteration 1 (cand_0001): pass, reward 1.000 — correct
  ti=-1  ✓ a1 at iteration 2 (cand_0002): pass, reward 1.000 — correct
  ti=-1  ✓ a1 at iteration 3 (cand_0003): pass, reward 1.000 — correct
  ti=-1  ✗ a4 at iteration 0 (seed): fail, reward 0.000 — expected '10' but agent produced 'I think 9 + 1 is roughly some number.'; …
  ti=-1  ✓ a4 at iteration 1 (cand_0001): pass, reward 1.000 — correct
  ti=-1  ✓ a4 at iteration 2 (cand_0002): pass, reward 1.000 — correct
  ti=-1  ✓ a4 at iteration 3 (cand_0003): pass, reward 1.000 — correct
  role=grid: grid    gridcells: 8

Hostile params still degrade safely — zero injection, zero page errors. Note rows 1, 3 and 4 now also normalise the bad param out of the URL (finding 4):

?tab=<script>alert(1)</script>                                 -> sel=['Fitness'] injected=0 errs=0
?candidate=../../etc/passwd&tab=trajectories                   -> sel=['Trajectories'] injected=0 errs=0
?tab=nope                                                      -> sel=['Fitness'] injected=0 errs=0
?tab=changes&mode=<img src=x onerror=alert(1)>                 -> sel=['Changes, memory & files'] injected=0 errs=0
?tab=trajectories&candidate=<img src=x onerror=…>&task=<svg/onload=…> -> sel=['Trajectories'] injected=0 errs=0
?tab=changes&candidate=../../../etc/passwd&mode=memory         -> sel=['Changes, memory & files'] injected=0 errs=0
?tab=trajectories&task=a1                                      -> sel=['Trajectories'] injected=0 errs=0

A11y suite still green, including the roving tabindex and the drawer focus contract (the two vitest cases the reviewer singled out — cell.isConnected === false before the fallback assertion, and expect(open).toHaveFocus() for the still-mounted opener — both pass unchanged).

Merged-tree control — still byte-identical. Control = origin/main + #117 + #138 + #118 + #130. Merged = the same four with this branch merged last, RunDeepDive.tsx hand-resolved as described:

CONTROL: 4 failed, 302 passed in 96.43s
MERGED:  4 failed, 302 passed in 96.90s

$ diff <(control FAILED names) <(merged FAILED names)
(only the timing line differs — the four FAILED names are identical)

FAILED core/tests/test_dashboard_launch.py::test_maybe_launch_spawns_when_available
FAILED core/tests/test_dashboard_pipeline.py::test_events_burn_equals_spent_for_every_algorithm
FAILED core/tests/test_dashboard_pipeline.py::test_gepa_emits_the_cost_fields_the_burn_reads
FAILED core/tests/test_dashboard_pipeline.py::test_optimizer_spend_is_counted_on_locally_rejected_gepa_iterations

#139 still touches zero Python files, so a pytest delta would be spooky action. Merged-tree frontend: tsc=0, ✓ built in 515ms, vitest 17 files / 96 tests passed.


Full suites on this branch

$ PYTHONPATH=/tmp/fx-253/core python -m pytest core/tests -q
179 passed in 63.55s (0:01:03)

$ cd dashboard/frontend && npx tsc -b --noEmit
tsc=0
$ npm run build
✓ built in 759ms
$ npm test
 Test Files  14 passed (14)
      Tests  61 passed (61)          # was 54 — +7 for the gaps this review found

$ git status --porcelain
(clean — dist/ NOT committed)

The 7 new cases cover exactly the reviewer's untested-paths list 1–3, plus the heatmap grid: the drawer-close lifecycle (their #1, the gap that let the bug ship), ?mode deep-linking with initialEntries (#2), unknown ?tab normalisation and a nonexistent ?candidate (#3), the 600-cell single-tab-stop bound, arrow movement, and both tablist names. Per #207 CI never runs vitest, so all of these were run locally.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dashboard Dashboard backend/frontend enhancement New feature or request javascript Pull requests that update javascript code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SPA: cross-link candidate dots/heatmap to trajectories+diff; consolidate overlapping tabs

3 participants