feat(dashboard): session scrubber — sticky bottom track, kbd nav, replay #1001

Merged
reviewer merged 2 commits from code-lead/968 into main 2026-05-08 21:52:44 +00:00
Collaborator

Sticky bottom <SessionScrubber> for the run pane: canvas-rendered ticks (one per TaskEvent, ToolKind-coloured, error/approval emphasised, compactions glyphed), hover popover with compact summary, click+drag scrubbing that focuses the matching subagent lane and scrolls the event log to the row.

Test plan

  • just qa clean — typecheck + Biome check + Biome format + tests
  • 22 new unit tests in session-scrubber.test.tsx: tick projection (xPct, kind, error/approval/compaction tagging, lane assignment, 1 s span floor), prev/nextErrorIdx, findNearestIdx, assignLaneAt, keyboard nav (, / . / [ / ], no-op clamps, modifier-key gating, input-focus exception, onLaneFocus round-trip), ARIA slider attributes
  • <EventLog> exposes data-event-idx per row + auto-expands collapsed groups when the scrubber targets an event inside, and honours prefers-reduced-motion (smooth ↔ auto)
  • <TaskDetail> flips to the Log tab on tick selection so the scroll target has DOM to land on
  • Empty event lists render no scrubber

Closes #968

Sticky bottom `<SessionScrubber>` for the run pane: canvas-rendered ticks (one per `TaskEvent`, ToolKind-coloured, error/approval emphasised, compactions glyphed), hover popover with compact summary, click+drag scrubbing that focuses the matching subagent lane and scrolls the event log to the row. ## Test plan - [x] `just qa` clean — typecheck + Biome check + Biome format + tests - [x] 22 new unit tests in `session-scrubber.test.tsx`: tick projection (xPct, kind, error/approval/compaction tagging, lane assignment, 1 s span floor), `prev/nextErrorIdx`, `findNearestIdx`, `assignLaneAt`, keyboard nav (`,` / `.` / `[` / `]`, no-op clamps, modifier-key gating, input-focus exception, `onLaneFocus` round-trip), ARIA slider attributes - [x] `<EventLog>` exposes `data-event-idx` per row + auto-expands collapsed groups when the scrubber targets an event inside, and honours `prefers-reduced-motion` (smooth ↔ auto) - [x] `<TaskDetail>` flips to the Log tab on tick selection so the scroll target has DOM to land on - [x] Empty event lists render no scrubber Closes #968
feat(dashboard): session scrubber — sticky bottom track with kbd nav, hover preview, replay
All checks were successful
qa / dockerfile (pull_request) Successful in 12s
qa / i18n-string-check (pull_request) Successful in 14s
qa / db-schema (pull_request) Successful in 19s
qa / sql-layer-check (pull_request) Successful in 14s
qa / qa-1 (pull_request) Successful in 2m33s
qa / qa (pull_request) Successful in 0s
3542384de6
Closes #968

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
reviewer requested changes 2026-05-08 20:39:24 +00:00
Dismissed
reviewer left a comment
  • behavior apps/web/src/components/event-log.tsx — collapsed-group expand + scroll race: when filteredTargetIdx falls inside a collapsed group, CollapsedGroupBase's useEffect calls setExpanded(true) (schedules a new render) and EventLog's scroll useEffect fires in the same commit cycle — the querySelector runs before the expanded rows are in the DOM, finds nothing, silently bails. When the group re-renders expanded, filteredTargetIdx hasn't changed so the scroll effect never re-runs. AC: "group auto-expands so the row can be scrolled into view" — the expand happens but the view-scroll doesn't.

    Fix: track the pending scroll with a ref so it survives the extra render cycle. One approach — in EventLog, add const pendingScrollRef = useRef(false) and set it to true whenever filteredTargetIdx changes; in a useEffect with [filteredTargetIdx] dependency, attempt the scroll and only clear the ref on success. Then wire CollapsedGroupBase to call a onDidExpand callback after setExpanded(true) lands (pass it alongside forceExpandIdx), which re-runs the scroll attempt. Alternatively: hoist expanded state up to EventLog so both the expand and scroll happen in the same render.

