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.

127 linesCodeBlameRaw
1import { Link, useFetcher } from "react-router";
2import { useEffect, useId, useState } from "react";
3
4import { APPETITES, HORIZONS, type Horizon, type WorkItem, appetiteLetter, horizonLabel } from "@g1t/contracts";
5
6import { AppetiteChip } from "./plan";
7import { ErrorText } from "./ui";
8import { Input, InputAddon, InputGroup } from "./ui/input";
9import { Badge } from "./ui/badge";
10import { Button } from "./ui/button";
11import { SelectField } from "./ui/select";
12import { Switch } from "./ui/switch";
13import { ToggleGroup, ToggleGroupItem } from "./ui/toggle-group";
14import { Hint } from "./ui/hint";
15import { APPETITE_HINTS, APPETITE_SUMMARY, HORIZON_HINTS, budgetText, planPath } from "../lib/plan";
16
17/**
18 * The item page's Plan, in its sidebar beside Milestone: where it stands
19 * in the plan (a horizon), its appetite, a budget in dollars for an agent
20 * working on it, and urgent, which puts it first in Now. Each change posts
21 * `action=plan` to the page. Read-only for who cannot plan (`canEdit`, the
22 * Triage role); Now and urgent also take the right to manage milestones
23 * (`canNow`), which the work service checks again.
24 */
25export function PlanBox({
26 item,
27 workspace,
28 canEdit,
29 canNow,
30}: {
31 item: Pick<WorkItem, "horizon" | "appetite" | "budgetUsd" | "urgent" | "state">;
32 workspace: string;
33 canEdit: boolean;
34 canNow: boolean;
35}) {
36 const fetcher = useFetcher<{ error?: string } | null>();
37 const budgetId = useId();
38 const editable = canEdit && item.state === "open";
39 // While saving, what was asked for.
40 const posted = fetcher.state !== "idle" ? fetcher.formData : null;
41 const horizon = (posted?.get("urgent") === "1" ? "now" : (posted?.get("horizon") as Horizon | null)) ?? item.horizon;
42 const appetite = posted?.has("appetite") ? (posted.get("appetite") === "none" ? null : (posted.get("appetite") as WorkItem["appetite"])) : item.appetite;
43 const urgent = posted?.has("urgent") ? posted.get("urgent") === "1" : item.urgent;
44 const [budget, setBudget] = useState(item.budgetUsd == null ? "" : String(item.budgetUsd));
45 useEffect(() => setBudget(item.budgetUsd == null ? "" : String(item.budgetUsd)), [item.budgetUsd]);
46 const post = (fields: Record<string, string>) => fetcher.submit({ action: "plan", ...fields }, { method: "post" });
47 const lockedInNow = horizon === "now" && !canNow;
48 return (
49 <section>
50 <div className="flex items-center justify-between gap-2">
51 <h3 className="text-sm font-medium">Plan</h3>
52 <Link to={planPath(workspace)} className="text-xs text-faint hover:text-fg">
53 Open the plan
54 </Link>
55 </div>
56 {editable ? (
57 <div className="mt-2 space-y-3">
58 <SelectField
59 aria-label="Horizon"
60 size="sm"
61 value={horizon}
62 disabled={lockedInNow}
63 onValueChange={(value) => value !== horizon && post({ horizon: value })}
64 options={HORIZONS.map((one) => ({
65 value: one,
66 label: horizonLabel(one),
67 description: HORIZON_HINTS[one],
68 disabled: one === "now" && !canNow,
69 }))}
70 />
71 <div className="flex items-center justify-between gap-2">
72 <Hint label={APPETITE_SUMMARY}>
73 <span className="text-xs text-muted underline decoration-line-strong decoration-dotted underline-offset-4">Appetite</span>
74 </Hint>
75 <ToggleGroup
76 type="single"
77 variant="outline"
78 size="sm"
79 aria-label="Appetite"
80 value={appetite ?? ""}
81 onValueChange={(value) => post({ appetite: value || "none" })}
82 >
83 {APPETITES.map((one) => (
84 <ToggleGroupItem key={one} value={one} aria-label={APPETITE_HINTS[one]} className="font-mono">
85 {appetiteLetter(one)}
86 </ToggleGroupItem>
87 ))}
88 </ToggleGroup>
89 </div>
90 <fetcher.Form method="post" className="flex items-center gap-2">
91 <input type="hidden" name="action" value="plan" />
92 <label htmlFor={budgetId} className="grow text-xs text-muted">
93 Budget
94 </label>
95 <InputGroup className="h-8 w-24">
96 <InputAddon className="pr-0 text-xs">$</InputAddon>
97 <Input id={budgetId} name="budget" inputMode="decimal" value={budget} onChange={(event) => setBudget(event.target.value)} className="pl-1.5" />
98 </InputGroup>
99 <Button type="submit" size="sm" variant="outline" disabled={budget === (item.budgetUsd == null ? "" : String(item.budgetUsd))}>
100 Save
101 </Button>
102 </fetcher.Form>
103 <label className="flex items-center justify-between gap-2">
104 <span className="min-w-0">
105 <span className="block text-xs text-muted">Urgent</span>
106 <span className="block text-[0.6875rem] text-faint">First in Now, until it closes</span>
107 </span>
108 <Switch size="sm" checked={urgent} disabled={!canNow} onCheckedChange={(on) => post({ urgent: on ? "1" : "0" })} aria-label="Urgent" />
109 </label>
110 {lockedInNow && <p className="text-xs text-faint">Moving it out of Now takes the right to manage milestones.</p>}
111 </div>
112 ) : (
113 <div className="mt-2 flex flex-wrap items-center gap-1.5 text-sm">
114 {item.urgent && <Badge tone="danger">Urgent</Badge>}
115 <span>{horizonLabel(item.horizon)}</span>
116 {item.appetite && <AppetiteChip appetite={item.appetite} />}
117 {item.budgetUsd != null && <span className="text-xs text-muted">{budgetText(item.budgetUsd)} budget</span>}
118 </div>
119 )}
120 {fetcher.state === "idle" && fetcher.data?.error && (
121 <div className="mt-2">
122 <ErrorText>{fetcher.data.error}</ErrorText>
123 </div>
124 )}
125 </section>
126 );
127}