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.

344 linesCodeBlameRaw
1import { ArrowDown, ArrowUp, ArrowUpToLine, Bot, CornerDownRight, GripVertical, MoreHorizontal, Sparkles } from "lucide-react";
2import type { DragEvent, ReactNode } from "react";
3import { Link } from "react-router";
4
5import { type Appetite, type Hill, type Horizon, type Progress as Standing, type WorkItem, appetiteLetter, hillLabel, horizonLabel } from "@g1t/contracts";
6
7import { HarnessMark, StateDot } from "./sessions";
8import { RepoChips } from "./work-items";
9import { Avatar } from "./ui/avatar";
10import { Badge } from "./ui/badge";
11import { Button } from "./ui/button";
12import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "./ui/dropdown-menu";
13import { Hint } from "./ui/hint";
14import { Progress } from "./ui/progress";
15import { cn } from "../lib/cn";
16import {
17 APPETITE_HINTS,
18 type CapState,
19 HILL_STEPS,
20 type Move,
21 type OnIt,
22 type Position,
23 hillStep,
24 menuHorizons,
25 moveDown,
26 moveUp,
27 quietText,
28 waitingLabel,
29} from "../lib/plan";
30import { onItemSessionTitle, runsIn, sessionHref } from "../lib/sessions";
31import { timeAgo } from "../lib/time-ago";
32import { itemPath } from "../lib/work-items";
33
34/** A work item's appetite as its letter in a small square, with what it means on hover. */
35export function AppetiteChip({ appetite }: { appetite: Appetite }) {
36 return (
37 <Hint label={APPETITE_HINTS[appetite]}>
38 <Badge shape="tag" className="size-5 justify-center p-0 font-mono" aria-label={APPETITE_HINTS[appetite]}>
39 {appetiteLetter(appetite)}
40 </Badge>
41 </Hint>
42 );
43}
44
45/** Where each of a hill's four stages sits on its curve, as x and y in a 44 by 16 box. */
46const HILL_POINTS = HILL_STEPS.map((_, step) => {
47 const along = (step + 0.5) / HILL_STEPS.length;
48 return { x: 3 + along * 38, y: 14 - Math.sin(along * Math.PI) * 10 };
49});
50
51/**
52 * How far along a work item in Now is, as its record says: a small hill
53 * with its four stages (figuring it out, decided, building, landing) and
54 * the one it is at filled in. Its stage is its accessible name and its
55 * hint; `label` also shows it in words, before the hill.
56 */
57export function HillMark({ hill, label = false }: { hill: Hill; label?: boolean }) {
58 const at = hillStep(hill);
59 const words = `${hillLabel(hill)}, stage ${at + 1} of ${HILL_STEPS.length}`;
60 return (
61 <Hint label={words}>
62 <span role="img" aria-label={words} className="inline-flex shrink-0 items-center gap-1.5">
63 {label && <span className="text-[0.6875rem] whitespace-nowrap text-faint">{hillLabel(hill)}</span>}
64 <svg viewBox="0 0 44 16" className="h-4 w-11" aria-hidden>
65 <path d="M2 15 Q 22 -5 42 15" fill="none" className="stroke-line-strong" strokeWidth="1.25" />
66 {HILL_POINTS.map((point, step) =>
67 step === at ? (
68 <circle key={step} cx={point.x} cy={point.y} r="3" className="fill-accent" />
69 ) : (
70 <circle key={step} cx={point.x} cy={point.y} r="1.25" className={step < at ? "fill-accent/50" : "fill-line-strong"} />
71 ),
72 )}
73 </svg>
74 </span>
75 </Hint>
76 );
77}
78
79/**
80 * Where a work item in Now stands, in g1t's words from its record (its
81 * sessions, decisions and pull requests), muted, with a small mark that
82 * says g1t drafted it and when. Nothing until it has been drafted.
83 */
84export function StandingText({ standing, className }: { standing: Pick<Standing, "standing" | "standingAt">; className?: string }) {
85 if (!standing.standing) return null;
86 const when = standing.standingAt ? ` ${timeAgo(standing.standingAt)}` : "";
87 return (
88 <p className={cn("text-xs leading-relaxed text-muted", className)}>
89 {standing.standing}
90 <Hint label={<span suppressHydrationWarning>{`Drafted by g1t from its record${when}`}</span>}>
91 <span className="ml-1 inline-flex translate-y-px items-center text-faint" aria-label="Drafted by g1t" role="img">
92 <Sparkles size={11} />
93 </span>
94 </Hint>
95 </p>
96 );
97}
98
99/** "Quiet for 6 days", for a work item in Now nothing has happened on lately. */
100export function QuietBadge({ progress }: { progress: Pick<Standing, "quiet" | "activeAt"> }) {
101 const text = quietText(progress, Date.now());
102 if (!text) return null;
103 return (
104 <Badge tone="warn" suppressHydrationWarning>
105 {text}
106 </Badge>
107 );
108}
109
110/** Who is on a work item: people's avatars, and the live sessions on it with what they run in, as the board shows them. */
111export function OnItLine({ on, workspace }: { on: OnIt | undefined; workspace: string }) {
112 if (!on || (on.people.length === 0 && on.sessions.length === 0)) return null;
113 const { people, sessions } = on;
114 return (
115 <span className="inline-flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
116 {people.length > 0 && (
117 <Hint label={people.join(", ")}>
118 <span className="flex -space-x-1.5">
119 {people.slice(0, 3).map((name) => (
120 <span key={name} className="rounded-full ring-2 ring-surface">
121 <Avatar name={name} size={16} />
122 </span>
123 ))}
124 </span>
125 </Hint>
126 )}
127 {people.length > 3 && <span className="text-[0.6875rem] text-faint">+{people.length - 3}</span>}
128 {sessions.slice(0, 1).map((session) => {
129 const to = sessionHref(workspace, session);
130 const label = (
131 <span className="inline-flex min-w-0 items-center gap-1 text-[0.6875rem] text-muted">
132 <StateDot state={session.state} />
133 <HarnessMark session={session} size={11} />
134 <span className="truncate">{session.actor ?? runsIn(session)}</span>
135 </span>
136 );
137 return (
138 <Hint key={session.id} label={`${onItemSessionTitle(session)} · ${runsIn(session)}`}>
139 {to ? (
140 <Link to={to} className="min-w-0 hover:text-fg">
141 {label}
142 </Link>
143 ) : (
144 label
145 )}
146 </Hint>
147 );
148 })}
149 {sessions.length > 1 && (
150 <span className="inline-flex items-center gap-0.5 text-[0.6875rem] text-faint">
151 <Bot size={11} />+{sessions.length - 1}
152 </span>
153 )}
154 </span>
155 );
156}
157
158/** Now's count against its cap, and a bar that turns to the warning color past it. */
159export function CapBar({ cap }: { cap: CapState }) {
160 return (
161 <div className="flex items-center gap-2">
162 <Progress
163 value={cap.share * 100}
164 aria-label={`${cap.count} of ${cap.cap} in Now`}
165 className="h-1 w-16 sm:w-24"
166 indicatorClassName={cap.over ? "bg-warn" : undefined}
167 />
168 <span className={cn("font-mono text-xs tabular-nums", cap.over ? "text-warn" : "text-faint")}>
169 {cap.count} of {cap.cap}
170 </span>
171 </div>
172 );
173}
174
175/**
176 * A row's menu: the keyboard's and a phone's way to make every move a drag
177 * makes. Up, down and to the top within the list it is in (`list`, the
178 * numbers shown, when it is ranked there), then to each other horizon.
179 */
180export function PlanRowMenu({
181 item,
182 list,
183 canNow,
184 onMove,
185}: {
186 item: WorkItem;
187 /** The ranked list it is in, as shown, top first; null where it is not ranked (Later). */
188 list: readonly number[] | null;
189 canNow: boolean;
190 onMove: (move: Move) => void;
191}) {
192 const rankable = list != null && (item.horizon !== "now" || canNow);
193 const move = (position: Position | null, horizon: Horizon = item.horizon) => position && onMove({ number: item.number, horizon, ...position });
194 const up = list ? moveUp(list, item.number) : null;
195 const down = list ? moveDown(list, item.number) : null;
196 const elsewhere = menuHorizons(item.horizon, canNow);
197 if (!rankable && elsewhere.length === 0) return null;
198 return (
199 <DropdownMenu>
200 <DropdownMenuTrigger asChild>
201 <Button variant="ghost" size="icon-xs" aria-label={`Move #${item.number}`} className="text-faint max-sm:size-9">
202 <MoreHorizontal size={15} />
203 </Button>
204 </DropdownMenuTrigger>
205 <DropdownMenuContent align="end">
206 {rankable && (
207 <>
208 <DropdownMenuItem disabled={!up} onSelect={() => move(up)}>
209 <ArrowUp />
210 Move up
211 </DropdownMenuItem>
212 <DropdownMenuItem disabled={!down} onSelect={() => move(down)}>
213 <ArrowDown />
214 Move down
215 </DropdownMenuItem>
216 <DropdownMenuItem disabled={!up} onSelect={() => move({ top: true })}>
217 <ArrowUpToLine />
218 Move to top
219 </DropdownMenuItem>
220 {elsewhere.length > 0 && <DropdownMenuSeparator />}
221 </>
222 )}
223 {elsewhere.length > 0 && <DropdownMenuLabel>Move to</DropdownMenuLabel>}
224 {elsewhere.map((horizon) => (
225 <DropdownMenuItem key={horizon} onSelect={() => move({}, horizon)}>
226 <CornerDownRight />
227 {horizonLabel(horizon)}
228 </DropdownMenuItem>
229 ))}
230 </DropdownMenuContent>
231 </DropdownMenu>
232 );
233}
234
235/** What a row needs to be dragged and dropped on; none for a viewer who cannot move it. */
236export type RowDrag = {
237 onDragStart: (event: DragEvent<HTMLLIElement>) => void;
238 onDragEnd: () => void;
239 onDragOver: (event: DragEvent<HTMLLIElement>) => void;
240 onDrop: (event: DragEvent<HTMLLIElement>) => void;
241 /** Where a drop would land, drawn as a line: above this row or below it. */
242 edge: "above" | "below" | null;
243 dragging: boolean;
244 draggable: boolean;
245};
246
247/**
248 * One work item in the plan: its place (`rank`), urgent first, its number
249 * and title, its repositories, what it waits on, who is on it, its
250 * appetite, and its menu. In Now, also how it stands (`progress`): its
251 * hill, whether it has gone quiet, and where it stands under its title.
252 */
253export function PlanRow({
254 item,
255 rank,
256 workspace,
257 on,
258 waiting,
259 drag,
260 menu,
261 progress,
262}: {
263 item: WorkItem;
264 rank: number | null;
265 workspace: string;
266 on: OnIt | undefined;
267 /** Open work items it is blocked by. */
268 waiting: readonly number[];
269 drag?: RowDrag;
270 menu?: ReactNode;
271 progress?: Standing;
272}) {
273 const quiet = progress?.quiet ?? false;
274 return (
275 <li
276 draggable={drag?.draggable || undefined}
277 onDragStart={drag?.onDragStart}
278 onDragEnd={drag?.onDragEnd}
279 onDragOver={drag?.onDragOver}
280 onDrop={drag?.onDrop}
281 className={cn(
282 "group relative grid grid-cols-[1.5rem_minmax(0,1fr)_auto] items-center gap-2 rounded-md px-2 py-2 transition-colors hover:bg-raised/60",
283 drag?.dragging && "opacity-40",
284 drag?.edge === "above" && "shadow-[inset_0_2px_0_0_var(--color-accent)]",
285 drag?.edge === "below" && "shadow-[inset_0_-2px_0_0_var(--color-accent)]",
286 )}
287 >
288 <span className="relative flex justify-end font-mono text-xs tabular-nums text-faint">
289 {drag?.draggable && (
290 <GripVertical
291 size={14}
292 aria-hidden
293 className="absolute top-1/2 -left-1 hidden -translate-y-1/2 cursor-grab text-faint opacity-0 group-hover:opacity-100 sm:block"
294 />
295 )}
296 {rank ?? ""}
297 </span>
298 <div className="min-w-0">
299 {/* One line on a computer; two on a phone, where the title has less room. */}
300 <Link
301 to={itemPath(workspace, item.number)}
302 prefetch="intent"
303 className="line-clamp-2 min-w-0 text-sm font-medium wrap-break-word hover:text-accent sm:line-clamp-1"
304 >
305 {item.urgent && (
306 <Badge tone="danger" className="mr-1.5 align-[1px]">
307 Urgent
308 </Badge>
309 )}
310 <span className="mr-1.5 font-mono text-xs font-normal text-faint">#{item.number}</span>
311 {item.title}
312 </Link>
313 {progress && <StandingText standing={progress} className="mt-0.5 line-clamp-2" />}
314 {(progress || item.repos.length > 0 || waiting.length > 0 || quiet || (on && (on.people.length > 0 || on.sessions.length > 0))) && (
315 <div className="mt-1 flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-xs text-faint">
316 {/* On a phone the hill goes under the title, in words, where there is room for it. */}
317 {progress && (
318 <span className="sm:hidden">
319 <HillMark hill={progress.hill} label />
320 </span>
321 )}
322 <RepoChips repos={item.repos} workspace={workspace} max={1} />
323 {progress && <QuietBadge progress={progress} />}
324 {waiting.length > 0 && (
325 <Badge tone="warn" className="max-w-full">
326 <span className="truncate">{waitingLabel(waiting)}</span>
327 </Badge>
328 )}
329 <OnItLine on={on} workspace={workspace} />
330 </div>
331 )}
332 </div>
333 <div className="flex shrink-0 items-center gap-1">
334 {progress && (
335 <span className="mr-1 max-sm:hidden">
336 <HillMark hill={progress.hill} />
337 </span>
338 )}
339 {item.appetite && <AppetiteChip appetite={item.appetite} />}
340 {menu}
341 </div>
342 </li>
343 );
344}