Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

172 linesCodeBlameRaw
1import { Link, useFetcher } from "react-router";
2import { useEffect, useId, useState } from "react";
3
4import { Check, Sparkles } from "lucide-react";
5
6import { APPETITES, HORIZONS, type Horizon, type Progress, type WorkItem, appetiteLetter, hillLabel, horizonLabel } from "@g1t/contracts";
7
8import { AppetiteChip, HillMark, QuietBadge, StandingText } from "./plan";
9import { ErrorText } from "./ui";
10import { Input, InputAddon, InputGroup } from "./ui/input";
11import { Badge } from "./ui/badge";
12import { Button } from "./ui/button";
13import { SelectField } from "./ui/select";
14import { Switch } from "./ui/switch";
15import { ToggleGroup, ToggleGroupItem } from "./ui/toggle-group";
16import { Card } from "./ui/card";
17import { Hint } from "./ui/hint";
18import { cn } from "../lib/cn";
19import { itemPath } from "../lib/work-items";
20import { APPETITE_HINTS, APPETITE_SUMMARY, HORIZON_HINTS, acceptForm, budgetText, canAccept, planPath, suggestionText } from "../lib/plan";
21
22/**
23 * The item page's Plan, in its sidebar beside Milestone: where it stands
24 * in the plan (a horizon), its appetite, a budget in dollars for an agent
25 * working on it, and urgent, which puts it first in Now. Each change posts
26 * `action=plan` to the page. Read-only for who cannot plan (`canEdit`, the
27 * Triage role); Now and urgent also take the right to manage milestones
28 * (`canNow`), which the work service checks again. In Triage, where g1t
29 * suggests it goes, with Accept; in Now, how it stands (`progress`): its
30 * hill and where it stands, as the plan reads them.
31 */
32export function PlanBox({
33 item,
34 workspace,
35 canEdit,
36 canNow,
37 progress,
38}: {
39 item: Pick<WorkItem, "horizon" | "appetite" | "budgetUsd" | "urgent" | "state" | "suggestion">;
40 workspace: string;
41 canEdit: boolean;
42 canNow: boolean;
43 /** How it stands, while it is in Now; null when it could not be read. */
44 progress?: Progress | null;
45}) {
46 const fetcher = useFetcher<{ error?: string } | null>();
47 const budgetId = useId();
48 const editable = canEdit && item.state === "open";
49 // While saving, what was asked for.
50 const posted = fetcher.state !== "idle" ? fetcher.formData : null;
51 const horizon = (posted?.get("urgent") === "1" ? "now" : (posted?.get("horizon") as Horizon | null)) ?? item.horizon;
52 const appetite = posted?.has("appetite") ? (posted.get("appetite") === "none" ? null : (posted.get("appetite") as WorkItem["appetite"])) : item.appetite;
53 const urgent = posted?.has("urgent") ? posted.get("urgent") === "1" : item.urgent;
54 const [budget, setBudget] = useState(item.budgetUsd == null ? "" : String(item.budgetUsd));
55 useEffect(() => setBudget(item.budgetUsd == null ? "" : String(item.budgetUsd)), [item.budgetUsd]);
56 const post = (fields: Record<string, string>) => fetcher.submit({ action: "plan", ...fields }, { method: "post" });
57 const lockedInNow = horizon === "now" && !canNow;
58 const suggestion = item.horizon === "triage" && horizon === "triage" && item.state === "open" ? (item.suggestion ?? null) : null;
59 const standing = item.horizon === "now" && horizon === "now" && item.state === "open" ? (progress ?? null) : null;
60 return (
61 <section>
62 <div className="flex items-center justify-between gap-2">
63 <h3 className="text-sm font-medium">Plan</h3>
64 <Link to={planPath(workspace)} className="text-xs text-faint hover:text-fg">
65 Open the plan
66 </Link>
67 </div>
68 {suggestion && (
69 <Card tone="plain" radius="lg" className="mt-2 space-y-1.5 border-accent/20 bg-accent/5 px-3 py-2 text-xs">
70 <p className="flex items-center gap-1.5">
71 <Sparkles size={12} className="shrink-0 text-accent" aria-hidden />
72 <span className="font-medium text-fg">Suggested: {suggestionText(suggestion)}</span>
73 </p>
74 {suggestion.duplicateOf != null && (
75 <p className="text-muted">
76 Looks like{" "}
77 <Link to={itemPath(workspace, suggestion.duplicateOf)} className="font-mono text-fg underline-offset-4 hover:underline">
78 #{suggestion.duplicateOf}
79 </Link>
80 </p>
81 )}
82 <p className="text-muted">{suggestion.why}</p>
83 {editable && canAccept(suggestion, canNow) && (
84 <Button size="xs" variant="accent" disabled={fetcher.state !== "idle"} onClick={() => post(acceptForm(suggestion))}>
85 <Check size={13} />
86 Accept
87 </Button>
88 )}
89 </Card>
90 )}
91 {standing && (
92 <div className="mt-2 space-y-1.5">
93 <div className="flex flex-wrap items-center gap-2">
94 <HillMark hill={standing.hill} />
95 <span className="text-xs text-muted">{hillLabel(standing.hill)}</span>
96 <QuietBadge progress={standing} />
97 </div>
98 <StandingText standing={standing} />
99 </div>
100 )}
101 {editable ? (
102 <div className={cn("space-y-3", suggestion || standing ? "mt-3" : "mt-2")}>
103 <SelectField
104 aria-label="Horizon"
105 size="sm"
106 value={horizon}
107 disabled={lockedInNow}
108 onValueChange={(value) => value !== horizon && post({ horizon: value })}
109 options={HORIZONS.map((one) => ({
110 value: one,
111 label: horizonLabel(one),
112 description: HORIZON_HINTS[one],
113 disabled: one === "now" && !canNow,
114 }))}
115 />
116 <div className="flex items-center justify-between gap-2">
117 <Hint label={APPETITE_SUMMARY}>
118 <span className="text-xs text-muted underline decoration-line-strong decoration-dotted underline-offset-4">Appetite</span>
119 </Hint>
120 <ToggleGroup
121 type="single"
122 variant="outline"
123 size="sm"
124 aria-label="Appetite"
125 value={appetite ?? ""}
126 onValueChange={(value) => post({ appetite: value || "none" })}
127 >
128 {APPETITES.map((one) => (
129 <ToggleGroupItem key={one} value={one} aria-label={APPETITE_HINTS[one]} className="font-mono">
130 {appetiteLetter(one)}
131 </ToggleGroupItem>
132 ))}
133 </ToggleGroup>
134 </div>
135 <fetcher.Form method="post" className="flex items-center gap-2">
136 <input type="hidden" name="action" value="plan" />
137 <label htmlFor={budgetId} className="grow text-xs text-muted">
138 Budget
139 </label>
140 <InputGroup className="h-8 w-24">
141 <InputAddon className="pr-0 text-xs">$</InputAddon>
142 <Input id={budgetId} name="budget" inputMode="decimal" value={budget} onChange={(event) => setBudget(event.target.value)} className="pl-1.5" />
143 </InputGroup>
144 <Button type="submit" size="sm" variant="outline" disabled={budget === (item.budgetUsd == null ? "" : String(item.budgetUsd))}>
145 Save
146 </Button>
147 </fetcher.Form>
148 <label className="flex items-center justify-between gap-2">
149 <span className="min-w-0">
150 <span className="block text-xs text-muted">Urgent</span>
151 <span className="block text-[0.6875rem] text-faint">First in Now, until it closes</span>
152 </span>
153 <Switch size="sm" checked={urgent} disabled={!canNow} onCheckedChange={(on) => post({ urgent: on ? "1" : "0" })} aria-label="Urgent" />
154 </label>
155 {lockedInNow && <p className="text-xs text-faint">Moving it out of Now takes the right to manage milestones.</p>}
156 </div>
157 ) : (
158 <div className={cn("flex flex-wrap items-center gap-1.5 text-sm", suggestion || standing ? "mt-3" : "mt-2")}>
159 {item.urgent && <Badge tone="danger">Urgent</Badge>}
160 <span>{horizonLabel(item.horizon)}</span>
161 {item.appetite && <AppetiteChip appetite={item.appetite} />}
162 {item.budgetUsd != null && <span className="text-xs text-muted">{budgetText(item.budgetUsd)} budget</span>}
163 </div>
164 )}
165 {fetcher.state === "idle" && fetcher.data?.error && (
166 <div className="mt-2">
167 <ErrorText>{fetcher.data.error}</ErrorText>
168 </div>
169 )}
170 </section>
171 );
172}