Skip to content

feat: multi-session terminals per project - #1

Merged
omartelo merged 2 commits into
mainfrom
feat/multi-session
Jul 8, 2026
Merged

omartelo merged 2 commits into
mainfrom
feat/multi-session

Conversation

@omartelo

@omartelo omartelo commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds multiple terminal sessions per project. A project no longer maps to a single terminal — it owns an ordered list of sessions, each with its own PTY. The UI is reorganized around this:

  • Projects become browser-style tabs on top (was the Discord-style Rail).
  • Sessions live in a resizable left sidebar, leaving room for future per-session git info (branch, diff, rename).

Changes

  • lib/sessions.ts — pure session-state reducer (add/close/activate/remove) with per-project active tracking. Fully unit-tested.
  • Projects provider — sessions map + newSession/closeSession/activateSession. Closing the last session recreates an empty one (a project always has ≥1 terminal).
  • ProjectTabs (top) and SessionSidebar (left) with a native pointer-drag resize handle (12–30rem, persisted to localStorage). No new dependency — evaluated shadcn Resizable (react-resizable-panels) but its percentage-based panel-group model is a poor fit for a fixed-range edge-drag sidebar.
  • TerminalView — keys the PTY by session id and refits via a ResizeObserver, so the terminal grid and PTY stay in sync on sidebar drag, window resize and zoom (guarded against sub-pixel refit loops).
  • Terminal service — now keyed by an opaque session id (doc comments only; the backend already used an opaque string key, so no logic change).
  • Tooling — pin pnpm via packageManager; session logic covered by vitest.

Test plan

  • pnpm test — 13/13 (session reducer)
  • pnpm exec tsc --noEmit — clean
  • pnpm build (tsc + vite) — clean
  • go test ./... — pass
  • Manual: open project, add/close/switch sessions, drag-resize sidebar and confirm the terminal reflows without breaking the PTY

omartelo added 2 commits July 8, 2026 09:11
Decouple terminal sessions from projects: a project now owns an ordered
list of sessions, each with its own PTY. The UI is reorganized so projects
are browser-style tabs on top and sessions live in a resizable left sidebar
(replacing the Rail), leaving room for future per-session git info.

- lib/sessions.ts: pure session-state reducer (add/close/activate/remove)
  with per-project active tracking, covered by unit tests
- projects provider: sessions map + newSession/closeSession/activateSession;
  closing the last session recreates an empty one
- ProjectTabs (top) and SessionSidebar (left) with a native pointer-drag
  resize handle (12-30rem, persisted to localStorage)
- TerminalView: key PTY by session id; refit via ResizeObserver so the grid
  and PTY stay in sync on sidebar drag, window resize and zoom
