Skip to content

refactor(frontend): collapse the duplicated layers a review turned up - #114

Merged
omartelo merged 7 commits into
mainfrom
feature/frontend-maintainability
Jul 28, 2026
Merged

omartelo merged 7 commits into
mainfrom
feature/frontend-maintainability

Conversation

@omartelo

@omartelo omartelo commented Jul 28, 2026 •

Copy link
Copy Markdown
Collaborator

A maintainability pass over frontend/, no user-facing change — hence no CHANGELOG.md entry. Seven commits, each one finding on its own.

The one worth reading first

vitest.config.ts claimed the .tsx components stayed out of the coverage denominator. They did not: include only decides which unloaded files join the report, so anything a test imports was measured regardless — components pulled in transitively, the React hooks no node-environment test can render, and the OS/framework boundaries invariant #1 exempts.

The number reaching review was 61% against an 80% bar, for a suite whose actual subject sits at 95%. a074fd7 excludes what the suite cannot reach so the summary means what invariant #1 says it means. Same tests, same assertions — only the denominator moved.

Duplication removed

  • ae8ffab — five hooks each rebuilt the same sequence-guarded fetch, and the copies had drifted: the diff hook skipped the focus refetch, the checkouts hook swallowed its error, only the list blanked its rows when the request behind them changed. useRemoteResource owns the skeleton and each of those becomes a named option instead of an omission. useDiffEditor/useFileEditor were the same file twice; the view lifecycle moves to useCodeMirrorView and both stay as thin wrappers with their distinct selection contracts.
  • 1592ccc — eight sites hand-rolled the same localStorage read, each with its own idea of what a bad stored value means. lib/prefs.ts splits it the way parsePullsSort already did: pure parsing the suite owns, a two-line boundary it does not. localStorage is no longer touched anywhere else. usePanelVisible covers both collapsible navigators, keeping the stored encoding so no install loses its choice.
  • faa1b88 — one SkeletonLines behind the three loading skeletons; one FooterButton behind the status strip's five. That also clears the last template-literal classNames outside components/ui, so cn() is now genuinely the only class-merging helper.

Files that had grown past their job

  • 3e51c8b — Pulls.tsx held six components in 732 lines. The screen (which pull request is in view, what an action there means for the checkout it lives in) and the view (header plus four tabs) have separate jobs and no seam between them. Each moves to a file named after it: 732 → 255, nothing in the folder above 330.
  • e0f854c — the worktree keep/remove/force question was five functions and two useStates interleaved with the sidebar's layout and resize wiring; it becomes useWorktreeClose and the sidebar keeps only the two dialogs. SessionGroup was forwarding three callbacks it never read plus a projectName already derivable from the projectPath beside it. SessionSidebar 327 → 253; SessionGroup 14 props → 11.

Behaviour changes

Two, both deliberate:

  • A boolean pref holding something writePref never emitted now reads as its default instead of as on.
  • f1511b5 fixes a real screen-reader bug: the notifications badge announced its count through an aria-label on a bare span element, which carries no role — so nothing announced it. The count moves into the trigger's own accessible name and the dot goes aria-hidden. The review button in the footer also gained a label it never had, via FooterButton. Biome a11y warnings 13 → 12; the rest of that documented backlog stays.

Same commit also makes THEMES/TERMINAL_THEMES actually drive the Appearance picker (it redeclared both lists, so a mismatch was silent — a missing label is now a type error), gives AppUpdateGate the dependency array it was missing, and moves the store setting keys to lib/project-settings.ts, since the contract they mirror is internal/store/settings.go's and not a settings pane's.

Verification

Local gate green: gofmt/go vet/go test, biome check, tsc, vite build, 491 frontend tests.

The suite cannot render components, so the render path was driven for real — the binary built and served to a headless Chromium:

  • sidebar → group → card with branch and diff stat, live PTY, footer segments with correct labels and aria-pressed;
  • the Review dock mounting CodeMirror over an actual diff, gutter and line decorations intact;
  • Settings → Appearance, and a theme click landing in both html.dark and the stored pref;
  • the split Pulls screen rendering its list column, skeleton and error branches.

No page errors. What this did not reach: the happy path of PullRequestView and PullsEmptyState both need gh and a live PR, so they are worth a pass in task dev before merging.

claude added 7 commits July 28, 2026 11:46
The coverage config's comment claimed the .tsx components stayed out of the
denominator; they did not. `include` only decides which unloaded files are
added to the report — a file a test imports is measured whether or not it
matches, so every component pulled in transitively (providers/settings.tsx,
ContextRing.tsx, the two CodeMirror hooks) was counted as uncovered, along
with the React hooks no node-environment test can render and the OS/framework
boundaries invariant #1 exempts.

The number that reached review was 61%, against an 80% bar, for a suite whose
actual subject sits at 95%. Exclude what the suite cannot reach so the summary
means what invariant #1 says it means.

