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