Skip to content
202 linesCodeBlameRaw
1import { ChevronRight, History } from "lucide-react";
2import { useState } from "react";
3import { Form, data, redirect, useNavigation, useOutletContext } from "react-router";
4
5import type { AgentVersion, WorkspaceAgent } from "@g1t/contracts";
6
7import type { Route } from "./+types/profile";
8import { AgentForm } from "../../../components/agents-mode";
9import { readOrNull } from "../../../components/agents/actions.server";
10import { versionChanges } from "../../../components/agents/format";
11import { TimeAgo } from "../../../components/ui";
12import { isOrchestrator } from "../../../components/orchestrator";
13import { readAgentForm } from "../../../lib/agent-form";
14import { identity, workspaceAgents } from "../../../lib/services.server";
15import { assertSameOrigin, requireUser, roleIn } from "../../../lib/session.server";
16
17/** The workspace's teams, to put the agent on one, and its saved versions. */
18export async function loader({ params, context, request }: Route.LoaderArgs) {
19 const viewer = requireUser(context, request);
20 const [teams, versions] = await Promise.all([
21 identity.listTeams(viewer, params.owner).catch(() => null),
22 readOrNull(workspaceAgents.versions(params.owner.toLowerCase(), params.handle.toLowerCase(), viewer)),
23 ]);
24 return { teams: teams?.ok ? teams.value.map((team) => ({ slug: team.slug, name: team.name })) : [], versions };
25}
26
27/** Saving makes a new version; every run records which one it ran. */
28export async function action({ params, context, request }: Route.ActionArgs) {
29 assertSameOrigin(request);
30 const viewer = requireUser(context, request);
31 const slug = params.owner.toLowerCase();
32 if (!roleIn(viewer, slug)) throw data(null, { status: 404 });
33 const form = await request.formData();
34 // Archiving keeps the agent's history (runs, replies, spend) but takes it
35 // out of chat and frees its handle. g1t itself can't be archived.
36 if (form.get("intent") === "archive") {
37 const archived = await workspaceAgents.archive(slug, params.handle.toLowerCase(), viewer).catch(() => null);
38 if (!archived) return { errors: { form: "The agents service didn't answer. Try again in a moment." }, saved: false };
39 if (!archived.ok) return { errors: { form: archived.error.message }, saved: false };
40 throw redirect(`/${slug}/-/agents`);
41 }
42 const read = readAgentForm(form, { orchestrator: params.handle.toLowerCase() === "g1t" });
43 if (!read.ok) return { errors: read.errors, saved: false };
44 const saved = await workspaceAgents.update(slug, params.handle.toLowerCase(), viewer, read.input).catch(() => null);
45 if (!saved) return { errors: { form: "The agents service didn't answer. Try again in a moment." }, saved: false };
46 if (!saved.ok) return { errors: { [saved.error.code === "conflict" ? "handle" : "form"]: saved.error.message }, saved: false };
47 // A new handle is a new address.
48 if (saved.value.handle !== params.handle.toLowerCase()) throw redirect(`/${slug}/-/agents/${saved.value.handle}/profile`);
49 return { errors: undefined, saved: true };
50}
51
52export default function Profile({ loaderData, actionData }: Route.ComponentProps) {
53 const agent = useOutletContext<WorkspaceAgent>();
54 const errors = actionData?.errors as Record<string, string> | undefined;
55 return (
56 <>
57 {actionData?.saved && (
58 <p role="status" className="mb-6 rounded-lg border border-success/30 bg-success/10 px-4 py-2.5 text-sm text-success">
59 Saved as version {agent.version}.
60 </p>
61 )}
62 {errors?.form && <p className="mb-6 rounded-lg border border-danger/40 bg-danger/10 px-4 py-3 text-sm text-danger">{errors.form}</p>}
63 <AgentForm
64 draft={agent}
65 errors={errors}
66 submit="Save changes"
67 intent="update"
68 formKey={`${agent.id}:${agent.version}`}
69 locked={isOrchestrator(agent)}
70 teams={loaderData.teams}
71 seed={agent.avatar_seed || agent.id}
72 />
73 {loaderData.versions && loaderData.versions.length > 0 && <Versions versions={loaderData.versions} current={agent.version} />}
74 {!isOrchestrator(agent) && <Archive name={agent.display_name} />}
75 </>
76 );
77}
78
79/** Retiring an agent: asked twice, since it leaves every channel at once. */
80function Archive({ name }: { name: string }) {
81 const [asking, setAsking] = useState(false);
82 const busy = useNavigation().formData?.get("intent") === "archive";
83 return (
84 <section className="mt-12 border-t border-line pt-8 md:grid md:grid-cols-[14rem_1fr] md:gap-10">
85 <div className="mb-4 md:mb-0">
86 <h2 className="text-sm font-semibold text-fg">Archive</h2>
87 <p className="mt-1 text-sm text-muted">Retire this agent. Only owners can.</p>
88 </div>
89 <div>
90 <p className="text-sm text-muted">
91 {name} stops answering, leaves every channel and DM, and its handle is free to use again. Its runs, replies and spend stay in
92 the audit log and on Usage.
93 </p>
94 {asking ? (
95 <Form method="post" className="mt-4 flex flex-wrap items-center gap-3">
96 <input type="hidden" name="intent" value="archive" />
97 <button
98 type="submit"
99 disabled={busy}
100 className="rounded-lg border border-danger/50 bg-danger/15 px-4 py-2 text-sm font-medium text-danger hover:bg-danger/25 disabled:opacity-60"
101 >
102 {busy ? "Archiving…" : `Archive ${name}`}
103 </button>
104 <button type="button" onClick={() => setAsking(false)} className="px-2 py-2 text-sm text-muted hover:text-fg">
105 Keep {name}
106 </button>
107 </Form>
108 ) : (
109 <button
110 type="button"
111 onClick={() => setAsking(true)}
112 className="mt-4 rounded-lg border border-line px-4 py-2 text-sm text-muted hover:border-danger/50 hover:text-danger"
113 >
114 Archive agent…
115 </button>
116 )}
117 </div>
118 </section>
119 );
120}
121
122/**
123 * Every saved version of the agent, newest first: who saved it, when, and
124 * what changed from the one before. Each run and reply records the version
125 * it ran with.
126 */
127function Versions({ versions, current }: { versions: AgentVersion[]; current: number }) {
128 const [open, setOpen] = useState(false);
129 const sorted = [...versions].sort((a, b) => b.version - a.version);
130 const shown = open ? sorted : sorted.slice(0, 3);
131 return (
132 <section className="mt-12 border-t border-line pt-8 md:grid md:grid-cols-[14rem_1fr] md:gap-10">
133 <div className="mb-4 md:mb-0">
134 <h2 className="flex items-center gap-2 text-sm font-semibold text-fg">
135 <History size={14} className="text-faint" />
136 Version history
137 </h2>
138 <p className="mt-1 text-sm text-muted">Every save is a new version. Sessions and replies record the one they ran with.</p>
139 </div>
140 <div>
141 <ol className="divide-y divide-line/60 overflow-hidden rounded-xl border border-line bg-surface">
142 {shown.map((version) => {
143 const before = sorted.find((v) => v.version < version.version) ?? null;
144 const definition = version.definition as Record<string, unknown>;
145 return (
146 <li key={version.version} className="px-4 py-3">
147 <details className="group">
148 <summary className="flex cursor-pointer list-none items-center gap-3 text-sm [&::-webkit-details-marker]:hidden">
149 <ChevronRight size={14} className="shrink-0 text-faint transition-transform group-open:rotate-90" />
150 <span className="font-medium tabular-nums">Version {version.version}</span>
151 {version.version === current && <span className="rounded-full bg-accent/15 px-1.5 py-px text-[0.6875rem] font-medium text-accent">Current</span>}
152 <span className="min-w-0 grow truncate text-muted">
153 {before ? versionChanges(before.definition as Record<string, unknown>, definition) : version.version === 1 ? "Created" : "Saved"}
154 </span>
155 <span className="shrink-0 text-xs text-faint">
156 @{version.changed_by} · <TimeAgo at={version.created_at} />
157 </span>
158 </summary>
159 <dl className="mt-3 grid gap-x-6 gap-y-2 pl-7 text-xs sm:grid-cols-2">
160 {summaryOf(definition).map(([label, value]) => (
161 <div key={label} className="min-w-0">
162 <dt className="text-faint">{label}</dt>
163 <dd className="mt-0.5 line-clamp-3 text-fg-soft">{value}</dd>
164 </div>
165 ))}
166 </dl>
167 </details>
168 </li>
169 );
170 })}
171 </ol>
172 {sorted.length > 3 && (
173 <button type="button" onClick={() => setOpen(!open)} className="mt-3 text-sm text-muted hover:text-fg">
174 {open ? "Show fewer" : `Show all ${sorted.length} versions`}
175 </button>
176 )}
177 </div>
178 </section>
179 );
180}
181
182/** A saved definition, in a few lines. */
183function summaryOf(definition: Record<string, unknown>): [string, string][] {
184 const out: [string, string][] = [];
185 const text = (value: unknown) => (typeof value === "string" && value.trim() ? value.trim() : null);
186 const name = text(definition.display_name);
187 if (name) out.push(["Name", `${name}${text(definition.handle) ? ` (@${definition.handle})` : ""}`]);
188 const title = text(definition.title) ?? text(definition.role);
189 if (title) out.push(["Role", title]);
190 const job = text(definition.instructions);
191 if (job) out.push(["Job", job]);
192 const voice = text(definition.personality_preset);
193 if (voice) out.push(["Personality", [voice, text(definition.personality)].filter(Boolean).join(" · ")]);
194 const budget = definition.budget as { monthly_micros?: number | null; task_micros?: number | null } | undefined;
195 if (budget && (budget.monthly_micros != null || budget.task_micros != null)) {
196 const dollars = (m: number) => `$${(m / 1_000_000).toLocaleString("en-US", { maximumFractionDigits: 2 })}`;
197 out.push(["Budget", [budget.monthly_micros != null ? `${dollars(budget.monthly_micros)} a month` : null, budget.task_micros != null ? `${dollars(budget.task_micros)} a session` : null].filter(Boolean).join(", ")]);
198 }
199 const duties = Array.isArray(definition.responsibilities) ? (definition.responsibilities as string[]) : [];
200 if (duties.length > 0) out.push(["Responsibilities", duties.join("; ")]);
201 return out;
202}