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

packages/forkhorse-dashboard/src/race-usage.tsx

Snapshot at 34e3175

Containing directory

7,569 bytes · textView raw
1import { assertDetail } from "./review.tsx";
2import { useEffect, useState, type ReactNode } from "react";
3import type { Brand, ProjectState, Race, ReviewDetail } from "./contracts.ts";
4import { entrantChecks, raceEntrants, raceStart, raceSplit } from "./race-model.ts";
5import { oldestLoadedActivity, packetPushes, timestamp } from "./fork-graph-model.ts";
6import { BudgetUsage } from "./usage.tsx";
7import { totalTokens } from "./usage.ts";
8import { agentIdentities, Saddlecloth, type AgentIdentity } from "./lane.tsx";
9
10const elapsed = (start: number | undefined, end?: string | null) => {
11  const at = Date.parse(end ?? "");
12  return start !== undefined && Number.isFinite(start) && Number.isFinite(at) && at >= start
13    ? at - start
14    : undefined;
15};
16const best = (values: (number | undefined)[], index: number) =>
17  values.length > 1 &&
18  values.every((value) => value !== undefined) &&
19  new Set(values).size > 1 &&
20  values[index] === Math.min(...(values as number[]));
21
22function CompareCell({ agent, identity, children }: { agent: string; identity?: AgentIdentity; children: ReactNode }) {
23  return <td>
24    <span className="compare-entrant" aria-hidden="true"><Saddlecloth identity={identity} /><span title={agent}>{agent}</span></span>
25    <span className="compare-value">{children}</span>
26  </td>;
27}
28
29/** Facts below the accepted FH-055 track, using its claim order and recorded race clock. */
30export function RaceCompare({
31  race: rawRace,
32  state,
33  brand,
34  getReview,
35  advice,
36}: {
37  race: Race;
38  state: ProjectState;
39  brand: Brand;
40  advice?: ReactNode;
41  getReview?: (id: string) => Promise<ReviewDetail>;
42}) {
43  const race = { ...rawRace, entrants: raceEntrants(rawRace) };
44  const reviews = race.entrants.map((entrant) =>
45    state.reviews
46      .filter(
47        (r) =>
48          r.packet === race.packet && r.repoName === entrant.repoName && r.agent === entrant.agent,
49      )
50      .toSorted((a, b) => (b.requestedAt ?? "").localeCompare(a.requestedAt ?? ""))
51      .at(0),
52  );
53  const keys = JSON.stringify(reviews.map((r) => (r ? [r.id, r.sha] : null)));
54  const [details, setDetails] = useState<Record<string, ReviewDetail>>({});
55  useEffect(() => {
56    let active = true;
57    setDetails({});
58    if (getReview)
59      void Promise.allSettled(
60        reviews
61          .filter((r) => !!r)
62          .map(async (review) => {
63            const detail = await getReview(review.id);
64            assertDetail(detail, review.id);
65            if (
66              active &&
67              detail.review.id === review.id &&
68              detail.review.sha === review.sha &&
69              Number.isSafeInteger(detail.changes.fileCount)
70            )
71              setDetails((loaded) => ({ ...loaded, [review.id]: detail }));
72          }),
73      );
74    return () => {
75      active = false;
76    };
77    // Exact review identities trigger reads; host action objects need not be stable.
78    // eslint-disable-next-line react-hooks/exhaustive-deps
79  }, [keys, getReview]);
80  const start = raceStart(race);
81  const activity = state.recentActivity ?? [];
82  const oldestLoaded = oldestLoadedActivity(activity);
83  const pushesAvailable = oldestLoaded !== Infinity && race.entrants.every((entrant) => {
84    const claimed = timestamp(entrant.claimedAt);
85    return claimed !== undefined && oldestLoaded <= claimed;
86  });
87  const now = Date.now();
88  const approved = race.entrants.map((e) => elapsed(start, e.approvedAt));
89  const promoted = race.entrants.map((e) => elapsed(start, e.promotedAt));
90  const changes = reviews.map((r) => (r ? details[r.id]?.changes.fileCount : undefined));
91  const tokenValues = (source: "reported" | "metered") =>
92    race.entrants.map((e) => (e.usage?.[source] ? totalTokens(e.usage[source]!) : undefined));
93  const tokens = (index: number, compare = true) => {
94    const e = race.entrants[index];
95    return (
96      <>
97        <BudgetUsage
98          usage={e.usage}
99          budget={race.tokenBudget}
100          status={e.budget}
101          agent={e.agent}
102          brand={brand}
103        />
104        {compare &&
105          (["reported", "metered"] as const).map(
106            (source) =>
107              best(tokenValues(source), index) && (
108                <span className="caption muted" key={source}>
109                  fewest ({source})
110                </span>
111              ),
112          )}
113      </>
114    );
115  };
116  const identities = agentIdentities(state.agents);
117  return (
118    <section className="race-usage" aria-labelledby="race-compare-title">
119      <h3 id="race-compare-title">Compare</h3>
120      {advice}
121      <p className="caption muted">
122        Times since the first entrant claimed. Files come from the latest review detail.
123        {pushesAvailable && " Push counts cover each entrant's race interval in loaded activity."}
124      </p>
125      <div className="table-scroll">
126        <table className="race-compare">
127          <thead>
128            <tr>
129              <th scope="col">Fact</th>
130              {race.entrants.map((e) => (
131                <th scope="col" key={e.repoName}>
132                  {e.agent}
133                </th>
134              ))}
135            </tr>
136          </thead>
137          <tbody>
138            {(
139              [
140                ["Time to Approved", approved, "approvedAt"],
141                ["Time to Promoted", promoted, "promotedAt"],
142              ] as const
143            ).map(([label, values, key]) => (
144              <tr key={label}>
145                <th scope="row">{label}</th>
146                {values.map((_value, i) => (
147                  <CompareCell key={race.entrants[i].repoName} agent={race.entrants[i].agent} identity={identities.get(race.entrants[i].agent)}>
148                    {raceSplit(race.entrants[i][key], start) || "Not yet"}
149                    {best([...values], i) && <span className="caption muted">fastest</span>}
150                  </CompareCell>
151                ))}
152              </tr>
153            ))}
154            <tr>
155              <th scope="row">Checks passed</th>
156              {race.entrants.map((e) => (
157                <CompareCell key={e.repoName} agent={e.agent} identity={identities.get(e.agent)}>{entrantChecks(state, race, e)}</CompareCell>
158              ))}
159            </tr>
160            <tr>
161              <th scope="row">Files changed</th>
162              {changes.map((value, i) => (
163                <CompareCell key={race.entrants[i].repoName} agent={race.entrants[i].agent} identity={identities.get(race.entrants[i].agent)}>
164                  {value ?? (reviews[i] ? "Unavailable" : "No review yet")}
165                  {best(changes, i) && <span className="caption muted">fewest</span>}
166                </CompareCell>
167              ))}
168            </tr>
169            {pushesAvailable && <tr>
170              <th scope="row">Recorded pushes</th>
171              {race.entrants.map((e) => (
172                <CompareCell key={e.repoName} agent={e.agent} identity={identities.get(e.agent)}>
173                  {packetPushes(
174                    activity, e.repoName, state.project.defaultBranch,
175                    timestamp(e.claimedAt) ?? Infinity,
176                    timestamp(e.closedAt) ?? timestamp(race.finishedAt) ?? now,
177                  ).length}
178                </CompareCell>
179              ))}
180            </tr>}
181            <tr>
182              <th scope="row">Tokens</th>
183              {race.entrants.map((e, i) => (
184                <CompareCell key={e.repoName} agent={e.agent} identity={identities.get(e.agent)}>{tokens(i)}</CompareCell>
185              ))}
186            </tr>
187          </tbody>
188        </table>
189      </div>
190    </section>
191  );
192}
193