Skip to content

feat(tui): PR 4 — RunTimeline + Ledger + Todos (gated WIZARD_NEW_UX=1)#786

Closed
kelsonpw wants to merge 1 commit into
feat/timeline-uxfrom
feat/timeline-ux-pr-4
Closed

feat(tui): PR 4 — RunTimeline + Ledger + Todos (gated WIZARD_NEW_UX=1)#786
kelsonpw wants to merge 1 commit into
feat/timeline-uxfrom
feat/timeline-ux-pr-4

Conversation

@kelsonpw
Copy link
Copy Markdown
Member

@kelsonpw kelsonpw commented May 15, 2026

Summary

PR 4 of 10 in the Timeline UX redesign. Adds a new vertical, append-only RunTimeline view that renders inside RunScreen when WIZARD_NEW_UX=1 is set. The legacy tabs path is byte-for-byte unchanged when the env var is unset.

  • RunTimeline.tsx composer — top status line (BrailleSpinner + latest pushStatus), top-5 todo block from $tasks, last 3–5 ledger rows from $fileWrites, optional lilac extras row when an Overlay is queued, elapsed Xs footer
  • RunTimelineTodos.tsx — ✓/❯/○ glyphs (ascii * > o)
  • RunTimelineLedger.tsx✎ path +X −Y via summarizeLedgerPath with bytes / op-keyword fallback; head-truncates long paths to fit
  • useAtomSelector hook — useSyncExternalStore + memoized snapshot with optional isEqual (default Object.is); ships shallowArrayEqual for list slices. No @nanostores/react dep.
  • RunScreen.tsx minimal fork — early return to RunScreenTimeline when env is set, including an [l] toggle that opens a LogViewer overlay (Esc / l close)

Adjusted vs original AC

Original AC items intentionally deferred (not yet feasible on this base):

  • agentCostUsd footer — no cost field on WizardSession today; deferred until cost tracking lands
  • terminalCapabilities integration — PR 1 (feat(tui): PR 1 — terminalCapabilities + ScreenShell + StepIndicator #783) is not yet merged into feat/timeline-ux. Inlined a minimal UTF-8 / WIZARD_FORCE_ASCII check inside RunTimeline so this PR doesn't depend on PR 1
  • Typed extras for mcp / slack / session-replayPostAgentStep has no type discriminator and MCP/Slack are tracked as Overlay entries on the router, not in postAgentSteps. Current scope reads store.router.hasOverlay and renders a generic lilac "queued" line when any overlay is pending
  • voice.* integration — PR 2 (feat(tui): PR 2 — WizardVoice library + lint guardrail #784) is not yet merged; raw pushStatus strings for now (PR 10 will sweep voice across the suite)

Hard constraints met

Test plan

  • pnpm exec tsc --noEmit — clean
  • pnpm exec eslint (changed files) — clean
  • pnpm 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 lands
  • useAtomSelector.test.tsx — selected-slice identity preserved across unrelated store ticks; fresh snapshot when slice changes; shallowArrayEqual helper

🤖 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 RunTimeline view (gated by WIZARD_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) and RunTimelineLedger (last-N file writes with path truncation and +/- diff summary fallback), plus a new useAtomSelector hook to memoize selected store slices (with shallowArrayEqual) for fewer downstream rerenders.

Updates RunScreen to early-return into the new timeline path when enabled, including an [l]/Esc logs overlay toggle, and adds snapshot + append-only invariant tests for RunTimeline and identity/memoization tests for useAtomSelector.

Reviewed by Cursor Bugbot for commit e7222b9. Bugbot is set up for automated code reviews on this repo. Configure here.

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>
@kelsonpw kelsonpw requested a review from a team as a code owner May 15, 2026 16:35
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

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.

Create PR

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;
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.

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit e7222b9. Configure here.

@kelsonpw
Copy link
Copy Markdown
Member Author

Closing — redesign track abandoned per user feedback. Not merging.

@kelsonpw kelsonpw closed this May 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant