Repository navigation
feat: multi-session terminals per project - #1
Merged
Merged
Conversation
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.
4 tasks done
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>
5 tasks done
5 of 6 tasks
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.
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:
Changes
lib/sessions.ts— pure session-state reducer (add/close/activate/remove) with per-project active tracking. Fully unit-tested.newSession/closeSession/activateSession. Closing the last session recreates an empty one (a project always has ≥1 terminal).ProjectTabs(top) andSessionSidebar(left) with a native pointer-drag resize handle (12–30rem, persisted tolocalStorage). No new dependency — evaluated shadcnResizable(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 aResizeObserver, so the terminal grid and PTY stay in sync on sidebar drag, window resize and zoom (guarded against sub-pixel refit loops).pnpmviapackageManager; session logic covered by vitest.Test plan
pnpm test— 13/13 (session reducer)pnpm exec tsc --noEmit— cleanpnpm build(tsc + vite) — cleango test ./...— pass