| 1 | import { ChevronDown, ChevronRight, Cloud, HardDrive, Moon, Power, RotateCcw, Terminal } from "lucide-react"; |
| 2 | import { useState } from "react"; |
| 3 | import { Link, useFetcher, useOutletContext } from "react-router"; |
| 4 | |
| 5 | import type { AgentComputerCommand, AgentComputerState, AgentComputerView, WorkspaceAgent } from "@g1t/contracts"; |
| 6 | |
| 7 | import type { Route } from "./+types/computer"; |
| 8 | import { agentsAction, answer } from "../../../components/agents/actions.server"; |
| 9 | import { type ActionResult, Confirm } from "../../../components/agents/dialogs"; |
| 10 | import { Quiet } from "../../../components/agents/parts"; |
| 11 | import { TimeAgo } from "../../../components/ui"; |
| 12 | import { Alert, AlertDescription } from "../../../components/ui/alert"; |
| 13 | import { Badge, type BadgeTone } from "../../../components/ui/badge"; |
| 14 | import { Button } from "../../../components/ui/button"; |
| 15 | import { Card } from "../../../components/ui/card"; |
| 16 | import { Hint } from "../../../components/ui/hint"; |
| 17 | import { Progress } from "../../../components/ui/progress"; |
| 18 | import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../../components/ui/table"; |
| 19 | import { page } from "../../../lib/meta"; |
| 20 | import { useRefreshWhile } from "../../../lib/refresh"; |
| 21 | import { workspaceAgents } from "../../../lib/services.server"; |
| 22 | import { requireUser, roleIn } from "../../../lib/session.server"; |
| 23 | |
| 24 | export function meta({ params, ...args }: Route.MetaArgs) { |
| 25 | return page(args, { title: `Computer · @${params.handle} · ${params.owner} · g1t` }); |
| 26 | } |
| 27 | |
| 28 | /** The agent's computer: its state, disk and recent commands, and whether the viewer may act on it. */ |
| 29 | export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ view: AgentComputerView | null; error: string | null }> { |
| 30 | const viewer = requireUser(context, request); |
| 31 | if (!roleIn(viewer, params.owner)) throw new Response(null, { status: 404 }); |
| 32 | const found = await workspaceAgents.computer(params.owner.toLowerCase(), params.handle.toLowerCase(), viewer).catch(() => null); |
| 33 | if (!found) return { view: null, error: "The agents service didn't answer. Reload in a moment." }; |
| 34 | return found.ok ? { view: found.value, error: null } : { view: null, error: found.error.message }; |
| 35 | } |
| 36 | |
| 37 | /** Wake it, put it to sleep, or reset it: owners, or the member whose personal agent it is. */ |
| 38 | export async function action({ params, context, request }: Route.ActionArgs): Promise<ActionResult> { |
| 39 | const { viewer, slug, form } = await agentsAction(request, context, params.owner); |
| 40 | const intent = String(form.get("intent") ?? ""); |
| 41 | const handle = params.handle.toLowerCase(); |
| 42 | if (intent === "wake") return answer(intent, workspaceAgents.wakeComputer(slug, handle, viewer)); |
| 43 | if (intent === "sleep") return answer(intent, workspaceAgents.sleepComputer(slug, handle, viewer)); |
| 44 | if (intent === "reset") return answer(intent, workspaceAgents.resetComputer(slug, handle, viewer)); |
| 45 | return { ok: false, intent, error: "Unknown request." }; |
| 46 | } |
| 47 | |
| 48 | const STATES: Record<AgentComputerState, { label: string; tone: BadgeTone; moving: boolean }> = { |
| 49 | asleep: { label: "Asleep", tone: "neutral", moving: false }, |
| 50 | waking: { label: "Waking", tone: "info", moving: true }, |
| 51 | awake: { label: "Awake", tone: "success", moving: false }, |
| 52 | sleeping: { label: "Sleeping", tone: "info", moving: true }, |
| 53 | }; |
| 54 | |
| 55 | /** Bytes as people read them: 1.2 GB, 340 MB, 12 KB. */ |
| 56 | export function humanBytes(bytes: number): string { |
| 57 | if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(bytes >= 10e9 ? 0 : 1)} GB`; |
| 58 | if (bytes >= 1e6) return `${Math.round(bytes / 1e6)} MB`; |
| 59 | if (bytes >= 1e3) return `${Math.round(bytes / 1e3)} KB`; |
| 60 | return `${bytes} B`; |
| 61 | } |
| 62 | |
| 63 | /** A duration in milliseconds as a short word: 1.2 s, 42 s, 5 min. */ |
| 64 | export function took(ms: number): string { |
| 65 | const seconds = ms / 1000; |
| 66 | if (seconds < 10) return `${seconds.toFixed(1)} s`; |
| 67 | if (seconds < 90) return `${Math.round(seconds)} s`; |
| 68 | return `${Math.round(seconds / 60)} min`; |
| 69 | } |
| 70 | |
| 71 | /** |
| 72 | * The Computer tab: whether the agent's computer is awake, how full its |
| 73 | * home is, where it runs, the buttons to wake, sleep and reset it, and the |
| 74 | * commands it ran most recently with their output. |
| 75 | */ |
| 76 | export default function ComputerTab({ loaderData, params }: Route.ComponentProps) { |
| 77 | const agent = useOutletContext<WorkspaceAgent>(); |
| 78 | const { view, error } = loaderData; |
| 79 | const fetcher = useFetcher<ActionResult>({ key: `computer-${agent.id}` }); |
| 80 | const state = view?.status.state ?? "asleep"; |
| 81 | useRefreshWhile(state === "waking" || state === "sleeping" || fetcher.state !== "idle"); |
| 82 | const failed = fetcher.state === "idle" && fetcher.data && !fetcher.data.ok ? fetcher.data.error : null; |
| 83 | if (!view) { |
| 84 | return ( |
| 85 | <Quiet title="This computer can't be shown right now" icon={<Terminal size={18} />}> |
| 86 | {error ?? "The agents service didn't answer. Reload in a moment."} |
| 87 | </Quiet> |
| 88 | ); |
| 89 | } |
| 90 | const { status, commands, can_manage: canManage } = view; |
| 91 | const shown = STATES[status.state]; |
| 92 | const share = Math.min(100, Math.round((status.disk_used_bytes / status.disk_cap_bytes) * 100)); |
| 93 | const busy = fetcher.state !== "idle"; |
| 94 | const slug = params.owner; |
| 95 | return ( |
| 96 | <div className="space-y-8"> |
| 97 | <section aria-labelledby="computer-state" className="max-w-3xl"> |
| 98 | <h2 id="computer-state" className="sr-only"> |
| 99 | Its computer |
| 100 | </h2> |
| 101 | <p className="text-base leading-relaxed text-fg"> |
| 102 | {agent.display_name} has a computer of its own: a Linux machine with a home that stays between sessions. Sessions wake it to run commands and keep files; it sleeps |
| 103 | after ten minutes idle and is saved as it was. Chat replies never use it. |
| 104 | </p> |
| 105 | </section> |
| 106 | |
| 107 | {failed && ( |
| 108 | <Alert tone="danger" role="alert"> |
| 109 | <AlertDescription>{failed}</AlertDescription> |
| 110 | </Alert> |
| 111 | )} |
| 112 | {status.problem && ( |
| 113 | <Alert tone="warn"> |
| 114 | <AlertDescription>{status.problem}</AlertDescription> |
| 115 | </Alert> |
| 116 | )} |
| 117 | {!status.disk_attached && ( |
| 118 | <Alert tone="info"> |
| 119 | <AlertDescription>This computer's disk isn't attached yet: it works, but forgets its home when it sleeps.</AlertDescription> |
| 120 | </Alert> |
| 121 | )} |
| 122 | {status.delete_after && ( |
| 123 | <Alert tone="warn"> |
| 124 | <AlertDescription> |
| 125 | Its disk will be deleted <TimeAgo at={status.delete_after} />, since the agent was archived. |
| 126 | </AlertDescription> |
| 127 | </Alert> |
| 128 | )} |
| 129 | |
| 130 | <Card className="p-5 sm:p-6"> |
| 131 | <div className="flex flex-wrap items-center gap-3"> |
| 132 | <Badge tone={shown.tone} size="md" className={shown.moving ? "animate-pulse motion-reduce:animate-none" : undefined}> |
| 133 | <Power size={11} aria-hidden /> |
| 134 | {shown.label} |
| 135 | </Badge> |
| 136 | <span className="text-sm text-muted"> |
| 137 | since <TimeAgo at={status.since} /> |
| 138 | </span> |
| 139 | </div> |
| 140 | <dl className="mt-5 grid gap-x-6 gap-y-4 text-sm sm:grid-cols-2 lg:grid-cols-4"> |
| 141 | <div className="min-w-0 sm:col-span-2"> |
| 142 | <dt className="flex items-center gap-1.5 text-xs text-faint"> |
| 143 | <HardDrive size={12} aria-hidden /> |
| 144 | Disk |
| 145 | </dt> |
| 146 | <dd className="mt-1.5"> |
| 147 | <Progress value={share} aria-label="Disk used" indicatorClassName={share >= 90 ? "bg-danger" : share >= 75 ? "bg-warn" : undefined} /> |
| 148 | <p className="mt-1.5 tabular-nums text-fg-soft"> |
| 149 | {humanBytes(status.disk_used_bytes)} of {humanBytes(status.disk_cap_bytes)} <span className="text-faint">· included, no disk charge</span> |
| 150 | </p> |
| 151 | </dd> |
| 152 | </div> |
| 153 | <div className="min-w-0"> |
| 154 | <dt className="flex items-center gap-1.5 text-xs text-faint"> |
| 155 | <Cloud size={12} aria-hidden /> |
| 156 | Where it runs |
| 157 | </dt> |
| 158 | <dd className="mt-1.5 flex flex-wrap items-center gap-2"> |
| 159 | g1t cloud |
| 160 | <Hint label="Running an agent's computer on one of your own runners is coming."> |
| 161 | <Badge tone="neutral">Pin to your own runner · coming</Badge> |
| 162 | </Hint> |
| 163 | </dd> |
| 164 | </div> |
| 165 | <div className="min-w-0"> |
| 166 | <dt className="text-xs text-faint">Saved home</dt> |
| 167 | <dd className="mt-1.5 text-fg-soft"> |
| 168 | {status.snapshot_at ? ( |
| 169 | <> |
| 170 | {humanBytes(status.snapshot_bytes ?? 0)} · <TimeAgo at={status.snapshot_at} /> |
| 171 | </> |
| 172 | ) : ( |
| 173 | "Nothing saved yet" |
| 174 | )} |
| 175 | </dd> |
| 176 | </div> |
| 177 | <div className="min-w-0"> |
| 178 | <dt className="text-xs text-faint">Last awake</dt> |
| 179 | <dd className="mt-1.5 text-fg-soft">{status.last_woke_at ? <TimeAgo at={status.last_woke_at} /> : "Never"}</dd> |
| 180 | </div> |
| 181 | <div className="min-w-0"> |
| 182 | <dt className="text-xs text-faint">Last slept</dt> |
| 183 | <dd className="mt-1.5 text-fg-soft">{status.last_slept_at ? <TimeAgo at={status.last_slept_at} /> : "—"}</dd> |
| 184 | </div> |
| 185 | </dl> |
| 186 | <div className="mt-5 flex flex-wrap items-center gap-2 border-t border-line pt-4"> |
| 187 | {canManage ? ( |
| 188 | <> |
| 189 | <fetcher.Form method="post"> |
| 190 | <input type="hidden" name="intent" value="wake" /> |
| 191 | <Button type="submit" variant="accent" disabled={busy || status.state !== "asleep"}> |
| 192 | <Power size={15} /> |
| 193 | {busy && fetcher.formData?.get("intent") === "wake" ? "Waking…" : "Wake"} |
| 194 | </Button> |
| 195 | </fetcher.Form> |
| 196 | <fetcher.Form method="post"> |
| 197 | <input type="hidden" name="intent" value="sleep" /> |
| 198 | <Button type="submit" variant="outline" disabled={busy || status.state !== "awake"}> |
| 199 | <Moon size={15} /> |
| 200 | {busy && fetcher.formData?.get("intent") === "sleep" ? "Saving…" : "Put to sleep"} |
| 201 | </Button> |
| 202 | </fetcher.Form> |
| 203 | <Confirm |
| 204 | title="Reset this computer?" |
| 205 | confirm="Reset computer" |
| 206 | fields={{ intent: "reset" }} |
| 207 | fetcherKey={`computer-reset-${agent.id}`} |
| 208 | trigger={ |
| 209 | <Button type="button" variant="destructive"> |
| 210 | <RotateCcw size={15} /> |
| 211 | Reset computer |
| 212 | </Button> |
| 213 | } |
| 214 | > |
| 215 | This wipes {agent.display_name}'s home: its clones, installed tools, notes and the commands listed here. Its memory and artifacts are kept. If it is awake it is |
| 216 | stopped first. |
| 217 | </Confirm> |
| 218 | </> |
| 219 | ) : ( |
| 220 | <p className="text-sm text-muted">{agent.scope === "personal" ? "Its member" : "The workspace's owners"} wake, sleep and reset it. Sessions wake it by themselves.</p> |
| 221 | )} |
| 222 | <span className="ml-auto text-xs text-faint">Awake time is sandbox time on the workspace's usage, under this agent.</span> |
| 223 | </div> |
| 224 | </Card> |
| 225 | |
| 226 | <section aria-labelledby="commands" className="space-y-3"> |
| 227 | <div> |
| 228 | <h2 id="commands" className="text-sm font-semibold"> |
| 229 | Recent commands |
| 230 | </h2> |
| 231 | <p className="text-sm text-muted"> |
| 232 | {commands.length === 0 ? "Up to 50 of the commands it runs are kept here, newest first." : `The last ${commands.length === 1 ? "command" : `${commands.length} commands`} it ran, newest first.`} Each |
| 233 | session's page shows its own. |
| 234 | </p> |
| 235 | </div> |
| 236 | {commands.length === 0 ? ( |
| 237 | <Quiet title="Nothing run yet" icon={<Terminal size={18} />}> |
| 238 | When a session runs a command on this computer it is listed here with its output. |
| 239 | </Quiet> |
| 240 | ) : ( |
| 241 | <CommandsTable slug={slug} handle={agent.handle} commands={commands} /> |
| 242 | )} |
| 243 | </section> |
| 244 | </div> |
| 245 | ); |
| 246 | } |
| 247 | |
| 248 | /** The commands, one row each, with the output folded under the row. */ |
| 249 | function CommandsTable({ slug, handle, commands }: { slug: string; handle: string; commands: AgentComputerCommand[] }) { |
| 250 | const [open, setOpen] = useState<Set<string>>(() => new Set()); |
| 251 | const toggle = (id: string) => |
| 252 | setOpen((before) => { |
| 253 | const next = new Set(before); |
| 254 | if (next.has(id)) next.delete(id); |
| 255 | else next.add(id); |
| 256 | return next; |
| 257 | }); |
| 258 | return ( |
| 259 | <Table> |
| 260 | <TableHeader> |
| 261 | <TableRow> |
| 262 | <TableHead className="w-28">When</TableHead> |
| 263 | <TableHead className="w-32">Session</TableHead> |
| 264 | <TableHead>Command</TableHead> |
| 265 | <TableHead className="w-20 text-right">Exit</TableHead> |
| 266 | <TableHead className="w-20 text-right">Took</TableHead> |
| 267 | <TableHead className="w-24" /> |
| 268 | </TableRow> |
| 269 | </TableHeader> |
| 270 | <TableBody> |
| 271 | {commands.map((command) => { |
| 272 | const shown = open.has(command.id); |
| 273 | return [ |
| 274 | <TableRow key={command.id}> |
| 275 | <TableCell className="text-muted"> |
| 276 | <TimeAgo at={command.started_at} /> |
| 277 | </TableCell> |
| 278 | <TableCell> |
| 279 | {command.session_id ? ( |
| 280 | <Link to={`/${slug}/-/agents/${handle}/sessions/${command.session_id}`} className="font-mono text-xs hover:underline"> |
| 281 | {command.session_id.slice(0, 12)} |
| 282 | </Link> |
| 283 | ) : ( |
| 284 | <span className="text-faint">—</span> |
| 285 | )} |
| 286 | </TableCell> |
| 287 | <TableCell className="max-w-0"> |
| 288 | <span className="block truncate font-mono text-xs">{command.cmd.split("\n")[0]}</span> |
| 289 | </TableCell> |
| 290 | <TableCell className="text-right"> |
| 291 | <Badge tone={command.exit_code === 0 ? "success" : "danger"}>{command.timed_out ? "timed out" : command.exit_code}</Badge> |
| 292 | </TableCell> |
| 293 | <TableCell className="text-right tabular-nums text-muted">{took(command.duration_ms)}</TableCell> |
| 294 | <TableCell className="text-right"> |
| 295 | <Button type="button" variant="ghost" size="xs" aria-expanded={shown} aria-controls={`output-${command.id}`} onClick={() => toggle(command.id)}> |
| 296 | {shown ? <ChevronDown size={13} /> : <ChevronRight size={13} />} |
| 297 | Output |
| 298 | </Button> |
| 299 | </TableCell> |
| 300 | </TableRow>, |
| 301 | shown ? ( |
| 302 | <TableRow key={`${command.id}-output`} id={`output-${command.id}`}> |
| 303 | <TableCell colSpan={6} className="bg-bg"> |
| 304 | <p className="mb-1.5 font-mono text-xs text-faint"> |
| 305 | $ {command.cmd} <span className="text-faint">· in {command.cwd}</span> |
| 306 | </p> |
| 307 | <pre className="max-h-96 overflow-auto rounded-md border border-line bg-surface px-3 py-2 font-mono text-xs whitespace-pre-wrap text-muted">{command.output || "(no output)"}</pre> |
| 308 | {command.truncated && <p className="mt-1.5 text-xs text-faint">The output was cut.</p>} |
| 309 | </TableCell> |
| 310 | </TableRow> |
| 311 | ) : null, |
| 312 | ]; |
| 313 | })} |
| 314 | </TableBody> |
| 315 | </Table> |
| 316 | ); |
| 317 | } |