Skip to content

refactor(frontend): adopt biome, dedupe the repeated code, group lib/ by domain - #91

Merged
omartelo merged 5 commits into
mainfrom
refactor/frontend-cleanup
Jul 25, 2026
Merged

omartelo merged 5 commits into
mainfrom
refactor/frontend-cleanup

Conversation

@omartelo

Copy link
Copy Markdown
Collaborator

A review of the frontend turned up three things worth fixing and one thing that
only looked broken. Five commits, no user-facing change and no CHANGELOG entry.

The thing that only looked broken: components/ was already organised by
feature and did not need touching. What was actually wrong was narrower —
nothing enforced a format, some logic was written three times over, some markup
five, and lib/ was the one directory in the repo ignoring the repo's own
convention.

7b2766b — biome replaces prettier and eslint

There was no formatter. 80 files wrote import {x}, 54 wrote import { x },
and no single file mixed the two — a clean split down the middle, plus a few
files carrying indentation no tool would produce. eslint was never installed
either, so the five eslint-disable directives in the tree were addressing
nobody.

biome.jsonc encodes the style the code already mostly held (2-space indent,
double quotes, no semicolons) at lineWidth 100 — picked by measuring the
reflow at 80/88/96/100/110 and taking the least-churn width, not by taste.

Rules that were wrong every time they fired are off with the reason inline:
useExhaustiveDependencies (narrow deps are the pattern here — poll signals,
once-per-launch gates, refs read for the latest value) and useOptionalChain
(!x || !x.y states the guard). useImportType is off under components/ui,
which is shadcn CLI output. The a11y group is warn: real findings, but their
fixes are DOM changes.

Everything else the linter flagged is fixed, and none of it changes behaviour —
a missing type annotation, two forEach callbacks returning the callee's value,
a local shadowing Object.prototype.valueOf, two string concatenations. Six
deliberate exceptions carry a biome-ignore naming the reason.

CI gains a pnpm check step beside gofmt and vet.

834d06b — one keyed-store primitive

session-cwd-store, session-agent-store and session-usage-store were the
same store written three times: the same Map<string, Entry>, the same lazy
entryOf, the same per-key subscribe, the same get with an empty fallback,
the same notify loop. Only the value type, the event predicate and — for usage
alone — the definition of "unchanged" ever differed.

createKeyedStore(empty, equal?) is that shape once. The three stores keep
their names, their docs and their exact public surface, and shrink to the wiring
that was unique to them.

The four hooks over them were also one function four times. useKeyedStore
replaces them and takes useGitStatus with it: its empty-path guard — what
keeps a poll from ever starting on "" — generalises to "an empty key
subscribes to nothing".

git-status-store and session-status-store stay as they are on purpose: one
owns a poll cadence with per-entry timers, the other a seen flag and a
cross-project queue. Folding either in means widening the primitive for a single
caller.

9969a68 — extract the markup written more than once

Twelve extractions. Every caller keeps the pixels it had.

Notice (5 copies, two byte-identical) · EmptyScreen (Home / EmptySessions
were the same block down to the gap-4) · SidebarCard (Settings and
PullRequest were the same button class for class) · CloseButton (4 copies) ·
ResizeHandle (2, differing only by the edge usePanelWidth already knows) ·
Stepper (the two Appearance controls; DESIGN.md already named the pattern) ·
IconAction (was HeaderAction, exported from inside diff/FileDiff and
imported across features) · InjectMenu (2) · SearchInput (2) ·
useDiffBulk + CollapseAllAction (2, verbatim) · useInject (3) ·
RESTART_HINT (2).

Side effect worth naming: the a11y warning backlog drops from 36 to 12 without
touching the DOM
— the close affordance and the resize handle were reported
four and two times over and are now reported once. The remainder are real and
still warnings; fixing them means adding tab stops and keyboard activation,
which is a behaviour change and belongs in its own PR.

ddb5961 — the React comes out of lib/

Four files under src/lib were not library code. projects.tsx (523 lines) and
settings.tsx (301) are context Providers — the app's state layer — and move to
src/providers/. file-icon.tsx and provider-icons.tsx each export exactly
one component and move to src/components/ under the name they export.

e36ec4e — lib/ groups by domain

src/lib was 59 flat files while src/components has grouped by feature all
along. Five folders now hold what belongs together: session/ (13),
terminal/ (10), git/ (6), pulls/ (6), update/ (4), with 20 cross-cutting
modules at the root (rpc, api-types, app-events, utils, keyed-store,
the card stores, hotkeys, paths, panel width, dnd).

