Skip to content
119 linesCodeBlameRaw
1import { History, Lock, MessageSquare, Workflow } from "lucide-react";
2import { Link, data, useOutletContext } from "react-router";
3
4import type { AgentActivity, WorkspaceAgent } from "@g1t/contracts";
5
6import type { Route } from "./+types/activity";
7import { readOrNull } from "../../../components/agents/actions.server";
8import { Quiet, StatusChip } from "../../../components/agents/parts";
9import { TimeAgo } from "../../../components/ui";
10import { Card } from "../../../components/ui/card";
11import { channelPath } from "../../../lib/chat";
12import { workspaceAgents } from "../../../lib/services.server";
13import { requireUser, roleIn } from "../../../lib/session.server";
14import { money } from "../../../lib/money";
15
16/** Everything the agent did lately: its replies and its sessions, newest first. */
17export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ activity: AgentActivity[] | null }> {
18 const viewer = requireUser(context, request);
19 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
20 return { activity: await readOrNull(workspaceAgents.activity(params.owner.toLowerCase(), params.handle.toLowerCase(), viewer)) };
21}
22
23const REPLY_STATUS: Record<string, string> = { working: "Writing…", failed: "Failed", blocked: "Held back: budget or access" };
24
25export default function ActivityTab({ loaderData, params }: Route.ComponentProps) {
26 const agent = useOutletContext<WorkspaceAgent>();
27 const { activity } = loaderData;
28 if (!activity) return <Quiet title="Activity can't be shown right now">The agents service didn&apos;t answer. Reload in a moment.</Quiet>;
29 if (activity.length === 0) {
30 return (
31 <Quiet title="Nothing yet" icon={<History size={20} />}>
32 Every reply and session {agent.display_name} takes on shows here, with who asked, the model and what it cost.{" "}
33 <Link to={`/${params.owner}/-/audit`} className="text-accent hover:underline">
34 The audit log
35 </Link>{" "}
36 has every tool call.
37 </Quiet>
38 );
39 }
40 return (
41 <div className="space-y-4">
42 <Card asChild className="divide-y divide-line/60 overflow-hidden">
43 <ul>
44 {activity.map((item) => (
45 <Item key={`${item.kind}:${item.id}`} item={item} slug={params.owner} handle={agent.handle} />
46 ))}
47 </ul>
48 </Card>
49 <p className="text-xs text-faint">
50 The latest replies and sessions. Every tool call is in the{" "}
51 <Link to={`/${params.owner}/-/audit`} className="text-muted hover:text-fg hover:underline">
52 audit log
53 </Link>
54 .
55 </p>
56 </div>
57 );
58}
59
60function Item({ item, slug, handle }: { item: AgentActivity; slug: string; handle: string }) {
61 const where = item.visible ? (item.channel_name ? `#${item.channel_name}` : "A direct message") : null;
62 const href = !item.visible
63 ? null
64 : item.kind === "session"
65 ? `/${slug}/-/agents/${handle}/sessions/${item.id}`
66 : item.ref
67 ? `${channelPath(slug, { id: item.channel_id, kind: item.channel_name ? "channel" : "dm", name: item.channel_name })}?thread=${encodeURIComponent(item.ref)}`
68 : null;
69 const headline = !item.visible ? (
70 <span className="inline-flex items-center gap-1.5 text-muted italic">
71 <Lock size={12} className="not-italic" />
72 In a conversation you&apos;re not in
73 </span>
74 ) : item.kind === "session" ? (
75 <span className="font-medium">{item.title ?? "A session"}</span>
76 ) : (
77 <span>
78 Replied{item.asked_by_username ? <> to @{item.asked_by_username}</> : null} in {where}
79 </span>
80 );
81 return (
82 <li className="relative flex items-start gap-3 px-4 py-3 transition-colors hover:bg-raised/40">
83 <span className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-lg bg-raised text-muted">
84 {item.kind === "session" ? <Workflow size={14} /> : <MessageSquare size={14} />}
85 </span>
86 <div className="min-w-0 grow">
87 <p className="flex flex-wrap items-center gap-2 text-sm">
88 {href ? (
89 <Link to={href} className="min-w-0 truncate after:absolute after:inset-0 hover:underline">
90 {headline}
91 </Link>
92 ) : (
93 headline
94 )}
95 {item.kind === "session" ? (
96 <StatusChip status={item.status} />
97 ) : (
98 item.status !== "replied" && <span className="text-xs text-faint">{REPLY_STATUS[item.status] ?? item.status.replaceAll("_", " ")}</span>
99 )}
100 </p>
101 <p className="mt-0.5 flex flex-wrap gap-x-1.5 text-xs text-faint">
102 <span>{item.kind === "session" ? "Session" : "Reply"}</span>
103 {item.kind === "session" && where && <span>· {where}</span>}
104 {item.kind === "session" && item.visible && item.asked_by_username && <span>· asked by @{item.asked_by_username}</span>}
105 {item.model && <span className="font-mono">· {item.model}</span>}
106 {item.tools > 0 && (
107 <span>
108 · {item.tools} {item.tools === 1 ? "tool" : "tools"}
109 </span>
110 )}
111 <span>
112 · <TimeAgo at={item.created_at} />
113 </span>
114 </p>
115 </div>
116 <span className="shrink-0 text-sm tabular-nums">{money(item.charged_micros)}</span>
117 </li>
118 );
119}