| 1 | import { Bot, Check, CircleDashed, ExternalLink, GitCommitHorizontal, GitMerge, Lightbulb, Plus, X } from "lucide-react"; |
| 2 | import { useState } from "react"; |
| 3 | import { Form, Link, data, redirect } from "react-router"; |
| 4 | |
| 5 | import { |
| 6 | AGENT_MODES, |
| 7 | type ItemRepo, |
| 8 | type LinkRole, |
| 9 | type Pull, |
| 10 | PROVIDERS, |
| 11 | REACTIONS, |
| 12 | type SuggestedTarget, |
| 13 | type WorkPull, |
| 14 | type WorkRepo, |
| 15 | missingTargets, |
| 16 | workOwner, |
| 17 | } from "@g1t/contracts"; |
| 18 | |
| 19 | import type { Route } from "./+types/item"; |
| 20 | import { excerpt, page } from "../../../lib/meta"; |
| 21 | import { Markdown } from "../../../components/markdown"; |
| 22 | import { CopyLine, EmptyState, ErrorText, Input, SubmitButton, TimeAgo } from "../../../components/ui"; |
| 23 | import { Avatar } from "../../../components/ui/avatar"; |
| 24 | import { Button } from "../../../components/ui/button"; |
| 25 | import { Card } from "../../../components/ui/card"; |
| 26 | import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "../../../components/ui/dialog"; |
| 27 | import { Hint } from "../../../components/ui/hint"; |
| 28 | import { SelectField } from "../../../components/ui/select"; |
| 29 | import { CheckBadge } from "../../../components/checks"; |
| 30 | import { LabelChip, LabelsBox, MilestoneBox } from "../../../components/labels"; |
| 31 | import { Assignee, ChangeSize, CommentForm, CommentList, ItemState, PeoplePicker, PersonLink, PullIcon, plainText } from "../../../components/work"; |
| 32 | import { RoleBadge } from "../../../components/work-items"; |
| 33 | import { DetailsDisclosure, detailsSummary } from "../../../components/details-disclosure"; |
| 34 | import { DecisionList, IntegrationFailure, ItemWorkPanel, StartSessionDialog, TimelineSession } from "../../../components/coordination"; |
| 35 | import { Reactions } from "../../../components/reactions"; |
| 36 | import { SubscriptionBox } from "../../../components/notifications"; |
| 37 | import { subscriptionFromForm } from "../../../lib/inbox"; |
| 38 | import { Boundary, Unavailable } from "../../../components/boundary"; |
| 39 | import { notFound } from "../../../lib/not-found.server"; |
| 40 | import { openedBy } from "../../../lib/opened-by"; |
| 41 | import { agentSessions, identity, inbox, integrations, repos, work } from "../../../lib/services.server"; |
| 42 | import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../../lib/session.server"; |
| 43 | import { useRefreshWhile } from "../../../lib/refresh"; |
| 44 | import { isFailed, part } from "../../../lib/partial"; |
| 45 | import { parseBlockedBy, resolutionOf } from "../../../lib/coordination"; |
| 46 | import { refPath } from "../../../lib/refs.server"; |
| 47 | import { itemAbilities } from "../../../lib/work-access"; |
| 48 | import { LINK_ROLES, ROLE_LABELS, itemPath, originOf, repoFilter, sessionPath, shortPath, workPath } from "../../../lib/work-items"; |
| 49 | import { workspaceProjects } from "../../../lib/workspace-projects.server"; |
| 50 | import { workPages } from "../../../lib/repo-live"; |
| 51 | import { useWorkspaceLive } from "../../../lib/use-repo-live"; |
| 52 | |
| 53 | export function meta({ loaderData, params, ...args }: Route.MetaArgs) { |
| 54 | const item = loaderData?.item; |
| 55 | const title = item ? `${item.title} · Work item #${item.number} · ` : ""; |
| 56 | const state = item?.state === "open" ? "Open" : item?.reason === "not_planned" ? "Closed as not planned" : "Closed"; |
| 57 | const body = excerpt(item?.body); |
| 58 | return page(args, { |
| 59 | title: `${title}${params.owner} · g1t`, |
| 60 | description: item |
| 61 | ? `${state} work item #${item.number} in ${params.owner}, opened by ${item.author.username}${item.requestedBy ? ` for ${item.requestedBy.username}` : ""}.${body ? ` ${body}` : ""}` |
| 62 | : null, |
| 63 | // The card shows the title and the state. |
| 64 | version: item ? [item.title, item.state, item.reason] : undefined, |
| 65 | type: "article", |
| 66 | }); |
| 67 | } |
| 68 | |
| 69 | /** `owner/name` as a repository path. */ |
| 70 | function pathOf(path: string): { namespace: string; name: string } { |
| 71 | const [namespace = "", name = ""] = path.split("/"); |
| 72 | return { namespace, name }; |
| 73 | } |
| 74 | |
| 75 | export async function loader({ params, context }: Route.LoaderArgs) { |
| 76 | const viewer = getViewer(context); |
| 77 | const workspace = params.owner.toLowerCase(); |
| 78 | const number = Number(params.number); |
| 79 | if (!Number.isInteger(number) || number < 1) throw notFound("page"); |
| 80 | const member = roleIn(viewer, workspace) != null; |
| 81 | // The item is what the page is about: without it there is no page. |
| 82 | // Everything beside it is a part (lib/partial.ts), which says it could |
| 83 | // not be loaded in its own place when its service fails. |
| 84 | const [found, labels, milestones, members, teams, coordination, ended, agentSetup] = await Promise.all([ |
| 85 | work.getWorkItem(workspace, number, viewer), |
| 86 | part(work.listLabels(workspace, viewer), "Labels"), |
| 87 | // Open ones to choose from, and the one it is in, with its progress. |
| 88 | part(work.listMilestones(workspace, viewer), "Milestones"), |
| 89 | // A member picks assignees from the workspace's people. |
| 90 | member ? part(identity.listMembers(workspace, viewer), "The workspace's people") : null, |
| 91 | // The teams a member can mention, for the comment box: only a |
| 92 | // suggestion, so the box quietly does without them. |
| 93 | member ? identity.listTeams(viewer, workspace).catch(() => null) : null, |
| 94 | // Its repositories with their pull requests and what landed where, who |
| 95 | // is on it, its sessions, what it waits on, and what it may also need |
| 96 | // (services/work `get_work`). |
| 97 | part(work.getWork(workspace, number, viewer), "Work"), |
| 98 | // Its claims that ended, shown muted under the active ones: history |
| 99 | // the panel can do without when it cannot be read. |
| 100 | Promise.all( |
| 101 | (["released", "expired", "handed_off"] as const).map((state) => work.listClaims(workspace, viewer, { state, item: number }).catch(() => null)), |
| 102 | ), |
| 103 | // Whether a member can start a session on the workspace's Anthropic |
| 104 | // account: the button is simply not offered when this cannot be read. |
| 105 | member ? agentSessions.setup(workspace, viewer).catch(() => null) : null, |
| 106 | ]); |
| 107 | if (!found.ok) { |
| 108 | // Work items and pull requests share the workspace's numbers: this may be a pull request. |
| 109 | const to = await refPath(viewer, { workspace }, number); |
| 110 | if (to && to !== itemPath(workspace, number)) throw redirect(to); |
| 111 | throw notFound("page"); |
| 112 | } |
| 113 | const { item } = found.value; |
| 114 | const origin = originOf(item.repos); |
| 115 | // Who may do what follows the item: the better of their role in the |
| 116 | // workspace and on its origin repository (lib/work-access.ts). |
| 117 | const [originRepo, links, subscription, sessionTrees] = await Promise.all([ |
| 118 | origin ? repos.getById(origin.repoId, viewer).catch(() => null) : null, |
| 119 | // What it is tied to outside g1t. Shown only once the item is known visible. |
| 120 | part(integrations.links(workspace, number), "Links from outside g1t"), |
| 121 | // Whether the viewer hears of it, for the sidebar's Notifications. |
| 122 | viewer ? part(inbox.subscription(viewer, { itemId: item.id, number }), "Notifications") : null, |
| 123 | // Every session tied to it in its repositories, with their subagents, for the Work panel's trees. |
| 124 | part( |
| 125 | Promise.all( |
| 126 | item.repos.slice(0, 6).map((linked) => work.listWorkSessions(pathOf(linked.path), viewer, { item: number, subagents: true })), |
| 127 | ).then((lists) => lists.flatMap((list) => (list.ok ? list.value : []))), |
| 128 | "Sessions", |
| 129 | ), |
| 130 | ]); |
| 131 | const ref = originRepo?.ok ? { id: originRepo.value.id, namespace: originRepo.value.namespace, isPrivate: originRepo.value.isPrivate } : null; |
| 132 | const can = itemAbilities(viewer, workspace, ref); |
| 133 | // The repositories that can be linked, for whoever may link them. |
| 134 | const projects = can.triage ? await part(workspaceProjects(workspace, viewer), "Repositories") : null; |
| 135 | return { |
| 136 | repos: |
| 137 | projects == null || isFailed(projects) || !projects.ok |
| 138 | ? null |
| 139 | : projects.value.filter((p) => p.workspace.toLowerCase() === workspace).map((p) => p.slug).sort(), |
| 140 | ...found.value, |
| 141 | workspace, |
| 142 | viewer, |
| 143 | member, |
| 144 | labels: isFailed(labels) ? labels : labels.ok ? labels.value : [], |
| 145 | milestones: isFailed(milestones) ? milestones : milestones.ok ? milestones.value : [], |
| 146 | links, |
| 147 | subscription, |
| 148 | coordination: isFailed(coordination) ? coordination : coordination.ok ? coordination.value : null, |
| 149 | sessionTrees, |
| 150 | // Only when it can start: the button is not shown otherwise. |
| 151 | agentSetup: agentSetup?.ok && agentSetup.value.available ? agentSetup.value : null, |
| 152 | endedClaims: ended |
| 153 | .flatMap((claims) => (claims?.ok ? claims.value : [])) |
| 154 | .sort((a, b) => Date.parse(b.endedAt ?? b.createdAt) - Date.parse(a.endedAt ?? a.createdAt)), |
| 155 | members: members == null ? [] : isFailed(members) ? members : members.ok ? members.value.map((m) => m.username) : [], |
| 156 | handles: teams?.ok ? teams.value.map((team) => `@${team.workspace}/${team.slug}`) : [], |
| 157 | // The author can close and reopen their own work item, and whoever g1t's |
| 158 | // agent filed one for, that one; Triage and up, anyone's. |
| 159 | canManage: viewer != null && (viewer.id === workOwner(item).id || can.triage), |
| 160 | // Maintain and up edit and delete anyone's comments; everyone, their own. |
| 161 | canModerate: can.manage_settings, |
| 162 | can, |
| 163 | }; |
| 164 | } |
| 165 | |
| 166 | /** The repository a form names, by path, if it is one the item is linked to (or, to link, one of the workspace). */ |
| 167 | function formRepo(form: FormData, workspace: string): { namespace: string; name: string } | null { |
| 168 | return repoFilter(workspace, String(form.get("repo") ?? "").trim()); |
| 169 | } |
| 170 | |
| 171 | export async function action({ request, params, context }: Route.ActionArgs) { |
| 172 | assertSameOrigin(request); |
| 173 | const user = requireUser(context, request); |
| 174 | const workspace = params.owner.toLowerCase(); |
| 175 | const number = Number(params.number); |
| 176 | const thread = { workspace, number }; |
| 177 | const form = await request.formData(); |
| 178 | const intent = String(form.get("action") ?? ""); |
| 179 | // Subscribing to it, from the sidebar's Notifications (components/notifications.tsx). |
| 180 | const choice = subscriptionFromForm(form); |
| 181 | if (choice) { |
| 182 | const found = await work.getWorkItem(workspace, number, user); |
| 183 | if (!found.ok) return data({ error: "That work item could not be found." }, { status: 404 }); |
| 184 | const subscription = await inbox.subscribe(user, { itemId: found.value.item.id, number }, choice.subscribed, choice.ignored).catch(() => null); |
| 185 | return subscription ? { error: null, subscription } : data({ error: "That did not save. Try again in a moment." }, { status: 503 }); |
| 186 | } |
| 187 | // Who may do each is checked by the work service, against the item's |
| 188 | // workspace and origin; what it refuses says why. |
| 189 | switch (intent) { |
| 190 | case "open-pull": { |
| 191 | const repo = formRepo(form, workspace); |
| 192 | if (!repo) return { error: "Choose the repository to open it in.", action: intent }; |
| 193 | const result = await work.openPull(user, repo, { item: number, agent: String(form.get("agent") ?? ""), runtime: "external" }); |
| 194 | if (!result.ok) return { error: result.error.message, action: intent }; |
| 195 | throw redirect(`/${repo.namespace}/${repo.name}/pull/${result.value.number}`); |
| 196 | } |
| 197 | // A session on the workspace's Anthropic account, in one of its |
| 198 | // repositories, which the integrations service checks the person may |
| 199 | // start; then its page. |
| 200 | case "start-session": { |
| 201 | const repo = formRepo(form, workspace); |
| 202 | if (!repo) return { error: "Link a repository for it to work in first.", action: intent }; |
| 203 | const mode = AGENT_MODES.find((one) => one.mode === form.get("mode"))?.mode ?? "implement"; |
| 204 | const instruction = String(form.get("instruction") ?? "").trim(); |
| 205 | const result = await agentSessions.start(user, repo, { item: number, mode, instruction: instruction || undefined, handoff: false }).catch(() => null); |
| 206 | if (!result) return { error: "The session could not be started. Try again in a moment.", action: intent }; |
| 207 | if (!result.ok) return { error: result.error.message, action: intent }; |
| 208 | const started = result.value; |
| 209 | const to = sitePath(started.sessionUrl, request.url) ?? (started.session ? sessionPath(repo.namespace, started.session.id) : null); |
| 210 | if (!to) return { error: "No session was started.", action: intent }; |
| 211 | throw redirect(to); |
| 212 | } |
| 213 | case "comment": { |
| 214 | const result = await work.addComment(user, thread, { body: String(form.get("body") ?? "") }); |
| 215 | return result.ok ? null : { error: result.error.message }; |
| 216 | } |
| 217 | // A reaction to the item or one of its comments, or taking one back. |
| 218 | case "react": { |
| 219 | const content = REACTIONS.find((one) => one.content === form.get("content"))?.content; |
| 220 | if (!content) return { error: "Pick a reaction.", action: intent }; |
| 221 | const result = await work.react(user, thread, { |
| 222 | commentId: String(form.get("comment") ?? "") || undefined, |
| 223 | content, |
| 224 | remove: form.get("remove") === "1", |
| 225 | }); |
| 226 | return result.ok ? null : { error: result.error.message, action: intent }; |
| 227 | } |
| 228 | case "assign": { |
| 229 | const result = await work.updateWorkItem(user, workspace, number, { |
| 230 | assignees: [...form.getAll("assignee").map(String), ...String(form.get("others") ?? "").split(/[\s,]+/)].filter(Boolean), |
| 231 | }); |
| 232 | return result.ok ? null : { error: result.error.message }; |
| 233 | } |
| 234 | case "labels": { |
| 235 | const result = await work.setLabels(user, thread, form.getAll("label").map(String)); |
| 236 | return result.ok ? null : { error: result.error.message, action: intent }; |
| 237 | } |
| 238 | case "blocked-by": { |
| 239 | const blockedBy = parseBlockedBy(String(form.get("blocked_by") ?? "")); |
| 240 | if (!blockedBy) return { error: "List work item numbers, such as #12, #14.", action: intent }; |
| 241 | const result = await work.updateWorkItem(user, workspace, number, { blocked_by: blockedBy }); |
| 242 | return result.ok ? null : { error: result.error.message, action: intent }; |
| 243 | } |
| 244 | case "milestone": { |
| 245 | const result = await work.updateWorkItem(user, workspace, number, { milestone: Number(form.get("milestone")) || 0 }); |
| 246 | return result.ok ? null : { error: result.error.message, action: intent }; |
| 247 | } |
| 248 | // Linking a repository in a role (a suggestion's Add as target is one), or unlinking one. |
| 249 | case "link": |
| 250 | case "unlink": { |
| 251 | const repo = formRepo(form, workspace); |
| 252 | if (!repo) return { error: "Choose a repository of the workspace.", action: "link" }; |
| 253 | const role = LINK_ROLES.find((one) => one === form.get("role")) ?? "target"; |
| 254 | const result = |
| 255 | intent === "link" ? await work.linkWorkItem(user, workspace, number, repo, role) : await work.unlinkWorkItem(user, workspace, number, repo); |
| 256 | return result.ok ? { linked: intent } : { error: result.error.message, action: "link" }; |
| 257 | } |
| 258 | case "reopen": { |
| 259 | const result = await work.reopenWorkItem(user, workspace, number); |
| 260 | return result.ok ? null : { error: result.error.message }; |
| 261 | } |
| 262 | // A comment's own text, changed or taken away: its author's to do, or |
| 263 | // a maintainer's, which work checks. |
| 264 | case "edit-comment": |
| 265 | case "delete-comment": { |
| 266 | const id = String(form.get("comment") ?? ""); |
| 267 | const result = |
| 268 | intent === "edit-comment" |
| 269 | ? await work.editComment(user, { workspace }, id, String(form.get("body") ?? "")) |
| 270 | : await work.deleteComment(user, { workspace }, id); |
| 271 | return result.ok ? null : { error: result.error.message, action: intent, comment: id }; |
| 272 | } |
| 273 | case "close-completed": |
| 274 | case "close-not-planned": { |
| 275 | const result = await work.closeWorkItem(user, workspace, number, intent === "close-not-planned" ? "not_planned" : "completed"); |
| 276 | return result.ok ? null : { error: result.error.message }; |
| 277 | } |
| 278 | } |
| 279 | throw data(null, { status: 400 }); |
| 280 | } |
| 281 | |
| 282 | /** The path of a page of this site an address names, to redirect to; null for none or another site's. */ |
| 283 | function sitePath(url: string | null | undefined, here: string): string | null { |
| 284 | if (!url) return null; |
| 285 | try { |
| 286 | const parsed = new URL(url, here); |
| 287 | const own = parsed.origin === new URL(here).origin || parsed.hostname === "g1t.sh"; |
| 288 | return own ? parsed.pathname + parsed.search : null; |
| 289 | } catch { |
| 290 | return null; |
| 291 | } |
| 292 | } |
| 293 | |
| 294 | /** What became of a pull request, in a few words. */ |
| 295 | function outcome(pull: { status: Pull["status"] } & Partial<Pick<Pull, "mergedBy" | "supersededBy">>): string { |
| 296 | if (pull.status === "merged") return pull.mergedBy ? `Merged by ${pull.mergedBy}` : "Merged"; |
| 297 | if (pull.supersededBy != null) return `Closed · #${pull.supersededBy} was merged instead`; |
| 298 | if (pull.status === "closed") return "Closed without merging"; |
| 299 | if (pull.status === "draft") return "Draft · in progress"; |
| 300 | return "Ready for review"; |
| 301 | } |
| 302 | |
| 303 | /** One of a repository's pull requests for the item, as a line under the repository. */ |
| 304 | function RepoPullLine({ repo, pull, full }: { repo: string; pull: WorkPull; full?: Pull }) { |
| 305 | const merged = pull.status === "merged"; |
| 306 | return ( |
| 307 | <li> |
| 308 | <Link |
| 309 | to={`/${repo}/pull/${pull.number}`} |
| 310 | prefetch="intent" |
| 311 | className={`flex min-w-0 items-start gap-2.5 rounded-lg px-2 py-2 transition-colors hover:bg-raised ${pull.status === "closed" ? "opacity-70" : ""}`} |
| 312 | > |
| 313 | <span className="mt-0.5"> |
| 314 | <PullIcon status={pull.status} size={15} /> |
| 315 | </span> |
| 316 | <span className="min-w-0 grow"> |
| 317 | <span className="block text-sm font-medium wrap-break-word"> |
| 318 | {pull.title} <span className="font-normal text-faint">#{pull.number}</span> |
| 319 | </span> |
| 320 | <span className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted"> |
| 321 | <span className={merged ? "font-medium text-merged" : ""}>{outcome({ status: pull.status, mergedBy: full?.mergedBy, supersededBy: full?.supersededBy })}</span> |
| 322 | <span>· {pull.author.username}</span> |
| 323 | {full && <ChangeSize files={full.files} />} |
| 324 | <CheckBadge status={pull.checkStatus} /> |
| 325 | {pull.queue && <span>· in the merge queue, {pull.queue.position === 1 ? "next" : `#${pull.queue.position}`}</span>} |
| 326 | </span> |
| 327 | </span> |
| 328 | </Link> |
| 329 | </li> |
| 330 | ); |
| 331 | } |
| 332 | |
| 333 | /** |
| 334 | * One repository the item is linked to: its path and role, whether what |
| 335 | * closes the item has landed there (or, for a target, that it is still |
| 336 | * missing), and its pull requests for it. With Triage, the cross unlinks |
| 337 | * it (never its origin). |
| 338 | */ |
| 339 | function RepoCard({ repo, pulls, full, canLink }: { repo: ItemRepo; pulls: WorkPull[]; full: Map<string, Pull>; canLink: boolean }) { |
| 340 | const missing = repo.role === "target" && !repo.landed; |
| 341 | return ( |
| 342 | <li className="px-3 py-3 sm:px-4"> |
| 343 | <div className="flex min-w-0 items-center gap-2"> |
| 344 | {repo.path ? ( |
| 345 | <Link to={`/${repo.path}`} className="min-w-0 truncate font-mono text-sm font-medium hover:text-accent"> |
| 346 | {repo.path} |
| 347 | </Link> |
| 348 | ) : ( |
| 349 | // Deleted before work items: nothing to link to, and no name kept. |
| 350 | <span className="min-w-0 truncate text-sm text-muted">A deleted repository</span> |
| 351 | )} |
| 352 | <RoleBadge role={repo.role} /> |
| 353 | {repo.legacyNumber != null && ( |
| 354 | <Hint label={repo.path ? `It was ${repo.path}#${repo.legacyNumber} before work items, and that still leads here.` : `It was #${repo.legacyNumber} there before work items.`}> |
| 355 | <span className="shrink-0 text-xs text-faint">was #{repo.legacyNumber}</span> |
| 356 | </Hint> |
| 357 | )} |
| 358 | <span className="ml-auto flex shrink-0 items-center gap-1"> |
| 359 | {repo.landed ? ( |
| 360 | <span className="inline-flex items-center gap-1 text-xs font-medium text-merged"> |
| 361 | <Check size={13} /> |
| 362 | Landed |
| 363 | </span> |
| 364 | ) : missing ? ( |
| 365 | <span className="inline-flex items-center gap-1 text-xs text-warn"> |
| 366 | <CircleDashed size={13} /> |
| 367 | Missing |
| 368 | </span> |
| 369 | ) : null} |
| 370 | {canLink && repo.role !== "origin" && ( |
| 371 | <Form method="post"> |
| 372 | <input type="hidden" name="action" value="unlink" /> |
| 373 | <input type="hidden" name="repo" value={repo.path} /> |
| 374 | <Hint label={`Unlink ${repo.path}`}> |
| 375 | <Button type="submit" variant="ghost" size="icon-xs" aria-label={`Unlink ${repo.path}`} className="max-sm:size-9"> |
| 376 | <X size={14} /> |
| 377 | </Button> |
| 378 | </Hint> |
| 379 | </Form> |
| 380 | )} |
| 381 | </span> |
| 382 | </div> |
| 383 | {pulls.length > 0 ? ( |
| 384 | <ul className="mt-1.5 -mx-2 space-y-0.5"> |
| 385 | {pulls.map((pull) => ( |
| 386 | <RepoPullLine key={pull.number} repo={repo.path} pull={pull} full={full.get(`${repo.repoId}#${pull.number}`)} /> |
| 387 | ))} |
| 388 | </ul> |
| 389 | ) : ( |
| 390 | <p className="mt-1 text-xs text-faint">{repo.role === "touched" ? "Touched by a commit." : "No pull request for it here yet."}</p> |
| 391 | )} |
| 392 | </li> |
| 393 | ); |
| 394 | } |
| 395 | |
| 396 | /** Link a repository: one of the workspace's, in a role. A sheet from the bottom on a phone. */ |
| 397 | function LinkRepository({ |
| 398 | workspace, |
| 399 | repos, |
| 400 | linked, |
| 401 | error, |
| 402 | wide = false, |
| 403 | }: { |
| 404 | workspace: string; |
| 405 | /** The workspace's repositories by name; null when they could not be read. */ |
| 406 | repos: string[] | null; |
| 407 | linked: string[]; |
| 408 | error?: string | null; |
| 409 | wide?: boolean; |
| 410 | }) { |
| 411 | const [open, setOpen] = useState(Boolean(error)); |
| 412 | const choices = (repos ?? []).filter((name) => !linked.includes(`${workspace}/${name}`.toLowerCase())); |
| 413 | return ( |
| 414 | <Dialog open={open} onOpenChange={setOpen}> |
| 415 | <DialogTrigger asChild> |
| 416 | <Button variant="outline" size="sm" className={wide ? "h-11 w-full" : undefined}> |
| 417 | <Plus size={14} /> |
| 418 | Link a repository |
| 419 | </Button> |
| 420 | </DialogTrigger> |
| 421 | <DialogContent> |
| 422 | <Form method="post" className="grid gap-5" onSubmit={() => setOpen(false)}> |
| 423 | <input type="hidden" name="action" value="link" /> |
| 424 | <DialogHeader> |
| 425 | <DialogTitle>Link a repository</DialogTitle> |
| 426 | <DialogDescription> |
| 427 | A target is where the work is meant to land: the item closes once every target has landed. Touched marks one it reached |
| 428 | without having to land there. |
| 429 | </DialogDescription> |
| 430 | </DialogHeader> |
| 431 | {repos == null ? ( |
| 432 | <Unavailable what="The workspace's repositories" compact /> |
| 433 | ) : ( |
| 434 | <SelectField |
| 435 | name="repo" |
| 436 | aria-label="Repository" |
| 437 | defaultValue={choices[0] ?? ""} |
| 438 | options={choices.length > 0 ? choices.map((name) => ({ value: name, label: name })) : [{ value: "", label: "No other repository to link" }]} |
| 439 | /> |
| 440 | )} |
| 441 | <SelectField |
| 442 | name="role" |
| 443 | aria-label="Role" |
| 444 | defaultValue="target" |
| 445 | options={(["target", "touched"] as LinkRole[]).map((role) => ({ value: role, label: ROLE_LABELS[role] }))} |
| 446 | /> |
| 447 | {error && <ErrorText>{error}</ErrorText>} |
| 448 | <DialogFooter> |
| 449 | <SubmitButton match={{ action: "link" }} pending="Linking…" className="max-sm:h-11"> |
| 450 | Link |
| 451 | </SubmitButton> |
| 452 | </DialogFooter> |
| 453 | </Form> |
| 454 | </DialogContent> |
| 455 | </Dialog> |
| 456 | ); |
| 457 | } |
| 458 | |
| 459 | /** A repository the work may also belong in, with why, and Add as target. */ |
| 460 | function SuggestionLine({ suggestion, workspace, canLink }: { suggestion: SuggestedTarget; workspace: string; canLink: boolean }) { |
| 461 | return ( |
| 462 | <li className="flex items-start gap-3 px-3 py-3 sm:px-4"> |
| 463 | <Lightbulb size={15} className="mt-0.5 shrink-0 text-faint" /> |
| 464 | <span className="min-w-0 grow"> |
| 465 | <Link to={`/${suggestion.path}`} className="font-mono text-sm font-medium hover:text-accent"> |
| 466 | {shortPath(suggestion.path, workspace)} |
| 467 | </Link> |
| 468 | <span className="mt-0.5 block text-xs text-muted">{suggestion.why}</span> |
| 469 | </span> |
| 470 | {canLink && ( |
| 471 | <Form method="post" className="shrink-0"> |
| 472 | <input type="hidden" name="action" value="link" /> |
| 473 | <input type="hidden" name="repo" value={suggestion.path} /> |
| 474 | <input type="hidden" name="role" value="target" /> |
| 475 | <SubmitButton variant="outline" size="sm" match={{ action: "link", repo: suggestion.path }} pending="Adding…" className="max-sm:h-10"> |
| 476 | Add as target |
| 477 | </SubmitButton> |
| 478 | </Form> |
| 479 | )} |
| 480 | </li> |
| 481 | ); |
| 482 | } |
| 483 | |
| 484 | export default function WorkItemPage({ loaderData, actionData }: Route.ComponentProps) { |
| 485 | const { item, pulls, comments, viewer, labels, members, canManage, can, workspace } = loaderData; |
| 486 | // Its Work, when it could be read; a failure says so in its places. |
| 487 | const coordination = isFailed(loaderData.coordination) ? null : loaderData.coordination; |
| 488 | const sessionTrees = isFailed(loaderData.sessionTrees) ? null : loaderData.sessionTrees; |
| 489 | // What changes to it (comments, labels, links, its pull requests) shows as it happens. |
| 490 | useWorkspaceLive({ workspace, member: loaderData.member, match: workPages.item(item.number), followBottom: true }); |
| 491 | // Follow pull requests at work without a manual reload. |
| 492 | useRefreshWhile(pulls.some((pull) => pull.status === "draft" || pull.checkStatus === "queued" || pull.checkStatus === "running")); |
| 493 | // Claims lapse and sessions go idle with no event to say so: looked at again now and then. |
| 494 | useRefreshWhile((coordination?.claims.length ?? 0) > 0 || (coordination?.sessions.some((session) => session.state !== "ended") ?? false), 30_000); |
| 495 | const now = Date.now(); |
| 496 | |
| 497 | const open = item.state === "open"; |
| 498 | const reference = `${workspace}#${item.number}`; |
| 499 | const canLink = can.triage; |
| 500 | // Each repository with its pull requests for the item: from its work |
| 501 | // context, else from the item's own pull requests. |
| 502 | const bySlug = new Map(item.repos.map((repo) => [repo.repoId, repo.path])); |
| 503 | const full = new Map(pulls.map((pull) => [`${pull.repoId}#${pull.number}`, pull])); |
| 504 | const repoRows: { repo: ItemRepo; pulls: WorkPull[] }[] = coordination |
| 505 | ? coordination.repos.map((repo: WorkRepo) => ({ repo, pulls: repo.pulls })) |
| 506 | : item.repos.map((repo) => ({ |
| 507 | repo, |
| 508 | pulls: pulls |
| 509 | .filter((pull) => pull.repoId === repo.repoId) |
| 510 | .map((pull) => ({ |
| 511 | repo: repo.path, |
| 512 | number: pull.number, |
| 513 | title: pull.title, |
| 514 | status: pull.status, |
| 515 | branch: pull.branch, |
| 516 | headCommit: pull.headCommit, |
| 517 | author: { id: pull.author.id, username: pull.author.username }, |
| 518 | checkStatus: pull.checkStatus, |
| 519 | requiredChecks: [], |
| 520 | mergeable: "unknown" as const, |
| 521 | overlaps: [], |
| 522 | queue: null, |
| 523 | })), |
| 524 | })); |
| 525 | const missing = coordination?.missing ?? missingTargets(item.repos); |
| 526 | const suggestions = (coordination?.suggestedTargets ?? []).filter((one) => !item.repos.some((repo) => repo.repoId === one.repoId)); |
| 527 | // Where each session's page is: under the repository it was recorded in. |
| 528 | const sessionBase = (session: { repoId: string }) => `/${bySlug.get(session.repoId) ?? item.repos[0]?.path ?? workspace}`; |
| 529 | // Its decisions as a story, oldest first; the failure that still stands, with what answered it. |
| 530 | const decisions = coordination ? [...coordination.decisions].reverse() : []; |
| 531 | const failure = coordination?.lastIntegrationFailure ?? null; |
| 532 | const resolution = failure ? resolutionOf(coordination!.decisions, failure) : null; |
| 533 | const failureBase = failure ? `/${item.repos[0]?.path ?? workspace}` : ""; |
| 534 | // Where a pull request or a session would be opened: a target still missing, else where it was filed. |
| 535 | const workIn = missing[0] ?? item.repos.find((repo) => repo.role === "target")?.path ?? originOf(item.repos)?.path ?? null; |
| 536 | const linkError = actionData && "action" in actionData && actionData.action === "link" ? actionData.error : null; |
| 537 | const sideError = actionData && "action" in actionData && ["blocked-by", "start-session", "react", "link", "open-pull"].includes(String(actionData.action)); |
| 538 | |
| 539 | return ( |
| 540 | // On a phone: the title, then the item, its repositories and the |
| 541 | // conversation, then the sidebar as a Details disclosure; beside it |
| 542 | // from lg up. Its main actions sit at the bottom, within a thumb's reach. |
| 543 | <div className="grid gap-x-8 gap-y-5 lg:grid-cols-[1fr_19rem] lg:grid-rows-[auto_1fr] lg:gap-y-0"> |
| 544 | <header className="min-w-0 lg:col-start-1"> |
| 545 | <h1 className="text-xl font-semibold tracking-tight text-balance wrap-break-word sm:text-2xl"> |
| 546 | {item.title} <span className="font-normal text-faint">#{item.number}</span> |
| 547 | </h1> |
| 548 | <div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-2 text-sm text-muted"> |
| 549 | <ItemState item={item} /> |
| 550 | <span className="flex min-w-0 items-center gap-2"> |
| 551 | <Avatar name={item.author.username} size={18} /> |
| 552 | <span className="min-w-0"> |
| 553 | <PersonLink name={item.author.username} className="font-medium text-fg hover:underline" /> opened this{" "} |
| 554 | {item.requestedBy && ( |
| 555 | <> |
| 556 | for <PersonLink name={item.requestedBy.username} className="font-medium text-fg-soft hover:underline" />{" "} |
| 557 | </> |
| 558 | )} |
| 559 | <TimeAgo at={item.createdAt} /> |
| 560 | </span> |
| 561 | </span> |
| 562 | {item.labels.map((name) => ( |
| 563 | // Without the workspace's labels, a chip still names its label, in the plain color. |
| 564 | <LabelChip key={name} name={name} color={isFailed(labels) ? undefined : labels.find((label) => label.name === name)?.color} /> |
| 565 | ))} |
| 566 | {open && item.agent && <Assignee agent={item.agent} />} |
| 567 | </div> |
| 568 | </header> |
| 569 | |
| 570 | <div className="min-w-0 max-lg:order-1 lg:col-start-1"> |
| 571 | {item.resolutions.length > 0 && ( |
| 572 | <ul className="space-y-2 lg:mt-5"> |
| 573 | {item.resolutions.map((landed, index) => ( |
| 574 | <li key={`${landed.repo}-${landed.pull ?? landed.commit ?? index}`}> |
| 575 | <Link |
| 576 | to={landed.pull != null ? `/${landed.repo}/pull/${landed.pull}` : `/${landed.repo}/commit/${landed.commit ?? ""}`} |
| 577 | className="flex items-start gap-3 rounded-xl border border-merged/40 bg-merged/5 px-4 py-3 text-sm transition-colors hover:border-merged/70" |
| 578 | > |
| 579 | {landed.pull != null ? <GitMerge size={18} className="mt-0.5 shrink-0 text-merged" /> : <GitCommitHorizontal size={18} className="mt-0.5 shrink-0 text-merged" />} |
| 580 | <span className="min-w-0"> |
| 581 | Landed in <span className="font-mono font-medium">{landed.repo}</span> by{" "} |
| 582 | {landed.pull != null ? ( |
| 583 | <span className="font-medium">#{landed.pull}</span> |
| 584 | ) : ( |
| 585 | <span className="font-mono font-medium">{landed.commit?.slice(0, 7)}</span> |
| 586 | )} |
| 587 | {landed.pushedBy && <span className="text-muted">, pushed by {landed.pushedBy}</span>} |
| 588 | </span> |
| 589 | </Link> |
| 590 | </li> |
| 591 | ))} |
| 592 | </ul> |
| 593 | )} |
| 594 | |
| 595 | {item.body ? ( |
| 596 | <div className={`${item.resolutions.length > 0 ? "mt-5" : "lg:mt-5"} rounded-xl border border-line bg-surface p-4 sm:p-5`}> |
| 597 | <Markdown source={item.body} workspace={workspace} /> |
| 598 | <Reactions reactions={loaderData.reactions} viewer={viewer?.username ?? null} className="mt-4" /> |
| 599 | </div> |
| 600 | ) : ( |
| 601 | (loaderData.reactions?.length ?? 0) > 0 && <Reactions reactions={loaderData.reactions} viewer={viewer?.username ?? null} className="mt-3 lg:mt-5" /> |
| 602 | )} |
| 603 | |
| 604 | <section aria-labelledby="repositories" className="mt-8"> |
| 605 | <div className="flex flex-wrap items-center justify-between gap-2"> |
| 606 | <h2 id="repositories" className="font-semibold tracking-tight"> |
| 607 | Repositories |
| 608 | </h2> |
| 609 | <span className="flex items-center gap-3"> |
| 610 | {missing.length > 0 && open && ( |
| 611 | <span className="text-sm text-muted"> |
| 612 | {missing.length} {missing.length === 1 ? "target" : "targets"} still to land |
| 613 | </span> |
| 614 | )} |
| 615 | {canLink && ( |
| 616 | <span className="max-sm:hidden"> |
| 617 | <LinkRepository workspace={workspace} repos={loaderData.repos} linked={item.repos.map((repo) => repo.path.toLowerCase())} error={linkError} /> |
| 618 | </span> |
| 619 | )} |
| 620 | </span> |
| 621 | </div> |
| 622 | {isFailed(loaderData.coordination) && ( |
| 623 | <div className="mt-2"> |
| 624 | <Unavailable what="Each repository's pull requests" compact /> |
| 625 | </div> |
| 626 | )} |
| 627 | {repoRows.length === 0 ? ( |
| 628 | <div className="mt-3"> |
| 629 | <EmptyState title="No repository yet"> |
| 630 | Filed in the workspace itself. Link the repositories it should land in, and it closes once each of them has. |
| 631 | </EmptyState> |
| 632 | </div> |
| 633 | ) : ( |
| 634 | <Card asChild divided className="mt-3"> |
| 635 | <ul> |
| 636 | {repoRows.map(({ repo, pulls: repoPulls }) => ( |
| 637 | <RepoCard key={repo.repoId} repo={repo} pulls={repoPulls} full={full} canLink={canLink} /> |
| 638 | ))} |
| 639 | </ul> |
| 640 | </Card> |
| 641 | )} |
| 642 | {suggestions.length > 0 && open && ( |
| 643 | <> |
| 644 | <h3 className="mt-5 text-sm font-medium">Suggested</h3> |
| 645 | <p className="mt-0.5 text-xs text-muted">Repositories it may also need, and why.</p> |
| 646 | <Card asChild divided className="mt-2"> |
| 647 | <ul> |
| 648 | {suggestions.map((suggestion) => ( |
| 649 | <SuggestionLine key={suggestion.repoId} suggestion={suggestion} workspace={workspace} canLink={canLink} /> |
| 650 | ))} |
| 651 | </ul> |
| 652 | </Card> |
| 653 | </> |
| 654 | )} |
| 655 | {linkError && ( |
| 656 | <div className="mt-2"> |
| 657 | <ErrorText>{linkError}</ErrorText> |
| 658 | </div> |
| 659 | )} |
| 660 | </section> |
| 661 | |
| 662 | {pulls.length === 0 && open && ( |
| 663 | <div className="mt-6"> |
| 664 | <EmptyState title="Nobody has worked on this yet"> |
| 665 | Point your own agent at <code className="font-mono">{reference}</code>. |
| 666 | </EmptyState> |
| 667 | </div> |
| 668 | )} |
| 669 | |
| 670 | {failure && <IntegrationFailure className="mt-4" failure={failure} base={failureBase} fixed={resolution?.passed ?? resolution?.fix} />} |
| 671 | |
| 672 | {isFailed(loaderData.coordination) ? ( |
| 673 | <> |
| 674 | <h2 className="mt-10 font-semibold tracking-tight">Decisions</h2> |
| 675 | <div className="mt-3"> |
| 676 | <Unavailable what="Its decisions" /> |
| 677 | </div> |
| 678 | </> |
| 679 | ) : ( |
| 680 | decisions.length > 0 && ( |
| 681 | <> |
| 682 | <div className="mt-10 flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1"> |
| 683 | <h2 className="font-semibold tracking-tight">Decisions</h2> |
| 684 | <p className="text-sm text-muted">Why the work is the way it is, and how sure that is</p> |
| 685 | </div> |
| 686 | <div className="mt-3"> |
| 687 | <DecisionList decisions={decisions} base={failureBase || `/${item.repos[0]?.path ?? workspace}`} /> |
| 688 | </div> |
| 689 | </> |
| 690 | ) |
| 691 | )} |
| 692 | |
| 693 | <h2 className="mt-10 font-semibold tracking-tight">Discussion</h2> |
| 694 | <div className="mt-3"> |
| 695 | <div className="space-y-4"> |
| 696 | {isFailed(loaderData.sessionTrees) && <Unavailable what="Its sessions" compact />} |
| 697 | <CommentList |
| 698 | comments={comments} |
| 699 | workspace={workspace} |
| 700 | viewerId={viewer?.id ?? null} |
| 701 | viewerName={viewer?.username ?? null} |
| 702 | extra={(sessionTrees ?? []) |
| 703 | .filter((session) => !session.parentId) |
| 704 | .map((session) => ({ id: session.id, at: session.startedAt, node: <TimelineSession session={session} base={sessionBase(session)} /> }))} |
| 705 | canModerate={loaderData.canModerate} |
| 706 | failed={actionData && "comment" in actionData ? { comment: String(actionData.comment), error: actionData.error } : null} |
| 707 | /> |
| 708 | <div id="comment"> |
| 709 | <CommentForm author={viewer?.username ?? null} resetKey={comments.length} handles={loaderData.handles} /> |
| 710 | </div> |
| 711 | {canManage && ( |
| 712 | <Form method="post" className="flex flex-wrap justify-end gap-2 max-sm:hidden"> |
| 713 | {open ? ( |
| 714 | <> |
| 715 | <SubmitButton variant="outline" name="action" value="close-not-planned" pending="Closing…"> |
| 716 | Close as not planned |
| 717 | </SubmitButton> |
| 718 | <SubmitButton variant="outline" name="action" value="close-completed" pending="Closing…"> |
| 719 | Close work item |
| 720 | </SubmitButton> |
| 721 | </> |
| 722 | ) : ( |
| 723 | <SubmitButton variant="outline" name="action" value="reopen" pending="Reopening…"> |
| 724 | Reopen work item |
| 725 | </SubmitButton> |
| 726 | )} |
| 727 | </Form> |
| 728 | )} |
| 729 | </div> |
| 730 | </div> |
| 731 | <div className="mt-2"> |
| 732 | {/* A refused comment edit says so under the comment; the sidebar's own say so there. */} |
| 733 | {!(actionData && ("comment" in actionData || sideError)) && <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText>} |
| 734 | </div> |
| 735 | |
| 736 | {/* On a phone, its main actions at the bottom, above the tab bar and clear of the keyboard. */} |
| 737 | {(canManage || canLink) && ( |
| 738 | <div className="sticky bottom-(--tabbar-h) z-10 -mx-4 mt-6 flex items-center gap-2 border-t border-line bg-bg/90 px-4 py-3 backdrop-blur in-data-[keyboard=open]:hidden sm:hidden"> |
| 739 | {canLink && ( |
| 740 | <div className="min-w-0 grow"> |
| 741 | <LinkRepository workspace={workspace} repos={loaderData.repos} linked={item.repos.map((repo) => repo.path.toLowerCase())} error={null} wide /> |
| 742 | </div> |
| 743 | )} |
| 744 | {canManage && ( |
| 745 | <Form method="post" className="min-w-0 grow *:h-11 *:w-full"> |
| 746 | {open ? ( |
| 747 | <SubmitButton variant="outline" name="action" value="close-completed" pending="Closing…"> |
| 748 | Close |
| 749 | </SubmitButton> |
| 750 | ) : ( |
| 751 | <SubmitButton variant="outline" name="action" value="reopen" pending="Reopening…"> |
| 752 | Reopen |
| 753 | </SubmitButton> |
| 754 | )} |
| 755 | </Form> |
| 756 | )} |
| 757 | </div> |
| 758 | )} |
| 759 | </div> |
| 760 | |
| 761 | <DetailsDisclosure |
| 762 | className="max-lg:order-2 lg:col-start-2 lg:row-span-2 lg:row-start-1" |
| 763 | summary={detailsSummary([ |
| 764 | [coordination?.claims.length ?? 0, "claim", "claims"], |
| 765 | [item.assignees.length, "assignee", "assignees"], |
| 766 | [item.labels.length, "label", "labels"], |
| 767 | [item.milestone ? 1 : 0, "milestone", "milestones"], |
| 768 | ])} |
| 769 | > |
| 770 | {isFailed(loaderData.coordination) ? ( |
| 771 | <section> |
| 772 | <h3 className="text-sm font-medium">Work</h3> |
| 773 | <div className="mt-2"> |
| 774 | <Unavailable what="Who is on it" compact /> |
| 775 | </div> |
| 776 | </section> |
| 777 | ) : ( |
| 778 | coordination && ( |
| 779 | <Boundary what="Work" fallback={<Unavailable what="Who is on it" compact />}> |
| 780 | <ItemWorkPanel |
| 781 | work={coordination} |
| 782 | ended={loaderData.endedClaims} |
| 783 | base={sessionBase} |
| 784 | now={now} |
| 785 | canEdit={canManage} |
| 786 | error={actionData && "action" in actionData && actionData.action === "blocked-by" ? actionData.error : null} |
| 787 | sessions={sessionTrees} |
| 788 | start={ |
| 789 | loaderData.agentSetup && open && workIn ? ( |
| 790 | <StartSessionDialog |
| 791 | setup={loaderData.agentSetup} |
| 792 | repo={workIn} |
| 793 | error={actionData && "action" in actionData && actionData.action === "start-session" ? actionData.error : null} |
| 794 | /> |
| 795 | ) : undefined |
| 796 | } |
| 797 | /> |
| 798 | </Boundary> |
| 799 | ) |
| 800 | )} |
| 801 | {isFailed(loaderData.links) ? ( |
| 802 | <section> |
| 803 | <h3 className="text-sm font-medium">From outside g1t</h3> |
| 804 | <div className="mt-2"> |
| 805 | <Unavailable what="Its links" compact /> |
| 806 | </div> |
| 807 | </section> |
| 808 | ) : ( |
| 809 | loaderData.links.length > 0 && ( |
| 810 | <section> |
| 811 | <h3 className="text-sm font-medium">From outside g1t</h3> |
| 812 | <ul className="mt-2 space-y-1.5 text-sm"> |
| 813 | {loaderData.links.map((link) => ( |
| 814 | <li key={`${link.connectionId}-${link.key}`}> |
| 815 | <a |
| 816 | href={link.url} |
| 817 | target="_blank" |
| 818 | rel="noreferrer" |
| 819 | className="group flex items-center gap-2 rounded-lg border border-line px-2.5 py-2 transition-colors hover:border-line-strong" |
| 820 | > |
| 821 | <span className="min-w-0 grow"> |
| 822 | <span className="block truncate font-medium"> |
| 823 | {PROVIDERS[link.provider].label} <span className="font-mono text-muted">{link.key}</span> |
| 824 | </span> |
| 825 | <span className="block text-xs text-faint"> |
| 826 | {link.count > 1 ? `Seen ${link.count} times, last ` : "Linked "} |
| 827 | <TimeAgo at={link.count > 1 ? link.lastSeen : link.firstSeen} /> |
| 828 | </span> |
| 829 | </span> |
| 830 | <ExternalLink size={13} className="shrink-0 text-faint group-hover:text-fg" /> |
| 831 | </a> |
| 832 | </li> |
| 833 | ))} |
| 834 | </ul> |
| 835 | </section> |
| 836 | ) |
| 837 | )} |
| 838 | <section> |
| 839 | <h3 className="text-sm font-medium">Assignees</h3> |
| 840 | <ul className="mt-2 space-y-1.5 text-sm"> |
| 841 | {item.assignees.map((name) => ( |
| 842 | <li key={name} className="flex items-center gap-2 px-1"> |
| 843 | <Avatar name={name} size={20} agent={name === "g1t"} /> |
| 844 | <span className="grow truncate font-mono text-xs">{name === "g1t" ? "g1t" : <PersonLink name={name} className="hover:underline" />}</span> |
| 845 | </li> |
| 846 | ))} |
| 847 | {item.assignees.length === 0 && <li className="px-1 text-xs text-faint">Nobody yet</li>} |
| 848 | </ul> |
| 849 | {!coordination && item.blockedBy.length > 0 && ( |
| 850 | <p className="mt-3 text-xs text-muted"> |
| 851 | Depends on{" "} |
| 852 | {item.blockedBy.map((number, index) => ( |
| 853 | <span key={number}> |
| 854 | {index > 0 && ", "} |
| 855 | <Link to={itemPath(workspace, number)} className="font-medium text-fg hover:underline"> |
| 856 | #{number} |
| 857 | </Link> |
| 858 | </span> |
| 859 | ))} |
| 860 | {open ? ", which has to land first." : "."} |
| 861 | </p> |
| 862 | )} |
| 863 | {viewer && can.triage && open && ( |
| 864 | <div className="mt-3 space-y-2"> |
| 865 | {/* g1t takes it on in a session on the workspace's Claude, as whoever assigned it. */} |
| 866 | {loaderData.agentSetup && !item.assignees.includes("g1t") && ( |
| 867 | <Form method="post"> |
| 868 | <input type="hidden" name="action" value="assign" /> |
| 869 | {item.assignees.map((name) => ( |
| 870 | <input key={name} type="hidden" name="assignee" value={name} /> |
| 871 | ))} |
| 872 | <input type="hidden" name="assignee" value="g1t" /> |
| 873 | <div className="*:w-full"> |
| 874 | <SubmitButton variant="outline" name="who" value="g1t" pending="Assigning…"> |
| 875 | <Bot size={14} /> |
| 876 | Assign to g1t |
| 877 | </SubmitButton> |
| 878 | </div> |
| 879 | </Form> |
| 880 | )} |
| 881 | {!item.assignees.includes(viewer.username) && ( |
| 882 | <Form method="post"> |
| 883 | <input type="hidden" name="action" value="assign" /> |
| 884 | {item.assignees.map((name) => ( |
| 885 | <input key={name} type="hidden" name="assignee" value={name} /> |
| 886 | ))} |
| 887 | <input type="hidden" name="assignee" value={viewer.username} /> |
| 888 | <div className="*:w-full"> |
| 889 | <SubmitButton variant="outline" name="who" value="self" pending="Assigning…"> |
| 890 | Assign yourself |
| 891 | </SubmitButton> |
| 892 | </div> |
| 893 | </Form> |
| 894 | )} |
| 895 | <details className="group"> |
| 896 | <summary className="cursor-pointer text-xs text-faint hover:text-fg">Assign people</summary> |
| 897 | <Form method="post" className="mt-2 space-y-2" key={item.assignees.join()}> |
| 898 | <input type="hidden" name="action" value="assign" /> |
| 899 | {/* Without the workspace's people the picker still holds who is |
| 900 | assigned now, so saving never clears them. */} |
| 901 | {isFailed(members) && <Unavailable what="The workspace's people" compact />} |
| 902 | <PeoplePicker name="assignee" members={isFailed(members) ? [] : members} chosen={item.assignees} /> |
| 903 | <SubmitButton variant="outline" name="who" value="picked" pending="Saving…"> |
| 904 | Save assignees |
| 905 | </SubmitButton> |
| 906 | </Form> |
| 907 | </details> |
| 908 | </div> |
| 909 | )} |
| 910 | </section> |
| 911 | |
| 912 | {/* Another way to start the work: not once a pull request is doing it. */} |
| 913 | {open && !pulls.some((pull) => pull.status === "open" || pull.status === "draft") && ( |
| 914 | <Card asChild className="p-4"> |
| 915 | <section> |
| 916 | <h3 className="text-sm font-medium">Bring your own agent</h3> |
| 917 | <p className="mt-1 text-xs text-muted">With g1t connected to your agent, ask it to work on this work item.</p> |
| 918 | <div className="mt-3"> |
| 919 | <CopyLine text={reference} /> |
| 920 | </div> |
| 921 | {viewer && item.repos.length > 0 ? ( |
| 922 | <Form method="post" className="mt-4 space-y-2 border-t border-line pt-4"> |
| 923 | <input type="hidden" name="action" value="open-pull" /> |
| 924 | <p className="text-xs text-muted">Or open a draft pull request yourself and get a fork to push to.</p> |
| 925 | <SelectField |
| 926 | name="repo" |
| 927 | aria-label="Repository" |
| 928 | defaultValue={workIn ?? item.repos[0]!.path} |
| 929 | options={item.repos.map((repo) => ({ value: repo.path, label: `${repo.path} (${ROLE_LABELS[repo.role].toLowerCase()})` }))} |
| 930 | /> |
| 931 | <Input name="agent" placeholder="Who is working, e.g. claude-code" maxLength={60} /> |
| 932 | <div className="*:w-full"> |
| 933 | <SubmitButton match={{ action: "open-pull" }} pending="Opening…"> |
| 934 | Open pull request |
| 935 | </SubmitButton> |
| 936 | </div> |
| 937 | {actionData && "action" in actionData && actionData.action === "open-pull" && <ErrorText>{actionData.error}</ErrorText>} |
| 938 | {workIn && ( |
| 939 | <p className="text-xs text-muted"> |
| 940 | Already pushed a branch?{" "} |
| 941 | <Link to={`/${workIn}/pulls/new?item=${item.number}`} className="text-fg underline underline-offset-4"> |
| 942 | Open a pull request from it |
| 943 | </Link> |
| 944 | . |
| 945 | </p> |
| 946 | )} |
| 947 | </Form> |
| 948 | ) : ( |
| 949 | !viewer && ( |
| 950 | <p className="mt-4 border-t border-line pt-4 text-sm text-muted"> |
| 951 | <Link to="/login" className="text-fg underline underline-offset-4"> |
| 952 | Sign in |
| 953 | </Link>{" "} |
| 954 | to open a pull request. |
| 955 | </p> |
| 956 | ) |
| 957 | )} |
| 958 | </section> |
| 959 | </Card> |
| 960 | )} |
| 961 | |
| 962 | {isFailed(labels) ? ( |
| 963 | <SideUnavailable heading="Labels" what="Labels" /> |
| 964 | ) : ( |
| 965 | <LabelsBox |
| 966 | labels={labels} |
| 967 | chosen={item.labels} |
| 968 | canEdit={canManage} |
| 969 | canCreate={can.manage_labels} |
| 970 | manageUrl={can.manage_labels ? workPath(workspace, "labels") : undefined} |
| 971 | /> |
| 972 | )} |
| 973 | {isFailed(loaderData.milestones) ? ( |
| 974 | <SideUnavailable heading="Milestone" what="Milestones" /> |
| 975 | ) : ( |
| 976 | <MilestoneBox milestones={loaderData.milestones} current={item.milestone} canEdit={can.triage} base={workPath(workspace)} /> |
| 977 | )} |
| 978 | {viewer && |
| 979 | (isFailed(loaderData.subscription) ? ( |
| 980 | <SideUnavailable heading="Notifications" what="Your notification settings" /> |
| 981 | ) : ( |
| 982 | <SubscriptionBox action={itemPath(workspace, item.number)} number={item.number} kind="item" subscription={loaderData.subscription} /> |
| 983 | ))} |
| 984 | </DetailsDisclosure> |
| 985 | </div> |
| 986 | ); |
| 987 | } |
| 988 | |
| 989 | /** A sidebar section, under its usual heading, that could not be loaded. */ |
| 990 | function SideUnavailable({ heading, what }: { heading: string; what: string }) { |
| 991 | return ( |
| 992 | <section> |
| 993 | <h3 className="text-sm font-medium">{heading}</h3> |
| 994 | <div className="mt-2"> |
| 995 | <Unavailable what={what} compact /> |
| 996 | </div> |
| 997 | </section> |
| 998 | ); |
| 999 | } |