Also drop components/ui/card.tsx: a stock shadcn primitive, never imported
since it landed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RhBpWr7np1JiiiQhfaa4dV
Five hooks were each rebuilding the same sequence-guarded fetch — a ref
counter so a late answer cannot land on a newer request, the loading/error
pair, the focus listener — and the copies had drifted: the diff hook skipped
focus, the checkouts hook swallowed its error, and only the list blanked its
rows when the request behind them changed. useRemoteResource owns the skeleton
and makes each of those a named option instead of an omission.

Its key is the contract: it stands for the whole request, because `load` is
read through a ref so callers can pass an inline closure, and a key missing an
argument is a hook that answers with the previous request's data.

useDiffEditor and useFileEditor were the same file twice, differing in their
extension list and in what a selected doc line means afterwards. The view
lifecycle moves to useCodeMirrorView; the two keep their names and their
distinct selection contracts as thin wrappers.

No behaviour change intended: same requests, same invalidation, same empty and
error states.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RhBpWr7np1JiiiQhfaa4dV
…hows

Pulls.tsx held six components in 732 lines: the screen, the pull request in
full, its merge dialog, the skeleton, the empty state and a notice. The screen
and the view have genuinely separate jobs — which pull request is in view and
what an action there means for the checkout it lives in, against the header
and the four tabs that read one — and the file gave no seam between them.

Each component moves to a file named after it. The merge dialog takes its seed
messages with it as mergeEditFor, which is where the "what GitHub would have
written" decision belongs; the view keeps the in-flight state of the actions it
owns and nothing else.

Pulls.tsx: 732 -> 255 lines, and no file in the folder is now above 330.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RhBpWr7np1JiiiQhfaa4dV
Closing a worktree's last session is a three-state question — keep the
checkout, remove it, or confirm again because it is dirty — and it lived as
five functions and two useStates inside SessionSidebar, interleaved with the
sidebar's own layout, resize and card wiring. useWorktreeClose owns the state
machine; the sidebar keeps only the two dialogs that render it.

SessionGroup was forwarding three callbacks it never read, each closing over
nothing but a session id, and a projectName that was already baseName of the
projectPath beside it. Select, rename and open-a-terminal are wired where the
cards are; the sidebar keeps close (its dialog) and reorder (its sibling
groups), which are the two that genuinely carry its state.

SessionSidebar: 327 -> 253 lines. SessionGroup: 14 props -> 11.

The suite cannot render either of these, so this was verified by reading the
diff and by tsc; the sidebar still wants a pass in `task dev`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RhBpWr7np1JiiiQhfaa4dV
Eight sites hand-rolled the same localStorage read, each with its own idea of
what a bad stored value means: the theme reader accepted anything the array
contained, the numeric ones rejected a non-positive number, the context-usage
flag treated every string but "false" as on, and the two collapsible panels
wrote the same "1 = hidden" encoding through opposite-looking expressions.

lib/prefs.ts splits it the way parsePullsSort already did — pure parsing the
suite owns, a two-line boundary it does not — and every reader now falls back
for a value this build does not understand rather than each deciding for
itself. localStorage is no longer touched anywhere else.

usePanelVisible is the collapsible navigator (the pull request list, the
changed-files tree), keeping the stored encoding so no install loses its
choice.

One behaviour change, deliberate: a flag holding something writePref never
emitted now reads as its default instead of as on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RhBpWr7np1JiiiQhfaa4dV
The three loading skeletons each rebuilt the same ragged-width bar stack, and
the footer built its Tooltip-wrapped button five times over two nearly
identical class strings — the accent fill marking an open dock panel spelled
out once per button.

SkeletonLines takes the widths and owns the idiom; the ragged sets stay with
their callers, because what is arriving differs (prose, file names, code). The
pull request list keeps its paired rows explicit: a title over a subtitle is
not a line stack.

FooterButton carries the strip's one shape — glyph, tooltip, pressed fill —
with `wide` for the segments that put a readout beside the glyph.

That also clears the last template-literal classNames outside components/ui,
so cn() is now the only class-merging helper in the app, as CLAUDE.md says.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RhBpWr7np1JiiiQhfaa4dV
The notifications badge announced its count through an aria-label on a plain
span, which has no role and so announced it to nobody; the count moves into the
trigger's own accessible name and the dot goes aria-hidden. That is one real
screen-reader bug out of the a11y warning backlog, not a lint appeasement — the
rest stay warnings.

THEMES and TERMINAL_THEMES were documented as driving the Appearance picker,
but the picker redeclared both lists, so adding a theme meant editing two
places and a mismatch was silent. The options are built off the exported
arrays; a missing label is now a type error.

AppUpdateGate held the app's only dependency-array-less effect, re-registering
the manual checker on every render. It reads the latest checkNow through a ref
and registers once.

Also: the store setting keys move out of the settings panes into
lib/project-settings.ts, since the contract they mirror is the Go store's
(internal/store/settings.go) and not the pane's; IconAction's props interface
stops shadowing the component; and the one hand-inlined error narrowing goes
through errorText.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RhBpWr7np1JiiiQhfaa4dV
@omartelo
omartelo merged commit 29c8f23 into main Jul 28, 2026
3 checks passed
@omartelo
omartelo deleted the feature/frontend-maintainability branch July 28, 2026 13:27
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.

2 participants