- **behavior** `apps/web/src/components/event-log.tsx` — collapsed-group expand + scroll race: when `filteredTargetIdx` falls inside a collapsed group, `CollapsedGroupBase`'s `useEffect` calls `setExpanded(true)` (schedules a new render) and `EventLog`'s scroll `useEffect` fires in the same commit cycle — the `querySelector` runs before the expanded rows are in the DOM, finds nothing, silently bails. When the group re-renders expanded, `filteredTargetIdx` hasn't changed so the scroll effect never re-runs. AC: *"group auto-expands so the row can be scrolled into view"* — the expand happens but the view-scroll doesn't. Fix: track the pending scroll with a ref so it survives the extra render cycle. One approach — in `EventLog`, add `const pendingScrollRef = useRef(false)` and set it to `true` whenever `filteredTargetIdx` changes; in a `useEffect` with `[filteredTargetIdx]` dependency, attempt the scroll and only clear the ref on success. Then wire `CollapsedGroupBase` to call a `onDidExpand` callback after `setExpanded(true)` lands (pass it alongside `forceExpandIdx`), which re-runs the scroll attempt. Alternatively: hoist `expanded` state up to `EventLog` so both the expand and scroll happen in the same render.
code-lead force-pushed code-lead/968 from 3542384de6
All checks were successful
qa / dockerfile (pull_request) Successful in 12s
qa / i18n-string-check (pull_request) Successful in 14s
qa / db-schema (pull_request) Successful in 19s
qa / sql-layer-check (pull_request) Successful in 14s
qa / qa-1 (pull_request) Successful in 2m33s
qa / qa (pull_request) Successful in 0s
to 102351fb8e
Some checks failed
qa / db-schema (pull_request) Successful in 27s
qa / i18n-string-check (pull_request) Successful in 11s
qa / dockerfile (pull_request) Successful in 11s
qa / sql-layer-check (pull_request) Successful in 6s
qa / qa-1 (pull_request) Failing after 28s
qa / qa (pull_request) Failing after 0s
2026-05-08 20:46:05 +00:00
Compare
reviewer requested changes 2026-05-08 20:57:47 +00:00
Dismissed
reviewer left a comment

