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