From a438ec67328aabd840d8c58b7aab978435e50056 Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Sat, 11 Jul 2026 07:26:23 -0500 Subject: [PATCH 01/11] =?UTF-8?q?feat(machine):=20Settings=20tab=20?= =?UTF-8?q?=E2=80=94=20name/description,=20access=20toggles,=20delete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01NwV8emSn4yaietjD9BCwb6 --- .../terminal/tabs/SettingsTab.test.tsx | 136 ++++++++ .../page-views/terminal/tabs/SettingsTab.tsx | 292 +++++++++++++++++- 2 files changed, 422 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/components/layout/middle-content/page-views/terminal/tabs/SettingsTab.test.tsx diff --git a/apps/web/src/components/layout/middle-content/page-views/terminal/tabs/SettingsTab.test.tsx b/apps/web/src/components/layout/middle-content/page-views/terminal/tabs/SettingsTab.test.tsx new file mode 100644 index 0000000000..19fe912f05 --- /dev/null +++ b/apps/web/src/components/layout/middle-content/page-views/terminal/tabs/SettingsTab.test.tsx @@ -0,0 +1,136 @@ +import { describe, test, beforeEach, vi } from 'vitest'; +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { assert } from '@/stores/__tests__/riteway'; +import type { MachineSettings } from '@pagespace/lib/services/machines/machine-settings'; + +const push = vi.fn(); +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push }), + useParams: () => ({ driveId: 'drive-1' }), +})); + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +const fetchWithAuth = vi.fn(); +const apiPatch = vi.fn(); +const apiDelete = vi.fn(); +vi.mock('@/lib/auth/auth-fetch', () => ({ + fetchWithAuth: (...args: unknown[]) => fetchWithAuth(...args), + patch: (...args: unknown[]) => apiPatch(...args), + del: (...args: unknown[]) => apiDelete(...args), +})); + +import SettingsTab from './SettingsTab'; +import { toast } from 'sonner'; + +const baseSettings: MachineSettings = { + name: 'My Machine', + description: 'does things', + visibleToGlobalAssistant: false, + allowPageAgents: false, +}; + +function mockLoad(settings: MachineSettings = baseSettings) { + fetchWithAuth.mockResolvedValue({ ok: true, json: async () => ({ settings }) }); +} + +describe('SettingsTab', () => { + beforeEach(() => vi.clearAllMocks()); + + test('loads the machine settings into the form', async () => { + mockLoad(); + render(); + + const nameInput = (await screen.findByLabelText('Name')) as HTMLInputElement; + const globalToggle = screen.getByRole('switch', { name: /visible to global assistant/i }); + + assert({ + given: 'a Machine whose settings load successfully', + should: 'populate the name field and reflect the access toggle state', + actual: { name: nameInput.value, visible: globalToggle.getAttribute('aria-checked') }, + expected: { name: 'My Machine', visible: 'false' }, + }); + }); + + test('toggling an access switch optimistically PATCHes and reconciles with the server', async () => { + mockLoad(); + apiPatch.mockResolvedValue({ settings: { ...baseSettings, visibleToGlobalAssistant: true } }); + render(); + + const toggle = await screen.findByRole('switch', { name: /visible to global assistant/i }); + await userEvent.click(toggle); + + await waitFor(() => assert({ + given: 'the visible-to-global-assistant switch clicked', + should: 'PATCH the settings route with the machineId and the flipped flag', + actual: apiPatch.mock.calls[0], + expected: ['/api/machines/settings', { machineId: 'm1', visibleToGlobalAssistant: true }], + })); + + await waitFor(() => assert({ + given: 'a successful PATCH response', + should: 'leave the switch on and never toast an error', + actual: { checked: toggle.getAttribute('aria-checked'), errored: (toast.error as ReturnType).mock.calls.length }, + expected: { checked: 'true', errored: 0 }, + })); + }); + + test('a failed PATCH rolls the toggle back and surfaces an error toast', async () => { + mockLoad(); + apiPatch.mockRejectedValue(new Error('nope')); + render(); + + const toggle = await screen.findByRole('switch', { name: /allow page agents/i }); + await userEvent.click(toggle); + + await waitFor(() => assert({ + given: 'the PATCH rejected', + should: 'show an error toast', + actual: (toast.error as ReturnType).mock.calls.length, + expected: 1, + })); + + await waitFor(() => assert({ + given: 'the optimistic update rolled back', + should: 'restore the switch to its pre-click (off) state', + actual: toggle.getAttribute('aria-checked'), + expected: 'false', + })); + }); + + test('Delete Machine is gated behind the confirm dialog', async () => { + mockLoad(); + apiDelete.mockResolvedValue({ success: true, spriteTornDown: true }); + render(); + + // Wait for load, then open the confirm dialog via the trigger. + await screen.findByLabelText('Name'); + const trigger = screen.getByRole('button', { name: /delete machine/i }); + await userEvent.click(trigger); + + assert({ + given: 'the delete trigger clicked (dialog opened, not confirmed)', + should: 'NOT call the DELETE route yet', + actual: apiDelete.mock.calls.length, + expected: 0, + }); + + const dialog = await screen.findByRole('alertdialog'); + await userEvent.click(within(dialog).getByRole('button', { name: /delete machine/i })); + + await waitFor(() => assert({ + given: 'the confirm action clicked', + should: 'DELETE the machine by id', + actual: apiDelete.mock.calls[0], + expected: ['/api/machines/settings?machineId=m1'], + })); + + await waitFor(() => assert({ + given: 'a successful delete', + should: 'navigate back to the drive, off the trashed page', + actual: push.mock.calls[0], + expected: ['/dashboard/drive-1'], + })); + }); +}); diff --git a/apps/web/src/components/layout/middle-content/page-views/terminal/tabs/SettingsTab.tsx b/apps/web/src/components/layout/middle-content/page-views/terminal/tabs/SettingsTab.tsx index 13c7e6871e..62cc46b919 100644 --- a/apps/web/src/components/layout/middle-content/page-views/terminal/tabs/SettingsTab.tsx +++ b/apps/web/src/components/layout/middle-content/page-views/terminal/tabs/SettingsTab.tsx @@ -1,13 +1,293 @@ "use client"; -import ComingSoonTab from './ComingSoonTab'; +import { useEffect, useState } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { toast } from 'sonner'; +import { Loader2, Trash2, TriangleAlert } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { Textarea } from '@/components/ui/textarea'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog'; +import { fetchWithAuth, patch as apiPatch, del as apiDelete } from '@/lib/auth/auth-fetch'; +import type { + MachineSettings, + MachineSettingsPatch, +} from '@pagespace/lib/services/machines/machine-settings'; /** - * PLACEHOLDER — the real Settings tab (machine config, env, resource controls) - * is built by a sibling follow-up PR (spec `o2yv9twc782guwfrr2pawcif`). The - * `{ machineId }` prop signature and file location are locked in here so that - * agent only fills in the body (replacing the shared ComingSoonTab placeholder). + * The Machine page's Settings tab: a full-width form over the machine-settings + * route (`/api/machines/settings`). It mirrors `TerminalAccessCard`'s shape — + * a plain `useState`/`useEffect` fetch plus an optimistic `persist()` helper + * (local update → PATCH → rollback + toast on error). No React Hook Form, no SWR. + * + * `machineId` IS the backing Machine page's id. The two access toggles + * (`visibleToGlobalAssistant` / `allowPageAgents`) are persisted here but not yet + * enforced anywhere — the whole surface sits behind `CODE_EXECUTION_ENABLED`. + * + * Delete is gated behind an `AlertDialog` because it is irreversible from the + * user's point of view (the route soft-trashes the page and tears down the + * Sprite); on success we navigate back to the drive, off the now-trashed page. */ export default function SettingsTab({ machineId }: { machineId: string }) { - return ; + const router = useRouter(); + const params = useParams(); + const driveId = typeof params?.driveId === 'string' ? params.driveId : undefined; + + const [settings, setSettings] = useState(null); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(false); + const [saving, setSaving] = useState(false); + const [deleting, setDeleting] = useState(false); + const [reloadToken, setReloadToken] = useState(0); + + // Local drafts for the text fields — inputs stay editable per-keystroke and + // only persist on blur (the toggles persist immediately). Kept in sync with + // the server truth whenever `settings` is (re)loaded or reconciled. + const [nameDraft, setNameDraft] = useState(''); + const [descriptionDraft, setDescriptionDraft] = useState(''); + + useEffect(() => { + let cancelled = false; + setLoading(true); + setLoadError(false); + (async () => { + try { + const response = await fetchWithAuth( + `/api/machines/settings?machineId=${encodeURIComponent(machineId)}`, + ); + if (!response.ok) throw new Error('Failed to load machine settings'); + const json = (await response.json()) as { settings: MachineSettings }; + if (!cancelled) { + setSettings(json.settings); + setNameDraft(json.settings.name); + setDescriptionDraft(json.settings.description ?? ''); + } + } catch (error) { + console.error('Failed to load machine settings:', error); + if (!cancelled) { + setLoadError(true); + toast.error('Failed to load machine settings'); + } + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, [machineId, reloadToken]); + + /** + * Optimistically apply `patch` to local state, PATCH it, and reconcile with the + * server's authoritative response — rolling back (and toasting) on failure. + */ + async function persist(patch: MachineSettingsPatch) { + if (!settings) return; + const previous = settings; + const optimistic: MachineSettings = { ...settings, ...patch }; + setSettings(optimistic); + setSaving(true); + try { + const { settings: updated } = await apiPatch<{ settings: MachineSettings }>( + '/api/machines/settings', + { machineId, ...patch }, + ); + setSettings(updated); + setNameDraft(updated.name); + setDescriptionDraft(updated.description ?? ''); + } catch (error) { + setSettings(previous); + setNameDraft(previous.name); + setDescriptionDraft(previous.description ?? ''); + toast.error(error instanceof Error ? error.message : 'Failed to update machine settings'); + } finally { + setSaving(false); + } + } + + function commitName() { + if (!settings) return; + const trimmed = nameDraft.trim(); + // An empty name is rejected server-side (name is the page title). Revert the + // draft rather than firing a doomed PATCH. + if (trimmed.length === 0) { + setNameDraft(settings.name); + return; + } + if (trimmed === settings.name) return; + persist({ name: trimmed }); + } + + function commitDescription() { + if (!settings) return; + const trimmed = descriptionDraft.trim(); + const current = settings.description ?? ''; + if (trimmed === current) return; + // Whitespace-only clears the description (round-trips to null server-side). + persist({ description: trimmed.length === 0 ? null : trimmed }); + } + + async function handleDelete() { + setDeleting(true); + try { + await apiDelete(`/api/machines/settings?machineId=${encodeURIComponent(machineId)}`); + toast.success('Machine deleted'); + router.push(driveId ? `/dashboard/${driveId}` : '/dashboard'); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to delete machine'); + setDeleting(false); + } + } + + if (loading) { + return ( +
+ +
+ ); + } + + if (loadError || !settings) { + return ( +
+

Could not load machine settings.

+ +
+ ); + } + + return ( +
+
+ + + General + The Machine's name and description. + + +
+ + setNameDraft(e.target.value)} + onBlur={commitName} + /> +
+
+ +