Skip to content

feat(machine): Settings tab — name/description, access toggles, delete - #2002

Merged
2witstudios merged 11 commits into
masterfrom
pu/machine-settings-tab
Jul 11, 2026
Merged

2witstudios merged 11 commits into
masterfrom
pu/machine-settings-tab

Conversation

@2witstudios

@2witstudios 2witstudios commented Jul 11, 2026 •

Copy link
Copy Markdown
Owner

What

Fills in the Machine page's Settings tab body (spec tkvkd5e4ruegplpe9affljbv), replacing the ComingSoonTab placeholder the 4-tab shell (#1999) locked in. Keeps the exact export default function SettingsTab({ machineId }) signature; ComingSoonTab.tsx is untouched (Code/Diff still use it).

A full-width form over the machine-settings route (/api/machines/settings, from #1967), following apps/web/src/app/settings/ai/TerminalAccessCard.tsx: plain useState/useEffect fetch + an optimistic persist() (local update → PATCH → rollback + toast on error). No React Hook Form, no SWR.

  • Name + Description — persist on blur. Empty name reverts without a PATCH (the route 400s it; name is the page title); blank description persists as null, matching the route's normalization.
  • Visible to global assistant / Allow page agents — the app's ui/switch.tsx, persist on toggle.
  • Delete Machine — destructive, gated behind the app's ui/alert-dialog.tsx. On success navigates to /dashboard/{driveId}, off the now-trashed page.

No inner sidebar — the shell already supplies the tab body.

Structure

  • hooks/useMachineSettings.ts — the load/save/resync state machine, and where all the subtlety lives.
  • tabs/SettingsTab.tsx — the form and the delete action.

The split is not cosmetic: none of the machinery below is about rendering, and tangled into the JSX it reads as incidental complexity. The hook sits beside its useMachineProjects/useMachineBranches siblings but deliberately isn't on SWR like they are — the spec pins this surface to the TerminalAccessCard pattern. Revalidation is exactly what SWR would have given for free, and exactly why most of the hook exists, so it's spelled out rather than left implicit.

The interesting part: the failure path

Text fields are drafts committed on blur; toggles persist immediately — so two saves can be in flight at once, and every rule here is written for that. Each behaviour below is mutation-verified: breaking it fails exactly one test.

  1. An in-flight save must not disable the controls. Clicking a Switch blurs a text field, whose blur-commit sets saving → re-render → the Switch is disabled before the click's mouseup lands, so onCheckedChange never fires. The toggle is silently lost. The save is surfaced ("Saving…"), never enforced.
  2. Never call setState inside a state updater. Reading settings via a setSettings updater to decide whether to show the spinner is an impure updater. Next 15 enables reactStrictMode by default, so React replays it at render time — after the load's finally already cleared loading. The tab hung on a permanent spinner in bun run dev. A ref that moves in lockstep with the state answers the same question with no state read.
  3. A failed save can't trust a client-side rollback. A PATCH that commits and then times out would strand the tab showing the old value forever — nothing here revalidates it. So a failure reverts and then refetches. The refetch is deferred until every in-flight save drains: a GET fired while another PATCH is airborne reads pre-PATCH state and is then overwritten by it, recreating the exact staleness it exists to fix.
  4. Concurrent saves must not clobber each other. A failure reverts only the keys that save owned (a whole-snapshot revert would roll back an unrelated edit that succeeded meanwhile); a resync adopts server values only for drafts the user hasn't touched (so it can't delete text mid-keystroke); and a save that settles after the tab moved to another machine is dropped rather than written onto that machine's form.

Delete is a controlled dialog: it stays open across the request (a failure is retryable, not toasted at a dismissed dialog), and closing it always clears deleting, so an Escape during a hung request can't strand a permanently-disabled "Deleting…" button.

Tests

21 colocated tests. Every non-obvious behaviour is mutation-verified — that check earned its keep twice:

  • Two vacuous assertions. waitFor polls until an assertion passes, so "the count is still N" / "X did not happen" succeeds on the first tick, before the behaviour under test could occur — asserting nothing. Both now gate on an observable signal, or drain with act() and assert once.
  • Five surviving mutants. Breaking the dirty-draft guard, the deleting reset on dialog close, the unchanged-value short-circuit, name trimming, or the stale-machine guard all left the suite green. Each now has a test.

Verification

  • bun run typecheck (apps/web) — green
  • bun run build --filter=web — green
  • Tests: 21/21 SettingsTab · 56/56 terminal tree · 20/20 machine-settings route · 500/500 hooks
  • eslint — clean
  • CI green

The surface stays behind CODE_EXECUTION_ENABLED (OFF). The two access toggles are persisted but not yet enforced — enforcement is a separate follow-up node, per the service's own docs.

🤖 Generated with Claude Code

Replace the ComingSoonTab placeholder in the Machine page's Settings tab
with a full-width form over the machine-settings route. Mirrors
TerminalAccessCard's optimistic persist() pattern (local update → PATCH →
rollback + toast on error); no React Hook Form, no SWR.

