Skip to content
141 linesCodeBlameRaw
1import { Lock, MessageSquare } from "lucide-react";
2import { Form, Outlet, data, redirect, useNavigation } from "react-router";
3
4import type { WorkspaceAgent } from "@g1t/contracts";
5
6import type { Route } from "./+types/agent";
7import { AgentFace } from "../../../components/agents-mode";
8import { isOrchestrator } from "../../../components/orchestrator";
9import { AgentPill, StatusDot, statusLabel } from "../../../components/chat/marks";
10import { TabLink } from "../../../components/ui";
11import { Button } from "../../../components/ui/button";
12import { Badge } from "../../../components/ui/badge";
13import { Card } from "../../../components/ui/card";
14import { channelPath } from "../../../lib/chat";
15import { page } from "../../../lib/meta";
16import { chat, workspaceAgents } from "../../../lib/services.server";
17import { assertSameOrigin, requireUser, roleIn } from "../../../lib/session.server";
18
19export function meta({ loaderData, params, ...args }: Route.MetaArgs) {
20 const name = loaderData?.agent?.display_name ?? `@${params.handle}`;
21 return page(args, { title: `${name} · Agents · ${params.owner} · g1t` });
22}
23
24/** One agent: who it is, for its tabs. Null when the agents service does not answer. */
25export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ agent: WorkspaceAgent | null; viewerId: string }> {
26 const viewer = requireUser(context, request);
27 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
28 const found = await workspaceAgents.get(params.owner.toLowerCase(), params.handle.toLowerCase(), viewer).catch(() => null);
29 if (found && !found.ok && found.error.code === "not_found") throw data(null, { status: 404 });
30 return { agent: found?.ok ? found.value : null, viewerId: viewer.id };
31}
32
33/** Message: the direct message with this agent, opened or made. */
34export async function action({ params, context, request }: Route.ActionArgs) {
35 assertSameOrigin(request);
36 const viewer = requireUser(context, request);
37 const slug = params.owner.toLowerCase();
38 if (!roleIn(viewer, slug)) throw data(null, { status: 404 });
39 const form = await request.formData();
40 if (form.get("intent") !== "message") return { error: "Unknown request." };
41 const id = String(form.get("agent") ?? "");
42 const dm = await chat.openDm(slug, viewer, [{ kind: "agent", id }]).catch(() => null);
43 if (!dm?.ok) return { error: dm ? dm.error.message : "Chat didn't answer. Try again in a moment." };
44 throw redirect(channelPath(slug, dm.value));
45}
46
47/** The button that opens the direct message with an agent. */
48export function MessageAgent({ slug, agent, variant = "outline" }: { slug: string; agent: WorkspaceAgent; variant?: "outline" | "accent" }) {
49 const navigation = useNavigation();
50 const busy = navigation.state !== "idle" && navigation.formData?.get("intent") === "message";
51 return (
52 <Form method="post" action={`/${slug}/-/agents/${agent.handle}`}>
53 <input type="hidden" name="intent" value="message" />
54 <input type="hidden" name="agent" value={agent.id} />
55 <Button type="submit" disabled={busy} variant={variant}>
56 <MessageSquare size={15} />
57 {busy ? "Opening…" : "Message"}
58 </Button>
59 </Form>
60 );
61}
62
63export default function AgentPage({ loaderData, params }: Route.ComponentProps) {
64 const { agent } = loaderData;
65 const base = `/${params.owner}/-/agents/${params.handle}`;
66 if (!agent) {
67 return (
68 <Card tone="plain" className="border-dashed px-6 py-14 text-center">
69 <p className="font-medium">@{params.handle} can't be shown right now</p>
70 <p className="mt-1.5 text-sm text-muted">The agents service didn't answer. It's usually back within a minute; reload to try again.</p>
71 </Card>
72 );
73 }
74 return (
75 <div>
76 <header className="flex flex-wrap items-start gap-4">
77 <span className="relative">
78 <AgentFace agent={{ ...agent, builtin: isOrchestrator(agent) }} size={56} />
79 <StatusDot status={agent.status} className="absolute -right-0.5 -bottom-0.5 size-3 ring-[3px] ring-bg" />
80 </span>
81 <div className="min-w-0 grow">
82 <div className="flex flex-wrap items-center gap-2">
83 <h1 className="truncate text-2xl font-semibold tracking-tight">{agent.display_name}</h1>
84 <AgentPill />
85 {agent.scope === "personal" && (
86 <Badge>
87 <Lock size={11} />
88 {agent.personal_owner_id === loaderData.viewerId ? "Yours" : `@${agent.personal_owner ?? "a member"}'s`}
89 </Badge>
90 )}
91 </div>
92 <p className="mt-0.5 text-sm text-muted">
93 <span className="font-mono">@{agent.handle}</span>
94 <span className="mx-1.5 text-line-strong">·</span>
95 {agent.role}
96 </p>
97 <p className="mt-1.5 flex items-center gap-1.5 text-xs text-faint">
98 <StatusDot status={agent.status} />
99 {statusLabel(agent.status)}
100 <span className="text-line-strong">·</span>
101 Version {agent.version}
102 </p>
103 </div>
104 {/* Only its member talks to a personal agent. */}
105 {(agent.scope !== "personal" || agent.personal_owner_id === loaderData.viewerId) && <MessageAgent slug={params.owner} agent={agent} variant="accent" />}
106 </header>
107 <nav aria-label={`${agent.display_name}'s pages`} className="mt-8 flex gap-1 overflow-x-auto border-b border-line [scrollbar-width:none]">
108 <TabLink to={base} end also={`${base}/sessions`} icon={null}>
109 Sessions
110 </TabLink>
111 <TabLink to={`${base}/skills`} icon={null}>
112 Skills
113 </TabLink>
114 <TabLink to={`${base}/abilities`} icon={null}>
115 Abilities
116 </TabLink>
117 <TabLink to={`${base}/computer`} icon={null}>
118 Computer
119 </TabLink>
120 <TabLink to={`${base}/memory`} icon={null}>
121 Memory
122 </TabLink>
123 <TabLink to={`${base}/routines`} icon={null}>
124 Routines
125 </TabLink>
126 <TabLink to={`${base}/spend`} icon={null}>
127 Spend
128 </TabLink>
129 <TabLink to={`${base}/activity`} icon={null}>
130 Activity
131 </TabLink>
132 <TabLink to={`${base}/profile`} icon={null}>
133 Profile
134 </TabLink>
135 </nav>
136 <div className="pt-8">
137 <Outlet context={agent} />
138 </div>
139 </div>
140 );
141}