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.

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