- Name + description text fields (persist on blur)
- visibleToGlobalAssistant / allowPageAgents access switches (persist on toggle)
- Destructive Delete Machine gated behind AlertDialog; on success navigates
  back to the drive off the now-trashed page
- Colocated tests: optimistic toggle + rollback-on-error, delete-confirm gate

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NwV8emSn4yaietjD9BCwb6
@coderabbitai

coderabbitai Bot commented Jul 11, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@2witstudios, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 19 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4bf58224-27b8-4182-afe6-33cb0dfdb349

📥 Commits

Reviewing files that changed from the base of the PR and between 9138066 and 1287fa4.

📒 Files selected for processing (3)
  • apps/web/src/components/layout/middle-content/page-views/terminal/tabs/SettingsTab.test.tsx
  • apps/web/src/components/layout/middle-content/page-views/terminal/tabs/SettingsTab.tsx
  • apps/web/src/hooks/useMachineSettings.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch pu/machine-settings-tab

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

2witstudios and others added 4 commits July 11, 2026 07:32
…gered it

Disabling the form controls while a PATCH was in flight silently dropped
user input: clicking a Switch blurs a text field, whose blur-commit fires
persist() -> setSaving(true) -> re-render -> the Switch is disabled before
the click's mouseup lands, so onCheckedChange never fires. The toggle was
lost with no feedback. Surface the in-flight save as a 'Saving…' indicator
instead of enforcing it.

Also stop reconciling local state from the PATCH response. The route echoes
the whole MachineSettings, so applying it would clobber a field the user is
concurrently editing with a value already stale when the request left. We
normalize every field client-side exactly as the route does, so the
optimistic value never diverges. Rollback now reverts only the patched keys,
so a failed toggle can't revert an unrelated edit that landed mid-flight.
This is also closer to TerminalAccessCard, which never reads the response.

Adds regression tests for both (swallowed-toggle, name rollback).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NwV8emSn4yaietjD9BCwb6
Review pass on the Settings tab surfaced three real defects:

1. (major) A failed PATCH rolled back to a CLIENT-SIDE guess and never
   revalidated. A request that committed server-side and then timed out left
   the tab showing the old value forever — there is no SWR/socket here to
   revalidate it. persist() now reverts optimistically (no flicker) and then
   refetches, which is authoritative and also settles out-of-order saves.
2. A failed background resync tore the whole form down to the error screen even
   though it had perfectly good settings in hand. Dropped the loadError flag:
   `settings === null` after the first load IS the error state, and only the
   FIRST load blocks on a spinner (later refetches revalidate in place).
3. AlertDialogAction composes DialogClose, so the dialog unmounted on the same
   click that started the DELETE: the in-progress state could never render and a
   FAILED delete toasted against a dialog the user had to reopen. preventDefault
   holds it open until the request settles.

Also: 'saving' is now a COUNT, not a flag — a blur-commit and a switch click can
be in flight at once, and a boolean reported 'done' when the first settled.

Tests: 6 -> 10. Adds the commit-then-timeout resync, the load-error retry path,
the empty-name guard (must not fire a PATCH the route would 400), description
clearing to null, and pins the GET query-param shape.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NwV8emSn4yaietjD9BCwb6
The 'only the first load blocks on a spinner' optimization was keyed to a bare
boolean, so a machineId change skipped the spinner AND kept the previous
machine's settings in state — a failed load for the new machine would render the
old machine's name and toggles under the new machine's identity. Key the ref to
the machineId that actually loaded, and drop stale settings on a switch.

Test pins it: switch to a machine whose GET fails -> error state, not a stale form.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NwV8emSn4yaietjD9BCwb6
Second review pass caught two bugs the resync fix itself introduced. A text
field's blur-commit and a switch click can be in flight together, and the
failure path handled neither:

1. (major) The resync GET was fired eagerly from the failing save's catch, while
   another PATCH was still airborne. The GET reads pre-PATCH state and is then
   overwritten by the PATCH landing after it — stranding exactly the staleness
   the resync exists to fix. Now deferred until every save drains (pendingSaves
   hits 0), tracked via a ref because finally must read the CURRENT count.
2. (major) Failure reverted a whole snapshot, rolling back an unrelated edit that
   had succeeded while the failing save was in flight. Now reverts only the keys
   that save owned.

Also: the resync refetch reseeded both text drafts unconditionally, deleting
text mid-keystroke. It now adopts the server value only for drafts the user
hasn't touched since we last saw the server (tracked via a serverSettings ref).

Delete dialog is now controlled: closing it always clears 'deleting', so an
Escape during a hung request can't strand a permanently disabled 'Deleting…'
button. A failed delete keeps the dialog open so the user can retry.

Tests 11 -> 14, and beforeEach now resetAllMocks (clearAllMocks left unconsumed
mockResolvedValueOnce queues, order-coupling the suite). Both new concurrency
tests are mutation-verified: reverting either fix makes them fail. Fixed one
vacuous assertion — a bare waitFor on 'fetch count is still 1' passes on the
first poll before an eager refetch fires, so it's gated on the error toast.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NwV8emSn4yaietjD9BCwb6
…Mode