By domain, never by kind: a store and the hook that reads it are one unit, so
git/git-status-store.ts sits beside git/use-git-status.ts rather than in a
stores/ on the other side of the tree. The nine useXxx.ts hooks become
kebab-case, which every other file here already was.

git detects all 67 moves as renames (lowest similarity 86% — the tiny hook
wrappers whose body is mostly imports), so --follow still works.

Test plan

Local gate, green on every commit:

  • gofmt -l . clean, go vet ./... clean
  • go test ./... — 400 passed in 19 packages
  • pnpm check — 0 errors (12 a11y warnings, deliberate; see above)
  • pnpm build — tsc typecheck + vite build
  • pnpm test — 406 passed in 43 files, up from 398/42 at the branch point;
    the 8 new ones are keyed-store.test.ts, and no existing test changed

No Go file is touched, so no OS seam and no ci:os label.

The frontend suite runs in node with no jsdom, so it cannot catch a render
crash. The extraction commit was therefore hand-verified in task dev across
the surfaces it restructured: both sidebar cards, the dock's tabs / collapse-all
/ full screen / resize, the Pulls screen's three tabs and its empty state, both
Appearance steppers, the inject context menu on a diff and on a file preview,
both search fields, and closing a project tab. The two move commits need no such
pass — tsc resolves every specifier and vite build resolves the whole module
graph, so a bad import is a compile error; the graph and the import order are
otherwise unchanged (organizeImports is deliberately off, nothing was
reordered).

🤖 Generated with Claude Code

omartelo and others added 5 commits July 25, 2026 11:14
The frontend had no formatter. 80 files wrote `import {x}`, 54 wrote
`import { x }`, and no single file mixed the two — a clean split down the
middle, with a few files (SessionCard, WorktreeDialog) carrying indentation
no tool would produce. eslint was never installed either, so the five
`eslint-disable` directives in the tree were addressing nobody.

biome replaces both in one binary. `biome.jsonc` encodes the style the code
already mostly held — 2-space indent, double quotes, no semicolons — at
lineWidth 100, picked by measuring the reflow at 80/88/96/100/110 and taking
the least-churn width. Import order stays unenforced: it is an opinion of its
own and would bury the reflow it rode in with.

Rules that were wrong every time they fired are off, each with its reason in
the config: useExhaustiveDependencies (narrow deps are the pattern here — poll
signals, once-per-launch gates, refs read for the latest value) and
useOptionalChain (`!x || !x.y` states the guard; `!x?.y` folds two reads into
one). The a11y group is warn rather than error — those are real findings, but
their fixes are DOM changes, and they cluster in the duplicated close
affordance and resize handle that the component extraction collapses into one
place to fix. `useImportType` is off under `components/ui`, where the source is
shadcn CLI output and rewriting it only makes the next `shadcn add` diverge.

Everything else the linter flagged is fixed here, and nothing in it changes
behaviour:

- ClaudePluginGate types `let action` as PluginAction, matching AppUpdateGate.
- providers-store and useSessionStatus stop returning the callee's value out of
  a forEach callback.
- WorktreeDialog's local `valueOf` is renamed off Object.prototype's name.
- FilesPanel and SessionCard use template literals over concatenation.
- Deliberate exceptions carry a `biome-ignore` naming the reason: the palette
  and rename autofocus, the changelog's positional keys, the branch validator's
  control-character class, and the generic Label primitive.

CI gains a `pnpm check` step beside gofmt and vet, and both CLAUDE.md gates
list it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…readouts

session-cwd-store, session-agent-store and session-usage-store were the same
store written three times. Each carried its own `Map<string, Entry>`, its own
`entryOf` that lazily creates the entry, its own `subscribe` adding to a
per-key listener set, its own `get` falling back to an empty value, and its own
loop notifying listeners. What actually differed per store was the value type,
the event predicate feeding it, and — for usage alone — how "unchanged" is
decided.

`createKeyedStore(empty, equal?)` is that shape, once. The three stores keep
their names, their docs and their exact public surface (`subscribe` + `get`,
now typed as `ReadableKeyedStore<T>` so the write side stays with the event
handler), and shrink to the wiring that was ever unique to them. usage passes
its field comparison as `equal`, which is what keeps the snapshot reference
stable for useSyncExternalStore across an identical report.

