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