Third review pass found a bug the previous fix introduced, and it broke the tab
in dev outright.

Calling setLoading(true) from INSIDE a setSettings updater is an impure updater.
Next 15 enables reactStrictMode by default, so React replays queued updaters at
render time -- the replay ran after the load's finally had already cleared
loading, stranding loading:true with nothing left to clear it. Result: the
Settings tab sat on a spinner forever in dev. It only survived a single mount by
luck (the eager-bailout path never replays the updater).

serverSettings moves in exact lockstep with settings, so the ref answers "do we
have anything to show for this machine?" with no state read at all.

Also guards persist() against a save that settles AFTER the tab moved to another
machine: without it, machine A's failed rename reverted A's old name into
machine B's input -- where the next blur would have renamed B. Unreachable today
(TerminalKeepAliveHost keys each TerminalView by id) but the component's own
tests assert in-place switching, so contract and code now agree.

Tests 14 -> 16, both mutation-verified. The machine-switch test deliberately
does NOT use waitFor: when the guard works the correct outcome is that NOTHING
happens, so a polling assertion passes on its first tick and asserts nothing --
it drains the rejection with act() and then asserts once. (Same vacuity trap
caught earlier in the resync test.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NwV8emSn4yaietjD9BCwb6
@2witstudios

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 11, 2026 •

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

2witstudios and others added 5 commits July 11, 2026 08:41
resyncWhenIdle was never cleared on a machineId switch, so a resync owed to the
machine we left could fire a spurious refetch once an unrelated save on the NEW
machine drained. Harmless (an extra GET) but sloppy; clear it with the rest of
the outgoing machine's state.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NwV8emSn4yaietjD9BCwb6
It sat in the General card's header but fires for the Assistant-access toggles in
the card below it too, mislabelling which section was saving. Any control on the
form can be the one in flight, so the indicator belongs to the form.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NwV8emSn4yaietjD9BCwb6
A dedicated pass over the test file found no vacuity or flakiness in the existing
16, but four pieces of real component logic were entirely unguarded -- breaking
each left the suite green:

- Escape during a hung DELETE must clear `deleting`. The whole reason the dialog
  is controlled; without it, reopening shows a dead disabled "Deleting..." button.
- A resync must not clobber a draft the user is mid-edit in, while still adopting
  the server value for a field they haven't touched. This is the subtlest logic in
  the component and had zero coverage.
- Blurring an unchanged field must send no PATCH.
- A name is trimmed before it is persisted AND before it is displayed. This is the
  premise of "the optimistic value already equals the persisted one", which is what
  licenses not reconciling from the PATCH response -- so it deserves to be pinned.

All four are mutation-verified: breaking each behaviour fails exactly its own test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NwV8emSn4yaietjD9BCwb6
…Settings

SettingsTab had grown to 396 lines carrying 8 pieces of state and 4 refs, and
none of that machinery is about rendering -- it is the load/save/resync state
machine, where every subtlety lives (concurrent saves, per-key revert, deferred
resync, dirty-draft preservation, stale-machine results). Tangled with the form
JSX it reads as incidental complexity; on its own it reads as what it is.

SettingsTab is now 240 lines: the form, plus the delete action. The hook is 216
and documents its own invariants. It lives in apps/web/src/hooks/ beside its
useMachineProjects / useMachineBranches siblings -- deliberately NOT on SWR like
they are, because the spec pins this surface to TerminalAccessCard's plain
useState/optimistic-persist shape. (Revalidation is precisely what SWR would have
given for free, and precisely why most of the hook exists; better spelled out
than implicit.)

Behaviour-preserving: the 20 tests are UNCHANGED and still pass, and the four
mutation checks still kill their mutants against the extracted hook (StrictMode
impure-updater hang, eager resync racing an in-flight PATCH, dirty-draft clobber,
stale-machine write).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NwV8emSn4yaietjD9BCwb6
Two findings from a review of the freshly-extracted hook.

1. The stale-save guard keyed off the machine last LOADED, so between switching
   machines and the new machine's GET resolving, a save from the machine we LEFT
   still looked current: its failure toasted an error for a machine the user was
   no longer on and wrote its old name into the new machine's draft. It self-healed
   (the new load overwrites), but the window was real. The ref now means "the
   machine the tab is showing" and is claimed the moment machineId changes;
   "do we have data yet?" was always a separate question, answered by
   serverSettings. Renamed loadedFor -> activeMachine to say so.

2. setAccess was typed with the full MachineSettingsPatch, so a caller could
   setAccess({ name }) and silently skip commitName's trim + empty-name guard.
   Narrowed to MachineAccessPatch (the two toggles), making the bypass
   unrepresentable rather than merely discouraged.

New test covers the window in (1) and is mutation-verified: restoring the
claim-on-load behaviour fails it. 21 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NwV8emSn4yaietjD9BCwb6
@2witstudios
2witstudios merged commit fc28ec0 into master Jul 11, 2026
3 checks passed
@2witstudios
2witstudios deleted the pu/machine-settings-tab branch July 11, 2026 16:51
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