The four hooks over those stores were also one function four times — the same
`useCallback` subscribe plus `useSyncExternalStore`. `useKeyedStore(store, key)`
replaces them, and takes useGitStatus with it: its empty-path guard, which is
what keeps a poll from ever starting on "", generalises to "an empty key
subscribes to nothing" and holds for the session stores too, where subscribing
to "" only ever created a dead entry.

git-status-store and session-status-store keep their own maps on purpose. One
owns a poll cadence with per-entry timers and a seq guard, the other a seen
flag and a cross-project pending queue; folding either in means widening the
primitive for a single caller.

No behaviour change. The stores' existing suites drive them through
subscribe/get, which is untouched, and keyed-store gets its own — including the
custom-comparison case, where a rebuilt-but-equal value must neither notify nor
replace the reference React compares.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The review found the same JSX standing in several files at once. Each of these
is one component now, and the callers keep exactly the pixels they had.

- `Notice` — the muted line a panel shows instead of content. Five copies:
  Pulls, PullsFiles, PullsChecks, FilesPanel and ReviewPanel, the last two
  byte-identical. The default is the dock's density and the roomier surfaces
  pass their own padding, so nothing moves.
- `EmptyScreen` — Home and EmptySessions were the same block down to the
  `size-12` glyph and the `gap-4`; only icon, copy and action differed.
- `SidebarCard` — SettingsCard and PullRequestCard were the same button, class
  for class, except PullRequestCard reserved `pr-7` for its × and SettingsCard
  did not. The shared row keeps the reservation; "Settings" is left-aligned, so
  it does not move.
- `CloseButton` — the × that fades in on hover, in four places (both sidebar
  cards, the session card, the project tab). It stays a span: every caller nests
  it inside a button or an anchor. How the click is stopped from reaching that
  parent differs — stopPropagation on a button, preventDefault on a NavLink —
  so the handler stays with the caller.
- `ResizeHandle` — identical in the sidebar and the dock but for the edge, which
  usePanelWidth is already told.
- `Stepper` — the two Appearance controls (interface zoom, terminal text size)
  were the same four buttons around a bordered value box. DESIGN.md already
  names the pattern.
- `IconAction` — the header icon button, until now exported as `HeaderAction`
  from inside `diff/FileDiff` and imported across features by RightDock and
  PullsFiles.
- `InjectMenu` — the right-click "Inject file / Inject lines" menu over a code
  view, in FileDiff and in the dock's file preview. Both wrap the same isolate
  div; what differs is resolving the selection, which stays with the caller (a
  diff remaps doc lines to file lines, a preview does not).
- `SearchInput` — field with the magnifier inside its left edge, in the settings
  nav and the worktree dialog's branch filter.
- `useDiffBulk` + `CollapseAllAction` — the collapse/expand-all directive and
  its header control, duplicated verbatim between the review dock and a pull
  request's Files changed tab. `DiffBulk` moves out of FileDiff with them: it
  was always a contract between the panel and its files, not FileDiff's own.
- `useInject` — writing a reference into the active session's PTY, in Pulls,
  ReviewPanel and FilesPanel.
- `RESTART_HINT` — one string, previously typed out in both ClaudePluginGate and
  UpdatesSettings; it belongs with the gate decision in `lib/plugin-gate`.
- RightDock's two TabsTrigger class strings were identical; they are one const.

New shared pieces live under `components/common` rather than `components/ui`,
which is reserved for shadcn CLI output (see CLAUDE.md), or the components root,
which is already crowded.

This also cuts the a11y warning backlog from 36 to 12 without touching the DOM:
the close affordance and the resize handle were each reported four and two
times over, and are now reported once. The remaining findings are real and still
warnings — fixing them means adding keyboard activation and tab stops, which is
a behaviour change and not this commit's job.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four files under `src/lib` were not library code:

- `projects.tsx` (523 lines) and `settings.tsx` (301) are context Providers —
  the app's state layer, with 13 and 9 importers between them. They move to
  `src/providers/`, and `settings-clamp.test.ts` follows what it tests.
- `file-icon.tsx` and `provider-icons.tsx` each export exactly one component.
  They move to `src/components/` under the name they export, the convention
  every other component there already follows: `FileIcon.tsx`, `ProviderIcon.tsx`.

