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.

395 linesCodeBlameRaw
1/**
2 * Work items, drawn: a row of the Work list that reads on a phone, an
3 * item's repositories as chips, and the Work list's filters as menus on a
4 * computer and as a sheet that rises from the bottom on a phone. Where
5 * Work's pages are, and the filters, are worked out in lib/work-items.ts.
6 */
7import { Check, GitPullRequest, MessageSquare, Milestone as MilestoneIcon, SlidersHorizontal, X } from "lucide-react";
8import { type ReactNode, useState } from "react";
9import { Link } from "react-router";
10
11import type { ItemRepo, Label, LinkRole, Milestone, WorkItem } from "@g1t/contracts";
12
13import { FilterMenu, LabelChip, Swatch } from "./labels";
14import { BottomSheet } from "./mobile";
15import { TimeAgo } from "./ui";
16import { Badge } from "./ui/badge";
17import { Button } from "./ui/button";
18import { Hint } from "./ui/hint";
19import { AssigneeStack } from "./work";
20import { ItemIcon } from "./work-icons";
21import { cn } from "../lib/cn";
22import type { LabelColors } from "../lib/labels";
23import {
24 LINK_ROLES,
25 ROLE_LABELS,
26 type WorkFilters,
27 filterCount,
28 filtered,
29 itemPath,
30 repoChips,
31 shortPath,
32 workListPath,
33} from "../lib/work-items";
34
35/** How a repository is linked, as a quiet word beside its name. */
36export function RoleBadge({ role }: { role: LinkRole }) {
37 return <Badge tone={role === "target" ? "accent" : "neutral"}>{ROLE_LABELS[role]}</Badge>;
38}
39
40/**
41 * An item's repositories in a row: the first two (its origin first), then
42 * "+2" for the rest, so a row fits a phone. Each says how it is linked on
43 * hover, and a landed one is marked.
44 */
45export function RepoChips({ repos, workspace, max = 2, className }: { repos: ItemRepo[]; workspace: string; max?: number; className?: string }) {
46 if (repos.length === 0) return null;
47 const { shown, more } = repoChips(repos, max);
48 const rest = repos.slice(shown.length).map((repo) => shortPath(repo.path, workspace)).join(", ");
49 return (
50 <span className={cn("inline-flex min-w-0 items-center gap-1", className)}>
51 {shown.map((repo) => (
52 <Hint key={repo.repoId} label={`${repo.path}: ${ROLE_LABELS[repo.role].toLowerCase()}${repo.landed ? ", landed" : ""}`}>
53 <span
54 className={cn(
55 "inline-flex max-w-32 min-w-0 items-center gap-1 rounded-md border px-1.5 py-px font-mono text-[0.6875rem] leading-4",
56 repo.landed ? "border-merged/40 text-merged" : repo.role === "target" ? "border-line-strong text-fg-soft" : "border-line text-muted",
57 )}
58 >
59 {repo.landed && <Check size={10} className="shrink-0" />}
60 <span className="truncate">{shortPath(repo.path, workspace)}</span>
61 </span>
62 </Hint>
63 ))}
64 {more > 0 && (
65 <Hint label={rest}>
66 <span className="shrink-0 rounded-md border border-line px-1 py-px text-[0.6875rem] leading-4 text-muted">+{more}</span>
67 </Hint>
68 )}
69 </span>
70 );
71}
72
73/**
74 * One row of the Work list: its state, title and labels, then its number,
75 * who opened it and when, its repositories and milestone; its assignees,
76 * pull requests and comments at the end. On a phone the meta wraps under
77 * the title and the counts sit with it, so nothing scrolls sideways.
78 */
79export function WorkItemRow({ item, colors }: { item: WorkItem; colors: LabelColors }) {
80 return (
81 <li className="transition-colors hover:bg-surface">
82 <Link prefetch="intent" to={itemPath(item.workspace, item.number)} className="flex min-w-0 items-start gap-3 px-4 py-3">
83 <span className="mt-0.5">
84 <ItemIcon item={item} />
85 </span>
86 <span className="min-w-0 grow">
87 <span className="flex flex-wrap items-center gap-x-2 gap-y-1">
88 <span className="min-w-0 font-medium wrap-break-word">{item.title}</span>
89 {item.labels.map((name) => (
90 <LabelChip key={name} name={name} color={colors[name]} />
91 ))}
92 </span>
93 <span className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-faint">
94 <span className="whitespace-nowrap">
95 #{item.number} · <TimeAgo at={item.state === "closed" && item.closedAt ? item.closedAt : item.createdAt} />
96 </span>
97 <span className="hidden whitespace-nowrap sm:inline">
98 {item.state === "closed" ? "closed" : "opened"} by {item.author.username}
99 </span>
100 <RepoChips repos={item.repos} workspace={item.workspace} />
101 {item.milestone && (
102 <span className="inline-flex min-w-0 items-center gap-1">
103 <MilestoneIcon size={11} className="shrink-0" />
104 <span className="max-w-36 truncate">{item.milestone.title}</span>
105 </span>
106 )}
107 <span className="ml-auto flex shrink-0 items-center gap-3 text-muted sm:hidden">
108 <Counts item={item} />
109 </span>
110 </span>
111 </span>
112 <span className="mt-0.5 hidden shrink-0 items-center gap-3 text-xs text-muted sm:flex">
113 <AssigneeStack people={item.assignees} />
114 <Counts item={item} />
115 </span>
116 </Link>
117 </li>
118 );
119}
120
121function Counts({ item }: { item: WorkItem }) {
122 return (
123 <>
124 {item.pullCount > 0 && (
125 <span className="flex items-center gap-1" aria-label={`${item.pullCount} pull ${item.pullCount === 1 ? "request" : "requests"}`}>
126 <GitPullRequest size={13} />
127 {item.pullCount}
128 </span>
129 )}
130 {item.commentCount > 0 && (
131 <span className="flex items-center gap-1" aria-label={`${item.commentCount} ${item.commentCount === 1 ? "comment" : "comments"}`}>
132 <MessageSquare size={13} />
133 {item.commentCount}
134 </span>
135 )}
136 </>
137 );
138}
139
140/** What the Work list's filters offer: each may have failed to load, and says so in its menu. */
141export type FilterChoices = {
142 repos: string[] | null;
143 labels: Label[] | null;
144 milestones: Milestone[] | null;
145 /** The workspace's people, for a member; null when there are none to offer. */
146 people: string[] | null;
147 /** The viewer, for "Assigned to you". */
148 me: string | null;
149};
150
151/**
152 * The Work list's filters: on a computer, a menu each (repository, its
153 * role, label, milestone, assignee); on a phone, one Filters button that
154 * says how many are on and opens them as a sheet from the bottom, within
155 * reach of a thumb. Each choice is a link, so the address keeps them.
156 */
157export function WorkFiltersBar({ workspace, filters, choices }: { workspace: string; filters: WorkFilters; choices: FilterChoices }) {
158 const [open, setOpen] = useState(false);
159 const to = (change: Partial<WorkFilters>) => workListPath(workspace, filters, change);
160 const milestoneTitle = choices.milestones?.find((m) => m.number === filters.milestone)?.title ?? (filters.milestone ? `#${filters.milestone}` : null);
161 const count = filterCount(filters);
162 const clear = workListPath(workspace, { state: filters.state });
163 const assigneeLabel = filters.assignee === "@me" || filters.assignee === choices.me ? "You" : filters.assignee;
164 return (
165 <>
166 <div className="hidden flex-wrap items-center gap-2 sm:flex">
167 <FilterMenu
168 label="Repository"
169 active={filters.repo ? shortPath(filters.repo, workspace) : undefined}
170 clearTo={filters.repo ? to({ repo: "", role: null }) : undefined}
171 searchPlaceholder="Filter repositories"
172 emptyText={choices.repos ? "No repository matches." : "Repositories could not be loaded just now."}
173 options={(choices.repos ?? []).map((name) => ({
174 key: name,
175 to: to({ repo: name }),
176 keywords: name,
177 selected: shortPath(filters.repo, workspace) === name,
178 label: <span className="truncate font-mono text-[0.8125rem]">{name}</span>,
179 }))}
180 />
181 {filters.repo && (
182 <FilterMenu
183 label="Role"
184 active={filters.role ? ROLE_LABELS[filters.role] : undefined}
185 clearTo={filters.role ? to({ role: null }) : undefined}
186 searchPlaceholder="Filter roles"
187 emptyText="No role matches."
188 options={LINK_ROLES.map((role) => ({
189 key: role,
190 to: to({ role }),
191 keywords: role,
192 selected: filters.role === role,
193 label: <span>{ROLE_LABELS[role]}</span>,
194 }))}
195 />
196 )}
197 <FilterMenu
198 label="Label"
199 active={filters.label || undefined}
200 clearTo={filters.label ? to({ label: "" }) : undefined}
201 searchPlaceholder="Filter labels"
202 emptyText={choices.labels ? "No label matches." : "Labels could not be loaded just now."}
203 options={(choices.labels ?? []).map((label) => ({
204 key: label.name,
205 to: to({ label: label.name }),
206 keywords: `${label.name} ${label.description}`,
207 selected: label.name === filters.label,
208 label: (
209 <>
210 <Swatch color={label.color} />
211 <span className="truncate">{label.name}</span>
212 </>
213 ),
214 }))}
215 />
216 <FilterMenu
217 label="Milestone"
218 active={milestoneTitle ?? undefined}
219 clearTo={filters.milestone ? to({ milestone: null }) : undefined}
220 searchPlaceholder="Filter milestones"
221 emptyText={choices.milestones ? "No open milestone matches." : "Milestones could not be loaded just now."}
222 options={(choices.milestones ?? []).map((milestone) => ({
223 key: String(milestone.number),
224 to: to({ milestone: milestone.number }),
225 keywords: `${milestone.title} ${milestone.number}`,
226 selected: milestone.number === filters.milestone,
227 label: <span className="truncate">{milestone.title}</span>,
228 }))}
229 />
230 {(choices.people || choices.me) && (
231 <FilterMenu
232 label="Assignee"
233 active={assigneeLabel || undefined}
234 clearTo={filters.assignee ? to({ assignee: "" }) : undefined}
235 searchPlaceholder="Filter people"
236 emptyText="Nobody matches."
237 options={assigneeOptions(choices).map((name) => ({
238 key: name,
239 to: to({ assignee: name }),
240 keywords: name,
241 selected: name === filters.assignee,
242 label: <span className="truncate">{name === choices.me ? `${name} (you)` : name}</span>,
243 }))}
244 />
245 )}
246 {filtered(filters) && (
247 <Link to={clear} className="text-xs text-muted hover:text-fg">
248 Clear filters
249 </Link>
250 )}
251 </div>
252
253 <div className="flex items-center gap-2 sm:hidden">
254 <Button type="button" variant="outline" size="sm" onClick={() => setOpen(true)} aria-haspopup="dialog" className="h-10 gap-2 px-3.5">
255 <SlidersHorizontal size={15} />
256 Filters
257 {count > 0 && <span className="rounded-full bg-accent px-1.5 text-[0.6875rem] leading-4.5 font-bold text-bg tabular-nums">{count}</span>}
258 </Button>
259 {filters.repo && (
260 <span className="min-w-0 truncate font-mono text-xs text-muted">
261 {shortPath(filters.repo, workspace)}
262 {filters.role && ` · ${ROLE_LABELS[filters.role].toLowerCase()}`}
263 </span>
264 )}
265 </div>
266 <BottomSheet open={open} onOpenChange={setOpen} title="Filters">
267 <div className="flex items-center justify-between px-3 pt-1 pb-2">
268 <h2 className="text-base font-semibold">Filters</h2>
269 {count > 0 && (
270 <Link to={clear} onClick={() => setOpen(false)} className="inline-flex h-10 items-center gap-1 px-2 text-sm text-muted active:text-fg">
271 <X size={14} />
272 Clear all
273 </Link>
274 )}
275 </div>
276 <SheetChoices
277 title="Repository"
278 failed={choices.repos == null}
279 onPick={() => setOpen(false)}
280 choices={[
281 { key: "any", label: "Any repository", to: to({ repo: "", role: null }), on: !filters.repo },
282 ...(choices.repos ?? []).map((name) => ({
283 key: name,
284 label: <span className="font-mono text-sm">{name}</span>,
285 to: to({ repo: name }),
286 on: shortPath(filters.repo, workspace) === name,
287 })),
288 ]}
289 />
290 {filters.repo && (
291 <SheetChoices
292 title="Linked as"
293 onPick={() => setOpen(false)}
294 choices={[
295 { key: "any", label: "Any role", to: to({ role: null }), on: !filters.role },
296 ...LINK_ROLES.map((role) => ({ key: role, label: ROLE_LABELS[role], to: to({ role }), on: filters.role === role })),
297 ]}
298 />
299 )}
300 <SheetChoices
301 title="Label"
302 failed={choices.labels == null}
303 onPick={() => setOpen(false)}
304 choices={[
305 { key: "any", label: "Any label", to: to({ label: "" }), on: !filters.label },
306 ...(choices.labels ?? []).map((label) => ({
307 key: label.name,
308 label: (
309 <span className="inline-flex items-center gap-2">
310 <Swatch color={label.color} />
311 {label.name}
312 </span>
313 ),
314 to: to({ label: label.name }),
315 on: label.name === filters.label,
316 })),
317 ]}
318 />
319 <SheetChoices
320 title="Milestone"
321 failed={choices.milestones == null}
322 onPick={() => setOpen(false)}
323 choices={[
324 { key: "any", label: "Any milestone", to: to({ milestone: null }), on: !filters.milestone },
325 ...(choices.milestones ?? []).map((milestone) => ({
326 key: String(milestone.number),
327 label: milestone.title,
328 to: to({ milestone: milestone.number }),
329 on: milestone.number === filters.milestone,
330 })),
331 ]}
332 />
333 {(choices.people || choices.me) && (
334 <SheetChoices
335 title="Assignee"
336 onPick={() => setOpen(false)}
337 choices={[
338 { key: "any", label: "Anyone", to: to({ assignee: "" }), on: !filters.assignee },
339 ...assigneeOptions(choices).map((name) => ({
340 key: name,
341 label: name === choices.me ? `${name} (you)` : name,
342 to: to({ assignee: name }),
343 on: name === filters.assignee,
344 })),
345 ]}
346 />
347 )}
348 </BottomSheet>
349 </>
350 );
351}
352
353/** The people an item can be filtered by assignee: the viewer first, then everyone else in the workspace. */
354function assigneeOptions(choices: FilterChoices): string[] {
355 const others = (choices.people ?? []).filter((name) => name !== choices.me);
356 return choices.me ? [choices.me, ...others] : others;
357}
358
359/** One filter in the phone's sheet: a heading and its choices, 48px rows, the one on ticked. */
360export function SheetChoices({
361 title,
362 choices,
363 failed = false,
364 onPick,
365}: {
366 title: string;
367 choices: { key: string; label: ReactNode; to: string; on: boolean }[];
368 failed?: boolean;
369 onPick: () => void;
370}) {
371 return (
372 <section className="border-t border-line pt-2 pb-1">
373 <h3 className="px-3 pt-1 pb-1 text-xs font-medium text-faint">{title}</h3>
374 {failed && <p className="px-3 pb-2 text-xs text-muted">{title === "Repository" ? "Repositories" : `${title}s`} could not be loaded just now.</p>}
375 <ul>
376 {choices.map((choice) => (
377 <li key={choice.key}>
378 <Link
379 to={choice.to}
380 onClick={onPick}
381 aria-current={choice.on ? "true" : undefined}
382 className={cn(
383 "flex h-12 w-full items-center gap-3 rounded-md px-3 text-[0.9375rem] transition-colors active:bg-raised",
384 choice.on ? "font-medium text-fg" : "text-fg-soft",
385 )}
386 >
387 <span className="flex w-5 shrink-0 justify-center">{choice.on && <Check size={16} className="text-accent" />}</span>
388 <span className="min-w-0 grow truncate">{choice.label}</span>
389 </Link>
390 </li>
391 ))}
392 </ul>
393 </section>
394 );
395}