Hover hints in components are the site's tooltip, not the browser's title
Truncated commit messages and run steps, check and outcome badges, assignees, branch drift, scope chips, icon buttons and the merge box's copy and re-run buttons now use Hint. What the title said only to a hover is kept for screen readers where it was the only place it lived (sr-only text, aria-hidden on what it replaces). The landing page's provider tiles drop their title: the name is printed under each.
25 files+419−3310/25 viewed
| 9 | 9 | ||
| 10 | 10 | import type { CommitStatus, Conclusion, LogChunk, WorkflowNote } from "@g1t/contracts"; | |
| 11 | 11 | ||
| 12 | + | import { Hint } from "./ui/hint"; | |
| 13 | + | ||
| 12 | 14 | type Standing = { status: string; conclusion: Conclusion | null }; | |
| 13 | 15 | ||
| 14 | 16 | /** One icon for where a run, job or step stands. */ | |
| ⋯ | |||
| 258 | 260 | return ( | |
| 259 | 261 | <li key={status.context}> | |
| 260 | 262 | {path ? ( | |
| 261 | − | <Link to={path} className="flex items-center gap-2 hover:text-fg" title={status.description ?? undefined}> | |
| 262 | − | {row} | |
| 263 | − | </Link> | |
| 263 | + | <Hint label={status.description}> | |
| 264 | + | <Link to={path} className="flex items-center gap-2 hover:text-fg"> | |
| 265 | + | {row} | |
| 266 | + | </Link> | |
| 267 | + | </Hint> | |
| 264 | 268 | ) : ( | |
| 265 | 269 | <span className="flex items-center gap-2">{row}</span> | |
| 266 | 270 | )} | |
| 5 | 5 | ||
| 6 | 6 | import { Idle } from "./agents"; | |
| 7 | 7 | import { TimeAgo } from "./ui"; | |
| 8 | + | import { Hint } from "./ui/hint"; | |
| 8 | 9 | ||
| 9 | 10 | const ROLE: Record<RepoInstructions["files"][number]["role"], string> = { | |
| 10 | 11 | root: "Every run", | |
| ⋯ | |||
| 62 | 63 | <span className="ml-auto flex items-center gap-2 text-xs text-faint"> | |
| 63 | 64 | {file.lastChanged && ( | |
| 64 | 65 | <> | |
| 65 | − | <Link | |
| 66 | − | to={`${base}/commit/${file.lastChanged.commit}`} | |
| 67 | − | className="font-mono hover:text-fg" | |
| 68 | − | title={file.lastChanged.message} | |
| 69 | − | > | |
| 70 | − | {file.lastChanged.commit.slice(0, 7)} | |
| 71 | − | </Link> | |
| 66 | + | <Hint label={file.lastChanged.message}> | |
| 67 | + | <Link to={`${base}/commit/${file.lastChanged.commit}`} className="font-mono hover:text-fg"> | |
| 68 | + | {file.lastChanged.commit.slice(0, 7)} | |
| 69 | + | </Link> | |
| 70 | + | </Hint> | |
| 72 | 71 | <span>by {file.lastChanged.author}</span> | |
| 73 | 72 | <TimeAgo at={file.lastChanged.at} /> | |
| 74 | 73 | </> | |
| 24 | 24 | import { RunAudit } from "./audit"; | |
| 25 | 25 | import { STAGE_LABEL, StageDots } from "./lifecycle"; | |
| 26 | 26 | import { Avatar, SubmitButton, TimeAgo } from "./ui"; | |
| 27 | + | import { Hint } from "./ui/hint"; | |
| 27 | 28 | import { | |
| 28 | 29 | AlertDialog, | |
| 29 | 30 | AlertDialogAction, | |
| ⋯ | |||
| 259 | 260 | <RunStatusBadge status={run.status} /> | |
| 260 | 261 | </div> | |
| 261 | 262 | {run.step && ( | |
| 262 | − | <p className={`mt-2.5 truncate font-mono text-xs ${active ? "text-fg/85" : "text-muted"}`} title={inlinePlain(shownStep(run.step))}> | |
| 263 | − | {active && <span className="mr-1.5 inline-block size-1.5 animate-pulse rounded-full bg-merged align-middle" />} | |
| 264 | − | <InlineMarkdown text={shownStep(run.step)} /> | |
| 265 | − | </p> | |
| 263 | + | <Hint label={inlinePlain(shownStep(run.step))}> | |
| 264 | + | <p className={`mt-2.5 truncate font-mono text-xs ${active ? "text-fg/85" : "text-muted"}`}> | |
| 265 | + | {active && <span className="mr-1.5 inline-block size-1.5 animate-pulse rounded-full bg-merged align-middle" />} | |
| 266 | + | <InlineMarkdown text={shownStep(run.step)} /> | |
| 267 | + | </p> | |
| 268 | + | </Hint> | |
| 266 | 269 | )} | |
| 267 | 270 | {run.status === "failed" && run.error && <p className="mt-1.5 text-xs text-danger">{run.error}</p>} | |
| 268 | 271 | <div className="mt-2.5 flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-muted"> | |
| ⋯ | |||
| 271 | 274 | {run.startedAt || active ? <Elapsed from={run.startedAt ?? run.createdAt} to={run.finishedAt} /> : "—"} | |
| 272 | 275 | </span> | |
| 273 | 276 | {cost && ( | |
| 274 | − | <span className="flex items-center gap-1" title={active ? "So far, as the harness reports it" : "As the harness reported it"}> | |
| 275 | − | <Coins size={12} /> | |
| 276 | − | {cost} | |
| 277 | − | {active && " so far"} | |
| 278 | − | </span> | |
| 277 | + | <Hint label={active ? "So far, as the harness reports it" : "As the harness reported it"}> | |
| 278 | + | <span className="flex items-center gap-1"> | |
| 279 | + | <Coins size={12} /> | |
| 280 | + | {cost} | |
| 281 | + | {active && " so far"} | |
| 282 | + | </span> | |
| 283 | + | </Hint> | |
| 279 | 284 | )} | |
| 280 | 285 | {run.model && <span className="font-mono">{run.model}</span>} | |
| 281 | 286 | {run.startedBy && <span>started by {run.startedBy}</span>} | |
| ⋯ | |||
| 437 | 442 | <RunStatusBadge status={current.status} /> | |
| 438 | 443 | </div> | |
| 439 | 444 | {current.step && ( | |
| 440 | − | <p className="mt-3 truncate rounded-lg bg-bg px-3 py-2 font-mono text-xs text-fg/85 ring-1 ring-line" title={inlinePlain(shownStep(current.step))}> | |
| 441 | − | {active && <span className="mr-2 inline-block size-1.5 animate-pulse rounded-full bg-merged align-middle" />} | |
| 442 | − | <InlineMarkdown text={shownStep(current.step)} /> | |
| 443 | − | </p> | |
| 445 | + | <Hint label={inlinePlain(shownStep(current.step))}> | |
| 446 | + | <p className="mt-3 truncate rounded-lg bg-bg px-3 py-2 font-mono text-xs text-fg/85 ring-1 ring-line"> | |
| 447 | + | {active && <span className="mr-2 inline-block size-1.5 animate-pulse rounded-full bg-merged align-middle" />} | |
| 448 | + | <InlineMarkdown text={shownStep(current.step)} /> | |
| 449 | + | </p> | |
| 450 | + | </Hint> | |
| 444 | 451 | )} | |
| 445 | 452 | {confidence && <ConfidenceLine confidence={confidence} />} | |
| 446 | 453 | <div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-muted"> | |
| ⋯ | |||
| 476 | 483 | export function AgentBadge({ run }: { run: AgentRun | undefined }) { | |
| 477 | 484 | if (!run) return null; | |
| 478 | 485 | return ( | |
| 479 | − | <span | |
| 480 | − | title={run.step ? inlinePlain(shownStep(run.step)) : undefined} | |
| 481 | − | className="inline-flex shrink-0 items-center gap-1 rounded-full border border-merged/40 bg-merged/10 px-2 py-0.5 text-xs text-merged" | |
| 482 | − | > | |
| 483 | − | <Loader2 size={11} className="animate-spin" /> | |
| 484 | − | {RUN_KIND_LABEL[run.kind]} | |
| 485 | − | </span> | |
| 486 | + | <Hint label={run.step ? inlinePlain(shownStep(run.step)) : undefined}> | |
| 487 | + | <span className="inline-flex shrink-0 items-center gap-1 rounded-full border border-merged/40 bg-merged/10 px-2 py-0.5 text-xs text-merged"> | |
| 488 | + | <Loader2 size={11} className="animate-spin" /> | |
| 489 | + | {RUN_KIND_LABEL[run.kind]} | |
| 490 | + | </span> | |
| 491 | + | </Hint> | |
| 486 | 492 | ); | |
| 487 | 493 | } | |
| 488 | 494 | ||
| ⋯ | |||
| 529 | 535 | const run = data?.runs.find((run) => run.number === number && isActiveRun(run.status)); | |
| 530 | 536 | if (!run?.step) return null; | |
| 531 | 537 | return ( | |
| 532 | − | <span className="mt-0.5 block truncate font-mono text-[0.6875rem] text-faint" title={inlinePlain(shownStep(run.step))}> | |
| 533 | − | {RUN_KIND_LABEL[run.kind]}: <InlineMarkdown text={shownStep(run.step)} /> | |
| 534 | − | </span> | |
| 538 | + | <Hint label={inlinePlain(shownStep(run.step))}> | |
| 539 | + | <span className="mt-0.5 block truncate font-mono text-[0.6875rem] text-faint"> | |
| 540 | + | {RUN_KIND_LABEL[run.kind]}: <InlineMarkdown text={shownStep(run.step)} /> | |
| 541 | + | </span> | |
| 542 | + | </Hint> | |
| 535 | 543 | ); | |
| 536 | 544 | } | |
| 11 | 11 | ||
| 12 | 12 | import { actionLabel, actorLabel, ruleLabel, targetLabel } from "../lib/audit"; | |
| 13 | 13 | import { Avatar, TimeAgo } from "./ui"; | |
| 14 | + | import { Hint } from "./ui/hint"; | |
| 14 | 15 | import { Loading, SkeletonRows } from "./ui/skeleton"; | |
| 15 | 16 | ||
| 16 | 17 | function clock(at: string): string { | |
| ⋯ | |||
| 21 | 22 | export function OutcomeMark({ entry }: { entry: AuditEntry }) { | |
| 22 | 23 | const denied = entry.outcome === "denied"; | |
| 23 | 24 | return ( | |
| 24 | − | <span | |
| 25 | − | title={`${entry.outcome}: ${ruleLabel(entry.rule)} (${entry.rule})`} | |
| 26 | − | className={`inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 text-xs ${ | |
| 27 | − | denied ? "bg-danger/10 text-danger ring-1 ring-danger/30" : "bg-raised text-muted ring-1 ring-line" | |
| 28 | − | }`} | |
| 29 | − | > | |
| 30 | − | {denied ? <ShieldAlert size={11} /> : <ShieldCheck size={11} />} | |
| 31 | − | {entry.outcome} | |
| 32 | − | </span> | |
| 25 | + | <Hint label={`${entry.outcome}: ${ruleLabel(entry.rule)} (${entry.rule})`}> | |
| 26 | + | <span | |
| 27 | + | className={`inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 text-xs ${ | |
| 28 | + | denied ? "bg-danger/10 text-danger ring-1 ring-danger/30" : "bg-raised text-muted ring-1 ring-line" | |
| 29 | + | }`} | |
| 30 | + | > | |
| 31 | + | {denied ? <ShieldAlert size={11} /> : <ShieldCheck size={11} />} | |
| 32 | + | {entry.outcome} | |
| 33 | + | </span> | |
| 34 | + | </Hint> | |
| 33 | 35 | ); | |
| 34 | 36 | } | |
| 35 | 37 | ||
| ⋯ | |||
| 69 | 71 | <span className="truncate font-mono text-xs text-muted">{targetLabel(entry)}</span> | |
| 70 | 72 | </div> | |
| 71 | 73 | <p className="mt-1 flex flex-wrap gap-x-3 text-xs text-faint"> | |
| 72 | − | <span title={entry.rule}>{ruleLabel(entry.rule)}</span> | |
| 74 | + | <Hint label={entry.rule}> | |
| 75 | + | <span>{ruleLabel(entry.rule)}</span> | |
| 76 | + | </Hint> | |
| 73 | 77 | <span>{entry.surface.toUpperCase()}</span> | |
| 74 | 78 | {entry.result && entry.result !== "ok" && <span>result: {entry.result}</span>} | |
| 75 | 79 | {entry.runId && entry.repo && ( | |
| ⋯ | |||
| 83 | 87 | </Link> | |
| 84 | 88 | )} | |
| 85 | 89 | {entry.credentialId && ( | |
| 86 | − | <span className="font-mono" title="Credential"> | |
| 87 | − | {entry.credentialId} | |
| 88 | − | </span> | |
| 90 | + | <Hint label="Credential"> | |
| 91 | + | <span className="font-mono"> | |
| 92 | + | <span className="sr-only">Credential </span> | |
| 93 | + | {entry.credentialId} | |
| 94 | + | </span> | |
| 95 | + | </Hint> | |
| 89 | 96 | )} | |
| 90 | − | <span className="font-mono" title="Request id"> | |
| 91 | − | {entry.requestId} | |
| 92 | − | </span> | |
| 97 | + | <Hint label="Request id"> | |
| 98 | + | <span className="font-mono"> | |
| 99 | + | <span className="sr-only">Request id </span> | |
| 100 | + | {entry.requestId} | |
| 101 | + | </span> | |
| 102 | + | </Hint> | |
| 93 | 103 | </p> | |
| 94 | 104 | {entry.outcome === "denied" && entry.message && <p className="mt-1 text-xs text-danger">{entry.message}</p>} | |
| 95 | 105 | </div> | |
| 3 | 3 | import { useFetcher } from "react-router"; | |
| 4 | 4 | ||
| 5 | 5 | import { ErrorText } from "./ui"; | |
| 6 | + | import { Hint } from "./ui/hint"; | |
| 6 | 7 | import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "./ui/combobox"; | |
| 7 | 8 | import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; | |
| 8 | 9 | ||
| ⋯ | |||
| 47 | 48 | if (next && names.state === "idle" && !names.data) names.load(branchesUrl); | |
| 48 | 49 | }} | |
| 49 | 50 | > | |
| 50 | − | <PopoverTrigger asChild> | |
| 51 | − | <button | |
| 52 | − | type="button" | |
| 53 | − | title="Change the branch it merges into" | |
| 54 | − | className="group inline-flex items-center gap-1 rounded-md border border-transparent px-1 font-mono text-fg transition-colors hover:border-line hover:bg-raised" | |
| 55 | − | > | |
| 56 | − | {shown} | |
| 57 | − | <Pencil size={12} className="text-faint group-hover:text-fg" /> | |
| 58 | − | </button> | |
| 59 | − | </PopoverTrigger> | |
| 51 | + | <Hint label="Change the branch it merges into"> | |
| 52 | + | <PopoverTrigger asChild> | |
| 53 | + | <button | |
| 54 | + | type="button" | |
| 55 | + | className="group inline-flex items-center gap-1 rounded-md border border-transparent px-1 font-mono text-fg transition-colors hover:border-line hover:bg-raised" | |
| 56 | + | > | |
| 57 | + | {shown} | |
| 58 | + | <Pencil size={12} className="text-faint group-hover:text-fg" /> | |
| 59 | + | <span className="sr-only">Change the branch it merges into</span> | |
| 60 | + | </button> | |
| 61 | + | </PopoverTrigger> | |
| 62 | + | </Hint> | |
| 60 | 63 | <PopoverContent align="start" className="w-72 p-0"> | |
| 61 | 64 | <Command> | |
| 62 | 65 | <CommandInput placeholder="Find a branch to merge into" /> | |
| 7 | 7 | import { CheckBadge } from "./checks"; | |
| 8 | 8 | import { host } from "./deploy"; | |
| 9 | 9 | import { Avatar, TimeAgo } from "./ui"; | |
| 10 | + | import { Hint } from "./ui/hint"; | |
| 11 | + | ||
| 12 | + | function drift({ ahead, behind }: Drift, main: string): string { | |
| 13 | + | return `${ahead} ${ahead === 1 ? "commit" : "commits"} ahead of ${main}, ${behind} behind`; | |
| 14 | + | } | |
| 10 | 15 | ||
| 11 | 16 | export type ActiveBranch = { | |
| 12 | 17 | name: string; | |
| ⋯ | |||
| 36 | 41 | <Avatar name={branch.commit.author} size={13} /> | |
| 37 | 42 | <span className="shrink-0">{branch.commit.author}</span> | |
| 38 | 43 | <span className="text-faint">·</span> | |
| 39 | − | <Link | |
| 40 | − | to={`${base}/commit/${branch.commit.hash}`} | |
| 41 | − | className="min-w-0 truncate hover:text-fg" | |
| 42 | − | title={branch.commit.message} | |
| 43 | − | > | |
| 44 | − | {branch.commit.message} | |
| 45 | − | </Link> | |
| 44 | + | <Hint label={branch.commit.message}> | |
| 45 | + | <Link to={`${base}/commit/${branch.commit.hash}`} className="min-w-0 truncate hover:text-fg"> | |
| 46 | + | {branch.commit.message} | |
| 47 | + | </Link> | |
| 48 | + | </Hint> | |
| 46 | 49 | <span className="shrink-0 text-faint"> | |
| 47 | 50 | · <TimeAgo at={branch.commit.at} /> | |
| 48 | 51 | </span> | |
| ⋯ | |||
| 51 | 54 | </span> | |
| 52 | 55 | <span className="ml-6.5 flex shrink-0 flex-wrap items-center gap-2 text-xs sm:ml-0"> | |
| 53 | 56 | {branch.drift && ( | |
| 54 | − | <span | |
| 55 | − | className="inline-flex items-center gap-1.5 rounded-md border border-line px-1.5 py-0.5 font-mono tabular-nums text-muted" | |
| 56 | − | title={`${branch.drift.ahead} ${branch.drift.ahead === 1 ? "commit" : "commits"} ahead of ${main}, ${branch.drift.behind} behind`} | |
| 57 | − | > | |
| 58 | − | <span className="inline-flex items-center gap-0.5"> | |
| 59 | − | <ArrowUp size={11} className={branch.drift.ahead > 0 ? "text-accent" : "text-faint"} /> | |
| 60 | − | {branch.drift.ahead} | |
| 57 | + | <Hint label={drift(branch.drift, main)}> | |
| 58 | + | <span className="inline-flex items-center gap-1.5 rounded-md border border-line px-1.5 py-0.5 font-mono tabular-nums text-muted"> | |
| 59 | + | <span className="sr-only">{drift(branch.drift, main)}</span> | |
| 60 | + | <span className="inline-flex items-center gap-0.5" aria-hidden> | |
| 61 | + | <ArrowUp size={11} className={branch.drift.ahead > 0 ? "text-accent" : "text-faint"} /> | |
| 62 | + | {branch.drift.ahead} | |
| 63 | + | </span> | |
| 64 | + | <span className="inline-flex items-center gap-0.5" aria-hidden> | |
| 65 | + | <ArrowDown size={11} className={branch.drift.behind > 0 ? "text-warn" : "text-faint"} /> | |
| 66 | + | {branch.drift.behind} | |
| 67 | + | </span> | |
| 61 | 68 | </span> | |
| 62 | − | <span className="inline-flex items-center gap-0.5"> | |
| 63 | − | <ArrowDown size={11} className={branch.drift.behind > 0 ? "text-warn" : "text-faint"} /> | |
| 64 | − | {branch.drift.behind} | |
| 65 | − | </span> | |
| 66 | − | </span> | |
| 69 | + | </Hint> | |
| 67 | 70 | )} | |
| 68 | 71 | {branch.pull ? ( | |
| 69 | − | <Link | |
| 70 | − | to={`${base}/pull/${branch.pull.number}`} | |
| 71 | − | className="inline-flex items-center gap-1.5 rounded-md border border-line px-1.5 py-0.5 text-muted hover:border-line-strong hover:text-fg" | |
| 72 | − | title={branch.pull.title} | |
| 73 | − | > | |
| 74 | − | <GitPullRequest size={12} className={branch.pull.draft ? "text-faint" : "text-accent"} />#{branch.pull.number} | |
| 75 | − | <CheckBadge status={branch.pull.checkStatus} /> | |
| 76 | − | </Link> | |
| 72 | + | <Hint label={branch.pull.title}> | |
| 73 | + | <Link | |
| 74 | + | to={`${base}/pull/${branch.pull.number}`} | |
| 75 | + | className="inline-flex items-center gap-1.5 rounded-md border border-line px-1.5 py-0.5 text-muted hover:border-line-strong hover:text-fg" | |
| 76 | + | > | |
| 77 | + | <GitPullRequest size={12} className={branch.pull.draft ? "text-faint" : "text-accent"} />#{branch.pull.number} | |
| 78 | + | <span className="sr-only">{branch.pull.title}</span> | |
| 79 | + | <CheckBadge status={branch.pull.checkStatus} /> | |
| 80 | + | </Link> | |
| 81 | + | </Hint> | |
| 77 | 82 | ) : branch.drift?.ahead === 0 ? ( | |
| 78 | 83 | // Nothing here that the default branch lacks: no pull request to open. | |
| 79 | − | <span className="px-1.5 py-0.5 text-faint" title={`Every commit on ${branch.name} is already on ${main}.`}> | |
| 80 | − | Nothing to merge | |
| 81 | − | </span> | |
| 84 | + | <Hint label={`Every commit on ${branch.name} is already on ${main}.`}> | |
| 85 | + | <span className="px-1.5 py-0.5 text-faint">Nothing to merge</span> | |
| 86 | + | </Hint> | |
| 82 | 87 | ) : ( | |
| 83 | 88 | <Link | |
| 84 | 89 | to={`${base}/pulls/new?branch=${encodeURIComponent(branch.name)}`} | |
| ⋯ | |||
| 88 | 93 | </Link> | |
| 89 | 94 | )} | |
| 90 | 95 | {branch.preview && ( | |
| 91 | − | <a | |
| 92 | − | href={branch.preview} | |
| 93 | − | className="inline-flex max-w-48 items-center gap-1 truncate rounded-md border border-line px-1.5 py-0.5 text-muted hover:border-line-strong hover:text-fg" | |
| 94 | − | title={host(branch.preview)} | |
| 95 | − | > | |
| 96 | − | <Globe size={12} className="shrink-0 text-merged" /> | |
| 97 | − | Preview | |
| 98 | − | </a> | |
| 96 | + | <Hint label={host(branch.preview)}> | |
| 97 | + | <a | |
| 98 | + | href={branch.preview} | |
| 99 | + | className="inline-flex max-w-48 items-center gap-1 truncate rounded-md border border-line px-1.5 py-0.5 text-muted hover:border-line-strong hover:text-fg" | |
| 100 | + | > | |
| 101 | + | <Globe size={12} className="shrink-0 text-merged" /> | |
| 102 | + | Preview | |
| 103 | + | </a> | |
| 104 | + | </Hint> | |
| 99 | 105 | )} | |
| 100 | 106 | </span> | |
| 101 | 107 | </li> | |
| 4 | 4 | ||
| 5 | 5 | import { type ChecklistItem, dismiss, isDismissed, progress } from "../lib/checklist"; | |
| 6 | 6 | import { cn } from "../lib/cn"; | |
| 7 | + | import { Hint } from "./ui/hint"; | |
| 7 | 8 | ||
| 8 | 9 | const storage = () => (typeof window === "undefined" ? null : window.localStorage); | |
| 9 | 10 | const listeners = new Set<() => void>(); | |
| ⋯ | |||
| 48 | 49 | <span className="hidden h-1.5 w-24 overflow-hidden rounded-full bg-line sm:block" aria-hidden> | |
| 49 | 50 | <span className="block h-full rounded-full bg-accent" style={{ width: `${(done / total) * 100}%` }} /> | |
| 50 | 51 | </span> | |
| 51 | − | <button | |
| 52 | − | type="button" | |
| 53 | − | onClick={() => { | |
| 54 | − | dismiss(storage, base); | |
| 55 | − | for (const listener of listeners) listener(); | |
| 56 | − | }} | |
| 57 | − | className="ml-auto rounded-md p-1 text-faint transition-colors hover:bg-raised hover:text-fg" | |
| 58 | − | aria-label="Dismiss the checklist for this project" | |
| 59 | − | title="Dismiss for this project" | |
| 60 | − | > | |
| 61 | − | <X size={14} /> | |
| 62 | − | </button> | |
| 52 | + | <Hint label="Dismiss for this project"> | |
| 53 | + | <button | |
| 54 | + | type="button" | |
| 55 | + | onClick={() => { | |
| 56 | + | dismiss(storage, base); | |
| 57 | + | for (const listener of listeners) listener(); | |
| 58 | + | }} | |
| 59 | + | className="ml-auto rounded-md p-1 text-faint transition-colors hover:bg-raised hover:text-fg" | |
| 60 | + | aria-label="Dismiss the checklist for this project" | |
| 61 | + | > | |
| 62 | + | <X size={14} /> | |
| 63 | + | </button> | |
| 64 | + | </Hint> | |
| 63 | 65 | </div> | |
| 64 | 66 | <ol className="grid border-t border-line sm:grid-cols-2 lg:grid-cols-3"> | |
| 65 | 67 | {items.map((item) => ( | |
| 2 | 2 | ||
| 3 | 3 | import type { CheckStatus } from "@g1t/contracts"; | |
| 4 | 4 | ||
| 5 | + | import { Hint } from "./ui/hint"; | |
| 6 | + | ||
| 5 | 7 | /** | |
| 6 | 8 | * What a pull request's own check status says. A pull request's checks are | |
| 7 | 9 | * the workflows run on it (see the merge box); this is set when the merge | |
| ⋯ | |||
| 27 | 29 | export function CheckBadge({ status }: { status: CheckStatus | null }) { | |
| 28 | 30 | if (!status) return null; | |
| 29 | 31 | return ( | |
| 30 | − | <span className="flex shrink-0 items-center gap-1 text-xs text-muted" title={LABELS[status]}> | |
| 31 | − | <CheckIcon status={status} size={13} /> | |
| 32 | − | <span className="sr-only">{LABELS[status]}</span> | |
| 33 | − | </span> | |
| 32 | + | <Hint label={LABELS[status]}> | |
| 33 | + | <span className="flex shrink-0 items-center gap-1 text-xs text-muted"> | |
| 34 | + | <CheckIcon status={status} size={13} /> | |
| 35 | + | <span className="sr-only">{LABELS[status]}</span> | |
| 36 | + | </span> | |
| 37 | + | </Hint> | |
| 34 | 38 | ); | |
| 35 | 39 | } | |
| 46 | 46 | ||
| 47 | 47 | import { InlineMarkdown } from "./inline-markdown"; | |
| 48 | 48 | import { SubmitButton, TimeAgo } from "./ui"; | |
| 49 | + | import { Hint } from "./ui/hint"; | |
| 49 | 50 | import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog"; | |
| 50 | 51 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; | |
| 51 | 52 | ||
| ⋯ | |||
| 204 | 205 | Keep | |
| 205 | 206 | </button> | |
| 206 | 207 | <EditAndKeep memory={memory} action={action} /> | |
| 207 | − | <button | |
| 208 | − | type="button" | |
| 209 | − | aria-label="Dismiss" | |
| 210 | − | title="Dismiss: never suggested again in these words" | |
| 211 | − | onClick={() => fetcher.submit({ intent: "dismiss", id: memory.id }, { method: "post", action })} | |
| 212 | − | className="inline-flex items-center gap-1 rounded-md border border-line px-2 py-1 text-xs text-muted hover:border-line-strong hover:text-danger" | |
| 213 | − | > | |
| 214 | − | <X size={12} /> | |
| 215 | − | Dismiss | |
| 216 | − | </button> | |
| 208 | + | <Hint label="Dismiss: never suggested again in these words"> | |
| 209 | + | <button | |
| 210 | + | type="button" | |
| 211 | + | aria-label="Dismiss" | |
| 212 | + | onClick={() => fetcher.submit({ intent: "dismiss", id: memory.id }, { method: "post", action })} | |
| 213 | + | className="inline-flex items-center gap-1 rounded-md border border-line px-2 py-1 text-xs text-muted hover:border-line-strong hover:text-danger" | |
| 214 | + | > | |
| 215 | + | <X size={12} /> | |
| 216 | + | Dismiss | |
| 217 | + | </button> | |
| 218 | + | </Hint> | |
| 217 | 219 | </div> | |
| 218 | 220 | </li> | |
| 219 | 221 | ); | |
| ⋯ | |||
| 498 | 500 | <input type="hidden" name="intent" value="fix" /> | |
| 499 | 501 | <input type="hidden" name="project" value={project} /> | |
| 500 | 502 | <input type="hidden" name="rule" value={rule.rule} /> | |
| 501 | − | <SubmitButton | |
| 502 | − | fetcher={fetcher} | |
| 503 | − | pending="Opening…" | |
| 504 | − | title={`Opens “${rule.fix.title}” and puts an agent on it`} | |
| 505 | − | className="inline-flex shrink-0 items-center gap-1 rounded-md border border-line px-2 py-1 text-xs text-muted hover:border-accent-dim hover:text-fg disabled:opacity-50" | |
| 506 | − | > | |
| 507 | − | <Bot size={12} /> | |
| 508 | − | Fix with an agent | |
| 509 | − | </SubmitButton> | |
| 503 | + | <Hint label={`Opens “${rule.fix.title}” and puts an agent on it`}> | |
| 504 | + | <SubmitButton | |
| 505 | + | fetcher={fetcher} | |
| 506 | + | pending="Opening…" | |
| 507 | + | className="inline-flex shrink-0 items-center gap-1 rounded-md border border-line px-2 py-1 text-xs text-muted hover:border-accent-dim hover:text-fg disabled:opacity-50" | |
| 508 | + | > | |
| 509 | + | <Bot size={12} /> | |
| 510 | + | Fix with an agent | |
| 511 | + | </SubmitButton> | |
| 512 | + | </Hint> | |
| 510 | 513 | </fetcher.Form> | |
| 511 | 514 | )} | |
| 512 | 515 | </li> | |
| 39 | 39 | } from "../lib/dependency-updates"; | |
| 40 | 40 | import { TimeAgo } from "./ui"; | |
| 41 | 41 | import { Badge, type BadgeTone } from "./ui/badge"; | |
| 42 | + | import { Hint } from "./ui/hint"; | |
| 42 | 43 | ||
| 43 | 44 | type Done = { ok: boolean; error?: string } | undefined; | |
| 44 | 45 | ||
| ⋯ | |||
| 110 | 111 | </span> | |
| 111 | 112 | )} | |
| 112 | 113 | {entry.nextRunAt && ( | |
| 113 | − | <span title={utc(entry.nextRunAt)}>Next check {timeUntil(entry.nextRunAt)}</span> | |
| 114 | + | <Hint label={utc(entry.nextRunAt)}> | |
| 115 | + | <span>Next check {timeUntil(entry.nextRunAt)}</span> | |
| 116 | + | </Hint> | |
| 114 | 117 | )} | |
| 115 | 118 | <span> | |
| 116 | 119 | {entry.lastCheckedAt ? ( | |
| 6 | 6 | ||
| 7 | 7 | import { SubmitButton } from "./ui"; | |
| 8 | 8 | import { Badge, type BadgeTone } from "./ui/badge"; | |
| 9 | + | import { Hint } from "./ui/hint"; | |
| 9 | 10 | import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "./ui/dropdown-menu"; | |
| 10 | 11 | import { Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger } from "./ui/sheet"; | |
| 11 | 12 | import { Skeleton } from "./ui/skeleton"; | |
| ⋯ | |||
| 124 | 125 | {item.doneAt ? ( | |
| 125 | 126 | <fetcher.Form method="post" action={ACTION}> | |
| 126 | 127 | <Fields intent="undone" id={item.id} /> | |
| 127 | − | <SubmitButton fetcher={fetcher} icon aria-label="Move back to the inbox" title="Move back to the inbox" className={ICON_BUTTON}> | |
| 128 | − | <Undo2 size={14} /> | |
| 129 | − | </SubmitButton> | |
| 128 | + | <Hint label="Move back to the inbox"> | |
| 129 | + | <SubmitButton fetcher={fetcher} icon aria-label="Move back to the inbox" className={ICON_BUTTON}> | |
| 130 | + | <Undo2 size={14} /> | |
| 131 | + | </SubmitButton> | |
| 132 | + | </Hint> | |
| 130 | 133 | </fetcher.Form> | |
| 131 | 134 | ) : ( | |
| 132 | 135 | <fetcher.Form method="post" action={ACTION}> | |
| 133 | 136 | <Fields intent="done" id={item.id} /> | |
| 134 | − | <SubmitButton fetcher={fetcher} icon aria-label="Done" title="Done" className={ICON_BUTTON}> | |
| 135 | − | <Check size={14} /> | |
| 136 | − | </SubmitButton> | |
| 137 | + | <Hint label="Done"> | |
| 138 | + | <SubmitButton fetcher={fetcher} icon aria-label="Done" className={ICON_BUTTON}> | |
| 139 | + | <Check size={14} /> | |
| 140 | + | </SubmitButton> | |
| 141 | + | </Hint> | |
| 137 | 142 | </fetcher.Form> | |
| 138 | 143 | )} | |
| 139 | 144 | <DropdownMenu> | |
| 528 | 528 | <div className="space-y-6 lg:order-1"> | |
| 529 | 529 | <div className="grid grid-cols-3 gap-2 sm:grid-cols-4"> | |
| 530 | 530 | {STACK.map((provider) => ( | |
| 531 | − | <div | |
| 532 | − | key={provider} | |
| 533 | − | className="flex flex-col items-center gap-2 rounded-xl bg-surface px-2 py-3 ring-1 ring-line" | |
| 534 | − | title={PROVIDERS[provider].label} | |
| 535 | − | > | |
| 531 | + | <div key={provider} className="flex flex-col items-center gap-2 rounded-xl bg-surface px-2 py-3 ring-1 ring-line"> | |
| 536 | 532 | <ProviderMark provider={provider} size={30} /> | |
| 537 | 533 | <span className="w-full text-center text-xs leading-tight text-muted">{PROVIDERS[provider].label}</span> | |
| 538 | 534 | </div> |
| 23 | 23 | } from "./ui/alert-dialog"; | |
| 24 | 24 | import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog"; | |
| 25 | 25 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; | |
| 26 | + | import { Hint } from "./ui/hint"; | |
| 26 | 27 | import { Switch } from "./ui/switch"; | |
| 27 | 28 | ||
| 28 | 29 | const KIND: Record<MemoryKind, { label: string; about: string; tone: string }> = { | |
| ⋯ | |||
| 167 | 168 | </div> | |
| 168 | 169 | {editable && ( | |
| 169 | 170 | <div className="flex shrink-0 items-start gap-0.5"> | |
| 170 | − | <button | |
| 171 | − | type="button" | |
| 172 | − | aria-label={pinned ? "Unpin" : "Pin"} | |
| 173 | − | title={pinned ? "Unpin" : "Pin: given to every agent first"} | |
| 174 | − | onClick={() => fetcher.submit({ intent: "update", id: memory.id, pinned: String(!pinned) }, { method: "post", action })} | |
| 175 | − | disabled={fetcher.state !== "idle"} | |
| 176 | − | className="rounded p-1 text-faint transition-colors hover:bg-raised hover:text-fg disabled:opacity-50" | |
| 177 | − | > | |
| 178 | − | {pinned ? <PinOff size={13} /> : <Pin size={13} />} | |
| 179 | − | </button> | |
| 171 | + | <Hint label={pinned ? "Unpin" : "Pin: given to every agent first"}> | |
| 172 | + | <button | |
| 173 | + | type="button" | |
| 174 | + | aria-label={pinned ? "Unpin" : "Pin"} | |
| 175 | + | onClick={() => fetcher.submit({ intent: "update", id: memory.id, pinned: String(!pinned) }, { method: "post", action })} | |
| 176 | + | disabled={fetcher.state !== "idle"} | |
| 177 | + | className="rounded p-1 text-faint transition-colors hover:bg-raised hover:text-fg disabled:opacity-50" | |
| 178 | + | > | |
| 179 | + | {pinned ? <PinOff size={13} /> : <Pin size={13} />} | |
| 180 | + | </button> | |
| 181 | + | </Hint> | |
| 180 | 182 | <EditMemory memory={memory} action={action} /> | |
| 181 | 183 | <AlertDialog> | |
| 182 | 184 | <AlertDialogTrigger aria-label="Forget" className="rounded p-1 text-faint transition-colors hover:bg-raised hover:text-danger"> | |
| 29 | 29 | import { duration } from "./actions"; | |
| 30 | 30 | import { Elapsed, type Live, useRuns } from "./agents"; | |
| 31 | 31 | import { Button, CopyLine, ErrorText, SubmitButton, TimeAgo } from "./ui"; | |
| 32 | + | import { Hint } from "./ui/hint"; | |
| 32 | 33 | import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; | |
| 33 | 34 | import { SkeletonLine } from "./ui/skeleton"; | |
| 34 | 35 | ||
| ⋯ | |||
| 128 | 129 | } | |
| 129 | 130 | return ( | |
| 130 | 131 | <div className="relative"> | |
| 131 | − | <button | |
| 132 | − | type="button" | |
| 133 | − | aria-label="Copy the output" | |
| 134 | − | title="Copy the output" | |
| 135 | − | className="absolute top-1.5 right-3 z-10 rounded-md border border-line bg-surface p-1.5 text-faint transition-colors hover:text-fg" | |
| 136 | − | onClick={() => { | |
| 137 | − | void navigator.clipboard.writeText(stripAnsi(body)); | |
| 138 | − | setCopied(true); | |
| 139 | − | setTimeout(() => setCopied(false), 1500); | |
| 140 | − | }} | |
| 141 | − | > | |
| 142 | − | {copied ? <Check size={13} className="text-accent" /> : <Copy size={13} />} | |
| 143 | − | </button> | |
| 132 | + | <Hint label="Copy the output"> | |
| 133 | + | <button | |
| 134 | + | type="button" | |
| 135 | + | aria-label="Copy the output" | |
| 136 | + | className="absolute top-1.5 right-3 z-10 rounded-md border border-line bg-surface p-1.5 text-faint transition-colors hover:text-fg" | |
| 137 | + | onClick={() => { | |
| 138 | + | void navigator.clipboard.writeText(stripAnsi(body)); | |
| 139 | + | setCopied(true); | |
| 140 | + | setTimeout(() => setCopied(false), 1500); | |
| 141 | + | }} | |
| 142 | + | > | |
| 143 | + | {copied ? <Check size={13} className="text-accent" /> : <Copy size={13} />} | |
| 144 | + | </button> | |
| 145 | + | </Hint> | |
| 144 | 146 | {cut && ( | |
| 145 | 147 | <p className="border-b border-line px-3 py-1.5 text-xs text-faint"> | |
| 146 | 148 | Showing the end of the output. Earlier lines were not kept. | |
| ⋯ | |||
| 416 | 418 | <Form method="post" key={status.context}> | |
| 417 | 419 | <input type="hidden" name="action" value="rerun-workflow" /> | |
| 418 | 420 | <input type="hidden" name="run" value={runIdOf(status) ?? ""} /> | |
| 419 | − | <SubmitButton | |
| 420 | − | variant="quiet" | |
| 421 | − | match={{ action: "rerun-workflow", run: runIdOf(status) ?? "" }} | |
| 422 | − | pending="Re-running…" | |
| 423 | − | title={`Re-run the failed jobs of ${status.context}`} | |
| 424 | − | > | |
| 425 | − | <RotateCw size={13} /> | |
| 426 | − | Re-run failed jobs{failedRuns.length > 1 ? ` of ${status.context}` : ""} | |
| 427 | − | </SubmitButton> | |
| 421 | + | <Hint label={`Re-run the failed jobs of ${status.context}`}> | |
| 422 | + | <SubmitButton | |
| 423 | + | variant="quiet" | |
| 424 | + | match={{ action: "rerun-workflow", run: runIdOf(status) ?? "" }} | |
| 425 | + | pending="Re-running…" | |
| 426 | + | > | |
| 427 | + | <RotateCw size={13} /> | |
| 428 | + | Re-run failed jobs{failedRuns.length > 1 ? ` of ${status.context}` : ""} | |
| 429 | + | </SubmitButton> | |
| 430 | + | </Hint> | |
| 428 | 431 | </Form> | |
| 429 | 432 | ))} | |
| 430 | 433 | </div> | |
| ⋯ | |||
| 850 | 853 | is pushed. | |
| 851 | 854 | </p> | |
| 852 | 855 | {update.paths.length > 0 && ( | |
| 853 | − | <p className="mt-1 truncate font-mono text-xs text-faint" title={update.paths.join(", ")}> | |
| 854 | − | {update.paths.slice(0, 5).join(", ")} | |
| 855 | − | {update.paths.length > 5 && ` and ${update.paths.length - 5} more`} | |
| 856 | − | </p> | |
| 856 | + | <Hint label={update.paths.join(", ")}> | |
| 857 | + | <p className="mt-1 truncate font-mono text-xs text-faint"> | |
| 858 | + | {update.paths.slice(0, 5).join(", ")} | |
| 859 | + | {update.paths.length > 5 && ` and ${update.paths.length - 5} more`} | |
| 860 | + | </p> | |
| 861 | + | </Hint> | |
| 857 | 862 | )} | |
| 858 | − | <p | |
| 859 | − | className="mt-2 truncate rounded-lg bg-bg px-3 py-2 font-mono text-xs text-fg/85 ring-1 ring-line" | |
| 860 | − | title={run?.step ?? undefined} | |
| 861 | − | > | |
| 862 | − | <span className="mr-2 inline-block size-1.5 animate-pulse rounded-full bg-merged align-middle" /> | |
| 863 | − | {run?.step ?? (run ? "Starting a sandbox…" : "Waiting for a sandbox…")} | |
| 864 | − | </p> | |
| 863 | + | <Hint label={run?.step}> | |
| 864 | + | <p className="mt-2 truncate rounded-lg bg-bg px-3 py-2 font-mono text-xs text-fg/85 ring-1 ring-line"> | |
| 865 | + | <span className="mr-2 inline-block size-1.5 animate-pulse rounded-full bg-merged align-middle" /> | |
| 866 | + | {run?.step ?? (run ? "Starting a sandbox…" : "Waiting for a sandbox…")} | |
| 867 | + | </p> | |
| 868 | + | </Hint> | |
| 865 | 869 | <p className="mt-2 flex flex-wrap items-center gap-x-3 text-xs text-muted"> | |
| 866 | 870 | <Elapsed from={new Date(startedAt).toISOString()} /> | |
| 867 | 871 | <Link to={session} className="hover:text-fg"> | |
| 36 | 36 | import { Unavailable } from "./mission"; | |
| 37 | 37 | import { TokenUsagePanel } from "./token-usage"; | |
| 38 | 38 | import { Avatar, SubmitButton, TimeAgo } from "./ui"; | |
| 39 | + | import { Hint } from "./ui/hint"; | |
| 39 | 40 | import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger } from "./ui/dropdown-menu"; | |
| 40 | 41 | import type { Loaded } from "../routes/home"; | |
| 41 | 42 | ||
| ⋯ | |||
| 413 | 414 | ||
| 414 | 415 | function Stat({ label, value, hint, dot, title }: { label: string; value: string; hint: ReactNode; dot?: string; title?: string }) { | |
| 415 | 416 | return ( | |
| 416 | − | <div className="min-w-0 bg-surface px-4 py-3.5 sm:px-5 sm:py-4" title={title}> | |
| 417 | − | <p className="flex items-center gap-1.5 truncate text-xs text-muted"> | |
| 418 | − | {dot && <span className={cn("size-1.5 shrink-0 rounded-full", dot)} />} | |
| 419 | − | {label} | |
| 420 | − | </p> | |
| 421 | − | <p className="mt-1 font-display text-2xl font-semibold tracking-tight tabular-nums sm:text-[1.75rem]">{value}</p> | |
| 422 | − | <p className="mt-0.5 truncate text-xs text-faint">{hint}</p> | |
| 423 | − | </div> | |
| 417 | + | <Hint label={title}> | |
| 418 | + | <div className="min-w-0 bg-surface px-4 py-3.5 sm:px-5 sm:py-4"> | |
| 419 | + | <p className="flex items-center gap-1.5 truncate text-xs text-muted"> | |
| 420 | + | {dot && <span className={cn("size-1.5 shrink-0 rounded-full", dot)} />} | |
| 421 | + | {label} | |
| 422 | + | </p> | |
| 423 | + | <p className="mt-1 font-display text-2xl font-semibold tracking-tight tabular-nums sm:text-[1.75rem]">{value}</p> | |
| 424 | + | <p className="mt-0.5 truncate text-xs text-faint">{hint}</p> | |
| 425 | + | </div> | |
| 426 | + | </Hint> | |
| 424 | 427 | ); | |
| 425 | 428 | } | |
| 426 | 429 | ||
| 33 | 33 | import { cn } from "../lib/cn"; | |
| 34 | 34 | import { type ActivityGroup, type Need, type NeedKind, type Verb, isAgent, sparkPoints } from "../lib/mission"; | |
| 35 | 35 | import { Avatar, TimeAgo } from "./ui"; | |
| 36 | + | import { Hint } from "./ui/hint"; | |
| 36 | 37 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; | |
| 37 | 38 | ||
| 38 | 39 | // --- Panels ------------------------------------------------------------------- | |
| ⋯ | |||
| 127 | 128 | const look = NEED[need.kind]; | |
| 128 | 129 | return ( | |
| 129 | 130 | <li key={need.key} className="flex items-start gap-3 px-4 py-3"> | |
| 130 | − | <span className={cn("mt-0.5 shrink-0", look.tone)} title={look.label}> | |
| 131 | − | {look.icon} | |
| 132 | − | </span> | |
| 131 | + | <Hint label={look.label}> | |
| 132 | + | <span className={cn("mt-0.5 shrink-0", look.tone)}> | |
| 133 | + | {look.icon} | |
| 134 | + | <span className="sr-only">{look.label}</span> | |
| 135 | + | </span> | |
| 136 | + | </Hint> | |
| 133 | 137 | <span className="min-w-0 grow"> | |
| 134 | 138 | <Link to={need.to} prefetch="intent" className="block truncate text-sm font-medium hover:text-accent"> | |
| 135 | 139 | {need.title} | |
| ⋯ | |||
| 447 | 451 | </span> | |
| 448 | 452 | )} | |
| 449 | 453 | </div> | |
| 450 | − | <p className="mt-1.5 truncate text-[0.6875rem] text-faint" title={hint}> | |
| 451 | − | {hint} | |
| 452 | − | </p> | |
| 454 | + | <Hint label={hint}> | |
| 455 | + | <p className="mt-1.5 truncate text-[0.6875rem] text-faint">{hint}</p> | |
| 456 | + | </Hint> | |
| 453 | 457 | </> | |
| 454 | 458 | ); | |
| 455 | 459 | const box = "block rounded-xl border border-line bg-surface p-4 transition-colors"; | |
| ⋯ | |||
| 491 | 495 | {Array.from({ length: slots - shown.length }, (_, i) => ( | |
| 492 | 496 | <span key={`empty-${i}`} className="h-2 flex-1 rounded-sm bg-line/60" /> | |
| 493 | 497 | ))} | |
| 494 | − | {shown.map((build) => ( | |
| 495 | − | <Link | |
| 496 | − | key={build.id} | |
| 497 | − | to={`${base}/deployments/${build.id}`} | |
| 498 | − | title={`${build.kind === "production" ? "Production" : "Preview"} · ${build.commit.slice(0, 7)} · ${build.status} · ${new Date(build.createdAt).toLocaleString("en-US")}`} | |
| 499 | − | className={cn("flex-1 rounded-sm transition-opacity hover:opacity-80", STRIP_TONE[build.status], build.kind === "production" ? "h-8" : "h-5")} | |
| 500 | − | /> | |
| 501 | − | ))} | |
| 498 | + | {shown.map((build) => { | |
| 499 | + | const about = `${build.kind === "production" ? "Production" : "Preview"} · ${build.commit.slice(0, 7)} · ${build.status} · ${new Date(build.createdAt).toLocaleString("en-US")}`; | |
| 500 | + | return ( | |
| 501 | + | <Hint key={build.id} label={about}> | |
| 502 | + | <Link | |
| 503 | + | to={`${base}/deployments/${build.id}`} | |
| 504 | + | aria-label={about} | |
| 505 | + | className={cn("flex-1 rounded-sm transition-opacity hover:opacity-80", STRIP_TONE[build.status], build.kind === "production" ? "h-8" : "h-5")} | |
| 506 | + | /> | |
| 507 | + | </Hint> | |
| 508 | + | ); | |
| 509 | + | })} | |
| 502 | 510 | </div> | |
| 503 | 511 | ); | |
| 504 | 512 | } | |
| 10 | 10 | import { type Connection, MODEL_TASKS, type ModelRoute, type ModelTask, type Provider, PROVIDERS } from "@g1t/contracts"; | |
| 11 | 11 | ||
| 12 | 12 | import { Field, Input, SubmitButton } from "./ui"; | |
| 13 | + | import { Hint } from "./ui/hint"; | |
| 13 | 14 | import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from "./ui/select"; | |
| 14 | 15 | ||
| 15 | 16 | type Group = "labs" | "platforms" | "any"; | |
| ⋯ | |||
| 216 | 217 | <ProviderMark provider={provider} size={28} /> | |
| 217 | 218 | <span className="min-w-0 grow"> | |
| 218 | 219 | <span className="block truncate text-sm font-medium">{PROVIDERS[provider].label}</span> | |
| 219 | − | <span className="block truncate text-xs text-muted" title={blurb(provider)}> | |
| 220 | − | {blurb(provider)} | |
| 221 | − | </span> | |
| 220 | + | <Hint label={blurb(provider)}> | |
| 221 | + | <span className="block truncate text-xs text-muted">{blurb(provider)}</span> | |
| 222 | + | </Hint> | |
| 222 | 223 | </span> | |
| 223 | 224 | <ChevronRight size={14} className="shrink-0 text-faint transition-transform group-hover:translate-x-0.5" /> | |
| 224 | 225 | </Link> | |
| 15 | 15 | import { Avatar, CopyLine, TimeAgo, notACredential } from "./ui"; | |
| 16 | 16 | import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger } from "./ui/dropdown-menu"; | |
| 17 | 17 | import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; | |
| 18 | + | import { Hint } from "./ui/hint"; | |
| 18 | 19 | import { SkeletonLine } from "./ui/skeleton"; | |
| 19 | 20 | ||
| 20 | 21 | function encodePath(path: string): string { | |
| ⋯ | |||
| 256 | 257 | </Link> | |
| 257 | 258 | <span className="hidden min-w-0 sm:block"> | |
| 258 | 259 | {commit ? ( | |
| 259 | − | <Link | |
| 260 | − | to={`${base}/commit/${commit.hash}`} | |
| 261 | − | className="block animate-fade-in truncate text-muted hover:text-fg hover:underline" | |
| 262 | − | title={commit.message.split("\n")[0]} | |
| 263 | − | > | |
| 264 | − | {commit.message.split("\n")[0]} | |
| 265 | − | </Link> | |
| 260 | + | <Hint label={commit.message.split("\n")[0]}> | |
| 261 | + | <Link to={`${base}/commit/${commit.hash}`} className="block animate-fade-in truncate text-muted hover:text-fg hover:underline"> | |
| 262 | + | {commit.message.split("\n")[0]} | |
| 263 | + | </Link> | |
| 264 | + | </Hint> | |
| 266 | 265 | ) : last === undefined ? ( | |
| 267 | 266 | <SkeletonLine width="10rem" barClassName="max-w-full" /> | |
| 268 | 267 | ) : null} | |
| 14 | 14 | import type { SecretsAction, SecretsData } from "../lib/secrets.server"; | |
| 15 | 15 | import { ButtonLink, EmptyState, ErrorText, SubmitButton, TimeAgo } from "./ui"; | |
| 16 | 16 | import { CheckboxOption } from "./ui/checkbox"; | |
| 17 | + | import { Hint } from "./ui/hint"; | |
| 17 | 18 | import { RadioCard, RadioGroup, RadioOption } from "./ui/radio-group"; | |
| 18 | 19 | import { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from "./ui/select"; | |
| 19 | 20 | ||
| ⋯ | |||
| 183 | 184 | ) : ( | |
| 184 | 185 | <> | |
| 185 | 186 | {row.projects.length > 0 && ( | |
| 186 | − | <span className="mr-1 text-xs text-faint" title={row.projects.join(", ")}> | |
| 187 | − | {row.projects.length} {row.projects.length === 1 ? "project" : "projects"} | |
| 188 | − | </span> | |
| 187 | + | <Hint label={row.projects.join(", ")}> | |
| 188 | + | <span className="mr-1 text-xs text-faint"> | |
| 189 | + | {row.projects.length} {row.projects.length === 1 ? "project" : "projects"} | |
| 190 | + | <span className="sr-only">: {row.projects.join(", ")}</span> | |
| 191 | + | </span> | |
| 192 | + | </Hint> | |
| 189 | 193 | )} | |
| 190 | 194 | {manage && ( | |
| 191 | 195 | <> | |
| 25 | 25 | import { SeverityBadge } from "./security"; | |
| 26 | 26 | import { TimeAgo } from "./ui"; | |
| 27 | 27 | import { Badge } from "./ui/badge"; | |
| 28 | + | import { Hint } from "./ui/hint"; | |
| 28 | 29 | import { RadioGroup, RadioOption } from "./ui/radio-group"; | |
| 29 | 30 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; | |
| 30 | 31 | ||
| ⋯ | |||
| 390 | 391 | } | |
| 391 | 392 | return ( | |
| 392 | 393 | <span className="flex flex-col items-end gap-1"> | |
| 393 | − | <button | |
| 394 | − | type="button" | |
| 395 | − | disabled={fetcher.state !== "idle"} | |
| 396 | − | onClick={() => fetcher.submit({ intent: "fix", id }, { method: "post", action })} | |
| 397 | − | className={`${SMALL_BUTTON} inline-flex items-center gap-1.5`} | |
| 398 | − | title="Opens an issue assigned to g1t; its run is charged as agent usage" | |
| 399 | − | > | |
| 400 | − | <Sparkles size={13} /> | |
| 401 | − | {fetcher.state !== "idle" ? "Opening…" : "Fix with g1t"} | |
| 402 | − | </button> | |
| 394 | + | <Hint label="Opens an issue assigned to g1t; its run is charged as agent usage"> | |
| 395 | + | <button | |
| 396 | + | type="button" | |
| 397 | + | disabled={fetcher.state !== "idle"} | |
| 398 | + | onClick={() => fetcher.submit({ intent: "fix", id }, { method: "post", action })} | |
| 399 | + | className={`${SMALL_BUTTON} inline-flex items-center gap-1.5`} | |
| 400 | + | > | |
| 401 | + | <Sparkles size={13} /> | |
| 402 | + | {fetcher.state !== "idle" ? "Opening…" : "Fix with g1t"} | |
| 403 | + | </button> | |
| 404 | + | </Hint> | |
| 403 | 405 | {fetcher.data?.error && <span className="text-xs text-danger">{fetcher.data.error}</span>} | |
| 404 | 406 | </span> | |
| 405 | 407 | ); | |
| 54 | 54 | } from "../lib/security-alerts"; | |
| 55 | 55 | import { Avatar, TimeAgo } from "./ui"; | |
| 56 | 56 | import { Badge, type BadgeTone } from "./ui/badge"; | |
| 57 | + | import { Hint } from "./ui/hint"; | |
| 57 | 58 | import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog"; | |
| 58 | 59 | import { RadioGroup, RadioOption } from "./ui/radio-group"; | |
| 59 | 60 | ||
| ⋯ | |||
| 345 | 346 | <Link to={`${base}/security/secret-scanning/${finding.id}`} className="text-sm font-medium first-letter:uppercase hover:underline"> | |
| 346 | 347 | {finding.label} | |
| 347 | 348 | </Link> | |
| 348 | − | <Badge tone={badge.tone} title={badge.about}> | |
| 349 | − | {badge.label} | |
| 350 | − | </Badge> | |
| 351 | − | {finding.testValue && <Badge title={finding.testValue}>Likely test value</Badge>} | |
| 349 | + | <Hint label={badge.about}> | |
| 350 | + | <Badge tone={badge.tone}>{badge.label}</Badge> | |
| 351 | + | </Hint> | |
| 352 | + | {finding.testValue && ( | |
| 353 | + | <Hint label={finding.testValue}> | |
| 354 | + | <Badge>Likely test value</Badge> | |
| 355 | + | </Hint> | |
| 356 | + | )} | |
| 352 | 357 | {finding.validity === "active" && ( | |
| 353 | − | <Badge tone="danger" title="Its issuer says it still works"> | |
| 354 | − | Active | |
| 355 | − | </Badge> | |
| 358 | + | <Hint label="Its issuer says it still works"> | |
| 359 | + | <Badge tone="danger">Active</Badge> | |
| 360 | + | </Hint> | |
| 356 | 361 | )} | |
| 357 | − | {finding.validity === "inactive" && <Badge title="Its issuer refused it: revoked or expired">Inactive</Badge>} | |
| 358 | − | {finding.bypass && <Badge tone="warn" title={`Bypassed by ${finding.bypass.by}`}>Bypassed</Badge>} | |
| 362 | + | {finding.validity === "inactive" && ( | |
| 363 | + | <Hint label="Its issuer refused it: revoked or expired"> | |
| 364 | + | <Badge>Inactive</Badge> | |
| 365 | + | </Hint> | |
| 366 | + | )} | |
| 367 | + | {finding.bypass && ( | |
| 368 | + | <Hint label={`Bypassed by ${finding.bypass.by}`}> | |
| 369 | + | <Badge tone="warn">Bypassed</Badge> | |
| 370 | + | </Hint> | |
| 371 | + | )} | |
| 359 | 372 | {finding.state === "dismissed" && finding.dismissedReason && <Badge>{dismissLabel(finding.dismissedReason)}</Badge>} | |
| 360 | 373 | </div> | |
| 361 | 374 | <p className="mt-1 truncate font-mono text-xs"> | |
| 26 | 26 | normalizeScopes, | |
| 27 | 27 | } from "../lib/token-scopes"; | |
| 28 | 28 | import { Badge } from "./ui/badge"; | |
| 29 | + | import { Hint } from "./ui/hint"; | |
| 29 | 30 | import { CONTROL } from "./ui/input"; | |
| 30 | 31 | ||
| 31 | 32 | // Choosing what a token or an application may do: a classic checklist. | |
| ⋯ | |||
| 47 | 48 | const by = impliedBy(ticked, scope); | |
| 48 | 49 | const checked = by !== null || ticked.includes(scope); | |
| 49 | 50 | return ( | |
| 50 | − | <label | |
| 51 | − | className={cn("flex min-w-0 items-start gap-2.5 py-1", by ? "cursor-default" : "cursor-pointer")} | |
| 52 | − | title={by ? `Included in ${by}` : undefined} | |
| 53 | − | > | |
| 54 | − | <input | |
| 55 | − | type="checkbox" | |
| 56 | − | name="scope" | |
| 57 | − | value={scope} | |
| 58 | − | checked={checked} | |
| 59 | − | disabled={by !== null} | |
| 60 | − | onChange={(event) => onToggle(scope, event.target.checked)} | |
| 61 | − | className={cn("mt-0.5 size-4 shrink-0", isDangerous(scope) ? "accent-danger" : "accent-accent")} | |
| 62 | − | /> | |
| 63 | − | <span className="min-w-0"> | |
| 64 | − | <span className={cn("block font-mono text-[0.8125rem]", isDangerous(scope) ? "text-danger" : "text-fg", by && "opacity-60")}> | |
| 65 | − | {scope} | |
| 51 | + | <Hint label={by ? `Included in ${by}` : undefined}> | |
| 52 | + | <label className={cn("flex min-w-0 items-start gap-2.5 py-1", by ? "cursor-default" : "cursor-pointer")}> | |
| 53 | + | <input | |
| 54 | + | type="checkbox" | |
| 55 | + | name="scope" | |
| 56 | + | value={scope} | |
| 57 | + | checked={checked} | |
| 58 | + | disabled={by !== null} | |
| 59 | + | onChange={(event) => onToggle(scope, event.target.checked)} | |
| 60 | + | className={cn("mt-0.5 size-4 shrink-0", isDangerous(scope) ? "accent-danger" : "accent-accent")} | |
| 61 | + | /> | |
| 62 | + | <span className="min-w-0"> | |
| 63 | + | <span className={cn("block font-mono text-[0.8125rem]", isDangerous(scope) ? "text-danger" : "text-fg", by && "opacity-60")}> | |
| 64 | + | {scope} | |
| 65 | + | </span> | |
| 66 | + | <span className="block text-xs leading-snug text-faint">{describeScope(scope)}</span> | |
| 67 | + | {by && <span className="sr-only">Included in {by}</span>} | |
| 66 | 68 | </span> | |
| 67 | − | <span className="block text-xs leading-snug text-faint">{describeScope(scope)}</span> | |
| 68 | − | </span> | |
| 69 | − | </label> | |
| 69 | + | </label> | |
| 70 | + | </Hint> | |
| 70 | 71 | ); | |
| 71 | 72 | } | |
| 72 | 73 | ||
| ⋯ | |||
| 126 | 127 | <div className="flex flex-wrap items-center gap-1.5"> | |
| 127 | 128 | <span className="mr-1 text-sm font-medium text-muted">Scopes</span> | |
| 128 | 129 | {PRESETS.filter((option) => allowFull || option.id !== "full").map((option) => ( | |
| 129 | − | <button | |
| 130 | − | key={option.id} | |
| 131 | − | type="button" | |
| 132 | − | aria-pressed={preset === option.id} | |
| 133 | − | title={option.description} | |
| 134 | − | onClick={() => choosePreset(option.id)} | |
| 135 | − | className={cn( | |
| 136 | − | "rounded-full border px-2.5 py-0.5 text-xs transition-colors", | |
| 137 | − | preset === option.id | |
| 138 | − | ? option.id === "full" | |
| 139 | − | ? "border-danger/50 bg-danger/10 text-danger" | |
| 140 | − | : "border-accent/50 bg-accent/10 text-accent" | |
| 141 | − | : "border-line text-muted hover:border-line-strong hover:text-fg", | |
| 142 | − | )} | |
| 143 | − | > | |
| 144 | − | {option.label} | |
| 145 | − | </button> | |
| 130 | + | <Hint key={option.id} label={option.description}> | |
| 131 | + | <button | |
| 132 | + | type="button" | |
| 133 | + | aria-pressed={preset === option.id} | |
| 134 | + | onClick={() => choosePreset(option.id)} | |
| 135 | + | className={cn( | |
| 136 | + | "rounded-full border px-2.5 py-0.5 text-xs transition-colors", | |
| 137 | + | preset === option.id | |
| 138 | + | ? option.id === "full" | |
| 139 | + | ? "border-danger/50 bg-danger/10 text-danger" | |
| 140 | + | : "border-accent/50 bg-accent/10 text-accent" | |
| 141 | + | : "border-line text-muted hover:border-line-strong hover:text-fg", | |
| 142 | + | )} | |
| 143 | + | > | |
| 144 | + | {option.label} | |
| 145 | + | </button> | |
| 146 | + | </Hint> | |
| 146 | 147 | ))} | |
| 147 | 148 | <span className="ml-auto text-xs text-faint"> | |
| 148 | 149 | {count === null ? "Everything you can do" : count === 1 ? "1 scope" : `${count} scopes`} | |
| ⋯ | |||
| 241 | 242 | <div className={cn("mt-1.5 flex flex-wrap items-center gap-1.5", className)}> | |
| 242 | 243 | <Badge tone={tone}>{summary}</Badge> | |
| 243 | 244 | {scopes.map((scope) => ( | |
| 244 | − | <span | |
| 245 | − | key={scope} | |
| 246 | − | title={describeScope(scope)} | |
| 247 | − | className={cn( | |
| 248 | − | "rounded border px-1.5 py-px font-mono text-[0.6875rem]", | |
| 249 | − | isDangerous(scope) ? "border-danger/40 text-danger" : "border-line text-muted", | |
| 250 | − | )} | |
| 251 | − | > | |
| 252 | − | {scope} | |
| 253 | − | </span> | |
| 245 | + | <Hint key={scope} label={describeScope(scope)}> | |
| 246 | + | <span | |
| 247 | + | className={cn( | |
| 248 | + | "rounded border px-1.5 py-px font-mono text-[0.6875rem]", | |
| 249 | + | isDangerous(scope) ? "border-danger/40 text-danger" : "border-line text-muted", | |
| 250 | + | )} | |
| 251 | + | > | |
| 252 | + | {scope} | |
| 253 | + | </span> | |
| 254 | + | </Hint> | |
| 254 | 255 | ))} | |
| 255 | 256 | </div> | |
| 256 | 257 | ); | |
| 3 | 3 | ||
| 4 | 4 | import { cn } from "../lib/cn"; | |
| 5 | 5 | import { usd } from "../lib/mission-control"; | |
| 6 | + | import { Hint } from "./ui/hint"; | |
| 6 | 7 | import { | |
| 7 | 8 | HEAT_LEVELS, | |
| 8 | 9 | type MixPart, | |
| ⋯ | |||
| 109 | 110 | ||
| 110 | 111 | function Tile({ label, value, hint, title }: { label: string; value: string; hint?: string; title?: string }) { | |
| 111 | 112 | return ( | |
| 112 | − | <div className="rounded-lg border border-line bg-bg/40 px-3 py-2.5" title={title}> | |
| 113 | − | <dt className="text-[0.6875rem] text-muted">{label}</dt> | |
| 114 | − | <dd className="mt-0.5 text-lg font-semibold tracking-tight tabular-nums"> | |
| 115 | − | {value} | |
| 116 | − | {hint && <span className="ml-1 text-xs font-normal text-faint">{hint}</span>} | |
| 117 | − | </dd> | |
| 118 | − | </div> | |
| 113 | + | <Hint label={title}> | |
| 114 | + | <div className="rounded-lg border border-line bg-bg/40 px-3 py-2.5"> | |
| 115 | + | <dt className="text-[0.6875rem] text-muted">{label}</dt> | |
| 116 | + | <dd className="mt-0.5 text-lg font-semibold tracking-tight tabular-nums"> | |
| 117 | + | {value} | |
| 118 | + | {hint && <span className="ml-1 text-xs font-normal text-faint">{hint}</span>} | |
| 119 | + | </dd> | |
| 120 | + | </div> | |
| 121 | + | </Hint> | |
| 119 | 122 | ); | |
| 120 | 123 | } | |
| 121 | 124 | ||
| ⋯ | |||
| 179 | 182 | <h3 className="text-xs font-medium text-fg-soft">Token mix</h3> | |
| 180 | 183 | <div className="mt-2 flex h-2.5 gap-[2px] overflow-hidden rounded" role="img" aria-label={parts.map((p) => `${p.label} ${Math.round(p.share * 100)}%`).join(", ")}> | |
| 181 | 184 | {parts.map((part) => ( | |
| 182 | − | <span | |
| 183 | − | key={part.key} | |
| 184 | − | title={`${part.label}: ${compactTokens(part.tokens)} (${Math.round(part.share * 100)}%)`} | |
| 185 | − | className="h-full first:rounded-l last:rounded-r" | |
| 186 | − | style={{ width: `${part.share * 100}%`, minWidth: 3, background: MIX_COLOR[part.key] }} | |
| 187 | − | /> | |
| 185 | + | <Hint key={part.key} label={`${part.label}: ${compactTokens(part.tokens)} (${Math.round(part.share * 100)}%)`}> | |
| 186 | + | <span | |
| 187 | + | className="h-full first:rounded-l last:rounded-r" | |
| 188 | + | style={{ width: `${part.share * 100}%`, minWidth: 3, background: MIX_COLOR[part.key] }} | |
| 189 | + | /> | |
| 190 | + | </Hint> | |
| 188 | 191 | ))} | |
| 189 | 192 | </div> | |
| 190 | 193 | <ul className="mt-2.5 grid grid-cols-2 gap-x-3 gap-y-1.5 text-[0.6875rem]"> | |
| 11 | 11 | import type { WebhooksAction, WebhooksData } from "../lib/webhooks.server"; | |
| 12 | 12 | import { CopyLine, EmptyState, ErrorText, Field, Input, SubmitButton, TimeAgo } from "./ui"; | |
| 13 | 13 | import { CheckboxOption } from "./ui/checkbox"; | |
| 14 | + | import { Hint } from "./ui/hint"; | |
| 14 | 15 | import { RadioGroup, RadioOption } from "./ui/radio-group"; | |
| 15 | 16 | ||
| 16 | 17 | /** The events, in groups people recognise. */ | |
| ⋯ | |||
| 177 | 178 | /** One webhook's action, as an icon: a spinner in its place while that action goes. */ | |
| 178 | 179 | function IconButton({ intent, id, label, children }: { intent: string; id: string; label: string; children: React.ReactNode }) { | |
| 179 | 180 | return ( | |
| 180 | − | <SubmitButton | |
| 181 | − | icon | |
| 182 | − | name="intent" | |
| 183 | − | value={intent} | |
| 184 | − | match={{ id }} | |
| 185 | − | title={label} | |
| 186 | − | aria-label={label} | |
| 187 | − | className="rounded-md p-2 text-muted transition-colors hover:bg-raised hover:text-fg disabled:opacity-50" | |
| 188 | − | > | |
| 189 | − | {children} | |
| 190 | − | </SubmitButton> | |
| 181 | + | <Hint label={label}> | |
| 182 | + | <SubmitButton | |
| 183 | + | icon | |
| 184 | + | name="intent" | |
| 185 | + | value={intent} | |
| 186 | + | match={{ id }} | |
| 187 | + | aria-label={label} | |
| 188 | + | className="rounded-md p-2 text-muted transition-colors hover:bg-raised hover:text-fg disabled:opacity-50" | |
| 189 | + | > | |
| 190 | + | {children} | |
| 191 | + | </SubmitButton> | |
| 192 | + | </Hint> | |
| 191 | 193 | ); | |
| 192 | 194 | } | |
| 193 | 195 | ||
| 11 | 11 | import { MentionTextarea } from "./mention-textarea"; | |
| 12 | 12 | import { Avatar, SubmitButton, TimeAgo } from "./ui"; | |
| 13 | 13 | import { CheckboxOption } from "./ui/checkbox"; | |
| 14 | + | import { Hint } from "./ui/hint"; | |
| 14 | 15 | ||
| 15 | 16 | export { IssueIcon, PullIcon }; | |
| 16 | 17 | ||
| ⋯ | |||
| 57 | 58 | if (people.length === 0) return null; | |
| 58 | 59 | const shown = people.slice(0, 3); | |
| 59 | 60 | return ( | |
| 60 | − | <span | |
| 61 | − | className="flex shrink-0 items-center" | |
| 62 | − | title={`Assigned to ${people.join(", ")}`} | |
| 63 | − | > | |
| 64 | − | {shown.map((name, index) => ( | |
| 65 | − | <span key={name} className={`rounded-full ring-2 ring-bg ${index > 0 ? "-ml-1.5" : ""}`}> | |
| 66 | − | <Avatar name={name} size={18} /> | |
| 67 | − | </span> | |
| 68 | − | ))} | |
| 69 | − | {people.length > shown.length && ( | |
| 70 | − | <span className="ml-1 text-xs text-faint">+{people.length - shown.length}</span> | |
| 71 | − | )} | |
| 72 | − | </span> | |
| 61 | + | <Hint label={`Assigned to ${people.join(", ")}`}> | |
| 62 | + | <span className="flex shrink-0 items-center"> | |
| 63 | + | <span className="sr-only">Assigned to {people.join(", ")}</span> | |
| 64 | + | {shown.map((name, index) => ( | |
| 65 | + | <span key={name} className={`rounded-full ring-2 ring-bg ${index > 0 ? "-ml-1.5" : ""}`} aria-hidden> | |
| 66 | + | <Avatar name={name} size={18} /> | |
| 67 | + | </span> | |
| 68 | + | ))} | |
| 69 | + | {people.length > shown.length && ( | |
| 70 | + | <span className="ml-1 text-xs text-faint" aria-hidden> | |
| 71 | + | +{people.length - shown.length} | |
| 72 | + | </span> | |
| 73 | + | )} | |
| 74 | + | </span> | |
| 75 | + | </Hint> | |
| 73 | 76 | ); | |
| 74 | 77 | } | |
| 75 | 78 | ||
| 76 | 79 | /** The agent working on an issue now. */ | |
| 77 | 80 | export function Assignee({ agent }: { agent: string }) { | |
| 78 | 81 | return ( | |
| 79 | − | <span | |
| 80 | − | className="inline-flex shrink-0 items-center gap-1 rounded-full border border-accent/35 bg-accent/10 px-2 py-px text-xs font-medium text-accent" | |
| 81 | − | title={`Assigned to ${agent}`} | |
| 82 | − | > | |
| 83 | − | <Bot size={12} /> | |
| 84 | − | {agent} | |
| 85 | − | </span> | |
| 82 | + | <Hint label={`Assigned to ${agent}`}> | |
| 83 | + | <span className="inline-flex shrink-0 items-center gap-1 rounded-full border border-accent/35 bg-accent/10 px-2 py-px text-xs font-medium text-accent"> | |
| 84 | + | <Bot size={12} /> | |
| 85 | + | <span className="sr-only">Assigned to </span> | |
| 86 | + | {agent} | |
| 87 | + | </span> | |
| 88 | + | </Hint> | |
| 86 | 89 | ); | |
| 87 | 90 | } | |
| 88 | 91 | ||