|
1 | 1 | import { useCallback, useEffect, useRef, useState } from "react"; |
2 | 2 | import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; |
| 3 | +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; |
| 4 | +import { faCodeBranch, faExternalLinkAlt } from "@fortawesome/free-solid-svg-icons"; |
3 | 5 | import { |
4 | 6 | api, |
5 | 7 | type TaskItem, |
@@ -57,6 +59,122 @@ const PRIORITY_COLORS: Record< |
57 | 59 | low: "outline", |
58 | 60 | }; |
59 | 61 |
|
| 62 | +interface GithubReference { |
| 63 | + kind: "issue" | "pr"; |
| 64 | + label: string; |
| 65 | + url: string | null; |
| 66 | +} |
| 67 | + |
| 68 | +function isRecord(value: unknown): value is Record<string, unknown> { |
| 69 | + return typeof value === "object" && value !== null && !Array.isArray(value); |
| 70 | +} |
| 71 | + |
| 72 | +function toSafeExternalUrl(value: unknown): string | null { |
| 73 | + if (typeof value !== "string") return null; |
| 74 | + try { |
| 75 | + const parsed = new URL(value); |
| 76 | + if (parsed.protocol === "https:" || parsed.protocol === "http:") { |
| 77 | + return parsed.toString(); |
| 78 | + } |
| 79 | + return null; |
| 80 | + } catch { |
| 81 | + return null; |
| 82 | + } |
| 83 | +} |
| 84 | + |
| 85 | +function readGithubReference( |
| 86 | + value: unknown, |
| 87 | + kind: GithubReference["kind"], |
| 88 | +): GithubReference | null { |
| 89 | + if (!isRecord(value)) { |
| 90 | + return null; |
| 91 | + } |
| 92 | + |
| 93 | + const number = typeof value.number === "number" ? value.number : null; |
| 94 | + const repo = typeof value.repo === "string" ? value.repo : null; |
| 95 | + const url = toSafeExternalUrl(value.url); |
| 96 | + |
| 97 | + if (number === null && url === null && repo === null) { |
| 98 | + return null; |
| 99 | + } |
| 100 | + |
| 101 | + const noun = kind === "issue" ? "Issue" : "PR"; |
| 102 | + const label = number !== null ? `${noun} #${number}` : repo ? `${noun} ${repo}` : noun; |
| 103 | + |
| 104 | + return { kind, label, url }; |
| 105 | +} |
| 106 | + |
| 107 | +function getGithubReferences(metadata: Record<string, unknown>): GithubReference[] { |
| 108 | + const references = [ |
| 109 | + readGithubReference(metadata.github_issue, "issue"), |
| 110 | + readGithubReference(metadata.github_pr, "pr"), |
| 111 | + ].filter((reference): reference is GithubReference => reference !== null); |
| 112 | + |
| 113 | + return references; |
| 114 | +} |
| 115 | + |
| 116 | +function GithubMetadataBadges({ |
| 117 | + metadata, |
| 118 | + references: precomputed, |
| 119 | + compact = false, |
| 120 | +}: { |
| 121 | + metadata?: Record<string, unknown>; |
| 122 | + references?: GithubReference[]; |
| 123 | + compact?: boolean; |
| 124 | +}) { |
| 125 | + const references = precomputed ?? (metadata ? getGithubReferences(metadata) : []); |
| 126 | + if (references.length === 0) { |
| 127 | + return null; |
| 128 | + } |
| 129 | + |
| 130 | + return ( |
| 131 | + <div className="flex flex-wrap items-center gap-1.5"> |
| 132 | + {references.map((reference) => { |
| 133 | + const content = ( |
| 134 | + <> |
| 135 | + <FontAwesomeIcon icon={faCodeBranch} className="text-[10px]" /> |
| 136 | + <span>{reference.label}</span> |
| 137 | + {reference.url && ( |
| 138 | + <FontAwesomeIcon icon={faExternalLinkAlt} className="text-[9px]" /> |
| 139 | + )} |
| 140 | + </> |
| 141 | + ); |
| 142 | + |
| 143 | + const className = compact |
| 144 | + ? "cursor-pointer hover:border-blue-400/50 hover:text-blue-300" |
| 145 | + : "cursor-pointer hover:border-blue-400/50 hover:bg-blue-500/20 hover:text-blue-300"; |
| 146 | + |
| 147 | + if (reference.url) { |
| 148 | + return ( |
| 149 | + <a |
| 150 | + key={`${reference.kind}-${reference.label}`} |
| 151 | + href={reference.url} |
| 152 | + target="_blank" |
| 153 | + rel="noopener noreferrer" |
| 154 | + className="inline-flex" |
| 155 | + onClick={(event) => event.stopPropagation()} |
| 156 | + > |
| 157 | + <Badge variant="blue" size="sm" className={className}> |
| 158 | + {content} |
| 159 | + </Badge> |
| 160 | + </a> |
| 161 | + ); |
| 162 | + } |
| 163 | + |
| 164 | + return ( |
| 165 | + <Badge |
| 166 | + key={`${reference.kind}-${reference.label}`} |
| 167 | + variant="blue" |
| 168 | + size="sm" |
| 169 | + > |
| 170 | + {content} |
| 171 | + </Badge> |
| 172 | + ); |
| 173 | + })} |
| 174 | + </div> |
| 175 | + ); |
| 176 | +} |
| 177 | + |
60 | 178 | export function AgentTasks({ agentId }: { agentId: string }) { |
61 | 179 | const queryClient = useQueryClient(); |
62 | 180 | const { taskEventVersion } = useLiveContext(); |
@@ -331,6 +449,7 @@ function TaskCard({ |
331 | 449 | Worker |
332 | 450 | </Badge> |
333 | 451 | )} |
| 452 | + <GithubMetadataBadges metadata={task.metadata} compact /> |
334 | 453 | </div> |
335 | 454 |
|
336 | 455 | {/* Subtask progress bar */} |
@@ -577,6 +696,19 @@ function TaskDetailDialog({ |
577 | 696 | </div> |
578 | 697 | )} |
579 | 698 |
|
| 699 | + {(() => { |
| 700 | + const githubRefs = getGithubReferences(task.metadata); |
| 701 | + if (githubRefs.length === 0) return null; |
| 702 | + return ( |
| 703 | + <div> |
| 704 | + <label className="mb-1 block text-xs text-ink-dull"> |
| 705 | + GitHub Links |
| 706 | + </label> |
| 707 | + <GithubMetadataBadges references={githubRefs} /> |
| 708 | + </div> |
| 709 | + ); |
| 710 | + })()} |
| 711 | + |
580 | 712 | {/* Metadata */} |
581 | 713 | <div className="grid grid-cols-1 gap-2 text-xs text-ink-dull sm:grid-cols-2"> |
582 | 714 | <div>Created: {formatTimeAgo(task.created_at)}</div> |
|
0 commit comments