feat: session management sidebar + Zustand persist - #4
Conversation
- Add left sidebar showing active sessions with status, age, and kill button - Move session creation from header to sidebar - Persist sessionId and theme via Zustand persist middleware (ADR-011) - Auto-reconnect to last session on page refresh - Poll sessions every 5s, clear stale sessionId if server lost it - Scope scrollbar styles to terminal only (fix mobile bleed) - Bind Vite to 0.0.0.0 for Tailscale/LAN access - Set dark body background to prevent white flash on load Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
| body: JSON.stringify({ workingDirectory: "~" }), | ||
| }).catch(() => null); | ||
|
|
||
| if (!res) { |
There was a problem hiding this comment.
status is never reset to a normal value after a successful createSession call. The flow sets status: "spawning..." at the start but only resets it on failure paths — the success path ends at set({ sessionId: session.id }) and fetchSessions() without clearing the status.
Consequence: isSpawning stays true indefinitely, permanently disabling the "+" button after the first session is created. Users cannot create a second session without a full page refresh.
Fix: add a status reset on success, e.g.:
const session: SessionInfo = await res.json();
set({ sessionId: session.id, status: "connected" });
await get().fetchSessions();There was a problem hiding this comment.
Fixed in 8027528 — added status: "connected" to the success path. Good catch, this would have been a show-stopper after the first session.
| > | ||
| {formatAge(s.createdAt)} | ||
| </span> | ||
| <button |
There was a problem hiding this comment.
Nested <button> inside <button> is invalid HTML — the spec forbids interactive content inside <button>. Browsers may "fix" the DOM by ejecting the inner button, which can cause the kill button to render outside the session row and break the hover-reveal behavior.
The standard fix is to replace the outer <button> with a <div> (styled with role="button" and tabIndex={0} for a11y) or restructure so the kill button is a sibling rather than a child:
<div
role="button"
tabIndex={0}
className="group flex w-full ..."
onClick={() => switchSession(s.id)}
onKeyDown={(e) => e.key === "Enter" && switchSession(s.id)}
>
{/* ... */}
<button type="button" onClick={(e) => { e.stopPropagation(); killSession(s.id); }}>x</button>
</div>In practice Chromium handles the nesting gracefully today, but it is a latent rendering bug.
There was a problem hiding this comment.
Fixed in 8027528 — replaced the outer <button> with div[role=button] + tabIndex={0} + onKeyDown handler. The kill button is now a proper child of a non-interactive container.
nox-0x
left a comment
There was a problem hiding this comment.
Solid feature overall — the Zustand persist refactor is clean, the sidebar UX is well thought out, and the auto-reconnect on refresh is a nice touch. Two issues to fix before merge:
-
createSessionstatus never resets on success (store.ts:153) —isSpawningstaystrueindefinitely after the first session is created, permanently disabling the "+" button until a page refresh. One-liner fix: addstatus: "connected"to the successset(). -
Nested
<button>inside<button>(Sidebar.tsx:89) — spec violation; replace the outer<button>with adiv[role=button]to avoid browser DOM fixup surprises with the hover-reveal kill button.
The status bug is the blocker — fix that and this is good to go.
Without this, refreshing the page or switching sessions showed a blank terminal because the shell prompt and prior output were sent before the new WebSocket connected. Now the server buffers the last 100KB of PTY output per session and replays it when a client connects. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Reset status to "connected" after successful createSession (was stuck on "spawning..." forever, disabling the "+" button) - Replace nested <button> with div[role=button] for session row (nested buttons are invalid HTML per spec) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
nox-0x
left a comment
There was a problem hiding this comment.
Both issues from the previous review are addressed in 8027528: the spawning status now resets to 'connected' on success (fixing the permanently-disabled '+' button), and the outer button is replaced with div[role=button] (fixing the nested interactive element). The Zustand persist migration is solid — partialize is explicit, merge validates the theme, and transient state is correctly excluded. LGTM.
…/6) (#183) Moves the macOS Desktop build into CI as a universal2 artifact — one DMG that runs on Apple Silicon AND Intel — replacing the hand-built, never-CI-validated local DMG. This is the structural fix for "ship → user finds it broken": the DMG now can't reach a release without passing the bundle smoke test + native Intel validation. ## Universal binary (mechanism proven locally before writing CI) - `bundle-node.sh BUNDLE_NODE_UNIVERSAL=1` → fetch both arch Node binaries, lipo into one universal2 `node`. Validated: 256M fat binary, runs natively. - `stage-universal-server.sh` → lipo the native `.node` modules from the two arch server bundles (built on separate CI runners) into fat binaries under the arm64 filenames the bundled JS references. dlopen() picks the right slice at runtime regardless of filename. Validated: real impit arm64+x64 lipo'd and loaded under BOTH arches. - `smoke-test-bundle.sh` SMOKE_EXPECT_UNIVERSAL=1 → asserts every bundled `.node` + the Node binary contain both x86_64 and arm64 slices. ## release.yml (rewritten) Four stages: 1. build-server (matrix: darwin/linux × arm64/x64) — 4 tarballs for install.sh; the two darwin bundles also feed the DMG's native-module lipo. 2. build-dmg (macos-14) — stage universal resources → smoke test (HARD GATE) → electron-builder universal DMG/ZIP/blockmap → validate-dmg via CDP (continue-on-error for now: headless-runner behavior is the one unknown). 3. validate-intel (macos-15-intel, HARD GATE) — mount the DMG on REAL x64 hardware, run the smoke test natively, proving the x64 slice executes. 4. release — assemble tarballs + DMG + ZIP + blockmap + latest-mac.yml + SHA256SUMS; GitHub Release body = the CHANGELOG section (release-notes.ts). ## electron-builder.yml universal target + zip (Squirrel.Mac needs it) + github publish config (so latest-mac.yml is generated for electron-updater in PR #5). Signing stays off — PR #4 flips identity + notarize with the Apple key. ## Local builds unchanged build-dmg.sh now passes `--arm64`/`--x64` to keep local builds single-arch + fast. Validated: local DMG builds AND passes the full validate-dmg.sh end-to-end (Welcome cards, Try-It-Out, auto-auth, first-run UX, Dispatcher badge). ## Validated locally - universal Node lipo ✓ · stage-universal-server lipo + cross-arch load ✓ - actionlint clean on all 3 workflows ✓ · all YAML parses ✓ - `make check` 353/353 ✓ · `biome check packages/` clean ✓ - local single-arch DMG build + full CDP validation ✓ (no regression) CI iteration expected for: the universal CI build, the Intel native job, and whether validate-dmg runs headless. Babysitting CI. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Enable Developer ID signing + notarization for the universal2 DMG, conditional on CI secrets so credential-less builds (forks, pre-setup) stay green and unsigned. - electron-builder.yml: drop 'identity: null' and 'notarize: false' so signing is driven by CSC_LINK presence and notarization by a conditional CLI flag (pinning either would force-disable or conflict). hardenedRuntime + entitlements already in place. - release.yml build-dmg: pass CSC_LINK/CSC_KEY_PASSWORD (sign) + decode the base64 APPLE_API_KEY .p8 and enable --config.mac.notarize.teamId only when the notarization key is present. Team ID 39VWFRL3PM (not secret). - release.yml validate-intel: assert on real Intel hardware via codesign + 'spctl --assess' + 'stapler validate'. Invariant: a build is EITHER fully unsigned OR signed+notarized+stapled — a signed-but-not-notarized build (still warns on download) FAILS the gate rather than shipping. - build-dmg.sh: CSC_IDENTITY_AUTO_DISCOVERY=false keeps local builds fast/unsigned (no keychain prompts now that a Developer ID cert may be installed). - docs/RELEASE.md: document the five secrets + conditional behavior. Needs the 5 secrets set; validated via a signed dry-run before merge. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(app): Apple code signing + notarization (#4) Enable Developer ID signing + notarization for the universal2 DMG, conditional on CI secrets so credential-less builds (forks, pre-setup) stay green and unsigned. - electron-builder.yml: drop 'identity: null' and 'notarize: false' so signing is driven by CSC_LINK presence and notarization by a conditional CLI flag (pinning either would force-disable or conflict). hardenedRuntime + entitlements already in place. - release.yml build-dmg: pass CSC_LINK/CSC_KEY_PASSWORD (sign) + decode the base64 APPLE_API_KEY .p8 and enable --config.mac.notarize.teamId only when the notarization key is present. Team ID 39VWFRL3PM (not secret). - release.yml validate-intel: assert on real Intel hardware via codesign + 'spctl --assess' + 'stapler validate'. Invariant: a build is EITHER fully unsigned OR signed+notarized+stapled — a signed-but-not-notarized build (still warns on download) FAILS the gate rather than shipping. - build-dmg.sh: CSC_IDENTITY_AUTO_DISCOVERY=false keeps local builds fast/unsigned (no keychain prompts now that a Developer ID cert may be installed). - docs/RELEASE.md: document the five secrets + conditional behavior. Needs the 5 secrets set; validated via a signed dry-run before merge. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci(release): cap DMG build at 30min so a stalled Apple notary fails fast A degraded Apple notary service can leave 'notarytool --wait' hanging for hours (observed an 85+ min 'In Progress' stall during an Apple incident). Add timeout-minutes: 30 to the build+sign+notarize step so the release fails fast and is retryable, instead of burning hours of macOS runner time. Normal notarization is <10 min; 30 leaves headroom for a slow-but-healthy notary. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Summary
sessionIdpersisted via Zustandpersistmiddleware. Page refresh reconnects to the last active session. If the server lost it, sidebar clears automatically.persistmiddleware. No more scatteredlocalStoragecalls.partializeexplicitly declares what survives a refresh.0.0.0.0, dark body background prevents white flash, scrollbar styles scoped to terminal only.Test plan
🤖 Generated with Claude Code