Summary
Every keystroke in the workspace surface re-renders the whole React tree: header chips, sidebar (recomputes `repos.filter(...)` four times for the tab counts), main list (recomputes the windowed row slice), footer, help model. On a 50-repo workspace each cursor move calls `selectVisibleRepos` 3+ times. The flicker users used to see ([fixed in #1056 via `flexWrap` removal] / [#1060 via stable input handler]) was mostly that cost being exposed. The cost is still there; we just don't notice it under normal load.
Memoize the render path so a cursor move only re-renders the cursored row + footer.
Approach
Two complementary techniques:
-
Hoist pure computations behind `useMemo` in WorkspaceInkApp:
selectVisibleRepos(state) — keyed on (overview, sortMode, tab, filter, pullRequestCounts). Currently re-computed on every keystroke even when the inputs haven't changed.
buildWorkspaceSidebar(state) — same key.
buildWorkspaceListWindow(state, { width, rows, spinnerTick }) — keyed on (visibleRepos, selectedIndex, width, rows, spinnerTick). The window slice is the heaviest single computation.
buildWorkspaceHeaderChips(state, …) — keyed on its inputs.
-
Wrap section renderers in `React.memo`:
- A
<WorkspaceHeaderRow> that takes pre-built chips.
- A
<WorkspaceSidebarPanel> that takes the prebuilt sidebar model.
- A
<WorkspaceListPanel> that takes the windowed rows + layout dims.
- A
<WorkspaceFooter> that takes the footer model.
When the user moves the cursor (j/k), only selectedIndex changes. Memoization should make the header / sidebar / footer no-op, and the list panel re-render with just one row's cursor state changing.
Acceptance criteria
Files affected
src/workstation/surfaces/workspace/runtime.ts — primary. Component split + memoization.
src/workstation/surfaces/workspace/view.ts — may need to split `renderWorkspaceApp` into per-panel React components instead of plain functions.
src/workstation/surfaces/workspace/view.test.ts — snapshots may need refresh if component boundaries change the rendered tree.
Risk
- Medium. Memoization bugs are subtle — a wrong dep array can make the panel render stale data without crashing. The snapshot tests will catch most cases; manual TTY testing required to confirm cursor moves actually skip the unrelated panels.
- Touching the render tree shape may shift snapshots even when the visible output is identical (React.memo wrappers introduce additional
<Component> nodes in the serialized snapshot).
- Test seam: consider exposing a `renderCount` ref or similar so we can assert "header didn't re-render on cursor move" — otherwise the perf claim is hand-waved.
Effort estimate
~2 hours including snapshot refresh + manual TTY testing.
Out of scope
Why defer
The visible flicker from earlier sessions was largely caused by flexWrap (#1056) and stdin churn (#1060), both already fixed. With those out, the re-render cost is paid but not painful. Optimization is more about long-term architecture cleanliness than a current user-visible problem. Pick this up when:
- A user reports lag on a workspace with 100+ repos, or
- We're already in
runtime.ts for the split refactor and can do both at once.
Sourced from the post-#880 workspace surface audit.
Summary
Every keystroke in the workspace surface re-renders the whole React tree: header chips, sidebar (recomputes `repos.filter(...)` four times for the tab counts), main list (recomputes the windowed row slice), footer, help model. On a 50-repo workspace each cursor move calls `selectVisibleRepos` 3+ times. The flicker users used to see ([fixed in #1056 via `flexWrap` removal] / [#1060 via stable input handler]) was mostly that cost being exposed. The cost is still there; we just don't notice it under normal load.
Memoize the render path so a cursor move only re-renders the cursored row + footer.
Approach
Two complementary techniques:
Hoist pure computations behind `useMemo` in
WorkspaceInkApp:selectVisibleRepos(state)— keyed on(overview, sortMode, tab, filter, pullRequestCounts). Currently re-computed on every keystroke even when the inputs haven't changed.buildWorkspaceSidebar(state)— same key.buildWorkspaceListWindow(state, { width, rows, spinnerTick })— keyed on(visibleRepos, selectedIndex, width, rows, spinnerTick). The window slice is the heaviest single computation.buildWorkspaceHeaderChips(state, …)— keyed on its inputs.Wrap section renderers in `React.memo`:
<WorkspaceHeaderRow>that takes pre-built chips.<WorkspaceSidebarPanel>that takes the prebuilt sidebar model.<WorkspaceListPanel>that takes the windowed rows + layout dims.<WorkspaceFooter>that takes the footer model.When the user moves the cursor (
j/k), onlyselectedIndexchanges. Memoization should make the header / sidebar / footer no-op, and the list panel re-render with just one row's cursor state changing.Acceptance criteria
selectVisibleReposis computed at most once per render.j/k) don't trigger the sidebar / header / footer renderers (verifiable by adding arenderCount.current++in auseEffectper panel during development).Files affected
src/workstation/surfaces/workspace/runtime.ts— primary. Component split + memoization.src/workstation/surfaces/workspace/view.ts— may need to split `renderWorkspaceApp` into per-panel React components instead of plain functions.src/workstation/surfaces/workspace/view.test.ts— snapshots may need refresh if component boundaries change the rendered tree.Risk
<Component>nodes in the serialized snapshot).Effort estimate
~2 hours including snapshot refresh + manual TTY testing.
Out of scope
WorkspaceInkRuntime) #1078).runtime.ts— tracked separately (#TBD). Some of the component splits proposed here overlap with that issue; both should be coordinated when picked up.Why defer
The visible flicker from earlier sessions was largely caused by
flexWrap(#1056) and stdin churn (#1060), both already fixed. With those out, the re-render cost is paid but not painful. Optimization is more about long-term architecture cleanliness than a current user-visible problem. Pick this up when:runtime.tsfor the split refactor and can do both at once.Sourced from the post-#880 workspace surface audit.