| 1 | import { MessageSquare } from "lucide-react"; |
| 2 | import { Form, Outlet, data, redirect, useNavigation } from "react-router"; |
| 3 | |
| 4 | import type { WorkspaceAgent } from "@g1t/contracts"; |
| 5 | |
| 6 | import type { Route } from "./+types/agent"; |
| 7 | import { AgentFace } from "../../../components/agents-mode"; |
| 8 | import { isOrchestrator } from "../../../components/orchestrator"; |
| 9 | import { AgentPill, StatusDot, statusLabel } from "../../../components/chat/marks"; |
| 10 | import { TabLink } from "../../../components/ui"; |
| 11 | import { channelPath } from "../../../lib/chat"; |
| 12 | import { page } from "../../../lib/meta"; |
| 13 | import { chat, workspaceAgents } from "../../../lib/services.server"; |
| 14 | import { assertSameOrigin, requireUser, roleIn } from "../../../lib/session.server"; |
| 15 | |
| 16 | export 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. */ |
| 22 | export 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. */ |
| 31 | export 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. */ |
| 45 | export 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 | |
| 66 | export 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 | } |