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.

248 linesCodeBlameRaw
1import { ArrowDown, ArrowUp, ArrowUpToLine, Bot, CornerDownRight, GripVertical, MoreHorizontal } from "lucide-react";
2import type { DragEvent, ReactNode } from "react";
3import { Link } from "react-router";
4
5import { type Appetite, type Horizon, type WorkItem, appetiteLetter, 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 { APPETITE_HINTS, type CapState, type Move, type OnIt, type Position, menuHorizons, moveDown, moveUp, waitingLabel } from "../lib/plan";
17import { onItemSessionTitle, runsIn, sessionHref } from "../lib/sessions";
18import { itemPath } from "../lib/work-items";
19
20/** A work item's appetite as its letter in a small square, with what it means on hover. */
21export function AppetiteChip({ appetite }: { appetite: Appetite }) {
22 return (
23 <Hint label={APPETITE_HINTS[appetite]}>
24 <Badge shape="tag" className="size-5 justify-center p-0 font-mono" aria-label={APPETITE_HINTS[appetite]}>
25 {appetiteLetter(appetite)}
26 </Badge>
27 </Hint>
28 );
29}
30
31/** 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. */
32export function OnItLine({ on, workspace }: { on: OnIt | undefined; workspace: string }) {
33 if (!on || (on.people.length === 0 && on.sessions.length === 0)) return null;
34 const { people, sessions } = on;
35 return (
36 <span className="inline-flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
37 {people.length > 0 && (
38 <Hint label={people.join(", ")}>
39 <span className="flex -space-x-1.5">
40 {people.slice(0, 3).map((name) => (
41 <span key={name} className="rounded-full ring-2 ring-surface">
42 <Avatar name={name} size={16} />
43 </span>
44 ))}
45 </span>
46 </Hint>
47 )}
48 {people.length > 3 && <span className="text-[0.6875rem] text-faint">+{people.length - 3}</span>}
49 {sessions.slice(0, 1).map((session) => {
50 const to = sessionHref(workspace, session);
51 const label = (
52 <span className="inline-flex min-w-0 items-center gap-1 text-[0.6875rem] text-muted">
53 <StateDot state={session.state} />
54 <HarnessMark session={session} size={11} />
55 <span className="truncate">{session.actor ?? runsIn(session)}</span>
56 </span>
57 );
58 return (
59 <Hint key={session.id} label={`${onItemSessionTitle(session)} · ${runsIn(session)}`}>
60 {to ? (
61 <Link to={to} className="min-w-0 hover:text-fg">
62 {label}
63 </Link>
64 ) : (
65 label
66 )}
67 </Hint>
68 );
69 })}
70 {sessions.length > 1 && (
71 <span className="inline-flex items-center gap-0.5 text-[0.6875rem] text-faint">
72 <Bot size={11} />+{sessions.length - 1}
73 </span>
74 )}
75 </span>
76 );
77}
78
79/** Now's count against its cap, and a bar that turns to the warning color past it. */
80export function CapBar({ cap }: { cap: CapState }) {
81 return (
82 <div className="flex items-center gap-2">
83 <Progress
84 value={cap.share * 100}
85 aria-label={`${cap.count} of ${cap.cap} in Now`}
86 className="h-1 w-16 sm:w-24"
87 indicatorClassName={cap.over ? "bg-warn" : undefined}
88 />
89 <span className={cn("font-mono text-xs tabular-nums", cap.over ? "text-warn" : "text-faint")}>
90 {cap.count} of {cap.cap}
91 </span>
92 </div>
93 );
94}
95
96/**
97 * A row's menu: the keyboard's and a phone's way to make every move a drag
98 * makes. Up, down and to the top within the list it is in (`list`, the
99 * numbers shown, when it is ranked there), then to each other horizon.
100 */
101export function PlanRowMenu({
102 item,
103 list,
104 canNow,
105 onMove,
106}: {
107 item: WorkItem;
108 /** The ranked list it is in, as shown, top first; null where it is not ranked (Later). */
109 list: readonly number[] | null;
110 canNow: boolean;
111 onMove: (move: Move) => void;
112}) {
113 const rankable = list != null && (item.horizon !== "now" || canNow);
114 const move = (position: Position | null, horizon: Horizon = item.horizon) => position && onMove({ number: item.number, horizon, ...position });
115 const up = list ? moveUp(list, item.number) : null;
116 const down = list ? moveDown(list, item.number) : null;
117 const elsewhere = menuHorizons(item.horizon, canNow);
118 if (!rankable && elsewhere.length === 0) return null;
119 return (
120 <DropdownMenu>
121 <DropdownMenuTrigger asChild>
122 <Button variant="ghost" size="icon-xs" aria-label={`Move #${item.number}`} className="text-faint max-sm:size-9">
123 <MoreHorizontal size={15} />
124 </Button>
125 </DropdownMenuTrigger>
126 <DropdownMenuContent align="end">
127 {rankable && (
128 <>
129 <DropdownMenuItem disabled={!up} onSelect={() => move(up)}>
130 <ArrowUp />
131 Move up
132 </DropdownMenuItem>
133 <DropdownMenuItem disabled={!down} onSelect={() => move(down)}>
134 <ArrowDown />
135 Move down
136 </DropdownMenuItem>
137 <DropdownMenuItem disabled={!up} onSelect={() => move({ top: true })}>
138 <ArrowUpToLine />
139 Move to top
140 </DropdownMenuItem>
141 {elsewhere.length > 0 && <DropdownMenuSeparator />}
142 </>
143 )}
144 {elsewhere.length > 0 && <DropdownMenuLabel>Move to</DropdownMenuLabel>}
145 {elsewhere.map((horizon) => (
146 <DropdownMenuItem key={horizon} onSelect={() => move({}, horizon)}>
147 <CornerDownRight />
148 {horizonLabel(horizon)}
149 </DropdownMenuItem>
150 ))}
151 </DropdownMenuContent>
152 </DropdownMenu>
153 );
154}
155
156/** What a row needs to be dragged and dropped on; none for a viewer who cannot move it. */
157export type RowDrag = {
158 onDragStart: (event: DragEvent<HTMLLIElement>) => void;
159 onDragEnd: () => void;
160 onDragOver: (event: DragEvent<HTMLLIElement>) => void;
161 onDrop: (event: DragEvent<HTMLLIElement>) => void;
162 /** Where a drop would land, drawn as a line: above this row or below it. */
163 edge: "above" | "below" | null;
164 dragging: boolean;
165 draggable: boolean;
166};
167
168/**
169 * One work item in the plan: its place (`rank`), urgent first, its number
170 * and title, its repositories, what it waits on, who is on it, its
171 * appetite, and its menu.
172 */
173export function PlanRow({
174 item,
175 rank,
176 workspace,
177 on,
178 waiting,
179 drag,
180 menu,
181}: {
182 item: WorkItem;
183 rank: number | null;
184 workspace: string;
185 on: OnIt | undefined;
186 /** Open work items it is blocked by. */
187 waiting: readonly number[];
188 drag?: RowDrag;
189 menu?: ReactNode;
190}) {
191 return (
192 <li
193 draggable={drag?.draggable || undefined}
194 onDragStart={drag?.onDragStart}
195 onDragEnd={drag?.onDragEnd}
196 onDragOver={drag?.onDragOver}
197 onDrop={drag?.onDrop}
198 className={cn(
199 "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",
200 drag?.dragging && "opacity-40",
201 drag?.edge === "above" && "shadow-[inset_0_2px_0_0_var(--color-accent)]",
202 drag?.edge === "below" && "shadow-[inset_0_-2px_0_0_var(--color-accent)]",
203 )}
204 >
205 <span className="relative flex justify-end font-mono text-xs tabular-nums text-faint">
206 {drag?.draggable && (
207 <GripVertical
208 size={14}
209 aria-hidden
210 className="absolute top-1/2 -left-1 hidden -translate-y-1/2 cursor-grab text-faint opacity-0 group-hover:opacity-100 sm:block"
211 />
212 )}
213 {rank ?? ""}
214 </span>
215 <div className="min-w-0">
216 {/* One line on a computer; two on a phone, where the title has less room. */}
217 <Link
218 to={itemPath(workspace, item.number)}
219 prefetch="intent"
220 className="line-clamp-2 min-w-0 text-sm font-medium wrap-break-word hover:text-accent sm:line-clamp-1"
221 >
222 {item.urgent && (
223 <Badge tone="danger" className="mr-1.5 align-[1px]">
224 Urgent
225 </Badge>
226 )}
227 <span className="mr-1.5 font-mono text-xs font-normal text-faint">#{item.number}</span>
228 {item.title}
229 </Link>
230 {(item.repos.length > 0 || waiting.length > 0 || (on && (on.people.length > 0 || on.sessions.length > 0))) && (
231 <div className="mt-1 flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-xs text-faint">
232 <RepoChips repos={item.repos} workspace={workspace} max={1} />
233 {waiting.length > 0 && (
234 <Badge tone="warn" className="max-w-full">
235 <span className="truncate">{waitingLabel(waiting)}</span>
236 </Badge>
237 )}
238 <OnItLine on={on} workspace={workspace} />
239 </div>
240 )}
241 </div>
242 <div className="flex shrink-0 items-center gap-1">
243 {item.appetite && <AppetiteChip appetite={item.appetite} />}
244 {menu}
245 </div>
246 </li>
247 );
248}