- terminal service now keys sessions by opaque id (doc comments; no logic change)
- pin pnpm via packageManager field; tests run under bun test
Replace the bun test runner (a stopgap from when npm installs were failing
on this pnpm project) with vitest, the intended runner for the stack. Pure
session logic, so a standalone vitest.config.ts with a node environment is
enough; vite.config.ts is avoided because its Wails plugin needs the desktop
bridge absent under the runner.
@omartelo
omartelo merged commit e514477 into main Jul 8, 2026
@omartelo
omartelo deleted the feat/multi-session branch July 8, 2026 12:33
omartelo added a commit that referenced this pull request Jul 16, 2026
Add ci.yml — a per-PR and push-to-main gate that runs gofmt, go vet and both
suites; release.yml now runs its test steps through the same shared scripts.
Both render pass/fail counts and coverage into the GitHub Actions job summary
(.github/scripts/{go,web}-test-summary.sh) and still fail the job on a red test.
Frontend coverage is measured over the .ts logic layer via a new
@vitest/coverage-v8 devDep; the .tsx DOM/xterm boundary stays out of the
denominator (invariant #1). CLAUDE.md documents the local gate — fmt, vet, test,
build before every commit or PR. Previously CI ran no tests on PRs at all.
omartelo added a commit that referenced this pull request Jul 17, 2026
* feat: adds helium browser support

* ci: surface test counts + coverage in the pipeline (#25)

Add ci.yml — a per-PR and push-to-main gate that runs gofmt, go vet and both
suites; release.yml now runs its test steps through the same shared scripts.
Both render pass/fail counts and coverage into the GitHub Actions job summary
(.github/scripts/{go,web}-test-summary.sh) and still fail the job on a red test.
Frontend coverage is measured over the .ts logic layer via a new
@vitest/coverage-v8 devDep; the .tsx DOM/xterm boundary stays out of the
denominator (invariant #1). CLAUDE.md documents the local gate — fmt, vet, test,
build before every commit or PR. Previously CI ran no tests on PRs at all.

* chore: mutation testing via gremlins (#24)

`task mutation` runs go-gremlins/gremlins over the module; scope a package with
`task mutation -- ./internal/store/`. .gremlins.yaml pins workers to 1, which is
load-bearing: the default one-worker-per-CPU runs mutant test binaries in
parallel and the SQLite-backed store tests then contend and time out en masse
(every mutant TIMED OUT, efficacy 0%). Single-worker efficacy on internal/store
is ~97% with zero timeouts. timeout-coefficient gives slow packages (terminal
spawns real PTYs) headroom before a genuine hang is declared timed out.

* test: cover the pre-migration store schema path (#26)

open()'s ADD COLUMN migrations were only ever run against a current-schema
database, where every ALTER is a duplicate-column no-op — the branch where a
migration actually applies (err == nil) had no test. Seed a database with the
pre-migration schema (sessions without kind/path/…, projects without position)
and assert open() adds the missing columns and they round-trip through
AddSession/LoadState.

Mutation testing flagged the gap: internal/store efficacy goes 97.4% -> 100%,
killing the surviving `err != nil` -> `err == nil` mutant at store.go:130.

* feat: experimental macOS build (#27)

macOS is Unix, so the terminal (creack/pty), shell and folder picker (osascript
via ncruces/zenity) already run through the shared !windows seams and the whole
binary cross-compiles for darwin. The one gap was browser discovery: macOS
installs live in .app bundles, never on PATH.

Add candidates_darwin.go + darwinBrowserCandidates (Chrome/Chromium/Edge/Brave
under /Applications and ~/Applications), narrowing candidates_unix.go to
`!windows && !darwin`, mirroring the Windows seam and keeping the pure list
testable off-macOS. task build:mac cross-compiles arm64 + amd64; the release
workflow gains a macos-latest job that runs the backend suite — real PTY tests
on Apple Silicon — and ships both-arch raw binaries. Unsigned (Gatekeeper) and
no .dmg yet; recorded in Known Ceilings.

* docs: cut CHANGELOG v0.6.0 (#28)

Move the [Unreleased] entries under a 0.6.0 - 2026-07-16 heading and refresh the
compare links. 0.6.0 ships the PR-badge-on-cards feature, persistent structured
logging, the Windows installer + GUI-subsystem build, discreet scrollbars, the
single-line diff-reference fix, and the experimental macOS build.

* docs: README platform is no longer Linux-only

v0.6.0 ships experimental macOS and Windows builds. Update the platform badge
and the Platform note with the per-OS runtime needs (browser + folder picker).

* fix: count untracked additions in a repo with no commits (#29)

Diff ran `git diff --numstat HEAD`, which errors in a freshly `git init`'d
repo (no HEAD) and returned early, skipping the untracked-file counting
block. The +/- badge on the session card and footer showed +0 -0 even
though the review panel rendered the full diff.

Resolve the diff base the way DiffText already does — fall back to git's
empty tree when HEAD is missing — and wrap the numstat parse in an
err == nil guard so a numstat failure never skips the untracked block.

* feat: rich session card tooltip (#30)

Session card tooltip showed only the working directory, so a long label
clipped in the card was unreadable without widening the sidebar. Expand
it into a mini card that opens to the right: full label, path, branch, PR
and diff badge — reusing the git/pr data the card already computes.

Theme it to match the card (bg-card, border, foreground/muted text)
instead of the default inverted pill, and let the tooltip arrow inherit
the popup background so it follows any themed instance.

* docs: changelog for session card tooltip and no-commit diff count (#31)

Backfill the two [Unreleased] entries for PRs merged after the v0.6.0 cut that
did not touch CHANGELOG.md: the rich session card tooltip (#30) and the fix for
the +/- diff badge showing +0 -0 in a repo with no commits (#29).

* fix: stop Windows console-window flood from child processes (#32)

lich's Windows binary is built for the GUI subsystem (-H=windowsgui) and has
no console of its own. Any console tool it spawns via exec.Command — git, gh,
claude — therefore got a fresh console window allocated by the OS per call.
With git status polled every ~3s per session card and the gh PR lookup firing
on every window focus, the desktop filled with console windows and the machine
became unusable.

Add internal/winexec.Hide, which sets CREATE_NO_WINDOW on Windows and is a
no-op everywhere else, and route every Windows-reachable console spawn through
it: git/gh in internal/project (via a package-local command() helper) and the
claude CLI in internal/claudeplugin.

* chore: cut v0.7.0 (#33)

* docs: changelog for Helium browser support

* test: fix build tag that broke the macOS release suite

candidates_unix_test.go was tagged `!windows`, but the file it exercises
(candidates_unix.go) is `!windows && !darwin`. On darwin the test still
compiled in and called the *darwin* browserCandidates(), whose list is the
.app paths plus chromium/google-chrome — so chromium-browser,
google-chrome-stable, helium-browser and brave were all absent and the
membership loop hit t.Fatalf. Per-PR CI is Linux-only so it stayed green; the
macOS job only runs on a release tag (release.yml), where it would turn red.

- Tag the test `!windows && !darwin` so it compiles only where that
  browserCandidates() actually lives.
- Pin the exact Unix list with slices.Equal, mirroring the darwin/windows
  tests — membership alone missed order and stray-entry regressions.
- CI: cross-compile build + vet for linux/darwin/windows on every PR, so a
  broken build tag fails the PR instead of a release tag.

---------

Co-authored-by: martelo <125408621+omartelo@users.noreply.github.com>
Co-authored-by: omartelo <meopedevts@proton.me>
omartelo added a commit that referenced this pull request Jul 28, 2026
…#114)

* test: measure coverage against the logic the suite can reach

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

* refactor(frontend): one lookup skeleton, one CodeMirror view

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

* refactor(frontend): split the Pulls screen from the pull request it shows

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

* refactor(frontend): give the worktree close flow its own hook

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

* refactor(frontend): one place that reads the page's prefs

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

* refactor(frontend): shared skeleton lines and one footer button

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

* refactor(frontend): close the consistency gaps the review turned up

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
omartelo added a commit that referenced this pull request Jul 30, 2026
* feat: batch review comments into one prompt for the session

A diff could hand the session a reference — a file, a line range — but
never an instruction, so anything that actually had to change was typed
out by hand while looking straight at it.

Right-clicking a selection now also offers "Comment on lines", in both
review surfaces: the Review dock's working diff and a pull request's
Files changed. The note is held against those lines rather than written
out. Comments collect at the foot of the panel in the order the diff was
read, each removable on its own, and one Send hands the batch over as a
single prompt.

The batch goes in as a bracketed paste. Newlines are what make the block
readable and also what a TUI reads as send — one per line — so the
envelope xterm puts around text pasted by hand is what keeps the batch
one paste, and unsent: Enter stays the user's. Verified against a real
Claude Code, which takes it as "[Pasted text #1 +N lines]".

Comments live in a module-level store keyed by what is under review — the
checkout's path, or the pull request's URL — in memory only: an anchor is
a line number of the diff as it was rendered, and outliving a restart
would mean outliving every edit made in between.

useInject now answers whether the write landed, so a Send with no session
to take it says so and keeps the comments instead of dropping them.

Refs #119

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

* refactor: write a review comment between the lines, not over the screen

The composer was a modal in the middle of the window: it covered the diff
being read and put the note as far from the lines it was about as the
screen allows. It is now a CodeMirror block widget that opens under the
selection — the anchor as its label, the field, and the two keys that
close it.

Plain DOM rather than a React portal: the editor owns this node, and eq()
keeps the widget alive across unrelated state changes, so what was typed
survives. The root is contenteditable=false and every keydown stops
there — the editor's content is contenteditable, and without that the
note would run through CodeMirror's own input handling.

useCodeMirrorView gains dispatchEffects, the seam an extension is driven
through from outside, and useDiffEditor holds the submit handler in a ref
so the extension set stays built once per document.

Refs #119

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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