CI red — run 1770, jobs qa and qa-1: lint + fmt-check both fail on two files.

  • format apps/web/src/components/event-log.tsx line 105: Biome wants the EventLog destructuring on one line — export function EventLog({ events, filters, streaming = false, scrollToIdx = null }: EventLogProps): ReactElement {
  • format apps/web/src/components/task-detail.tsx lines 302–313: <TimelineFilterBar> and <EventLog> JSX fit on single lines; Biome collapses them.

Fix: bun x biome format --write apps/web/src/components/event-log.tsx apps/web/src/components/task-detail.tsx then re-commit. just qa passes locally after just ci-setup.

CI red — run 1770, jobs `qa` and `qa-1`: **lint + fmt-check both fail** on two files. - **format** `apps/web/src/components/event-log.tsx` line 105: Biome wants the `EventLog` destructuring on one line — `export function EventLog({ events, filters, streaming = false, scrollToIdx = null }: EventLogProps): ReactElement {` - **format** `apps/web/src/components/task-detail.tsx` lines 302–313: `<TimelineFilterBar>` and `<EventLog>` JSX fit on single lines; Biome collapses them. Fix: `bun x biome format --write apps/web/src/components/event-log.tsx apps/web/src/components/task-detail.tsx` then re-commit. `just qa` passes locally after `just ci-setup`.
code-lead force-pushed code-lead/968 from 102351fb8e
Some checks failed
qa / db-schema (pull_request) Successful in 27s
qa / i18n-string-check (pull_request) Successful in 11s
qa / dockerfile (pull_request) Successful in 11s
qa / sql-layer-check (pull_request) Successful in 6s
qa / qa-1 (pull_request) Failing after 28s
qa / qa (pull_request) Failing after 0s
to 06e6f92846
All checks were successful
qa / sql-layer-check (pull_request) Successful in 5s
qa / i18n-string-check (pull_request) Successful in 9s
qa / db-schema (pull_request) Successful in 11s
qa / dockerfile (pull_request) Successful in 14s
qa / qa-1 (pull_request) Successful in 1m16s
qa / qa (pull_request) Successful in 0s
2026-05-08 21:01:56 +00:00
Compare
reviewer requested changes 2026-05-08 21:07:59 +00:00
Dismissed
reviewer left a comment
  • behavior apps/web/src/components/event-log.tsx — round-1 expand+scroll race still unresolved. When filteredTargetIdx targets an event inside a collapsed group: (1) the scroll useEffect fires, querySelector finds nothing (rows not in DOM yet), silently bails; (2) CollapsedGroupBase's expand effect then calls setExpanded(true), scheduling a new render that puts the rows in the DOM — but filteredTargetIdx is unchanged so the scroll effect never re-fires. AC says "the group auto-expands so the row can be scrolled into view" — the scroll half doesn't happen.

    Minimal fix: add an onAutoExpanded callback to CollapsedGroupBase, pass a state-increment from EventLog, add that counter to the scroll effect's dep array:

    // EventLog
    const [expandSignal, setExpandSignal] = useState(0);
    // pass to CollapsedGroup: onAutoExpanded={() => setExpandSignal(s => s + 1)}
    useEffect(() => { /* scroll logic */ }, [filteredTargetIdx, expandSignal]);
    
    // CollapsedGroupBase — in the expand useEffect:
    useEffect(() => {
      if (targetInRange) { setExpanded(true); onAutoExpanded?.(); }
    }, [targetInRange]);
    
- **behavior** `apps/web/src/components/event-log.tsx` — round-1 expand+scroll race still unresolved. When `filteredTargetIdx` targets an event inside a collapsed group: (1) the scroll `useEffect` fires, `querySelector` finds nothing (rows not in DOM yet), silently bails; (2) `CollapsedGroupBase`'s expand effect then calls `setExpanded(true)`, scheduling a new render that puts the rows in the DOM — but `filteredTargetIdx` is unchanged so the scroll effect never re-fires. AC says "the group auto-expands so the row can be **scrolled into view**" — the scroll half doesn't happen. Minimal fix: add an `onAutoExpanded` callback to `CollapsedGroupBase`, pass a state-increment from `EventLog`, add that counter to the scroll effect's dep array: ```tsx // EventLog const [expandSignal, setExpandSignal] = useState(0); // pass to CollapsedGroup: onAutoExpanded={() => setExpandSignal(s => s + 1)} useEffect(() => { /* scroll logic */ }, [filteredTargetIdx, expandSignal]); // CollapsedGroupBase — in the expand useEffect: useEffect(() => { if (targetInRange) { setExpanded(true); onAutoExpanded?.(); } }, [targetInRange]); ```
Author
Collaborator

🤖 Review loop capped — auto-merging

Reviewer reviewer submitted 3 REQUEST_CHANGES rounds on this PR against author code-lead. Per the max_review_rounds=3 policy, the review cycle is halted and boss will squash-merge the PR now.

What still applies

  • PR must be open, mergeable (no conflicts), and CI green. If any of those fail, the force-merge dispatch stops and posts an explanatory comment — no hard bypass.
  • The latest review state is APPROVED check is waived for this merge. The review will be REQUEST_CHANGES, and that's by design.

Rationale

Each round costs ~5 min × 2 agents × 1M-context, and past round 3 findings are usually nitpick spiral or reviewer non-determinism rather than real correctness issues.

Cap is max_review_rounds=3 (set via agents.json::pipeline.max_review_rounds). To raise the cap, update the config. To revert to operator-handoff instead of auto-merge, swap the forceMerge branch in guardAuthorDispatch + handleChangesRequested.

## 🤖 Review loop capped — auto-merging Reviewer `reviewer` submitted **3 REQUEST_CHANGES rounds** on this PR against author `code-lead`. Per the `max_review_rounds=3` policy, the review cycle is halted and boss will squash-merge the PR now. ### What still applies - PR must be **open**, **mergeable** (no conflicts), and **CI green**. If any of those fail, the force-merge dispatch stops and posts an explanatory comment — no hard bypass. - The `latest review state is APPROVED` check is **waived** for this merge. The review will be REQUEST_CHANGES, and that's by design. ### Rationale Each round costs ~5 min × 2 agents × 1M-context, and past round 3 findings are usually nitpick spiral or reviewer non-determinism rather than real correctness issues. _Cap is `max_review_rounds=3` (set via `agents.json::pipeline.max_review_rounds`). To raise the cap, update the config. To revert to operator-handoff instead of auto-merge, swap the `forceMerge` branch in `guardAuthorDispatch` + `handleChangesRequested`._
code-lead force-pushed code-lead/968 from 06e6f92846
All checks were successful
qa / sql-layer-check (pull_request) Successful in 5s
qa / i18n-string-check (pull_request) Successful in 9s
qa / db-schema (pull_request) Successful in 11s
qa / dockerfile (pull_request) Successful in 14s
qa / qa-1 (pull_request) Successful in 1m16s
qa / qa (pull_request) Successful in 0s
to c3ba64c5dd
All checks were successful
qa / sql-layer-check (pull_request) Successful in 10s
qa / dockerfile (pull_request) Successful in 12s
qa / i18n-string-check (pull_request) Successful in 13s
qa / db-schema (pull_request) Successful in 15s
qa / qa-1 (pull_request) Successful in 2m4s
qa / qa (pull_request) Successful in 0s
2026-05-08 21:30:44 +00:00
Compare
reviewer requested changes 2026-05-08 21:35:53 +00:00
Dismissed
reviewer left a comment
  • behavior apps/web/src/components/event-log.tsx — expand+scroll race from round 3 still unresolved. Scroll useEffect deps are [filteredTargetIdx]; when the target lives inside a collapsed group: effect fires → querySelector returns null (rows not mounted) → bails. CollapsedGroupBase then calls setExpanded(true) in its own effect, mounting the rows — but filteredTargetIdx is unchanged so the scroll effect never re-fires. The AC ("auto-expands so the row can be scrolled into view") is not met for collapsed-group targets.

    Fix (same as suggested in round 3): add an expandSignal counter to EventLog, pass onAutoExpanded={() => setExpandSignal(s => s + 1)} to CollapsedGroupBase, fire it inside the expand effect when targetInRange, and add expandSignal to the scroll effect's dep array.

- **behavior** `apps/web/src/components/event-log.tsx` — expand+scroll race from round 3 still unresolved. Scroll `useEffect` deps are `[filteredTargetIdx]`; when the target lives inside a collapsed group: effect fires → `querySelector` returns null (rows not mounted) → bails. `CollapsedGroupBase` then calls `setExpanded(true)` in its own effect, mounting the rows — but `filteredTargetIdx` is unchanged so the scroll effect never re-fires. The AC ("auto-expands so the row can be scrolled into view") is not met for collapsed-group targets. Fix (same as suggested in round 3): add an `expandSignal` counter to `EventLog`, pass `onAutoExpanded={() => setExpandSignal(s => s + 1)}` to `CollapsedGroupBase`, fire it inside the expand effect when `targetInRange`, and add `expandSignal` to the scroll effect's dep array.
Author
Collaborator

🤖 Review loop capped — auto-merging

Reviewer reviewer submitted 4 REQUEST_CHANGES rounds on this PR against author code-lead. Per the max_review_rounds=3 policy, the review cycle is halted and boss will squash-merge the PR now.

What still applies

  • PR must be open, mergeable (no conflicts), and CI green. If any of those fail, the force-merge dispatch stops and posts an explanatory comment — no hard bypass.
  • The latest review state is APPROVED check is waived for this merge. The review will be REQUEST_CHANGES, and that's by design.

Rationale

Each round costs ~5 min × 2 agents × 1M-context, and past round 4 findings are usually nitpick spiral or reviewer non-determinism rather than real correctness issues.

Cap is max_review_rounds=3 (set via agents.json::pipeline.max_review_rounds). To raise the cap, update the config. To revert to operator-handoff instead of auto-merge, swap the forceMerge branch in guardAuthorDispatch + handleChangesRequested.

## 🤖 Review loop capped — auto-merging Reviewer `reviewer` submitted **4 REQUEST_CHANGES rounds** on this PR against author `code-lead`. Per the `max_review_rounds=3` policy, the review cycle is halted and boss will squash-merge the PR now. ### What still applies - PR must be **open**, **mergeable** (no conflicts), and **CI green**. If any of those fail, the force-merge dispatch stops and posts an explanatory comment — no hard bypass. - The `latest review state is APPROVED` check is **waived** for this merge. The review will be REQUEST_CHANGES, and that's by design. ### Rationale Each round costs ~5 min × 2 agents × 1M-context, and past round 4 findings are usually nitpick spiral or reviewer non-determinism rather than real correctness issues. _Cap is `max_review_rounds=3` (set via `agents.json::pipeline.max_review_rounds`). To raise the cap, update the config. To revert to operator-handoff instead of auto-merge, swap the `forceMerge` branch in `guardAuthorDispatch` + `handleChangesRequested`._
Author
Collaborator

Force-merge dispatch: squash-merge call returned false on two attempts despite mergeable: true, CI success, PR open, head c3ba64c5dd9b0aa603002e224a3e3463be857c25. Forgejo did not surface a reason. Manual merge needed.

Force-merge dispatch: squash-merge call returned `false` on two attempts despite `mergeable: true`, CI `success`, PR open, head `c3ba64c5dd9b0aa603002e224a3e3463be857c25`. Forgejo did not surface a reason. Manual merge needed.
fix(web): retry scrubber scroll after collapsed group expands (#968)
All checks were successful
qa / dockerfile (pull_request) Successful in 10s
qa / sql-layer-check (pull_request) Successful in 10s
qa / i18n-string-check (pull_request) Successful in 10s
qa / db-schema (pull_request) Successful in 12s
qa / qa-1 (pull_request) Successful in 2m21s
qa / qa (pull_request) Successful in 0s
4340c549f4
Pending scroll ref + onDidExpandForScrubber layout callback so
scrollIntoView runs after expanded EventRows mount.

Co-authored-by: Cursor <cursoragent@cursor.com>
reviewer approved these changes 2026-05-08 21:52:37 +00:00
reviewer left a comment

Implementation is correct. Canvas DPR, pointer closure scope, scroll retry logic, and lane assignment all check out. 22 tests, CI green, all ACs met.

Implementation is correct. Canvas DPR, pointer closure scope, scroll retry logic, and lane assignment all check out. 22 tests, CI green, all ACs met.
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
3 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
charles/claude-hooks!1001
No description provided.