diff --git a/apps/next/src/app/(app)/machine/page.tsx b/apps/next/src/app/(app)/machine/page.tsx new file mode 100644 index 0000000..0a854e3 --- /dev/null +++ b/apps/next/src/app/(app)/machine/page.tsx @@ -0,0 +1,5 @@ +import { MachineShell } from '@/components/machine/machine-shell'; + +const MachinePage = () => ; + +export default MachinePage; diff --git a/apps/next/src/components/agent-workspace/xterm-session.tsx b/apps/next/src/components/agent-workspace/xterm-session.tsx index 4d575e0..0a45ba6 100644 --- a/apps/next/src/components/agent-workspace/xterm-session.tsx +++ b/apps/next/src/components/agent-workspace/xterm-session.tsx @@ -75,12 +75,14 @@ export const XtermSession = ({ sessionKey, waitingLabel, copyOnSelect, + label = 'workspace shell', }: { active: boolean; tokenUrl: string; sessionKey: string; waitingLabel?: string; copyOnSelect?: boolean; + label?: string; }) => { const containerRef = useRef(null); const termRef = useRef(null); @@ -183,11 +185,9 @@ export const XtermSession = ({ sendResize, fontsReady: document.fonts.ready, loadNerdFont: () => - document.fonts - .load("16px 'Symbols Nerd Font Mono'", '') - .then(() => { - if (!isAborted()) term.refresh(0, term.rows - 1); - }), + document.fonts.load("16px 'Symbols Nerd Font Mono'", '').then(() => { + if (!isAborted()) term.refresh(0, term.rows - 1); + }), raf: (cb) => requestAnimationFrame(cb), isAborted, }); @@ -243,7 +243,7 @@ export const XtermSession = ({

{status === 'connected' - ? 'Connected · workspace shell' + ? `Connected · ${label}` : status === 'connecting' ? 'Connecting…' : status === 'closed' diff --git a/apps/next/src/components/layout/header/index.tsx b/apps/next/src/components/layout/header/index.tsx index 609b704..f2f8bf7 100644 --- a/apps/next/src/components/layout/header/index.tsx +++ b/apps/next/src/components/layout/header/index.tsx @@ -8,6 +8,7 @@ import { GitBranch, LayoutDashboard, MessagesSquare, + Server, Settings, ShieldCheck, } from 'lucide-react'; @@ -37,6 +38,11 @@ const Header = (headerProps: ComponentProps<'header'>) => { icon: MessagesSquare, label: 'Threads', }, + { + href: '/machine', + icon: Server, + label: 'Machine', + }, { href: '/settings/profile', icon: Settings, diff --git a/apps/next/src/components/machine/box-status-card.tsx b/apps/next/src/components/machine/box-status-card.tsx new file mode 100644 index 0000000..ba393a2 --- /dev/null +++ b/apps/next/src/components/machine/box-status-card.tsx @@ -0,0 +1,147 @@ +'use client'; + +import { Loader2, Play, RotateCw, Server, Square } from 'lucide-react'; + +import { + Badge, + Button, + Card, + CardContent, + CardHeader, + CardTitle, +} from '@spoon/ui'; + +export type BoxStatus = { + running: boolean; + image: string | null; + startedAt: string | null; + memoryLimitBytes: number | null; + containerName: string; +}; + +export type LifecycleAction = 'start' | 'stop' | 'restart'; + +const formatUptime = (startedAt: string | null): string => { + if (!startedAt) return '—'; + const started = new Date(startedAt).getTime(); + if (Number.isNaN(started)) return '—'; + const seconds = Math.max(0, Math.floor((Date.now() - started) / 1000)); + const days = Math.floor(seconds / 86_400); + const hours = Math.floor((seconds % 86_400) / 3_600); + const minutes = Math.floor((seconds % 3_600) / 60); + if (days > 0) return `${days}d ${hours}h`; + if (hours > 0) return `${hours}h ${minutes}m`; + if (minutes > 0) return `${minutes}m`; + return `${seconds}s`; +}; + +const formatMemory = (bytes: number | null): string => { + if (bytes === null) return 'Unlimited'; + const gib = bytes / 1024 ** 3; + return `${gib.toFixed(gib < 10 ? 1 : 0)} GiB`; +}; + +const Detail = ({ label, value }: { label: string; value: string }) => ( +

+

+ {label} +

+

{value}

+
+); + +export const BoxStatusCard = ({ + status, + pendingAction, + onAction, +}: { + status: BoxStatus | null | undefined; + pendingAction: LifecycleAction | null; + onAction: (action: LifecycleAction) => void; +}) => { + const loading = status === undefined; + const running = Boolean(status?.running); + const busy = pendingAction !== null; + + return ( + + + + + My Machine + + {loading ? ( + + + Checking… + + ) : running ? ( + Running + ) : ( + Stopped + )} + + +
+ + + +
+
+ {running ? ( + <> + + + + ) : ( + + )} +
+
+
+ ); +}; diff --git a/apps/next/src/components/machine/machine-shell.tsx b/apps/next/src/components/machine/machine-shell.tsx new file mode 100644 index 0000000..dee84d3 --- /dev/null +++ b/apps/next/src/components/machine/machine-shell.tsx @@ -0,0 +1,351 @@ +'use client'; + +import type { + FileResponse, + FileTreeNode, +} from '@/components/agent-workspace/types'; +import { useCallback, useEffect, useState } from 'react'; +import { CodeEditor } from '@/components/agent-workspace/code-editor'; +import { FileTabs } from '@/components/agent-workspace/file-tabs'; +import { FileTree } from '@/components/agent-workspace/file-tree'; +import { XtermSession } from '@/components/agent-workspace/xterm-session'; +import { useQuery } from 'convex/react'; +import { FolderTree, SquareTerminal } from 'lucide-react'; +import { toast } from 'sonner'; + +import { api } from '@spoon/backend/convex/_generated/api.js'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@spoon/ui'; + +import type { BoxStatus, LifecycleAction } from './box-status-card'; +import { BoxStatusCard } from './box-status-card'; + +const STATUS_POLL_MS = 10_000; + +type OpenFileState = { + path: string; + content: string; + savedContent: string; + loading: boolean; + saving: boolean; +}; + +export const MachineShell = () => { + const environment = useQuery(api.userEnvironment.getMine, {}); + const username = environment?.username ?? ''; + const firstName = environment?.firstName; + + const [status, setStatus] = useState(undefined); + const [pendingAction, setPendingAction] = useState( + null, + ); + const [tree, setTree] = useState(null); + const [files, setFiles] = useState>({}); + const [openFilePaths, setOpenFilePaths] = useState([]); + const [activeFilePath, setActiveFilePath] = useState(); + const [expandedDirectoryPaths, setExpandedDirectoryPaths] = useState< + string[] + >([]); + const [vimEnabled, setVimEnabled] = useState(false); + const [activeTab, setActiveTab] = useState<'files' | 'terminal'>('files'); + + const refreshStatus = useCallback(async () => { + try { + const response = await fetch('/api/box/status'); + if (!response.ok) throw new Error(await response.text()); + const data = (await response.json()) as { status: BoxStatus }; + setStatus(data.status); + } catch (error) { + console.error(error); + setStatus((current) => current ?? null); + } + }, []); + + const loadTree = useCallback(async () => { + try { + const response = await fetch('/api/box/tree'); + if (!response.ok) throw new Error(await response.text()); + const data = (await response.json()) as { tree: FileTreeNode | null }; + setTree(data.tree); + } catch (error) { + console.error(error); + } + }, []); + + useEffect(() => { + void refreshStatus(); + void loadTree(); + const interval = window.setInterval(() => { + void refreshStatus(); + }, STATUS_POLL_MS); + return () => window.clearInterval(interval); + }, [refreshStatus, loadTree]); + + const runLifecycle = useCallback( + (action: LifecycleAction) => { + if (pendingAction) return; + setPendingAction(action); + void (async () => { + try { + const response = await fetch('/api/box/lifecycle', { + method: 'POST', + body: JSON.stringify({ action }), + }); + if (!response.ok) throw new Error(await response.text()); + const data = (await response.json()) as { status: BoxStatus }; + setStatus(data.status); + toast.success( + action === 'stop' + ? 'Machine stopped.' + : action === 'restart' + ? 'Machine restarted.' + : 'Machine started.', + ); + void loadTree(); + } catch (error) { + console.error(error); + toast.error(`Could not ${action} your machine.`); + void refreshStatus(); + } finally { + setPendingAction(null); + } + })(); + }, + [pendingAction, loadTree, refreshStatus], + ); + + const loadFile = useCallback(async (path: string) => { + setFiles((current) => ({ + ...current, + [path]: current[path] ?? { + path, + content: '', + savedContent: '', + loading: true, + saving: false, + }, + })); + const response = await fetch( + `/api/box/file?path=${encodeURIComponent(path)}`, + ); + if (!response.ok) throw new Error(await response.text()); + const data = (await response.json()) as FileResponse; + setFiles((current) => ({ + ...current, + [data.path]: { + path: data.path, + content: data.content, + savedContent: data.content, + loading: false, + saving: false, + }, + })); + }, []); + + const openFile = useCallback( + (path: string) => { + setOpenFilePaths((current) => + current.includes(path) ? current : [...current, path], + ); + setActiveFilePath(path); + if (!files[path]) { + void loadFile(path).catch((error) => { + console.error(error); + setFiles((current) => { + const next = { ...current }; + delete next[path]; + return next; + }); + setOpenFilePaths((current) => + current.filter((filePath) => filePath !== path), + ); + toast.error('Could not load file.'); + }); + } + }, + [files, loadFile], + ); + + const writeFileContent = async (path: string, content: string) => { + setFiles((current) => ({ + ...current, + [path]: { + ...(current[path] ?? { path, savedContent: '', loading: false }), + content, + saving: true, + }, + })); + const response = await fetch('/api/box/file', { + method: 'PUT', + body: JSON.stringify({ path, content }), + }); + if (!response.ok) { + setFiles((current) => ({ + ...current, + [path]: { + ...(current[path] ?? { + path, + content, + savedContent: '', + loading: false, + }), + saving: false, + }, + })); + toast.error('Could not save file.'); + throw new Error(await response.text()); + } + setFiles((current) => ({ + ...current, + [path]: { + ...(current[path] ?? { path, loading: false }), + content, + savedContent: content, + saving: false, + }, + })); + toast.success('File saved.'); + }; + + const saveFile = async (content: string) => { + if (!activeFilePath) return; + await writeFileContent(activeFilePath, content); + }; + + const closeFile = (path: string) => { + const index = openFilePaths.indexOf(path); + const nextOpen = openFilePaths.filter((filePath) => filePath !== path); + setOpenFilePaths(nextOpen); + setFiles((current) => { + const next = { ...current }; + delete next[path]; + return next; + }); + if (activeFilePath === path) { + setActiveFilePath(nextOpen[index - 1] ?? nextOpen[index] ?? undefined); + } + }; + + const toggleDirectory = (path: string) => { + setExpandedDirectoryPaths((current) => + current.includes(path) + ? current.filter((directoryPath) => directoryPath !== path) + : [...current, path], + ); + }; + + const activeFile = activeFilePath ? files[activeFilePath] : undefined; + const running = Boolean(status?.running); + + return ( +
+
+

+ {firstName ? `${firstName}'s Machine` : 'My Machine'} +

+

+ Your always-available dev box — a persistent home, a terminal, and a + file browser rooted at ~. +

+
+ + + + setActiveTab(value as 'files' | 'terminal')} + className='border-border bg-muted/20 flex h-[calc(100vh-8.5rem)] min-h-[600px] flex-col overflow-hidden rounded-md border' + > + + + + Files + + + + Terminal + + + + +
+ ({ + path, + dirty: files[path] + ? files[path].content !== files[path].savedContent + : false, + }))} + activePath={activeFilePath} + onActivate={setActiveFilePath} + onClose={closeFile} + /> + { + if (!activeFilePath) return; + setFiles((current) => ({ + ...current, + [activeFilePath]: { + ...(current[activeFilePath] ?? { + path: activeFilePath, + savedContent: '', + loading: false, + saving: false, + }), + content, + }, + })); + }} + /> +
+
+ + + +
+
+ ); +}; diff --git a/apps/next/tests/component/machine-shell.test.tsx b/apps/next/tests/component/machine-shell.test.tsx new file mode 100644 index 0000000..f4aab40 --- /dev/null +++ b/apps/next/tests/component/machine-shell.test.tsx @@ -0,0 +1,130 @@ +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { MachineShell } from '../../src/components/machine/machine-shell'; + +const { mockUseQuery, fetchSpy } = vi.hoisted(() => ({ + mockUseQuery: vi.fn(), + fetchSpy: vi.fn(), +})); + +vi.mock('convex/react', () => ({ + useQuery: mockUseQuery, +})); + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), +})); + +vi.mock('sonner', () => ({ + toast: { error: vi.fn(), success: vi.fn() }, +})); + +vi.mock('@/components/agent-workspace/xterm-session', () => ({ + XtermSession: () =>
terminal
, +})); + +vi.mock('@/components/agent-workspace/code-editor', () => ({ + CodeEditor: () =>
editor
, +})); + +const stoppedStatus = { + running: false, + image: 'spoon/devbox:latest', + startedAt: null, + memoryLimitBytes: null, + containerName: 'spoon-home-gib', +}; + +const tree = { + name: '', + path: '', + type: 'directory', + children: [{ name: 'README.md', path: 'README.md', type: 'file' }], +}; + +const jsonResponse = (body: unknown) => ({ + ok: true, + json: () => Promise.resolve(body), + text: () => Promise.resolve(JSON.stringify(body)), +}); + +beforeEach(() => { + mockUseQuery.mockReturnValue({ + username: 'gib', + firstName: 'Gib', + enabled: true, + }); + fetchSpy.mockImplementation((input: RequestInfo | URL) => { + const url = + typeof input === 'string' + ? input + : input instanceof URL + ? input.href + : input.url; + if (url.startsWith('/api/box/status')) { + return Promise.resolve(jsonResponse({ status: stoppedStatus })); + } + if (url.startsWith('/api/box/tree')) { + return Promise.resolve(jsonResponse({ tree })); + } + if (url.startsWith('/api/box/lifecycle')) { + return Promise.resolve( + jsonResponse({ + status: { + ...stoppedStatus, + running: true, + startedAt: new Date().toISOString(), + }, + }), + ); + } + return Promise.resolve(jsonResponse({})); + }); + vi.stubGlobal('fetch', fetchSpy); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +describe('MachineShell', () => { + it('renders a stopped status card with a Start button', async () => { + render(); + expect(await screen.findByText('Stopped')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Start' })).toBeInTheDocument(); + }); + + it('starts the box when Start is clicked', async () => { + render(); + const startButton = await screen.findByRole('button', { name: 'Start' }); + fireEvent.click(startButton); + await waitFor(() => + expect(fetchSpy).toHaveBeenCalledWith( + '/api/box/lifecycle', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ action: 'start' }), + }), + ), + ); + }); + + it('renders the home file tree', async () => { + render(); + expect(await screen.findByText('README.md')).toBeInTheDocument(); + }); + + it('renders the terminal', async () => { + render(); + expect(await screen.findByText('terminal')).toBeInTheDocument(); + }); +});