Skip to content
317 linesCodeBlameRaw
1import { ChevronDown, ChevronRight, Cloud, HardDrive, Moon, Power, RotateCcw, Terminal } from "lucide-react";
2import { useState } from "react";
3import { Link, useFetcher, useOutletContext } from "react-router";
4
5import type { AgentComputerCommand, AgentComputerState, AgentComputerView, WorkspaceAgent } from "@g1t/contracts";
6
7import type { Route } from "./+types/computer";
8import { agentsAction, answer } from "../../../components/agents/actions.server";
9import { type ActionResult, Confirm } from "../../../components/agents/dialogs";
10import { Quiet } from "../../../components/agents/parts";
11import { TimeAgo } from "../../../components/ui";
12import { Alert, AlertDescription } from "../../../components/ui/alert";
13import { Badge, type BadgeTone } from "../../../components/ui/badge";
14import { Button } from "../../../components/ui/button";
15import { Card } from "../../../components/ui/card";
16import { Hint } from "../../../components/ui/hint";
17import { Progress } from "../../../components/ui/progress";
18import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../../components/ui/table";
19import { page } from "../../../lib/meta";
20import { useRefreshWhile } from "../../../lib/refresh";
21import { workspaceAgents } from "../../../lib/services.server";
22import { requireUser, roleIn } from "../../../lib/session.server";
23
24export 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. */
29export 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. */
38export 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
48const 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. */
56export 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. */
64export 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 */
76export 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&apos;s disk isn&apos;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}&apos;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&apos;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&apos;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. */
249function 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}