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