Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ jobs:
pnpm install --frozen-lockfile
pnpm build

# biome is the frontend's gofmt + vet: formatting and lint in one pass.
# Warnings (the a11y backlog) are reported without failing; errors fail.
- name: Frontend format + lint
working-directory: frontend
run: pnpm check

- name: gofmt
run: |
unformatted="$(gofmt -l .)"
Expand Down
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ Frontend in isolation: `cd frontend && pnpm run build` (runs `tsc` + `vite build
## Local Gate (before every commit / PR)

- `gofmt -l .` clean (fix with `gofmt -w .`) and `go vet ./...` clean.
- `cd frontend && pnpm check` clean — biome is the frontend's gofmt + vet (fix with `pnpm format`).
- `go test ./...` (backend) and `cd frontend && pnpm test` (frontend) green — or `task test` for both.
- `cd frontend && pnpm build` succeeds (tsc typecheck + vite).
- Touched an OS seam or a `_test.go` build tag? Run the same cross-compile loop CI runs:
Expand Down Expand Up @@ -94,7 +95,7 @@ Deliberate limits and shortcuts — one line of *what and where*; the mechanism

- **Session cwd is polled** from the PTY child (`internal/terminal/cwd.go`, per-OS readers behind build tags); a
failed read degrades to the session's start directory. Tracks the direct child only, not nested shells.
- **git status is polled** — one shared poller per repository path (`frontend/src/lib/git-status-store.ts`); the
- **git status is polled** — one shared poller per repository path (`frontend/src/lib/git/git-status-store.ts`); the
lich plugin's `session-touched` hook nudges an immediate refresh. An fs watcher is the upgrade path.
- **Context-window usage is read off the transcript** (`internal/terminal/usage_claude.go`): on a turn-boundary
status hook, the tail of `~/.claude/projects/<slug>/<id>.jsonl` is parsed for the last main-thread assistant
Expand All @@ -111,7 +112,7 @@ Deliberate limits and shortcuts — one line of *what and where*; the mechanism
pinned at 47821; `LICH_LISTEN_PORT` overrides it, `LICH_PORT` is the distinct per-session hook variable), the
workspace in SQLite (`<config-dir>/lich/lich.db`, `internal/store`). Closing a session deletes its row; keeping a
worktree parks its session for a later resume; a closed project is hidden, never deleted.
- **Hidden sessions are serialized and destroyed**: 2MB replay rings on both sides (`frontend/src/lib/replay-buffer.ts`
- **Hidden sessions are serialized and destroyed**: 2MB replay rings on both sides (`frontend/src/lib/terminal/replay-buffer.ts`
page-side, `internal/terminal/replay.go` backend-side — the latter survives a full page reload). waveterm's disk
filestore is the upgrade path if size ever matters.
- **Terminal I/O rides the loopback WebSocket**; when the socket is down, output falls back to `/events` and input
Expand Down
2 changes: 1 addition & 1 deletion docs/chromium-shell.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ its CDP surface):
Why lich is ~80% there already:

- Terminal I/O already rides a loopback WebSocket with token auth
(`internal/terminal/transport.go` ↔ `frontend/src/lib/term-transport.ts`) —
(`internal/terminal/transport.go` ↔ `frontend/src/lib/terminal/term-transport.ts`) —
Chromium connects to it unchanged.
- The frontend is already embedded and static; serving it over HTTP replaces
handing it to the webview.
Expand Down
6 changes: 3 additions & 3 deletions docs/hooks/session-start.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,13 @@ holds the id of the Claude session currently in the card.
`New`) emits the global app event `session-agent` (`{id, agent: "claude"}`):
a report is proof Claude runs in this PTY, so a shell card wears Claude's
icon while it does. The mark lives in
`frontend/src/lib/session-agent-store.ts`, never the store: it clears on the
`frontend/src/lib/session/session-agent-store.ts`, never the store: it clears on the
session-state contract's `idle` (SessionEnd — Claude left) and on every PTY
spawn (the backend emits an empty agent), so it dies with the process that
earned it. The card's persisted kind — what a respawn runs, what the resume
prompt keys on — never changes.
- **Consumer** — the resume prompt. `LoadState` hydrates the id onto the
frontend session (`resumableSession` in `frontend/src/lib/sessions.ts`), and
frontend session (`resumableSession` in `frontend/src/lib/session/sessions.ts`), and
the first time a restored card is opened `TerminalHost` asks before spawning:
accepting passes the id to `terminal.Start`, which spawns `claude --resume
<id>`.
Expand All @@ -80,7 +80,7 @@ holds the id of the Claude session currently in the card.
- **Only Claude Code resumes.** The field and the column are provider-agnostic,
but `--resume` is a Claude Code flag: `resumeArgs` in
`internal/terminal/terminal.go` and `resumableSession` in
`frontend/src/lib/sessions.ts` both gate on the claude kind. Another provider
`frontend/src/lib/session/sessions.ts` both gate on the claude kind. Another provider
reporting an id would have it stored and ignored until its own resume flag is
wired.
- **The deprecated `claude_session_id` alias stays until the install gate can
Expand Down
4 changes: 2 additions & 2 deletions docs/hooks/session-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ what re-arms the spinner after them. Every tool re-reports `busy` (idempotent);
- **UI push** — `internal/terminal/terminal.go`: emits the global app event
`session-status` (`{id, state}`). Global rather than per-session because its
consumers outlive any one card.
- **Store** — `frontend/src/lib/session-status-store.ts`: one subscription taken
- **Store** — `frontend/src/lib/session/session-status-store.ts`: one subscription taken
at page load keeps the last state of every session, keyed by id. The card
cannot hold it: the sidebar only renders cards for the active project, so
switching projects unmounts them, and a status reported meanwhile would be lost.
Expand All @@ -66,7 +66,7 @@ what re-arms the spinner after them. Every tool re-reports `busy` (idempotent);
`busy` over `done`), shown only while the project is not the active one. A
`done` stops badging once the project has been on screen; `busy` and `waiting`
badge for as long as they hold, being live states rather than notifications.
- **Toast + route** — `frontend/src/lib/projects.tsx`: raises an actionable toast
- **Toast + route** — `frontend/src/providers/projects.tsx`: raises an actionable toast
that navigates to the session's card when a report says `waiting`, skipped for
the session already focused. It reads the raw event rather than the store: the
store collapses a repeat state into no notification, which would swallow a
Expand Down
2 changes: 1 addition & 1 deletion docs/hooks/session-title.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ while the label is still automatic (see below), so a stable title is idempotent.
Returns whether the label actually changed.
- **Live update** — `internal/terminal/terminal.go`: when the label changed,
emits the global app event `session-title` (`{id, label}`);
`frontend/src/lib/projects.tsx` mirrors it into session state so the card
`frontend/src/providers/projects.tsx` mirrors it into session state so the card
updates without a reload.

## Known ceilings
Expand Down
4 changes: 2 additions & 2 deletions docs/hooks/session-touched.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,9 @@ the hook's stdin payload if a single script filters instead of per-tool matchers
session id.
- **UI push** — `internal/terminal/terminal.go`: emits the global app event
`session-touched` (`{id}`).
- **Refresh** — `frontend/src/lib/projects.tsx`: resolves the session id to the
- **Refresh** — `frontend/src/providers/projects.tsx`: resolves the session id to the
path its card watches (its worktree, else the project path) and calls
`refreshGitStatus(path)` (`frontend/src/lib/useGitStatus.ts`), which fetches
`refreshGitStatus(path)` (`frontend/src/lib/git/use-git-status.ts`), which fetches
that path now, ahead of the poll tick. A no-op when no card watches the path
(the session lives in a background project), so it costs no git call.

Expand Down
46 changes: 45 additions & 1 deletion frontend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,29 @@ React 18 + TypeScript + Vite, built to a static bundle that the Go binary embeds
- **Terminal** is xterm.js 6 + WebGL. **Code / diff** is CodeMirror 6. **Icons** are lucide; file-type logos
are `devicons-react`. **Toasts** are sonner. **DnD** is dnd-kit.

## Layout

```
src/
├── providers/ context Providers — the app's state layer (projects, settings)
├── components/ one file per component, named after what it exports
│ ├── common/ shared across features (Notice, EmptyScreen, CloseButton, …)
│ ├── ui/ shadcn primitives — CLI output, see below
│ └── <feature>/ diff · dock · pulls · settings · sidebar · tabs
└── lib/ no React elements; a `.tsx` in here is misfiled
├── session/ sessions, their events, the four per-session stores + hooks
├── terminal/ xterm/PTY plumbing: term-*, replay, paste and setup queues
├── git/ status poller, diff parsing, file tree, branch names
├── pulls/ pull-request lookup, detail, diff, viewed ticks
├── update/ the app, plugin and patch-notes gates
└── *.ts cross-cutting: rpc, api-types, app-events, utils, keyed-store,
the card stores, hotkeys, paths, panel width, dnd
```

A store and the hook that reads it are a pair and live together (`git/git-status-store.ts` beside
`git/use-git-status.ts`) — the folders group by domain, never by kind. Files are kebab-case except
components, which are PascalCase after their export. Tests sit next to what they test.

## Stack specifics

- **Tailwind v4, CSS-first.** No `tailwind.config.*` — tokens and `@theme` live in `src/index.css`, wired via
Expand All @@ -28,18 +51,39 @@ React 18 + TypeScript + Vite, built to a static bundle that the Go binary embeds
```bash
pnpm build # tsc typecheck + vite build (the real gate)
pnpm test # vitest run
pnpm check # biome format + lint check (CI runs this; errors fail)
pnpm format # biome, writing the fixes
```

Wart: `pnpm test`/`build` can trip an implicit install that dies on `ERR_PNPM_IGNORED_BUILDS`. When it does,
run the binaries directly: `./node_modules/.bin/vitest run` or `pnpm exec vitest run`.

## Formatting and lint — biome

**biome** (`biome.jsonc`) is the frontend's gofmt + vet, in one binary. There is no prettier and no eslint;
`eslint-disable` comments are dead here — the directive is `biome-ignore <rule>: <reason>`.

- 2-space indent, double quotes, no semicolons, `lineWidth` 100 (measured as the least-churn width over the
source at adoption).
- A few recommended rules are off on purpose, each with its reason in `biome.jsonc` — read it before turning
one back on. Import order is deliberately not enforced.
- Warnings do not fail the gate; errors do. The standing warning backlog is the a11y one: `role="button"`
spans that take a click but no key, and the `role="separator"` resize handles.

## State — no state library

zustand and tanstack-query were both rejected (PR #13). The pattern is a **module-level store +
`useSyncExternalStore`**; see any `src/lib/*-store.ts`. Backend→frontend events are **global** payloads
`useSyncExternalStore`**; see any `*-store.ts`. Backend→frontend events are **global** payloads
`{ id, ... }` on the events channel, never per-session sockets — subscribe once, filter by id. Do not add a
state dep; extend the store pattern.

A per-session (or per-path) readout is `createKeyedStore` + `useKeyedStore`: one value per key, subscribers
scoped to that key, entries outliving their listeners because a card unmounts with its project while its
session keeps running. A new one is the event wiring and nothing else — cwd, agent and usage are three or
four lines each. Reach for it before hand-rolling a `Map` of listener sets. `git-status-store` and
`session-status-store` keep their own maps: one owns a poll cadence, the other a seen flag and a
cross-project queue, and neither fits without widening the primitive for a single caller.

## Testing

- vitest runs in the **node environment — there is no jsdom**. The gate covers pure logic (stores, parsers,
Expand Down
2 changes: 1 addition & 1 deletion frontend/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ inactive project tab (`ProjectTab`).
everywhere. Small controls (list rows, menu items, ghost buttons) sit around `rounded-md`.
- Density is high but breathing: list rows `~9px` vertical padding, `gap-1`–`gap-1.5` between them.
- Icons are **lucide** at `size-3.5`/`size-4`; `size-3` inside dense meta. Provider marks come from
`provider-icons.tsx`, file-type logos from `devicons-react` (`file-icon.tsx`).
`ProviderIcon.tsx`, file-type logos from `devicons-react` (`FileIcon.tsx`).

## Interaction states

Expand Down
65 changes: 65 additions & 0 deletions frontend/biome.jsonc
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
{
"$schema": "https://biomejs.dev/schemas/2.5.5/schema.json",
"files": {
"includes": ["src/**/*.ts", "src/**/*.tsx", "vite.config.ts", "vitest.config.ts"]
},
// The style the codebase already mostly held, so adopting the tool is a
// reflow, not a restyle: 2-space indent, double quotes, no semicolons.
// lineWidth 100 measured as the least-churn width over the existing source.
"formatter": {
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100
},
"javascript": {
"formatter": {
"semicolons": "asNeeded",
"quoteStyle": "double",
"jsxQuoteStyle": "double",
"arrowParentheses": "always",
"trailingCommas": "all",
"bracketSpacing": true
}
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"correctness": {
// Narrow dependency lists are the app's pattern, not an oversight: the
// git-status poll drives invalidation off status counts, gates run once
// per launch, and the latest project/session is read through a ref.
// Every hit of this rule so far has been deliberate.
"useExhaustiveDependencies": "off"
},
"complexity": {
// `!x || !x.y` states the guard; `!x?.y` folds two conditions into one
// read. Equivalent, and the explicit form is the one kept here.
"useOptionalChain": "off"
},
"a11y": {
// Reported, not enforced: the fixes are DOM changes, and the offenders
// are the duplicated close-affordance and resize handle that the
// component extraction collapses into one place to fix.
"useFocusableInteractive": "warn",
"useKeyWithClickEvents": "warn",
"useSemanticElements": "warn",
"useAriaPropsForRole": "warn",
"useAriaPropsSupportedByRole": "warn"
}
}
},
// Import order is an opinion of its own; keep it out of the format pass.
"assist": {
"actions": { "source": { "organizeImports": "off" } }
},
"overrides": [
{
// shadcn primitives are CLI output adapted to DESIGN.md, not hand-written
// (see CLAUDE.md). Their `import * as React` is what the generator emits;
// rewriting it only makes the next `shadcn add` diverge.
"includes": ["src/components/ui/**"],
"linter": { "rules": { "style": { "useImportType": "off" } } }
}
]
}
7 changes: 5 additions & 2 deletions frontend/package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "react-ts-latest",
"name": "lich-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
Expand All @@ -9,7 +9,9 @@
"build:dev": "tsc && vite build --minify false --mode development",
"build": "tsc && vite build --mode production",
"preview": "vite preview",
"test": "vitest run"
"test": "vitest run",
"check": "biome check .",
"format": "biome check --write ."
},
"dependencies": {
"@base-ui/react": "^1.6.0",
Expand Down Expand Up @@ -42,6 +44,7 @@
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@biomejs/biome": "^2.5.5",
"@tailwindcss/vite": "^4.3.2",
"@types/node": "^26.1.0",
"@types/react": "^18.2.43",
Expand Down
95 changes: 95 additions & 0 deletions frontend/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading