Repository navigation
refactor(frontend): adopt biome, dedupe the repeated code, group lib/ by domain - #91
Merged
Merged
Conversation
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>
3 tasks done
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
CHANGELOGentry.The thing that only looked broken:
components/was already organised byfeature 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 ownconvention.
7b2766b— biome replaces prettier and eslintThere was no formatter. 80 files wrote
import {x}, 54 wroteimport { 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-disabledirectives in the tree were addressingnobody.
biome.jsoncencodes the style the code already mostly held (2-space indent,double quotes, no semicolons) at
lineWidth100 — picked by measuring thereflow 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.ystates the guard).useImportTypeis off undercomponents/ui,which is shadcn CLI output. The a11y group is
warn: real findings, but theirfixes are DOM changes.
Everything else the linter flagged is fixed, and none of it changes behaviour —
a missing type annotation, two
forEachcallbacks returning the callee's value,a local shadowing
Object.prototype.valueOf, two string concatenations. Sixdeliberate exceptions carry a
biome-ignorenaming the reason.CI gains a
pnpm checkstep beside gofmt and vet.834d06b— one keyed-store primitivesession-cwd-store,session-agent-storeandsession-usage-storewere thesame store written three times: the same
Map<string, Entry>, the same lazyentryOf, the same per-keysubscribe, the samegetwith 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 keeptheir 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.
useKeyedStorereplaces them and takes
useGitStatuswith it: its empty-path guard — whatkeeps a poll from ever starting on
""— generalises to "an empty keysubscribes to nothing".
git-status-storeandsession-status-storestay as they are on purpose: oneowns 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 onceTwelve extractions. Every caller keeps the pixels it had.
Notice(5 copies, two byte-identical) ·EmptyScreen(Home / EmptySessionswere the same block down to the
gap-4) ·SidebarCard(Settings andPullRequest were the same button class for class) ·
CloseButton(4 copies) ·ResizeHandle(2, differing only by the edgeusePanelWidthalready knows) ·Stepper(the two Appearance controls;DESIGN.mdalready named the pattern) ·IconAction(wasHeaderAction, exported from insidediff/FileDiffandimported 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 oflib/Four files under
src/libwere not library code.projects.tsx(523 lines) andsettings.tsx(301) are context Providers — the app's state layer — and move tosrc/providers/.file-icon.tsxandprovider-icons.tsxeach export exactlyone component and move to
src/components/under the name they export.e36ec4e—lib/groups by domainsrc/libwas 59 flat files whilesrc/componentshas grouped by feature allalong. Five folders now hold what belongs together:
session/(13),terminal/(10),git/(6),pulls/(6),update/(4), with 20 cross-cuttingmodules 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.tssits besidegit/use-git-status.tsrather than in astores/on the other side of the tree. The nineuseXxx.tshooks becomekebab-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
--followstill works.Test plan
Local gate, green on every commit:
gofmt -l .clean,go vet ./...cleango test ./...— 400 passed in 19 packagespnpm check— 0 errors (12 a11y warnings, deliberate; see above)pnpm build— tsc typecheck + vite buildpnpm 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 changedNo Go file is touched, so no OS seam and no
ci:oslabel.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 devacrossthe 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 —
tscresolves every specifier andvite buildresolves the whole modulegraph, so a bad import is a compile error; the graph and the import order are
otherwise unchanged (
organizeImportsis deliberately off, nothing wasreordered).
🤖 Generated with Claude Code