Merge branch 'worktree-agent-ae1299e92e4462012'
50 files+842−5320/50 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, MODEL_TIERS, type ModelRoute, type ModelTask, type ModelTier, 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"> |
| 8 | 8 | import { AgentButton, InboxBell } from "./inbox"; | |
| 9 | 9 | import { PinButton } from "./pin-button"; | |
| 10 | 10 | import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; | |
| 11 | + | import { Hint } from "./ui/hint"; | |
| 11 | 12 | import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "./ui/sheet"; | |
| 12 | 13 | import { StatusDot, useSiteStatus } from "./footer"; | |
| 13 | 14 | import { Logo, Mark } from "./logo"; | |
| 136 | 137 | */ | |
| 137 | 138 | function SidebarSoon({ icon, children, about }: { icon: ReactNode; children: ReactNode; about: string }) { | |
| 138 | 139 | return ( | |
| 139 | − | <div | |
| 140 | − | title={about} | |
| 141 | − | aria-disabled="true" | |
| 142 | − | className="flex h-8 cursor-default items-center gap-2.5 rounded-md px-2 text-[0.8125rem] text-faint" | |
| 143 | − | > | |
| 144 | − | <span className="shrink-0 opacity-70">{icon}</span> | |
| 145 | − | <span className="grow truncate">{children}</span> | |
| 146 | − | <SoonPill /> | |
| 147 | − | </div> | |
| 140 | + | <Hint label={about} side="right"> | |
| 141 | + | <div aria-disabled="true" className="flex h-8 cursor-default items-center gap-2.5 rounded-md px-2 text-[0.8125rem] text-faint"> | |
| 142 | + | <span className="shrink-0 opacity-70">{icon}</span> | |
| 143 | + | <span className="grow truncate">{children}</span> | |
| 144 | + | <span className="sr-only">{about}</span> | |
| 145 | + | <SoonPill /> | |
| 146 | + | </div> | |
| 147 | + | </Hint> | |
| 148 | 148 | ); | |
| 149 | 149 | } | |
| 150 | 150 | ||
| 167 | 167 | }) { | |
| 168 | 168 | const { pathname } = useLocation(); | |
| 169 | 169 | return ( | |
| 170 | + | <Hint label={about} side="right"> | |
| 170 | 171 | <NavLink | |
| 171 | 172 | to={to} | |
| 172 | − | title={about} | |
| 173 | 173 | prefetch="intent" | |
| 174 | 174 | className={({ isActive }) => { | |
| 175 | 175 | const current = isActive || (also ?? []).some((prefix) => pathname === prefix || pathname.startsWith(prefix + "/")); | |
| 182 | 182 | <span className="min-w-0 grow truncate">{children}</span> | |
| 183 | 183 | <SoonPill /> | |
| 184 | 184 | </NavLink> | |
| 185 | + | </Hint> | |
| 185 | 186 | ); | |
| 186 | 187 | } | |
| 187 | 188 | ||
| 234 | 235 | <DropdownMenu> | |
| 235 | 236 | {/* The name goes to the workspace; only the arrows switch it. The | |
| 236 | 237 | name gets all the room there is, and the whole of it on hover. */} | |
| 238 | + | <Hint label={workspace ? `${label} · g1t.sh/${workspace.slug}` : undefined}> | |
| 237 | 239 | <Link | |
| 238 | 240 | to={current ? `/${current}` : "/workspaces/new"} | |
| 239 | 241 | prefetch="intent" | |
| 240 | − | title={workspace ? `${label} · g1t.sh/${workspace.slug}` : undefined} | |
| 241 | 242 | className="flex h-9 min-w-0 grow items-center gap-2 rounded-md px-2 transition-colors hover:bg-raised" | |
| 242 | 243 | > | |
| 243 | 244 | {workspace ? ( | |
| 247 | 248 | )} | |
| 248 | 249 | <span className="min-w-0 truncate text-[0.8125rem] font-medium">{label}</span> | |
| 249 | 250 | </Link> | |
| 251 | + | </Hint> | |
| 250 | 252 | <DropdownMenuTrigger | |
| 251 | 253 | aria-label="Switch workspace" | |
| 252 | 254 | className="flex h-9 w-7 shrink-0 items-center justify-center rounded-md text-faint outline-none transition-colors hover:bg-raised hover:text-fg focus-visible:ring-2 focus-visible:ring-accent data-[state=open]:bg-raised data-[state=open]:text-fg" | |
| 274 | 276 | )} | |
| 275 | 277 | <DropdownMenuLabel>Workspaces</DropdownMenuLabel> | |
| 276 | 278 | {(user.workspaces ?? []).map((membership) => ( | |
| 277 | − | <DropdownMenuItem asChild key={membership.slug}> | |
| 278 | − | <Link to={`/${membership.slug}`} title={displayName(membership)}> | |
| 279 | + | <Hint key={membership.slug} label={displayName(membership)} side="right"> | |
| 280 | + | <DropdownMenuItem asChild> | |
| 281 | + | <Link to={`/${membership.slug}`}> | |
| 279 | 282 | <Avatar name={membership.slug} image={membership.avatar} size={24} square /> | |
| 280 | 283 | <span className="flex min-w-0 grow flex-col leading-tight"> | |
| 281 | 284 | <span className="truncate">{displayName(membership)}</span> | |
| 289 | 292 | )} | |
| 290 | 293 | </Link> | |
| 291 | 294 | </DropdownMenuItem> | |
| 295 | + | </Hint> | |
| 292 | 296 | ))} | |
| 293 | 297 | <DropdownMenuSeparator /> | |
| 294 | 298 | <DropdownMenuItem asChild> | |
| 655 | 659 | event.preventDefault(); | |
| 656 | 660 | move(index, event.key === "ArrowUp" ? -1 : 1); | |
| 657 | 661 | }} | |
| 658 | − | title={pinned.length > 1 ? "Drag, or Alt and an arrow key, to reorder" : undefined} | |
| 659 | 662 | className={`group/pin relative ${dragging === index ? "opacity-50" : ""}`} | |
| 660 | 663 | > | |
| 661 | 664 | {row(project, true)} | |
| 662 | 665 | {pinned.length > 1 && ( | |
| 663 | − | <GripVertical | |
| 664 | − | size={12} | |
| 665 | − | aria-hidden="true" | |
| 666 | − | className="pointer-events-none absolute top-1/2 -left-1.5 -translate-y-1/2 text-faint opacity-0 transition-opacity group-hover/pin:opacity-100" | |
| 667 | − | /> | |
| 666 | + | <Hint label="Drag, or Alt and an arrow key, to reorder" side="right"> | |
| 667 | + | <span className="absolute top-1/2 -left-1.5 -translate-y-1/2 cursor-grab text-faint opacity-0 transition-opacity group-hover/pin:opacity-100"> | |
| 668 | + | <GripVertical size={12} aria-hidden="true" /> | |
| 669 | + | </span> | |
| 670 | + | </Hint> | |
| 668 | 671 | )} | |
| 669 | 672 | </li> | |
| 670 | 673 | ))} | |
| 903 | 906 | return ( | |
| 904 | 907 | <nav aria-label={`${repo.namespace}/${repo.name}`} className={PANEL}> | |
| 905 | 908 | <BackRow to={back.to} label={back.label} /> | |
| 909 | + | <Hint label="Overview" side="right"> | |
| 906 | 910 | <NavLink | |
| 907 | 911 | to={base} | |
| 908 | 912 | end | |
| 909 | 913 | prefetch="intent" | |
| 910 | − | title="Overview" | |
| 911 | 914 | className={({ isActive }) => | |
| 912 | 915 | `mt-2 flex items-center gap-2 rounded-md px-2 py-1.5 transition-colors ${isActive ? "bg-raised" : "hover:bg-raised/60"}` | |
| 913 | 916 | } | |
| 920 | 923 | <span className="font-semibold text-fg">{repo.name}</span> | |
| 921 | 924 | </span> | |
| 922 | 925 | </NavLink> | |
| 926 | + | </Hint> | |
| 923 | 927 | {/* A project's pages, in the order people use them. A page with more | |
| 924 | 928 | than one view shows them as tabs across its top. */} | |
| 925 | 929 | <Rule /> |
| 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]"> |
| 1 | + | import { type ComponentProps, type MouseEvent, type PointerEvent, type ReactElement, type ReactNode, useRef, useState } from "react"; | |
| 2 | + | ||
| 3 | + | import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip"; | |
| 4 | + | ||
| 5 | + | /** | |
| 6 | + | * A hover hint: the site's tooltip around exactly one element, in place of | |
| 7 | + | * the browser's `title`, which g1t does not use (app/lib/no-native-title.test.ts | |
| 8 | + | * keeps it out). It opens on hover and keyboard focus, and on a tap of | |
| 9 | + | * something that does nothing else when tapped, since a touch screen has no | |
| 10 | + | * hover. Without a `label` the child renders as it is. | |
| 11 | + | * | |
| 12 | + | * The hint is not the element's accessible name: an icon-only button keeps | |
| 13 | + | * its `aria-label`, and text cut short keeps its whole text for a screen | |
| 14 | + | * reader. Anything else passed (as from a parent's `asChild`) goes to the child. | |
| 15 | + | */ | |
| 16 | + | export function Hint({ | |
| 17 | + | label, | |
| 18 | + | children, | |
| 19 | + | side, | |
| 20 | + | align, | |
| 21 | + | disabled, | |
| 22 | + | onClick, | |
| 23 | + | onPointerDown, | |
| 24 | + | ...props | |
| 25 | + | }: Omit<ComponentProps<typeof TooltipTrigger>, "children"> & { | |
| 26 | + | label: ReactNode; | |
| 27 | + | /** One element that takes a ref and passes its props on (asChild). */ | |
| 28 | + | children: ReactElement; | |
| 29 | + | side?: ComponentProps<typeof TooltipContent>["side"]; | |
| 30 | + | align?: ComponentProps<typeof TooltipContent>["align"]; | |
| 31 | + | /** | |
| 32 | + | * The child is disabled, so it gets no pointer events and no focus: the | |
| 33 | + | * hint hangs on a focusable span around it instead, as when it says why. | |
| 34 | + | */ | |
| 35 | + | disabled?: boolean; | |
| 36 | + | }) { | |
| 37 | + | const [open, setOpen] = useState(false); | |
| 38 | + | const touch = useRef(false); | |
| 39 | + | if (label == null || label === false || label === "") return children; | |
| 40 | + | const trigger = disabled ? ( | |
| 41 | + | <span tabIndex={0} className="inline-flex rounded-md outline-none focus-visible:ring-2 focus-visible:ring-accent *:pointer-events-none"> | |
| 42 | + | {children} | |
| 43 | + | </span> | |
| 44 | + | ) : ( | |
| 45 | + | children | |
| 46 | + | ); | |
| 47 | + | return ( | |
| 48 | + | <Tooltip open={open} onOpenChange={setOpen}> | |
| 49 | + | <TooltipTrigger | |
| 50 | + | asChild | |
| 51 | + | {...props} | |
| 52 | + | onPointerDown={(event: PointerEvent<HTMLButtonElement>) => { | |
| 53 | + | touch.current = event.pointerType === "touch"; | |
| 54 | + | onPointerDown?.(event); | |
| 55 | + | }} | |
| 56 | + | onClick={(event: MouseEvent<HTMLButtonElement>) => { | |
| 57 | + | onClick?.(event); | |
| 58 | + | // A tap on text or a badge shows the hint; a tap on a link or a | |
| 59 | + | // button does what it does. preventDefault keeps the trigger's own | |
| 60 | + | // click handler from closing the hint again. | |
| 61 | + | if (touch.current && !event.defaultPrevented && !(event.target as Element).closest("a, button, input, select, textarea, label, summary")) { | |
| 62 | + | event.preventDefault(); | |
| 63 | + | setOpen(true); | |
| 64 | + | } | |
| 65 | + | }} | |
| 66 | + | > | |
| 67 | + | {trigger} | |
| 68 | + | </TooltipTrigger> | |
| 69 | + | <TooltipContent side={side} align={align}> | |
| 70 | + | {label} | |
| 71 | + | </TooltipContent> | |
| 72 | + | </Tooltip> | |
| 73 | + | ); | |
| 74 | + | } |
| 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 |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { readFileSync, readdirSync } from "node:fs"; | |
| 3 | + | import { join, relative } from "node:path"; | |
| 4 | + | import { test } from "node:test"; | |
| 5 | + | import { fileURLToPath } from "node:url"; | |
| 6 | + | ||
| 7 | + | // Hover hints are the site's tooltip (components/ui/hint.tsx, or Tooltip | |
| 8 | + | // from components/ui/tooltip.tsx), never the browser's `title`: it looks | |
| 9 | + | // foreign, waits a second and a half, and never shows on a touch screen or | |
| 10 | + | // to the keyboard. This fails on a `title` on an HTML element, or on a | |
| 11 | + | // component that hands its props to one. | |
| 12 | + | ||
| 13 | + | /** Components that pass `title` on to an HTML element. */ | |
| 14 | + | const FORWARDS = ["Link", "NavLink", "Button", "ButtonLink", "SubmitButton", "Badge", "Switch", "Input", "Textarea", "TabLink"]; | |
| 15 | + | ||
| 16 | + | /** | |
| 17 | + | * Where a native `title` is right. `file:line`, with the reason. An SVG's | |
| 18 | + | * `<title>` child is an element, not an attribute, and needs no entry. | |
| 19 | + | */ | |
| 20 | + | const ALLOWED: Record<string, string> = {}; | |
| 21 | + | ||
| 22 | + | const APP = fileURLToPath(new URL("..", import.meta.url)); | |
| 23 | + | ||
| 24 | + | function* sources(dir: string): Generator<string> { | |
| 25 | + | for (const entry of readdirSync(dir, { withFileTypes: true })) { | |
| 26 | + | const path = join(dir, entry.name); | |
| 27 | + | if (entry.isDirectory()) yield* sources(path); | |
| 28 | + | else if (entry.name.endsWith(".tsx")) yield path; | |
| 29 | + | } | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | /** Each opening tag of an HTML element or a forwarding component with a `title` attribute. */ | |
| 33 | + | function nativeTitles(source: string): { tag: string; line: number }[] { | |
| 34 | + | const found: { tag: string; line: number }[] = []; | |
| 35 | + | const opening = new RegExp(`<([a-z][a-z0-9]*|${FORWARDS.join("|")})(?=[\\s/>])`, "g"); | |
| 36 | + | for (let match = opening.exec(source); match; match = opening.exec(source)) { | |
| 37 | + | // The tag's attributes, up to its closing `>`, with every {...} left out. | |
| 38 | + | let attributes = ""; | |
| 39 | + | let depth = 0; | |
| 40 | + | let quote: string | null = null; | |
| 41 | + | for (let i = match.index + match[0].length; i < source.length; i++) { | |
| 42 | + | const c = source[i]; | |
| 43 | + | if (quote) { | |
| 44 | + | if (c === quote) quote = null; | |
| 45 | + | if (depth === 0) attributes += c; | |
| 46 | + | continue; | |
| 47 | + | } | |
| 48 | + | if (c === '"' || c === "'" || c === "`") quote = c; | |
| 49 | + | else if (c === "{") depth++; | |
| 50 | + | else if (c === "}") depth--; | |
| 51 | + | else if (c === ">" && depth === 0) break; | |
| 52 | + | if (depth === 0) attributes += c; | |
| 53 | + | } | |
| 54 | + | if (/(^|\s)title=/.test(attributes)) { | |
| 55 | + | found.push({ tag: match[1], line: source.slice(0, match.index).split("\n").length }); | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | return found; | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | test("finds a title on an element, not on a component's own prop or inside an expression", () => { | |
| 62 | + | const hits = nativeTitles( | |
| 63 | + | [ | |
| 64 | + | `<span title="x">a</span>`, | |
| 65 | + | `<Section title="Heading">`, | |
| 66 | + | `<div\n className="a"\n title={label}\n>`, | |
| 67 | + | `<Link to="/" title={x}>`, | |
| 68 | + | `<button onClick={() => open({ title: "x" })}>`, | |
| 69 | + | `<p className={cn("a", b && "title=")}>`, | |
| 70 | + | ].join("\n"), | |
| 71 | + | ); | |
| 72 | + | assert.deepEqual(hits, [ | |
| 73 | + | { tag: "span", line: 1 }, | |
| 74 | + | { tag: "div", line: 3 }, | |
| 75 | + | { tag: "Link", line: 7 }, | |
| 76 | + | ]); | |
| 77 | + | }); | |
| 78 | + | ||
| 79 | + | test("no native title hints in apps/web", () => { | |
| 80 | + | const offenders: string[] = []; | |
| 81 | + | for (const file of sources(APP)) { | |
| 82 | + | const name = relative(APP, file).replaceAll("\\", "/"); | |
| 83 | + | for (const { tag, line } of nativeTitles(readFileSync(file, "utf8"))) { | |
| 84 | + | if (!ALLOWED[`${name}:${line}`]) offenders.push(`app/${name}:${line} <${tag} title=…>`); | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | assert.deepEqual(offenders, [], "Use <Hint label=…> from components/ui/hint.tsx in place of a native title."); | |
| 88 | + | }); |
| 8 | 8 | import { page } from "../../lib/meta"; | |
| 9 | 9 | import { LogText, Notes, StatusIcon, duration, shortRef, standingWord, useJobLog } from "../../components/actions"; | |
| 10 | 10 | import { ErrorText, SubmitButton, TimeAgo, usePending } from "../../components/ui"; | |
| 11 | + | import { Hint } from "../../components/ui/hint"; | |
| 11 | 12 | import { listArtifacts } from "../../lib/artifacts.server"; | |
| 12 | 13 | import { actions } from "../../lib/services.server"; | |
| 13 | 14 | import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server"; | |
| 106 | 107 | ); | |
| 107 | 108 | // A link to the runners for those who manage them, a label for everyone else. | |
| 108 | 109 | return runnersPage ? ( | |
| 109 | − | <Link to={`/${workspace}/-/runners`} className={`${badge} hover:text-fg`} title="Self-hosted runner"> | |
| 110 | − | {label} | |
| 111 | − | </Link> | |
| 110 | + | <Hint label="Self-hosted runner"> | |
| 111 | + | <Link to={`/${workspace}/-/runners`} className={`${badge} hover:text-fg`}> | |
| 112 | + | {label} | |
| 113 | + | </Link> | |
| 114 | + | </Hint> | |
| 112 | 115 | ) : ( | |
| 113 | − | <span className={badge} title="Self-hosted runner"> | |
| 114 | − | {label} | |
| 115 | − | </span> | |
| 116 | + | <Hint label="Self-hosted runner"> | |
| 117 | + | <span className={badge}>{label}</span> | |
| 118 | + | </Hint> | |
| 116 | 119 | ); | |
| 117 | 120 | } | |
| 118 | 121 | if (!job.startedAt) return null; |
| 4 | 4 | import type { Route } from "./+types/branches"; | |
| 5 | 5 | import { ActiveBranches } from "../../components/branches"; | |
| 6 | 6 | import { Avatar, EmptyState, TimeAgo, notACredential } from "../../components/ui"; | |
| 7 | + | import { Hint } from "../../components/ui/hint"; | |
| 7 | 8 | import { readBranches } from "../../lib/branches.server"; | |
| 8 | 9 | import { page } from "../../lib/meta"; | |
| 9 | 10 | import { deployments, repos, work } from "../../lib/services.server"; | |
| 96 | 97 | <Avatar name={head.author} size={13} /> | |
| 97 | 98 | <span className="shrink-0">{head.author}</span> | |
| 98 | 99 | <span className="text-faint">·</span> | |
| 99 | − | <Link to={`${base}/commit/${head.hash}`} className="min-w-0 truncate hover:text-fg" title={head.message}> | |
| 100 | − | {head.message} | |
| 101 | − | </Link> | |
| 100 | + | <Hint label={head.message}> | |
| 101 | + | <Link to={`${base}/commit/${head.hash}`} className="min-w-0 truncate hover:text-fg"> | |
| 102 | + | {head.message} | |
| 103 | + | </Link> | |
| 104 | + | </Hint> | |
| 102 | 105 | <span className="shrink-0 text-faint"> | |
| 103 | 106 | · <TimeAgo at={head.at} /> | |
| 104 | 107 | </span> |
| 9 | 9 | import { page } from "../../lib/meta"; | |
| 10 | 10 | import { DiffView } from "../../components/diff-view"; | |
| 11 | 11 | import { Avatar, TimeAgo } from "../../components/ui"; | |
| 12 | + | import { Hint } from "../../components/ui/hint"; | |
| 12 | 13 | import { Skeleton } from "../../components/ui/skeleton"; | |
| 13 | 14 | import { immutable } from "../../lib/immutable.server"; | |
| 14 | 15 | import { pullForCommit } from "../../lib/provenance.server"; | |
| 157 | 158 | <span className="flex items-center gap-2"> | |
| 158 | 159 | <Avatar name={owner?.username ?? commit.author.name} image={owner?.avatar} size={20} /> | |
| 159 | 160 | {owner ? ( | |
| 160 | − | <Link to={`/u/${owner.username}`} className="font-medium hover:underline" title={commit.author.name}> | |
| 161 | − | {owner.username} | |
| 162 | − | </Link> | |
| 161 | + | <Hint label={commit.author.name}> | |
| 162 | + | <Link to={`/u/${owner.username}`} className="font-medium hover:underline"> | |
| 163 | + | {owner.username} | |
| 164 | + | </Link> | |
| 165 | + | </Hint> | |
| 163 | 166 | ) : ( | |
| 164 | 167 | <span className="font-medium">{commit.author.name}</span> | |
| 165 | 168 | )} |
| 6 | 6 | import type { Route } from "./+types/commits"; | |
| 7 | 7 | import { page } from "../../lib/meta"; | |
| 8 | 8 | import { Avatar, EmptyState, TimeAgo } from "../../components/ui"; | |
| 9 | + | import { Hint } from "../../components/ui/hint"; | |
| 9 | 10 | import { accounts, repos } from "../../lib/services.server"; | |
| 10 | 11 | import { getViewer, unwrap } from "../../lib/session.server"; | |
| 11 | 12 | ||
| 75 | 76 | {rest && <p className="mt-1 line-clamp-1 text-sm text-muted">{rest}</p>} | |
| 76 | 77 | <p className="mt-1 text-xs text-faint"> | |
| 77 | 78 | {owner ? ( | |
| 78 | − | <Link to={`/u/${owner.username}`} className="relative z-10 text-muted hover:text-fg" title={commit.author.name}> | |
| 79 | − | {owner.username} | |
| 80 | − | </Link> | |
| 79 | + | <Hint label={commit.author.name}> | |
| 80 | + | <Link to={`/u/${owner.username}`} className="relative z-10 text-muted hover:text-fg"> | |
| 81 | + | {owner.username} | |
| 82 | + | </Link> | |
| 83 | + | </Hint> | |
| 81 | 84 | ) : ( | |
| 82 | 85 | commit.author.name | |
| 83 | 86 | )}{" "} |
| 6 | 6 | import type { Route } from "./+types/compare"; | |
| 7 | 7 | import { DiffView } from "../../components/diff-view"; | |
| 8 | 8 | import { Avatar, ButtonLink, EmptyState, TimeAgo } from "../../components/ui"; | |
| 9 | + | import { Hint } from "../../components/ui/hint"; | |
| 9 | 10 | import { page } from "../../lib/meta"; | |
| 10 | 11 | import { repos } from "../../lib/services.server"; | |
| 11 | 12 | import { getViewer, unwrap } from "../../lib/session.server"; | |
| 133 | 134 | <li key={commit.hash} className="flex items-center gap-3 px-4 py-2.5 text-sm"> | |
| 134 | 135 | <GitCommitHorizontal size={15} className="shrink-0 text-faint" /> | |
| 135 | 136 | <Avatar name={commit.author} size={16} /> | |
| 136 | − | <Link to={`${repoBase}/commit/${commit.hash}`} className="min-w-0 grow truncate hover:text-accent" title={commit.message}> | |
| 137 | − | {commit.message} | |
| 138 | − | </Link> | |
| 137 | + | <Hint label={commit.message}> | |
| 138 | + | <Link to={`${repoBase}/commit/${commit.hash}`} className="min-w-0 grow truncate hover:text-accent"> | |
| 139 | + | {commit.message} | |
| 140 | + | </Link> | |
| 141 | + | </Hint> | |
| 139 | 142 | <span className="hidden shrink-0 text-xs text-muted sm:inline">{commit.author}</span> | |
| 140 | 143 | <Link to={`${repoBase}/commit/${commit.hash}`} className="shrink-0 font-mono text-xs text-faint hover:text-fg"> | |
| 141 | 144 | {commit.hash.slice(0, 7)} |
| 9 | 9 | import type { Route } from "./+types/deployments"; | |
| 10 | 10 | import { page } from "../../lib/meta"; | |
| 11 | 11 | import { ButtonLink, ComputeNote, EmptyState, ErrorText, SubmitButton, TimeAgo } from "../../components/ui"; | |
| 12 | + | import { Hint } from "../../components/ui/hint"; | |
| 12 | 13 | import { computeNoteFor } from "../../lib/compute.server"; | |
| 13 | 14 | import { billing, deployments, projects } from "../../lib/services.server"; | |
| 14 | 15 | import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server"; | |
| 132 | 133 | </p> | |
| 133 | 134 | ) : ( | |
| 134 | 135 | <Form method="post" className="mt-4"> | |
| 135 | − | <SubmitButton | |
| 136 | − | variant="accent" | |
| 137 | − | name="intent" | |
| 138 | − | value="enable" | |
| 139 | − | pending="Turning on…" | |
| 140 | − | disabled={!can.manage_integrations} | |
| 141 | − | title={whyNot(can, "manage_integrations")} | |
| 142 | − | > | |
| 143 | − | <Rocket size={14} /> | |
| 144 | − | Turn on deployments | |
| 145 | − | </SubmitButton> | |
| 136 | + | <Hint label={whyNot(can, "manage_integrations")} disabled={!can.manage_integrations}> | |
| 137 | + | <SubmitButton variant="accent" name="intent" value="enable" pending="Turning on…" disabled={!can.manage_integrations}> | |
| 138 | + | <Rocket size={14} /> | |
| 139 | + | Turn on deployments | |
| 140 | + | </SubmitButton> | |
| 141 | + | </Hint> | |
| 146 | 142 | </Form> | |
| 147 | 143 | )} | |
| 148 | 144 | </section> | |
| 297 | 293 | <Form method="post" className="flex items-center gap-2"> | |
| 298 | 294 | {branch != null && <input type="hidden" name="branch" value={branch} />} | |
| 299 | 295 | <input type="hidden" name="app" value={app} /> | |
| 300 | − | <SubmitButton | |
| 301 | − | variant="quiet" | |
| 302 | − | name="intent" | |
| 303 | − | value="redeploy" | |
| 304 | − | match={{ app }} | |
| 305 | − | icon={compact} | |
| 306 | − | pending="Redeploying…" | |
| 307 | − | title="Build again from the current head" | |
| 308 | − | > | |
| 309 | − | <RotateCw size={13} /> | |
| 310 | − | {!compact && "Redeploy"} | |
| 311 | − | </SubmitButton> | |
| 312 | − | {up && ( | |
| 296 | + | <Hint label="Build again from the current head"> | |
| 313 | 297 | <SubmitButton | |
| 314 | 298 | variant="quiet" | |
| 315 | 299 | name="intent" | |
| 316 | − | value="take-down" | |
| 300 | + | value="redeploy" | |
| 317 | 301 | match={{ app }} | |
| 318 | 302 | icon={compact} | |
| 319 | − | pending="Taking down…" | |
| 320 | − | title="Take it down now" | |
| 303 | + | pending="Redeploying…" | |
| 304 | + | aria-label={compact ? "Redeploy" : undefined} | |
| 321 | 305 | > | |
| 322 | − | <Trash2 size={13} /> | |
| 323 | − | {!compact && "Take down"} | |
| 306 | + | <RotateCw size={13} /> | |
| 307 | + | {!compact && "Redeploy"} | |
| 324 | 308 | </SubmitButton> | |
| 309 | + | </Hint> | |
| 310 | + | {up && ( | |
| 311 | + | <Hint label="Take it down now"> | |
| 312 | + | <SubmitButton | |
| 313 | + | variant="quiet" | |
| 314 | + | name="intent" | |
| 315 | + | value="take-down" | |
| 316 | + | match={{ app }} | |
| 317 | + | icon={compact} | |
| 318 | + | pending="Taking down…" | |
| 319 | + | aria-label={compact ? "Take down" : undefined} | |
| 320 | + | > | |
| 321 | + | <Trash2 size={13} /> | |
| 322 | + | {!compact && "Take down"} | |
| 323 | + | </SubmitButton> | |
| 324 | + | </Hint> | |
| 325 | 325 | )} | |
| 326 | 326 | </Form> | |
| 327 | 327 | ); |
| 7 | 7 | import { page } from "../../lib/meta"; | |
| 8 | 8 | import { ButtonLink, ComputeNote, EmptyState, ErrorText, SubmitButton, TimeAgo } from "../../components/ui"; | |
| 9 | 9 | import { Checkbox } from "../../components/ui/checkbox"; | |
| 10 | + | import { Hint } from "../../components/ui/hint"; | |
| 10 | 11 | import { | |
| 11 | 12 | Assignee, | |
| 12 | 13 | AssigneeStack, | |
| 279 | 280 | <span className="mt-0.5 flex shrink-0 items-center gap-3 text-xs text-muted"> | |
| 280 | 281 | <AssigneeStack people={issue.assignees} /> | |
| 281 | 282 | {issue.pullCount > 0 && ( | |
| 282 | − | <span | |
| 283 | − | className="flex items-center gap-1" | |
| 284 | − | title={`${issue.pullCount} pull ${issue.pullCount === 1 ? "request" : "requests"}`} | |
| 285 | − | > | |
| 286 | − | <GitPullRequest size={13} /> | |
| 287 | − | {issue.pullCount} | |
| 288 | − | </span> | |
| 283 | + | <Hint label={`${issue.pullCount} pull ${issue.pullCount === 1 ? "request" : "requests"}`}> | |
| 284 | + | <span className="flex items-center gap-1"> | |
| 285 | + | <GitPullRequest size={13} /> | |
| 286 | + | {issue.pullCount} | |
| 287 | + | <span className="sr-only">pull {issue.pullCount === 1 ? "request" : "requests"}</span> | |
| 288 | + | </span> | |
| 289 | + | </Hint> | |
| 289 | 290 | )} | |
| 290 | 291 | {issue.commentCount > 0 && ( | |
| 291 | 292 | <span className="flex items-center gap-1"> |
| 10 | 10 | import { page } from "../../lib/meta"; | |
| 11 | 11 | import { type Tab as PageTab, tabsFor } from "../../lib/project-nav"; | |
| 12 | 12 | import { Pill, SoonPill } from "../../components/ui"; | |
| 13 | + | import { Hint } from "../../components/ui/hint"; | |
| 13 | 14 | import { TabStrip } from "../../components/ui/tab-strip"; | |
| 14 | 15 | import { WatchMenu } from "../../components/notifications"; | |
| 15 | 16 | import { PinButton } from "../../components/pin-button"; | |
| 144 | 145 | return ( | |
| 145 | 146 | <TabStrip label="Views" className="-mb-px gap-1"> | |
| 146 | 147 | {tabs.map((tab) => ( | |
| 147 | − | <Link | |
| 148 | − | key={tab.path} | |
| 149 | − | to={`${base}/${tab.path}`} | |
| 150 | − | title={tab.about} | |
| 151 | − | prefetch="intent" | |
| 152 | − | // Current by the tab's own rule: a link's address alone would also | |
| 153 | − | // mark Overview current on every page under it. | |
| 154 | − | aria-current={current(tab) ? "page" : undefined} | |
| 155 | − | data-active={current(tab) || undefined} | |
| 156 | − | className={`flex items-center gap-1.5 border-b-2 px-3 pt-1 pb-2.5 text-sm transition-colors ${ | |
| 157 | − | current(tab) | |
| 158 | − | ? "border-accent font-medium text-fg" | |
| 159 | − | : tab.soon | |
| 160 | − | ? "border-transparent text-faint hover:text-muted" | |
| 161 | − | : "border-transparent text-muted hover:text-fg" | |
| 162 | − | }`} | |
| 163 | − | > | |
| 164 | − | {tab.label} | |
| 165 | − | {tab.soon && <SoonPill />} | |
| 166 | − | </Link> | |
| 148 | + | <Hint key={tab.path} label={tab.about}> | |
| 149 | + | <Link | |
| 150 | + | to={`${base}/${tab.path}`} | |
| 151 | + | prefetch="intent" | |
| 152 | + | // Current by the tab's own rule: a link's address alone would also | |
| 153 | + | // mark Overview current on every page under it. | |
| 154 | + | aria-current={current(tab) ? "page" : undefined} | |
| 155 | + | data-active={current(tab) || undefined} | |
| 156 | + | className={`flex items-center gap-1.5 border-b-2 px-3 pt-1 pb-2.5 text-sm transition-colors ${ | |
| 157 | + | current(tab) | |
| 158 | + | ? "border-accent font-medium text-fg" | |
| 159 | + | : tab.soon | |
| 160 | + | ? "border-transparent text-faint hover:text-muted" | |
| 161 | + | : "border-transparent text-muted hover:text-fg" | |
| 162 | + | }`} | |
| 163 | + | > | |
| 164 | + | {tab.label} | |
| 165 | + | {tab.soon && <SoonPill />} | |
| 166 | + | </Link> | |
| 167 | + | </Hint> | |
| 167 | 168 | ))} | |
| 168 | 169 | </TabStrip> | |
| 169 | 170 | ); |
| 43 | 43 | import { type ActiveBranch, ActiveBranches } from "../../components/branches"; | |
| 44 | 44 | import { ProductionChecklist } from "../../components/checklist"; | |
| 45 | 45 | import { PackageIcon } from "../../components/package-icon"; | |
| 46 | + | import { Hint } from "../../components/ui/hint"; | |
| 46 | 47 | import { Loading, Skeleton, SkeletonRows } from "../../components/ui/skeleton"; | |
| 47 | 48 | import { ProductionShot } from "../../components/production-shot"; | |
| 48 | 49 | import { GithubLinkStrip } from "../../components/github"; | |
| 421 | 422 | <span className="font-mono text-faint">#{card.number}</span> | |
| 422 | 423 | <span className="grow" /> | |
| 423 | 424 | {card.needsYou ? ( | |
| 424 | − | <span className="inline-flex items-center gap-1 text-warn" title="Stopped: a person decides what happens next"> | |
| 425 | − | <Hand size={11} /> you | |
| 426 | − | </span> | |
| 425 | + | <Hint label="Stopped: a person decides what happens next"> | |
| 426 | + | <span className="inline-flex items-center gap-1 text-warn"> | |
| 427 | + | <Hand size={11} /> you | |
| 428 | + | <span className="sr-only">: stopped, a person decides what happens next</span> | |
| 429 | + | </span> | |
| 430 | + | </Hint> | |
| 427 | 431 | ) : stage === "landed" ? ( | |
| 428 | 432 | <TimeAgo at={card.at} /> | |
| 429 | 433 | ) : card.runStarted ? ( | |
| 436 | 440 | )} | |
| 437 | 441 | </span> | |
| 438 | 442 | {card.step && ( | |
| 439 | − | <span className="mt-1.5 block truncate font-mono text-[0.6875rem] text-fg/70" title={card.step}> | |
| 440 | − | {card.runKind}: {card.step} | |
| 441 | − | </span> | |
| 443 | + | <Hint label={card.step}> | |
| 444 | + | <span className="mt-1.5 block truncate font-mono text-[0.6875rem] text-fg/70"> | |
| 445 | + | {card.runKind}: {card.step} | |
| 446 | + | </span> | |
| 447 | + | </Hint> | |
| 442 | 448 | )} | |
| 443 | 449 | </Link> | |
| 444 | 450 | </li> | |
| 771 | 777 | )} | |
| 772 | 778 | {settings?.enabled ? ( | |
| 773 | 779 | loaderData.can.run && <Form method="post"> | |
| 774 | − | <SubmitButton | |
| 775 | − | variant="quiet" | |
| 776 | − | name="intent" | |
| 777 | − | value="redeploy" | |
| 778 | − | pending="Redeploying…" | |
| 779 | − | title="Build production again from the default branch" | |
| 780 | − | > | |
| 781 | − | <RotateCw size={14} /> | |
| 782 | − | Redeploy | |
| 783 | − | </SubmitButton> | |
| 780 | + | <Hint label="Build production again from the default branch"> | |
| 781 | + | <SubmitButton variant="quiet" name="intent" value="redeploy" pending="Redeploying…"> | |
| 782 | + | <RotateCw size={14} /> | |
| 783 | + | Redeploy | |
| 784 | + | </SubmitButton> | |
| 785 | + | </Hint> | |
| 784 | 786 | </Form> | |
| 785 | 787 | ) : loaderData.can.manage_integrations && ( | |
| 786 | 788 | <ButtonLink to={`${base}/settings/deployments`} variant="accent"> | |
| 829 | 831 | to={commit ? `${base}/commit/${commit.hash}` : undefined} | |
| 830 | 832 | value={ | |
| 831 | 833 | commit ? ( | |
| 832 | − | <span title={commit.message.split("\n")[0]}> | |
| 833 | − | <span className="font-mono">{commit.hash.slice(0, 7)}</span>{" "} | |
| 834 | − | <span className="font-normal text-muted"> | |
| 835 | − | <TimeAgo at={commit.authoredAt} /> | |
| 834 | + | <Hint label={commit.message.split("\n")[0]}> | |
| 835 | + | <span> | |
| 836 | + | <span className="font-mono">{commit.hash.slice(0, 7)}</span>{" "} | |
| 837 | + | <span className="font-normal text-muted"> | |
| 838 | + | <TimeAgo at={commit.authoredAt} /> | |
| 839 | + | </span> | |
| 836 | 840 | </span> | |
| 837 | − | </span> | |
| 841 | + | </Hint> | |
| 838 | 842 | ) : ( | |
| 839 | 843 | "No commits yet" | |
| 840 | 844 | ) | |
| 897 | 901 | {run.title ?? "A run"} {run.number != null && <span className="text-faint">#{run.number}</span>} | |
| 898 | 902 | </Link> | |
| 899 | 903 | {run.step && ( | |
| 900 | − | <p className="mt-1 truncate font-mono text-[0.6875rem] text-fg/70" title={run.step}> | |
| 901 | − | <span className="mr-1.5 inline-block size-1.5 animate-pulse rounded-full bg-merged align-middle" /> | |
| 902 | − | {run.step} | |
| 903 | − | </p> | |
| 904 | + | <Hint label={run.step}> | |
| 905 | + | <p className="mt-1 truncate font-mono text-[0.6875rem] text-fg/70"> | |
| 906 | + | <span className="mr-1.5 inline-block size-1.5 animate-pulse rounded-full bg-merged align-middle" /> | |
| 907 | + | {run.step} | |
| 908 | + | </p> | |
| 909 | + | </Hint> | |
| 904 | 910 | )} | |
| 905 | 911 | <p className="mt-1.5 flex items-center gap-3 text-[0.6875rem] text-faint"> | |
| 906 | 912 | {formatCost(run.costUsd) && <span>{formatCost(run.costUsd)} so far</span>} |
| 12 | 12 | import { Outcome } from "../../components/outcome"; | |
| 13 | 13 | import { ErrorText, SubmitButton, TimeAgo, usePending } from "../../components/ui"; | |
| 14 | 14 | import { Checkbox } from "../../components/ui/checkbox"; | |
| 15 | + | import { Hint } from "../../components/ui/hint"; | |
| 15 | 16 | import { Label } from "../../components/work"; | |
| 16 | 17 | import { billing, events, identity, work } from "../../lib/services.server"; | |
| 17 | 18 | import { | |
| 263 | 264 | ||
| 264 | 265 | {plan.status === "ready" && ( | |
| 265 | 266 | <div className="mt-6 flex flex-wrap items-center gap-3"> | |
| 266 | − | <SubmitButton variant="accent" name="action" value="assign" pending="Opening issues…" disabled={applying || !loaderData.can.run} title={whyNot(loaderData.can, "run")}> | |
| 267 | − | <Sparkles size={15} /> | |
| 268 | − | Open these and assign g1t | |
| 269 | − | </SubmitButton> | |
| 270 | − | <SubmitButton variant="quiet" name="action" value="open" pending="Opening issues…" disabled={applying || !loaderData.can.run} title={whyNot(loaderData.can, "run")}> | |
| 271 | − | Only open the issues | |
| 272 | − | </SubmitButton> | |
| 267 | + | <Hint label={whyNot(loaderData.can, "run")} disabled={!loaderData.can.run}> | |
| 268 | + | <SubmitButton variant="accent" name="action" value="assign" pending="Opening issues…" disabled={applying || !loaderData.can.run}> | |
| 269 | + | <Sparkles size={15} /> | |
| 270 | + | Open these and assign g1t | |
| 271 | + | </SubmitButton> | |
| 272 | + | </Hint> | |
| 273 | + | <Hint label={whyNot(loaderData.can, "run")} disabled={!loaderData.can.run}> | |
| 274 | + | <SubmitButton variant="quiet" name="action" value="open" pending="Opening issues…" disabled={applying || !loaderData.can.run}> | |
| 275 | + | Only open the issues | |
| 276 | + | </SubmitButton> | |
| 277 | + | </Hint> | |
| 273 | 278 | <span className="text-xs text-muted"> | |
| 274 | 279 | Untick any you do not want. Agents work on the independent ones at once | |
| 275 | 280 | and the rest follow as what they depend on merges. |
| 9 | 9 | import { whyNot } from "../../lib/access"; | |
| 10 | 10 | import { page } from "../../lib/meta"; | |
| 11 | 11 | import { ComputeNote, EmptyState, ErrorText, SubmitButton, Textarea, TimeAgo } from "../../components/ui"; | |
| 12 | + | import { Hint } from "../../components/ui/hint"; | |
| 12 | 13 | import { computeNoteFor } from "../../lib/compute.server"; | |
| 13 | 14 | import { work } from "../../lib/services.server"; | |
| 14 | 15 | import { | |
| 79 | 80 | } | |
| 80 | 81 | /> | |
| 81 | 82 | <div className="flex flex-wrap items-center gap-3"> | |
| 82 | − | <SubmitButton variant="accent" pending="Starting the planner…" disabled={!loaderData.can.run} title={whyNot(loaderData.can, "run")}> | |
| 83 | − | <Sparkles size={15} /> | |
| 84 | − | Plan it | |
| 85 | − | </SubmitButton> | |
| 83 | + | <Hint label={whyNot(loaderData.can, "run")} disabled={!loaderData.can.run}> | |
| 84 | + | <SubmitButton variant="accent" pending="Starting the planner…" disabled={!loaderData.can.run}> | |
| 85 | + | <Sparkles size={15} /> | |
| 86 | + | Plan it | |
| 87 | + | </SubmitButton> | |
| 88 | + | </Hint> | |
| 86 | 89 | <span className="text-xs text-muted"> | |
| 87 | 90 | Nothing is opened until you have read the plan. | |
| 88 | 91 | </span> |
| 63 | 63 | usePending, | |
| 64 | 64 | } from "../../components/ui"; | |
| 65 | 65 | import { CheckboxOption } from "../../components/ui/checkbox"; | |
| 66 | + | import { Hint } from "../../components/ui/hint"; | |
| 66 | 67 | import { Loading, SkeletonLine } from "../../components/ui/skeleton"; | |
| 67 | 68 | import { TabStrip } from "../../components/ui/tab-strip"; | |
| 68 | 69 | import { WorkflowStatuses } from "../../components/actions"; | |
| 1213 | 1214 | /> | |
| 1214 | 1215 | )} | |
| 1215 | 1216 | <div className="flex flex-wrap items-center gap-3"> | |
| 1216 | − | <SubmitButton | |
| 1217 | − | variant="accent" | |
| 1218 | − | name="action" | |
| 1219 | − | value="merge" | |
| 1220 | − | disabled={mergeBlocked != null} | |
| 1221 | − | title={mergeBlocked ?? undefined} | |
| 1222 | − | pending={mergeQueue ? "Adding to the queue…" : "Merging…"} | |
| 1223 | − | > | |
| 1224 | − | {mergeQueue ? <Layers size={15} /> : <GitMerge size={15} />} | |
| 1225 | − | {mergeQueue ? "Add to the merge queue" : `Merge into ${defaultBranch}`} | |
| 1226 | − | </SubmitButton> | |
| 1217 | + | <Hint label={mergeBlocked} disabled={mergeBlocked != null}> | |
| 1218 | + | <SubmitButton | |
| 1219 | + | variant="accent" | |
| 1220 | + | name="action" | |
| 1221 | + | value="merge" | |
| 1222 | + | disabled={mergeBlocked != null} | |
| 1223 | + | pending={mergeQueue ? "Adding to the queue…" : "Merging…"} | |
| 1224 | + | > | |
| 1225 | + | {mergeQueue ? <Layers size={15} /> : <GitMerge size={15} />} | |
| 1226 | + | {mergeQueue ? "Add to the merge queue" : `Merge into ${defaultBranch}`} | |
| 1227 | + | </SubmitButton> | |
| 1228 | + | </Hint> | |
| 1227 | 1229 | <span className={`text-xs ${mergeBlocked ? "text-danger" : "text-muted"}`}> | |
| 1228 | 1230 | {mergeBlocked ?? (mergeQueue | |
| 1229 | 1231 | ? `Tested together with everything ahead of it, then lands on ${defaultBranch}.` |
| 5 | 5 | import { page } from "../../lib/meta"; | |
| 6 | 6 | import { openedBy } from "../../lib/opened-by"; | |
| 7 | 7 | import { ButtonLink, EmptyState, TimeAgo } from "../../components/ui"; | |
| 8 | + | import { Hint } from "../../components/ui/hint"; | |
| 8 | 9 | import { CheckBadge } from "../../components/checks"; | |
| 9 | 10 | import { ChangeSize, PullIcon, StateTabs } from "../../components/work"; | |
| 10 | 11 | import { AgentBadge, useActiveRuns } from "../../components/agents"; | |
| 112 | 113 | }))} | |
| 113 | 114 | /> | |
| 114 | 115 | {loaderData.base && ( | |
| 115 | − | <Link | |
| 116 | − | to={withFilter(list, current, "base", null)} | |
| 117 | − | className="inline-flex h-8 items-center gap-1.5 rounded-md border border-accent/40 bg-accent/5 px-2.5 text-sm" | |
| 118 | − | title="Show pull requests into every branch" | |
| 119 | − | > | |
| 120 | − | Into <span className="font-mono">{loaderData.base}</span> | |
| 121 | − | </Link> | |
| 116 | + | <Hint label="Show pull requests into every branch"> | |
| 117 | + | <Link | |
| 118 | + | to={withFilter(list, current, "base", null)} | |
| 119 | + | className="inline-flex h-8 items-center gap-1.5 rounded-md border border-accent/40 bg-accent/5 px-2.5 text-sm" | |
| 120 | + | > | |
| 121 | + | Into <span className="font-mono">{loaderData.base}</span> | |
| 122 | + | <span className="sr-only">: show pull requests into every branch</span> | |
| 123 | + | </Link> | |
| 124 | + | </Hint> | |
| 122 | 125 | )} | |
| 123 | 126 | {filtered && ( | |
| 124 | 127 | <Link to={state === "closed" ? `${list}?state=closed` : list} className="text-xs text-muted hover:text-fg"> |
| 5 | 5 | import type { Route } from "./+types/security-settings"; | |
| 6 | 6 | import { page } from "../../lib/meta"; | |
| 7 | 7 | import { ActivationPrompt, CARD, SectionHeader } from "../../components/security-suite"; | |
| 8 | + | import { Hint } from "../../components/ui/hint"; | |
| 8 | 9 | import { Switch } from "../../components/ui/switch"; | |
| 9 | 10 | import { securitySuite } from "../../lib/services.server"; | |
| 10 | 11 | import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server"; | |
| 122 | 123 | </label> | |
| 123 | 124 | </fieldset> | |
| 124 | 125 | <div className="flex flex-wrap items-center gap-3"> | |
| 125 | − | <button | |
| 126 | − | type="submit" | |
| 127 | − | disabled={disabled || fetcher.state !== "idle"} | |
| 128 | − | title={whyNot(can, "manage_settings")} | |
| 129 | − | className="rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg hover:bg-white disabled:opacity-50" | |
| 130 | − | > | |
| 131 | − | {fetcher.state !== "idle" ? "Saving…" : "Save"} | |
| 132 | − | </button> | |
| 126 | + | <Hint label={whyNot(can, "manage_settings")} disabled={!can.manage_settings}> | |
| 127 | + | <button | |
| 128 | + | type="submit" | |
| 129 | + | disabled={disabled || fetcher.state !== "idle"} | |
| 130 | + | className="rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg hover:bg-white disabled:opacity-50" | |
| 131 | + | > | |
| 132 | + | {fetcher.state !== "idle" ? "Saving…" : "Save"} | |
| 133 | + | </button> | |
| 134 | + | </Hint> | |
| 133 | 135 | <Link to={`${base}/settings/branches`} className="text-sm text-muted underline underline-offset-2 hover:text-fg"> | |
| 134 | 136 | Require the checks in branch protection | |
| 135 | 137 | </Link> |
| 13 | 13 | type UpgradeFix, | |
| 14 | 14 | VulnerabilityList, | |
| 15 | 15 | } from "../../components/security"; | |
| 16 | + | import { Hint } from "../../components/ui/hint"; | |
| 16 | 17 | import { Switch } from "../../components/ui/switch"; | |
| 17 | 18 | import { security, work } from "../../lib/services.server"; | |
| 18 | 19 | import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server"; | |
| 205 | 206 | </span> | |
| 206 | 207 | {upkeep.data?.error && <span className="mt-1 block text-xs text-danger">{upkeep.data.error}</span>} | |
| 207 | 208 | </span> | |
| 208 | − | <Switch | |
| 209 | − | className="mt-0.5" | |
| 210 | − | checked={upkeepOn} | |
| 211 | − | disabled={upkeep.state !== "idle" || !can.manage_settings} | |
| 212 | − | title={whyNot(can, "manage_settings")} | |
| 213 | − | onCheckedChange={(checked) => upkeep.submit({ intent: "upkeep", enabled: String(checked) }, { method: "post", action })} | |
| 214 | − | /> | |
| 209 | + | <Hint label={whyNot(can, "manage_settings")} disabled={!can.manage_settings}> | |
| 210 | + | <Switch | |
| 211 | + | className="mt-0.5" | |
| 212 | + | checked={upkeepOn} | |
| 213 | + | disabled={upkeep.state !== "idle" || !can.manage_settings} | |
| 214 | + | onCheckedChange={(checked) => upkeep.submit({ intent: "upkeep", enabled: String(checked) }, { method: "post", action })} | |
| 215 | + | /> | |
| 216 | + | </Hint> | |
| 215 | 217 | </label> | |
| 216 | 218 | </div> | |
| 217 | 219 | </section> |
| 7 | 7 | import { page } from "../../lib/meta"; | |
| 8 | 8 | import { RunCard, formatCost, useLiveRefresh } from "../../components/agents"; | |
| 9 | 9 | import { Avatar, TimeAgo } from "../../components/ui"; | |
| 10 | + | import { Hint } from "../../components/ui/hint"; | |
| 10 | 11 | import { agents } from "../../lib/services.server"; | |
| 11 | 12 | import { getViewer, unwrap } from "../../lib/session.server"; | |
| 12 | 13 | import { accessTo } from "../../lib/access.server"; | |
| 57 | 58 | <li className="flex gap-2.5 px-4 py-2 text-xs"> | |
| 58 | 59 | <Wrench size={13} className="mt-0.5 shrink-0 text-faint" /> | |
| 59 | 60 | <span className="shrink-0 font-medium text-muted">{entry.tool}</span> | |
| 60 | − | <code className="min-w-0 grow truncate font-mono text-fg/85" title={entry.text}> | |
| 61 | − | {entry.text} | |
| 62 | − | </code> | |
| 61 | + | <Hint label={entry.text}> | |
| 62 | + | <code className="min-w-0 grow truncate font-mono text-fg/85">{entry.text}</code> | |
| 63 | + | </Hint> | |
| 63 | 64 | </li> | |
| 64 | 65 | ); | |
| 65 | 66 | } |
| 7 | 7 | import { page } from "../../lib/meta"; | |
| 8 | 8 | import { RepoSettingsHeading } from "../../components/repo-settings-heading"; | |
| 9 | 9 | import { Avatar, EmptyState, ErrorText, Field, Input, SubmitButton } from "../../components/ui"; | |
| 10 | + | import { Hint } from "../../components/ui/hint"; | |
| 10 | 11 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select"; | |
| 11 | 12 | import { projects } from "../../lib/services.server"; | |
| 12 | 13 | import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server"; | |
| 53 | 54 | <span className="text-xs text-faint">no variable</span> | |
| 54 | 55 | )} | |
| 55 | 56 | {link.source === "file" && ( | |
| 56 | − | <span className="inline-flex items-center gap-1 text-xs text-faint" title="Declared in .g1t/project.yml"> | |
| 57 | − | <FileCode2 size={12} /> | |
| 58 | − | project.yml | |
| 59 | − | </span> | |
| 57 | + | <Hint label="Declared in .g1t/project.yml"> | |
| 58 | + | <span className="inline-flex items-center gap-1 text-xs text-faint"> | |
| 59 | + | <FileCode2 size={12} /> | |
| 60 | + | <span className="sr-only">Declared in .g1t/</span> | |
| 61 | + | project.yml | |
| 62 | + | </span> | |
| 63 | + | </Hint> | |
| 60 | 64 | )} | |
| 61 | 65 | {removable && link.source === "ui" && ( | |
| 62 | 66 | <Form method="post" className="ml-auto"> |
| 20 | 20 | AlertDialogTrigger, | |
| 21 | 21 | } from "../../components/ui/alert-dialog"; | |
| 22 | 22 | import { CheckboxOption } from "../../components/ui/checkbox"; | |
| 23 | + | import { Hint } from "../../components/ui/hint"; | |
| 23 | 24 | import { deployments } from "../../lib/services.server"; | |
| 24 | 25 | import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server"; | |
| 25 | 26 | import { requireCapability, requireInsider } from "../../lib/access.server"; | |
| 238 | 239 | <Form method="post"> | |
| 239 | 240 | <input type="hidden" name="intent" value="refresh" /> | |
| 240 | 241 | <input type="hidden" name="id" value={domain.id} /> | |
| 241 | − | <SubmitButton | |
| 242 | − | variant="quiet" | |
| 243 | − | match={{ intent: "refresh", id: domain.id }} | |
| 244 | − | pending="Checking…" | |
| 245 | − | title="Ask Cloudflare to check the records again now" | |
| 246 | − | > | |
| 247 | − | <RotateCw size={14} /> | |
| 248 | − | Check now | |
| 249 | − | </SubmitButton> | |
| 242 | + | <Hint label="Ask Cloudflare to check the records again now"> | |
| 243 | + | <SubmitButton variant="quiet" match={{ intent: "refresh", id: domain.id }} pending="Checking…"> | |
| 244 | + | <RotateCw size={14} /> | |
| 245 | + | Check now | |
| 246 | + | </SubmitButton> | |
| 247 | + | </Hint> | |
| 250 | 248 | </Form> | |
| 251 | 249 | )} | |
| 252 | 250 | <RemoveDomain domain={domain} /> |
| 3 | 3 | import type { Route } from "./+types/github"; | |
| 4 | 4 | import { page } from "../../lib/meta"; | |
| 5 | 5 | import { ErrorText, SubmitButton, TimeAgo } from "../../components/ui"; | |
| 6 | + | import { Hint } from "../../components/ui/hint"; | |
| 6 | 7 | import { assertSameOrigin, requireUser } from "../../lib/session.server"; | |
| 7 | 8 | import { GithubMark } from "../../components/github"; | |
| 8 | 9 | import { githubSignIn } from "../../lib/github.server"; | |
| 54 | 55 | )} | |
| 55 | 56 | <Form method="post"> | |
| 56 | 57 | <input type="hidden" name="intent" value="unlink-github" /> | |
| 57 | − | <SubmitButton variant="quiet" pending="Unlinking…" match={{ intent: "unlink-github" }} disabled={!github.hasPassword} title={github.hasPassword ? undefined : "GitHub is how you sign in. Set a password first."}> | |
| 58 | − | Unlink | |
| 59 | − | </SubmitButton> | |
| 58 | + | <Hint label={github.hasPassword ? undefined : "GitHub is how you sign in. Set a password first."} disabled={!github.hasPassword}> | |
| 59 | + | <SubmitButton variant="quiet" pending="Unlinking…" match={{ intent: "unlink-github" }} disabled={!github.hasPassword}> | |
| 60 | + | Unlink | |
| 61 | + | </SubmitButton> | |
| 62 | + | </Hint> | |
| 60 | 63 | </Form> | |
| 61 | 64 | </div> | |
| 62 | 65 | </div> |
| 9 | 9 | import { ConfirmDialog } from "../../components/repo-lifecycle"; | |
| 10 | 10 | import { Button, CopyLine, ErrorText, SubmitButton, TimeAgo } from "../../components/ui"; | |
| 11 | 11 | import { Badge } from "../../components/ui/badge"; | |
| 12 | + | import { Hint } from "../../components/ui/hint"; | |
| 12 | 13 | import { page } from "../../lib/meta"; | |
| 13 | 14 | import { ECOSYSTEM_LABEL, formatBytes, installCommands, shortDigest } from "../../lib/packages"; | |
| 14 | 15 | import { packages } from "../../lib/services.server"; | |
| 221 | 222 | <span className="text-xs text-faint">Untagged</span> | |
| 222 | 223 | )} | |
| 223 | 224 | {!npm && ( | |
| 224 | − | <code className="font-mono text-xs text-muted" title={version.digest}> | |
| 225 | − | {shortDigest(version.digest)} | |
| 226 | − | </code> | |
| 225 | + | <Hint label={version.digest}> | |
| 226 | + | <code className="font-mono text-xs text-muted">{shortDigest(version.digest)}</code> | |
| 227 | + | </Hint> | |
| 227 | 228 | )} | |
| 228 | 229 | {version.deprecated && ( | |
| 229 | − | <Badge tone="neutral" title={version.deprecated}> | |
| 230 | − | Deprecated | |
| 231 | − | </Badge> | |
| 230 | + | <Hint label={version.deprecated}> | |
| 231 | + | <Badge tone="neutral">Deprecated</Badge> | |
| 232 | + | </Hint> | |
| 232 | 233 | )} | |
| 233 | 234 | {version.symbols && ( | |
| 234 | − | <Badge tone="neutral" title="A symbol package (.snupkg) was pushed: debuggers load its PDBs from the feed's symbol server."> | |
| 235 | − | Symbols | |
| 236 | − | </Badge> | |
| 235 | + | <Hint label="A symbol package (.snupkg) was pushed: debuggers load its PDBs from the feed's symbol server."> | |
| 236 | + | <Badge tone="neutral">Symbols</Badge> | |
| 237 | + | </Hint> | |
| 237 | 238 | )} | |
| 238 | 239 | </div> | |
| 239 | 240 | {version.deprecated && <p className="text-xs text-muted">{version.deprecated}</p>} | |
| 246 | 247 | )} | |
| 247 | 248 | {version.platforms.length > 0 && <span>{version.platforms.join(", ")}</span>} | |
| 248 | 249 | {attached.length > 0 && ( | |
| 249 | − | <span title={attached.map((a) => a.artifact_type ?? a.media_type ?? "artifact").join(", ")}> | |
| 250 | − | {attached.length} attached ({attached.map((a) => artifactWord(a)).join(", ")}) | |
| 251 | − | </span> | |
| 250 | + | <Hint label={attached.map((a) => a.artifact_type ?? a.media_type ?? "artifact").join(", ")}> | |
| 251 | + | <span> | |
| 252 | + | {attached.length} attached ({attached.map((a) => artifactWord(a)).join(", ")}) | |
| 253 | + | </span> | |
| 254 | + | </Hint> | |
| 252 | 255 | )} | |
| 253 | 256 | <span> | |
| 254 | 257 | {version.published_by ? `${version.published_by} · ` : ""} |
| 7 | 7 | import { PackageIcon } from "../../components/package-icon"; | |
| 8 | 8 | import { CopyLine, EmptyState, TimeAgo } from "../../components/ui"; | |
| 9 | 9 | import { Badge } from "../../components/ui/badge"; | |
| 10 | + | import { Hint } from "../../components/ui/hint"; | |
| 10 | 11 | import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../../components/ui/dropdown-menu"; | |
| 11 | 12 | import { cn } from "../../lib/cn"; | |
| 12 | 13 | import { page } from "../../lib/meta"; | |
| 224 | 225 | <span className="hidden shrink-0 items-center gap-5 text-xs text-muted tabular-nums sm:flex"> | |
| 225 | 226 | {pkg.latest && <span className="max-w-32 truncate font-mono text-fg-soft">{pkg.latest}</span>} | |
| 226 | 227 | <span className="w-16 text-right">{formatBytes(pkg.size)}</span> | |
| 227 | − | <span className="inline-flex w-14 items-center justify-end gap-1" title={`${pkg.downloads.toLocaleString("en-US")} downloads`}> | |
| 228 | − | <Download size={12} /> | |
| 229 | − | {shortCount(pkg.downloads)} | |
| 230 | − | </span> | |
| 228 | + | <Hint label={`${pkg.downloads.toLocaleString("en-US")} downloads`}> | |
| 229 | + | <span className="inline-flex w-14 items-center justify-end gap-1"> | |
| 230 | + | <Download size={12} /> | |
| 231 | + | {shortCount(pkg.downloads)} | |
| 232 | + | <span className="sr-only"> downloads</span> | |
| 233 | + | </span> | |
| 234 | + | </Hint> | |
| 231 | 235 | </span> | |
| 232 | 236 | </Link> | |
| 233 | 237 | </li> |