Fork Horse repository, listed by its owner. Publisher snapshot.

packages/forkhorse-dashboard/src/agent-status.tsx

Snapshot at 34e3175

Containing directory

8,253 bytes · textView raw
1import { useEffect, useRef, useState } from "react";
2import { Badge, Button, LayerCard, Table, Tooltip } from "@cloudflare/kumo";
3import type { Activity, Brand, ProjectState } from "./contracts.ts";
4import { agentStatusRows, type AgentStatusRow } from "./agent-status-model.ts";
5import { clockTime, oldestLoadedActivity, packetPushes, timestamp } from "./fork-graph-model.ts";
6import { Saddlecloth } from "./lane.tsx";
7import { relativeTime } from "./time.ts";
8import { agentUsageToday } from "./usage.ts";
9import { UsageFigures } from "./usage.tsx";
10import { FirstRunEmpty } from "./empty.tsx";
11
12function useNow() {
13  const [now, setNow] = useState(Date.now);
14  useEffect(() => {
15    const timer = setInterval(() => setNow(Date.now()), 30_000);
16    return () => clearInterval(timer);
17  }, []);
18  return now;
19}
20export function AgentStatusBadge({ row }: { row: AgentStatusRow }) {
21  return row.status ? <Badge appearance="dot" variant="neutral" className={`status-dot status-${row.status.tone}`}>
22    {row.status.label}
23  </Badge> : <Badge variant="secondary">Idle</Badge>;
24}
25function LastPush({ row, now, stacked = false }: { row: AgentStatusRow; now: number; stacked?: boolean }) {
26  return row.lastPush ? <Tooltip content={new Date(row.lastPush.recordedAt).toLocaleString()}
27    render={<span className={`agent-last-push${stacked ? " agent-last-push-stacked" : ""}`} />}>
28    <code>{row.lastPush.after.slice(0, 7)}</code>{!stacked && " · "}<time className={stacked ? "caption muted" : undefined} dateTime={row.lastPush.recordedAt}>{relativeTime(row.lastPush.recordedAt, now)}</time>
29  </Tooltip> : <span className="agent-missing-push caption muted">{row.missingPush}</span>;
30}
31function Fork({ value }: { value?: string }) {
32  return value ? <Tooltip content={value} render={<code className="truncated-code" />}>{value}</Tooltip> : null;
33}
34export function AgentStatusTable({ state, activity, brand, owner, openPacket, revealRepo }: {
35  state: ProjectState; activity: Activity[]; brand: Brand; owner: boolean; openPacket(id: string): void;
36  revealRepo?: string;
37}) {
38  const now = useNow();
39  const [expanded, setExpanded] = useState(false);
40  const rows = agentStatusRows(state, activity, now);
41  const live = rows.filter((row) => row.status);
42  const idle = rows.filter((row) => !row.status);
43  const section = useRef<HTMLElement>(null);
44  const revealedFork = state.forks.find((fork) => fork.repoName === revealRepo);
45  // Needs you can target an idle fork; reveal that disclosure before moving focus.
46  useEffect(() => {
47    if (revealedFork && !state.claims.some((claim) => claim.active && claim.agent === revealedFork.agent))
48      setExpanded(true);
49  }, [revealRepo, revealedFork, state.claims]);
50  useEffect(() => {
51    if (!revealRepo) return;
52    const row = [...(section.current?.querySelectorAll<HTMLElement>("[data-agent-repo]") ?? [])]
53      .find((entry) => entry.dataset.agentRepo === revealRepo);
54    row?.focus();
55    row?.scrollIntoView({ block: "nearest" });
56  }, [revealRepo, expanded]);
57  const table = (items: AgentStatusRow[], labelledBy = "agents-heading") => <div className="table-scroll table-agents table-agent-status">
58    <Table aria-labelledby={labelledBy}>
59      <Table.Header><Table.Row>{["Agent", "Status", "Packet", "Last push", "Waiting on", "Tokens"].map((heading) =>
60        <Table.Head key={heading} className={`column-${heading.toLowerCase().replaceAll(" ", "-")}`}>{heading}</Table.Head>)}</Table.Row></Table.Header>
61      <Table.Body>{items.map((row) => <Table.Row key={row.agent.name} data-agent={row.agent.name}
62        data-agent-repo={revealedFork?.agent === row.agent.name ? revealedFork.repoName : row.fork?.repoName} tabIndex={-1}>
63        <Table.Cell>
64          <span className="agent-status-name"><Saddlecloth identity={row.identity} />
65            <Tooltip content={row.agent.name} render={<span className="truncated-name" />}>{row.agent.name}</Tooltip>
66          </span>
67          <div className="caption muted agent-fork-caption"><Fork value={revealedFork?.agent === row.agent.name ? revealedFork.repoName : row.fork?.repoName} /></div>
68          <div className="caption muted folded">
69            <Fork value={revealedFork?.agent === row.agent.name ? revealedFork.repoName : row.fork?.repoName} />
70            <UsageFigures usage={agentUsageToday(state, row.agent.name)} agent={row.agent.name} brand={brand} />
71          </div>
72          {!row.agent.createdAt && <Badge variant="secondary">Legacy</Badge>}
73        </Table.Cell>
74        <Table.Cell><AgentStatusBadge row={row} />{row.status?.quietSince !== undefined &&
75          <p className="caption muted">No push since {clockTime(row.status.quietSince)} ({Math.max(0, Math.floor((now - row.status.quietSince) / 60_000))} min)</p>}
76        </Table.Cell>
77        <Table.Cell>{row.status ? <>
78          <Tooltip content={row.status.claim.packet} render={<button type="button" className="claim-link agent-packet-link"
79            aria-label={`View packet ${row.status.claim.packet}`} onClick={() => openPacket(row.status!.claim.packet)} />}>
80            {row.status.claim.packet}
81          </Tooltip>
82          {row.claims.length > 1 && <Tooltip content={row.claims.slice(1).map((entry) => entry.claim.packet).join(", ")}
83            render={<span className="caption muted" />}>+{row.claims.length - 1} more</Tooltip>}
84        </> : "—"}</Table.Cell>
85        <Table.Cell><LastPush row={row} now={now} stacked /></Table.Cell>
86        <Table.Cell>{row.status ? <>
87          {row.status.waiting === "owner" ? owner ? "You" : "the owner" : row.status.waiting === "checks" ? "Checks" : row.agent.name}
88          {row.status.caption && (owner || row.status.waiting === "agent") && <p className="caption muted">{row.status.caption}</p>}
89        </> : "—"}</Table.Cell>
90        <Table.Cell className="column-tokens"><UsageFigures usage={agentUsageToday(state, row.agent.name)} agent={row.agent.name} brand={brand} /></Table.Cell>
91      </Table.Row>)}</Table.Body>
92    </Table>
93  </div>;
94  return <section id="lanes" ref={section}>
95    <h2 id="agents-heading" tabIndex={-1}>Agents</h2>
96    {rows.length > 0 && <p className="caption muted">Cumulative usage from the latest reports received today, per packet and fork. These snapshots do not measure tokens consumed today.</p>}
97    {live.length > 0 && table(live)}
98    {idle.length > 0 && <>
99      <Button id="idle-agents-toggle" variant="ghost" size="sm" aria-expanded={expanded} aria-controls="idle-agents" onClick={() => setExpanded(!expanded)}>Idle agents ({idle.length})</Button>
100      {expanded && <div id="idle-agents">{table(idle, "agents-heading idle-agents-toggle")}</div>}
101    </>}
102    {!rows.length && <FirstRunEmpty kind="agents" owner={owner} />}
103  </section>;
104}
105
106/** Reduced FH-078 card: caller identity and public /state only, never inferred next actions. */
107export function ResumeCard({ state, activity, agent }: { state: ProjectState; activity: Activity[]; agent?: string }) {
108  const now = useNow();
109  if (!agent) return null;
110  const row = agentStatusRows(state, activity, now).find((entry) => entry.agent.name === agent);
111  if (!row) return null;
112  // The card names one assignment and fork; never borrow another fork's last push.
113  const lastPush = row.status ? packetPushes(activity, row.status.claim.repoName, state.project.defaultBranch,
114    timestamp(row.status.claim.createdAt) ?? 0, now).toSorted((a, b) =>
115    (timestamp(b.recordedAt) ?? 0) - (timestamp(a.recordedAt) ?? 0) || b.sequence - a.sequence)[0] : undefined;
116  const packet = state.packets.find((entry) => entry.id === row.status?.claim.packet);
117  const missingPush = (timestamp(row.status?.claim.createdAt) ?? -Infinity) > oldestLoadedActivity(activity)
118    ? "No push yet" as const : "Not in loaded activity" as const;
119  return <LayerCard className="p-4 resume-card">
120    <h3>Resume where you left off</h3>
121    {row.status ? <dl>
122      <dt>Packet</dt><dd><code>{row.status.claim.packet}</code>{packet && ` · ${packet.title}`}</dd>
123      <dt>Fork</dt><dd><Fork value={row.status.claim.repoName} /></dd>
124      <dt>Last push</dt><dd><LastPush row={{ ...row, lastPush, missingPush }} now={now} /></dd>
125      <dt>Status</dt><dd><AgentStatusBadge row={row} /></dd>
126    </dl> : <p>No live claim.</p>}
127  </LayerCard>;
128}
129