Nothing else changes. Imports are rewritten to keep the house style — a sibling
module through `./name`, anything else through the `@/` alias — and the three
hook docs plus DESIGN.md now point at the new paths. tsc resolves every import,
so the move is proven, not assumed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`src/lib` was 59 flat files while `src/components` has grouped by feature all
along — the one place in the repo not following the repo's own convention. Five
folders now hold what belongs together:

    session/   13   sessions, session-events, the four per-session stores with
                    their hooks, the active-session resolver, notifications,
                    the palette's search
    terminal/  10   term-fit/frame/keys/perf/transport, the replay ring, the
                    paste and setup queues, zoom chords, copy-on-select
    git/        6   the status poller and its hook, diff parsing, file tree,
                    language badges, branch-name validation
    pulls/      6   lookup, detail, diff, the viewed ticks, check durations
    update/     4   the app, plugin and patch-notes gates, the manual check
    (root)     20   rpc, api-types, app-events, utils, keyed-store, the card
                    stores, hotkeys, paths, panel width, dnd — cross-cutting

Grouped by domain, never by kind: a store and the hook that reads it are one
unit and change together, so `git-status-store.ts` sits beside
`use-git-status.ts` rather than in a `stores/` on the other side of the tree.
`pulls-card-store.ts` stays at the root despite the `pulls/` folder — it is one
of three instances of `card-store`, and splitting that trio to satisfy a folder
name would cost more than it buys.

The nine `useXxx.ts` hooks become kebab-case, which is what every other file
here already was; only components keep PascalCase, after the name they export.
Tests move with what they test.

Pure moves plus the import rewrite that follows from them, in the house style —
sibling through `./name`, everything else through `@/`. git detects all 67 as
renames (lowest similarity 86%, the tiny hook wrappers whose body is mostly
imports), so `--follow` still works. tsc resolves every specifier, so a missed
import is a compile error rather than a runtime surprise; the docs that name a
moved file are updated, and frontend/CLAUDE.md now carries the layout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@omartelo
omartelo merged commit 1ca410b into main Jul 25, 2026
3 checks passed
@omartelo
omartelo deleted the refactor/frontend-cleanup branch July 25, 2026 15:22
omartelo added a commit that referenced this pull request Jul 25, 2026
Opening **Files changed** on a wide pull request locked the app (#92). Every file
in a diff panel starts expanded, and an expanded file builds its own CodeMirror
view — so #91's 191 files were 191 `EditorView`s constructed in a single commit,
each followed by a lazy language chunk re-configuring its document. The existing
`LARGE_FILE_LINES` guard is evaluated per file and never fired: not one of the
191 was large, the diff was *wide*.

## What changed

- **`FileDiff` defers each editor to an `IntersectionObserver`.** A card holds a
  height-estimated placeholder until it comes within 600px of the viewport. The
  placeholder has to carry the file's height, or every card would sit in the
  viewport at once and nothing would be deferred. Mount is one-way, so scrolling
  back keeps a view's selection and highlighting; collapsing the file still
  destroys it. This is the shared path, so **Pulls' Files changed and the dock's
  Review tab both get it** — the dock had the same root cause.
- **The Pulls diff stops refetching on window focus.** HEAD moving already
  refetches it; replaying a wide diff on every alt-tab back into lich was the
  most surprising half of the bug.

## Measured

The frontend suite runs in node with no jsdom, so none of this is reachable by
the gate. Measured by hand instead: #91's real diff (191 files, 10,020 lines)
rendered through the same `FileDiff` cards, headless Chromium at 1600x1000.

| | editors at rest | main thread to idle |
| --- | --- | --- |
| before | 191 | 13,678 ms |
| after | **8** | **465 ms** |

Scrolling to the bottom builds 11 editors in total, and every visible card
carries its own. `LINE_HEIGHT_PX = 17` is calibrated against the diff theme's
measured 16.8px line box.

## Not in this PR

From the issue's list, deliberately skipped — with 465 ms remounts they buy
nothing today:

- caching the parsed diff by `(path, head)`
- budgeting how many files start expanded per panel
- the backend ceiling at `internal/project/pr.go:325` (427 KB was never the
  bottleneck)

## Test plan

- [x] Gate run from `node_modules/.bin` rather than through `pnpm`, which
      summarises biome's output and reported a false green here: `biome check .`
      clean, `tsc --noEmit` clean, 406 tests, `vite build` ok. After merging
      `main`: `go test ./...` green and `gofmt -l .` empty.
- [x] Editor count and time-to-idle measured before/after on #91's diff
- [x] Hand pass in the app: this pull request is reviewed and merged from lich's
      own Files changed tab — the screen the fix lands on.

Closes #92
@omartelo omartelo mentioned this pull request Jul 25, 2026
6 tasks done
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