Skip to content

feat: session management sidebar + Zustand persist - #4

Merged
aterrylu merged 3 commits into
mainfrom
terry/session-management
Mar 8, 2026
Merged

feat: session management sidebar + Zustand persist#4
aterrylu merged 3 commits into
mainfrom
terry/session-management

Conversation

@aterrylu

@aterrylu aterrylu commented Mar 8, 2026

Copy link
Copy Markdown
Owner

Summary

  • Session sidebar — left panel listing all active sessions with green status dot, age indicator, and kill button (hover to reveal). "+" button creates new sessions.
  • Auto-reconnectsessionId persisted via Zustand persist middleware. Page refresh reconnects to the last active session. If the server lost it, sidebar clears automatically.
  • Zustand as single source of truth (ADR-011) — all client state lives in one store with persist middleware. No more scattered localStorage calls. partialize explicitly declares what survives a refresh.
  • Mobile/Tailscale fixes — Vite binds to 0.0.0.0, dark body background prevents white flash, scrollbar styles scoped to terminal only.
┌─────────────────────────────────────────┐
│              Header                     │
├──────────┬──────────────────────────────┤
│ Sessions │       Terminal               │
│ ● Sess 1 │                              │
│ ○ Sess 2 │                              │
│   [+]    │                              │
└──────────┴──────────────────────────────┘

Test plan

  • Create multiple sessions via "+" button
  • Click between sessions — terminal switches cleanly
  • Refresh page — reconnects to last active session
  • Kill a session — removed from sidebar, terminal clears if active
  • Kill all sessions — shows "Create or select a session" placeholder
  • Access from phone via Tailscale IP — sidebar and terminal render correctly
  • Theme switching still works across all 3 themes
  • Rapid "+" clicks don't create duplicate sessions (spawn guard)

🤖 Generated with Claude Code

- 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>
@aterrylu
aterrylu enabled auto-merge (squash) March 8, 2026 08:08
body: JSON.stringify({ workingDirectory: "~" }),
}).catch(() => null);

if (!res) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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();

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 nox-0x left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. createSession status never resets on success (store.ts:153) — isSpawning stays true indefinitely after the first session is created, permanently disabling the "+" button until a page refresh. One-liner fix: add status: "connected" to the success set().

  2. Nested <button> inside <button> (Sidebar.tsx:89) — spec violation; replace the outer <button> with a div[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.

aterrylu and others added 2 commits March 8, 2026 00:13
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>
@aterrylu
aterrylu disabled auto-merge March 8, 2026 08:17

@nox-0x nox-0x left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@aterrylu
aterrylu merged commit 4fd03e4 into main Mar 8, 2026
1 check passed
@aterrylu
aterrylu deleted the terry/session-management branch March 8, 2026 08:22
aterrylu added a commit that referenced this pull request Jun 6, 2026
…/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>
aterrylu added a commit that referenced this pull request Jun 9, 2026
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>
aterrylu added a commit that referenced this pull request Jun 9, 2026
* 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>
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.

2 participants