feat(tui): PR 4 — RunTimeline + Ledger + Todos (gated WIZARD_NEW_UX=1)#786
feat(tui): PR 4 — RunTimeline + Ledger + Todos (gated WIZARD_NEW_UX=1)#786kelsonpw wants to merge 1 commit into
Conversation
Replaces the RunScreen tabs body with a single vertical Timeline view
when `WIZARD_NEW_UX=1` is set. The legacy path is byte-for-byte
unchanged when the env var is unset.
In scope
- [x] `RunTimeline.tsx` composer with narrow store selectors
- [x] `RunTimelineTodos.tsx` (top 5 tasks with ✓/❯/○ glyphs)
- [x] `RunTimelineLedger.tsx` (last 3–5 file writes, +X −Y from
`summarizeLedgerPath`, head-truncated paths)
- [x] `useAtomSelector` hook — `useSyncExternalStore` + memoized
snapshot with optional `isEqual`; ships `shallowArrayEqual`
helper for list selectors
- [x] Inline UTF-8 / `WIZARD_FORCE_ASCII=1` capability check (PR 1
not yet on `feat/timeline-ux`)
- [x] `[l]` log overlay only active under the new UX path
- [x] Outer `Box overflow="hidden"` per #779
- [x] Snapshot tests at 80 / 60 / 40 cols + append-only invariant
- [x] `useAtomSelector` unit tests for narrow subscription + snapshot
identity
Deferred (PR-body notes for reviewer)
- [ ] `agentCostUsd` footer — no cost field on `WizardSession` today
- [ ] `terminalCapabilities` integration — waits on PR 1 (#783)
- [ ] Typed extras for mcp/slack/session-replay — PostAgentStep has no
`type` discriminator; current scope surfaces lilac extras when
the router has any overlay queued
- [ ] `voice.*` integration — PR 2 (#784) not merged yet; raw status
strings for now (PR 10 sweep will replace)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Elapsed timer freezes without periodic re-render driver
- Added a useEffect with a 1-second setInterval that updates elapsedSeconds state whenever runStartedAt is set, ensuring the timer re-renders independently of store changes.
Or push these changes by commenting:
@cursor push 89c113edf9
Preview (89c113edf9)
diff --git a/src/ui/tui/components/RunTimeline.tsx b/src/ui/tui/components/RunTimeline.tsx
--- a/src/ui/tui/components/RunTimeline.tsx
+++ b/src/ui/tui/components/RunTimeline.tsx
@@ -37,9 +37,12 @@
*/
import { Box, Text } from 'ink';
-import { useCallback } from 'react';
+import { useCallback, useEffect, useState } from 'react';
import { useStdoutDimensions } from '../hooks/useStdoutDimensions.js';
-import { useAtomSelector, shallowArrayEqual } from '../hooks/useAtomSelector.js';
+import {
+ useAtomSelector,
+ shallowArrayEqual,
+} from '../hooks/useAtomSelector.js';
import { BrailleSpinner } from './BrailleSpinner.js';
import { RunTimelineTodos } from './RunTimelineTodos.js';
import { RunTimelineLedger } from './RunTimelineLedger.js';
@@ -79,12 +82,14 @@
const selectTopTasks = (store: WizardStore): readonly TaskItem[] =>
store.tasks.slice(0, MAX_TODOS);
-const selectInstallDir = (store: WizardStore): string => store.session.installDir;
+const selectInstallDir = (store: WizardStore): string =>
+ store.session.installDir;
const selectRunStartedAt = (store: WizardStore): number | null =>
store.session.runStartedAt;
-const selectOverlayActive = (store: WizardStore): boolean => store.router.hasOverlay;
+const selectOverlayActive = (store: WizardStore): boolean =>
+ store.router.hasOverlay;
export const RunTimeline = ({ store }: RunTimelineProps) => {
const [cols] = useStdoutDimensions();
@@ -105,17 +110,36 @@
},
[ledgerMax],
);
- const tailWrites = useAtomSelector(store, selectTailWrites, shallowArrayEqual);
+ const tailWrites = useAtomSelector(
+ store,
+ selectTailWrites,
+ shallowArrayEqual,
+ );
const installDir = useAtomSelector(store, selectInstallDir);
const runStartedAt = useAtomSelector(store, selectRunStartedAt);
const overlayActive = useAtomSelector(store, selectOverlayActive);
- const elapsedSeconds =
+ const [elapsedSeconds, setElapsedSeconds] = useState<number | null>(
runStartedAt !== null
? Math.max(0, Math.floor((Date.now() - runStartedAt) / 1000))
- : null;
+ : null,
+ );
+ useEffect(() => {
+ if (runStartedAt === null) {
+ setElapsedSeconds(null);
+ return;
+ }
+ const update = () =>
+ setElapsedSeconds(
+ Math.max(0, Math.floor((Date.now() - runStartedAt) / 1000)),
+ );
+ update();
+ const id = setInterval(update, 1000);
+ return () => clearInterval(id);
+ }, [runStartedAt]);
+
return (
<Box flexDirection="column" overflow="hidden">
{status !== null && (You can send follow-ups to the cloud agent here.
Reviewed by Cursor Bugbot for commit e7222b9. Configure here.
| const elapsedSeconds = | ||
| runStartedAt !== null | ||
| ? Math.max(0, Math.floor((Date.now() - runStartedAt) / 1000)) | ||
| : null; |
There was a problem hiding this comment.
Elapsed timer freezes without periodic re-render driver
Medium Severity
The elapsedSeconds value is derived from Date.now() during render, but RunTimeline has no periodic timer to trigger re-renders. The BrailleSpinner child drives its own internal interval (only updating itself), so the spinner animates while the elapsed counter stays frozen until the next store change. During quiet agent "thinking" periods the timer visibly stalls and then jumps. The legacy ProgressTab avoids this with a setInterval + setTick state that re-renders the whole component on each spinner frame.
Reviewed by Cursor Bugbot for commit e7222b9. Configure here.
|
Closing — redesign track abandoned per user feedback. Not merging. |



Summary
PR 4 of 10 in the Timeline UX redesign. Adds a new vertical, append-only
RunTimelineview that renders insideRunScreenwhenWIZARD_NEW_UX=1is set. The legacy tabs path is byte-for-byte unchanged when the env var is unset.RunTimeline.tsxcomposer — top status line (BrailleSpinner + latestpushStatus), top-5 todo block from$tasks, last 3–5 ledger rows from$fileWrites, optional lilac extras row when an Overlay is queued,elapsed XsfooterRunTimelineTodos.tsx— ✓/❯/○ glyphs (ascii* > o)RunTimelineLedger.tsx—✎ path +X −YviasummarizeLedgerPathwith bytes / op-keyword fallback; head-truncates long paths to fituseAtomSelectorhook —useSyncExternalStore+ memoized snapshot with optionalisEqual(defaultObject.is); shipsshallowArrayEqualfor list slices. No@nanostores/reactdep.RunScreen.tsxminimal fork — early return toRunScreenTimelinewhen env is set, including an[l]toggle that opens aLogVieweroverlay (Esc /lclose)Adjusted vs original AC
Original AC items intentionally deferred (not yet feasible on this base):
agentCostUsdfooter — no cost field onWizardSessiontoday; deferred until cost tracking landsterminalCapabilitiesintegration — PR 1 (feat(tui): PR 1 — terminalCapabilities + ScreenShell + StepIndicator #783) is not yet merged intofeat/timeline-ux. Inlined a minimal UTF-8 /WIZARD_FORCE_ASCIIcheck insideRunTimelineso this PR doesn't depend on PR 1mcp/slack/session-replay—PostAgentStephas notypediscriminator and MCP/Slack are tracked asOverlayentries on the router, not inpostAgentSteps. Current scope readsstore.router.hasOverlayand renders a generic lilac "queued" line when any overlay is pendingvoice.*integration — PR 2 (feat(tui): PR 2 — WizardVoice library + lint guardrail #784) is not yet merged; rawpushStatusstrings for now (PR 10 will sweep voice across the suite)Hard constraints met
src/utils/wizard-abort.tsuntouched--no-verify; lint-staged passed on commitfeat/timeline-ux-pr-4→feat/timeline-ux@nanostores/reactdependency addedBox overflow=\"hidden\"per fix(tui): stop overdraw across Setup Report, /diff overlay, slash palette, outro #779WIZARD_NEW_UXunset) is identical to before this PRTest plan
pnpm exec tsc --noEmit— cleanpnpm exec eslint(changed files) — cleanpnpm test— 4266 passed across 289 files (12 new)RunTimeline.test.tsx— snapshots at 80 / 60 / 40 cols + append-only invariant when a new file write landsuseAtomSelector.test.tsx— selected-slice identity preserved across unrelated store ticks; fresh snapshot when slice changes;shallowArrayEqualhelper🤖 Generated with Claude Code
Note
Medium Risk
Adds a new RunScreen rendering path and new store-subscription hook behavior; while gated behind
WIZARD_NEW_UX, it changes interactive flow (log overlay key handling) and relies on correct selector memoization to avoid render/perf regressions or stale UI slices.Overview
Introduces a new vertical, append-friendly
RunTimelineview (gated byWIZARD_NEW_UX=1) that replaces the tabbed RunScreen with a single timeline showing latest status, top tasks, recent file writes, an optional queued-overlay hint, and an elapsed timer.Adds presentational
RunTimelineTodos(status glyphs with ASCII fallback) andRunTimelineLedger(last-N file writes with path truncation and+/-diff summary fallback), plus a newuseAtomSelectorhook to memoize selected store slices (withshallowArrayEqual) for fewer downstream rerenders.Updates
RunScreento early-return into the new timeline path when enabled, including an[l]/Esc logs overlay toggle, and adds snapshot + append-only invariant tests forRunTimelineand identity/memoization tests foruseAtomSelector.Reviewed by Cursor Bugbot for commit e7222b9. Bugbot is set up for automated code reviews on this repo. Configure here.