mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-17 10:06:42 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Its UI is the operator's daily surface: task lists, boards, budgets, agent status — all built on shadcn components and Tailwind > - Visual values (colors, spacing, type sizes, radii) were hardcoded at ~1,600 call sites: the same "small gray label" was 9/10/11px depending on the file, charts disagreed with chips about status colors, two toggle-switch implementations coexisted in two greens, and there was no visual regression coverage > - This made the UI drift-prone and made any restyle a hundreds-of-files project, which discourages design iteration > - This pull request extracts visual values into a single token layer in `ui/src/index.css`, adds a Storybook visual regression suite backed by external immutable baseline archives, and then applies a deliberate retune reviewed change-by-change on screenshot diffs > - The benefit is that Paperclip's look becomes a config surface: retheming is a token edit reviewed as a snapshot diff, drift is blocked by a token gate, and future UI PRs can prove exactly what changed visually without committing hundreds of PNGs ## Linked Issues or Issue Description No existing public issue covers this work (searched "design tokens", "visual regression", "design system" across issues and PRs). Related in spirit: Refs #8982 (theming a hardcoded panel — a one-off instance of the same problem class this PR addresses systematically). **Problem (feature-request form):** UI visual values are hardcoded per call site with no source of truth and no regression coverage; consistency depends on reviewer memory, and restyling requires mass file edits. **Proposed solution (this PR):** a single token layer + enforcement gate + externally stored visual snapshot suite, then an intentional restyle on top of that foundation. ## What Changed - **Token extraction (zero visual change, machine-verified during development):** committed codemods (`scripts/codemod-*.mjs`) moved ~1,600 hardcoded color/type/spacing/radius/shadow/misc values into named tokens in a non-inline `:root` block of `ui/src/index.css`. - **Visual regression suite:** `pnpm test:storybook-visual` covers 255 stories × light/dark = 510 Playwright screenshots at `maxDiffPixels: 0`, plus new primitive-coverage stories and deterministic-render fixes. - **External visual baselines:** committed PNG snapshots were removed. `tests/storybook-visual/baseline-manifest.json` pins an immutable archive URL/hash/size/count, and `scripts/storybook-visual-baseline.mjs` handles `download`, `verify`, `pack`, and trusted maintainer `upload` flows. - **Opt-in visual CI artifacts:** added a `Storybook Visual` workflow that runs on manual dispatch or PRs labeled `storybook-visual`, downloads/verifies the baseline, runs Playwright, and uploads Playwright report/test-result artifacts for review. Normal PR runs do not mutate baseline objects. - **Token gate:** `pnpm check:token-gates` — zero hex literals, zero arbitrary bracket values, zero raw font-sizes in `ui/src/components/**` and `ui/src/pages/**`, with a documented inline allowlist for legitimate opt-outs. - **Theme retune (intentional, snapshot-reviewed):** new base theme values; radius ladder derived from a single `--radius` knob; micro-type cluster collapsed to a named ladder (`--text-nano/micro/compact` + Tailwind `text-xs`/`text-sm`); letter-spacing collapsed to named steps. - **One status-color vocabulary:** charts, quota/budget bar fills, RUNNING/live chips, and liveness indicators all use the canonical `--status-*` hues. Light-mode legibility fixes for red alert surfaces that used dark-tuned text classes. - **One switch:** `ToggleSwitch` restyled to the registry capsule form, second hand-rolled implementation removed, and all call sites unified. - **Docs:** `DESIGN.md` is the design contract; `doc/design/` holds audit reports, decision logs, and updated guidance for external baseline review/update workflows. - Dead code removed (`agentStatusBadge` duplicate map), byte-identical contrast constants consolidated, semantic renames (`--project-seed`/`--project-none`, `--liveness-blue`). ## Verification - `pnpm check:token-gates` — 3/3 gates CLEAN during the design-system run - `pnpm typecheck` && `pnpm --filter @paperclipai/ui build` — green during the design-system run - `node --test scripts/__tests__/storybook-visual-baseline.test.mjs` — pass after external-baseline rework - `pnpm exec tsc --noEmit --pretty false --module NodeNext --moduleResolution NodeNext --target ES2022 --types node,@playwright/test tests/storybook-visual/playwright.config.ts tests/storybook-visual/storybook-visual.spec.ts` — pass after external-baseline rework - `git diff --check origin/pr/9134..HEAD` — pass after external-baseline rework - `find tests/storybook-visual -type f -name '*.png' -print | wc -l` — `0` - `node scripts/storybook-visual-baseline.mjs verify` — intentionally fails closed until the first trusted maintainer publishes the baseline archive and updates `baseline-manifest.json` ## Risks - **Large but shallow:** the PR still touches many UI files due to mechanical token extraction and retune work, but committed PNG snapshot churn has been removed from the branch. - **Baseline publication required before the visual suite can pass in clean clones:** the manifest currently has placeholder archive metadata. A trusted maintainer must publish the first immutable archive, then update `baseline-manifest.json`. - **Rendering platform variance:** the external baseline should be captured in the documented Linux/Chromium environment. Future CI runs verify against the pinned archive and fail closed on checksum/count mismatch. - **Visual CI is opt-in while stabilizing:** add the `storybook-visual` label or dispatch the workflow manually to produce downloadable Playwright report/test-result artifacts. - **Scheduled follow-ups, deliberately out of scope:** Tailwind palette classes map to semantic tokens in a dedicated pass; card/pill component consolidation; ESLint ratchet. Tracked in `doc/design/DECISION-SHEET.md`. ## Model Used Claude Fable 5 (Anthropic, `claude-fable-5`, Mythos-class tier) with extended thinking, running in Claude Code with tool use; mechanical phases delegated to Claude Sonnet subagents. Follow-up external-baseline rework assisted by OpenAI Codex (`gpt-5` coding agent with repository, terminal, and GitHub tool use). All bulk rewrites executed via deterministic, idempotent scripts committed in `scripts/`; intentional visual changes were human-reviewed on screenshot contact sheets. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run targeted local verification and documented the intentional baseline-publication failure above - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green *(pending new CI run after this rework)* - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups *(pending review)* - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) and OpenAI Codex --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Dotta <bippadotta@protonmail.com> Co-authored-by: Paperclip <noreply@paperclip.ing>
440 lines
14 KiB
TypeScript
440 lines
14 KiB
TypeScript
import { parseAgentMentionHref } from "@paperclipai/shared";
|
|
|
|
/**
|
|
* Shared logic for the "interrupt handoff" UX clarity surfaces (PAP-10669).
|
|
*
|
|
* The single rule every surface enforces: an *agent* appearance — agent chip,
|
|
* "handed to <agent>" copy, a queued wake — is reserved for either (a) a durable
|
|
* `assigneeAgentId` mutation, or (b) a structured agent mention (`agent://<id>`).
|
|
* Plain text such as `QA` or `please get QA on this` never implies an agent wake.
|
|
*
|
|
* Backend interrupt semantics (see server/src/routes/issues.ts
|
|
* `operatorInterruptCancelOptions`): an operator-triggered interrupt cancels the
|
|
* active run with `errorCode: "operator_interrupted"` and
|
|
* `resultJson.operatorInterrupted = true` /
|
|
* `resultJson.interruptionSource = "issue_comment_interrupt"`.
|
|
*/
|
|
|
|
// --- Run interruption ---------------------------------------------------------
|
|
|
|
/**
|
|
* Whether a run's terminal record reflects an intentional operator interrupt
|
|
* (a board comment that cancelled the active run) rather than an unexplained
|
|
* failure or a plain control-plane cancel.
|
|
*/
|
|
export function isOperatorInterruptedRun(
|
|
resultJson: Record<string, unknown> | null | undefined,
|
|
errorCode?: string | null,
|
|
): boolean {
|
|
if (errorCode === "operator_interrupted") return true;
|
|
if (!resultJson || typeof resultJson !== "object") return false;
|
|
if (resultJson.operatorInterrupted === true) return true;
|
|
return resultJson.interruptionSource === "issue_comment_interrupt";
|
|
}
|
|
|
|
export function runStatusClassName(status: string): string {
|
|
switch (status) {
|
|
case "succeeded":
|
|
return "text-green-700 dark:text-green-300";
|
|
case "failed":
|
|
case "error":
|
|
return "text-red-700 dark:text-red-300";
|
|
case "timed_out":
|
|
return "text-orange-700 dark:text-orange-300";
|
|
case "running":
|
|
return "text-blue-700 dark:text-blue-300";
|
|
case "queued":
|
|
case "pending":
|
|
return "text-amber-700 dark:text-amber-300";
|
|
case "cancelled":
|
|
return "text-muted-foreground";
|
|
default:
|
|
return "text-foreground";
|
|
}
|
|
}
|
|
|
|
export interface RunStatusPresentation {
|
|
label: string;
|
|
className: string;
|
|
/** Screen-reader-only clarifier, or null. */
|
|
srHint: string | null;
|
|
}
|
|
|
|
/**
|
|
* Resolve the visible run status. A board-triggered interrupt reads as
|
|
* "interrupted" (amber, operator-intentional) instead of a muted "cancelled"
|
|
* that looks like an adapter failure.
|
|
*/
|
|
export function resolveRunStatusPresentation(
|
|
status: string,
|
|
opts: { operatorInterrupted?: boolean } = {},
|
|
): RunStatusPresentation {
|
|
if (status === "cancelled" && opts.operatorInterrupted) {
|
|
return {
|
|
label: "interrupted",
|
|
className: "text-amber-700 dark:text-amber-300",
|
|
srHint: "interrupted by board comment",
|
|
};
|
|
}
|
|
return {
|
|
label: status === "timed_out" ? "timed out" : status.replace(/_/g, " "),
|
|
className: runStatusClassName(status),
|
|
srHint: null,
|
|
};
|
|
}
|
|
|
|
// --- Structured mention vs plain text -----------------------------------------
|
|
|
|
const MARKDOWN_LINK_RE = /\[[^\]]*\]\(([^)]*)\)/g;
|
|
|
|
/** Ordered list of agent ids referenced via structured `agent://` mentions. */
|
|
export function extractAgentMentionIds(body: string): string[] {
|
|
const ids: string[] = [];
|
|
if (!body) return ids;
|
|
for (const match of body.matchAll(MARKDOWN_LINK_RE)) {
|
|
const href = match[1] ?? "";
|
|
const parsed = parseAgentMentionHref(href);
|
|
if (parsed?.agentId && !ids.includes(parsed.agentId)) {
|
|
ids.push(parsed.agentId);
|
|
}
|
|
}
|
|
return ids;
|
|
}
|
|
|
|
export function bodyHasAgentMention(body: string): boolean {
|
|
return extractAgentMentionIds(body).length > 0;
|
|
}
|
|
|
|
/** Strip every markdown link so chip labels/hrefs are not mistaken for plain text. */
|
|
function plainTextOutsideLinks(body: string): string {
|
|
return body.replace(MARKDOWN_LINK_RE, " ");
|
|
}
|
|
|
|
function escapeRegExp(value: string): string {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|
|
|
|
export interface HandoffAgentMention {
|
|
agentId: string;
|
|
name: string;
|
|
/** Optional role label (e.g. "QA"). */
|
|
role?: string | null;
|
|
}
|
|
|
|
export interface PlainAgentNameCandidate {
|
|
agentId: string;
|
|
/** The token (agent name or role) that matched in the body. */
|
|
matchedText: string;
|
|
}
|
|
|
|
/**
|
|
* Find a plain-text token in `body` that names a known agent (by display name or
|
|
* role) but is *not* a structured `agent://` mention. This is the signal the
|
|
* composer coach uses to offer an "Insert mention" upgrade. Returns the
|
|
* highest-confidence (longest-token) match, or null.
|
|
*/
|
|
export function findPlainAgentNameCandidate(
|
|
body: string,
|
|
mentions: readonly HandoffAgentMention[],
|
|
): PlainAgentNameCandidate | null {
|
|
if (!body.trim() || mentions.length === 0) return null;
|
|
const text = plainTextOutsideLinks(body);
|
|
let best: PlainAgentNameCandidate | null = null;
|
|
|
|
for (const mention of mentions) {
|
|
const tokens = [mention.name, mention.role ?? ""].filter((t) => t && t.trim().length >= 2);
|
|
for (const token of tokens) {
|
|
const re = new RegExp(`(?<![\\w@/])${escapeRegExp(token)}(?![\\w/])`, "i");
|
|
if (re.test(text)) {
|
|
if (!best || token.length > best.matchedText.length) {
|
|
best = { agentId: mention.agentId, matchedText: token };
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return best;
|
|
}
|
|
|
|
// --- Composer interpretation preview ------------------------------------------
|
|
|
|
export type HandoffPreviewTone = "neutral" | "warn";
|
|
|
|
export type ComposerHandoffPreviewKind =
|
|
| "interrupt_handoff_agent"
|
|
| "wake_agent"
|
|
| "user_handoff"
|
|
| "clear_assignee"
|
|
| "notify_agent"
|
|
| "plain_text_only"
|
|
| "none";
|
|
|
|
export interface ComposerHandoffPreview {
|
|
kind: ComposerHandoffPreviewKind;
|
|
tone: HandoffPreviewTone;
|
|
/** Copy rendered before the optional chip. */
|
|
text: string;
|
|
/** Copy rendered after the optional chip. */
|
|
suffix?: string;
|
|
/** Entity to render as a mini chip, if any. */
|
|
chip?: { kind: "agent" | "user"; id: string };
|
|
}
|
|
|
|
function parseReassignValue(value: string): { kind: "agent" | "user" | "none"; id: string | null } {
|
|
if (!value || value === "__none__") return { kind: "none", id: null };
|
|
if (value.startsWith("agent:")) {
|
|
const id = value.slice("agent:".length);
|
|
return { kind: "agent", id: id || null };
|
|
}
|
|
if (value.startsWith("user:")) {
|
|
const id = value.slice("user:".length);
|
|
return { kind: "user", id: id || null };
|
|
}
|
|
return { kind: "none", id: null };
|
|
}
|
|
|
|
export interface ComposerHandoffPreviewInput {
|
|
/** Current picker value, e.g. "agent:<id>", "user:<id>", "__none__", "". */
|
|
reassignTarget: string;
|
|
/** The issue's current assignee value in the same encoding. */
|
|
currentAssigneeValue: string;
|
|
/** Whether an agent run is currently in flight on this issue. */
|
|
hasActiveRun: boolean;
|
|
/** Whether the comment body contains a structured agent mention. */
|
|
bodyHasAgentMention: boolean;
|
|
/** First agent id structurally mentioned in the body, if any. */
|
|
mentionedAgentId?: string | null;
|
|
/** A plain-text agent-name candidate detected in the body, if any. */
|
|
plainNameCandidate?: PlainAgentNameCandidate | null;
|
|
}
|
|
|
|
/**
|
|
* Compute the one-line interpretation of what submitting this comment will
|
|
* durably do. This is the composer footer preview (design surface 1c) and the
|
|
* core of the agent-vs-user disambiguation.
|
|
*/
|
|
export function computeComposerHandoffPreview(
|
|
input: ComposerHandoffPreviewInput,
|
|
): ComposerHandoffPreview {
|
|
const hasReassignment = input.reassignTarget !== input.currentAssigneeValue;
|
|
|
|
if (hasReassignment) {
|
|
const target = parseReassignValue(input.reassignTarget);
|
|
if (target.kind === "agent" && target.id) {
|
|
return input.hasActiveRun
|
|
? {
|
|
kind: "interrupt_handoff_agent",
|
|
tone: "neutral",
|
|
text: "Interrupt current run, hand off to",
|
|
chip: { kind: "agent", id: target.id },
|
|
}
|
|
: {
|
|
kind: "wake_agent",
|
|
tone: "neutral",
|
|
text: "Wake",
|
|
chip: { kind: "agent", id: target.id },
|
|
};
|
|
}
|
|
if (target.kind === "user" && target.id) {
|
|
return {
|
|
kind: "user_handoff",
|
|
tone: "neutral",
|
|
text: "Hand off to",
|
|
chip: { kind: "user", id: target.id },
|
|
suffix: "— no agent will be notified",
|
|
};
|
|
}
|
|
// Cleared / no target chosen for the mutation.
|
|
return {
|
|
kind: "clear_assignee",
|
|
tone: "neutral",
|
|
text: "Clear responsible — no agent will be notified",
|
|
};
|
|
}
|
|
|
|
if (input.bodyHasAgentMention) {
|
|
return {
|
|
kind: "notify_agent",
|
|
tone: "neutral",
|
|
text: "Notify",
|
|
chip: input.mentionedAgentId ? { kind: "agent", id: input.mentionedAgentId } : undefined,
|
|
suffix: input.mentionedAgentId ? undefined : "the mentioned agent",
|
|
};
|
|
}
|
|
|
|
if (input.plainNameCandidate) {
|
|
return {
|
|
kind: "plain_text_only",
|
|
tone: "warn",
|
|
text: "No agent will be notified. Use @ to mention an agent.",
|
|
};
|
|
}
|
|
|
|
return { kind: "none", tone: "neutral", text: "" };
|
|
}
|
|
|
|
// --- Timeline assignee handoff / wake classification --------------------------
|
|
|
|
export type AssigneeHandoffKind = "agent_wake" | "user_handoff" | "unassigned";
|
|
|
|
export interface TimelineAssigneeLike {
|
|
agentId: string | null;
|
|
userId: string | null;
|
|
}
|
|
|
|
export interface AssigneeHandoffInfo {
|
|
kind: AssigneeHandoffKind;
|
|
/** Copy rendered after the "Wake" label in the activity card. */
|
|
wakeText: string;
|
|
}
|
|
|
|
/**
|
|
* Classify the wake outcome of an assignee change, given the *destination*
|
|
* assignee. This drives the timeline "Wake" sub-row so the three required
|
|
* states are self-describing in the activity log.
|
|
*/
|
|
export function classifyAssigneeHandoff(
|
|
to: TimelineAssigneeLike,
|
|
opts: { agentName?: string | null; interruptedRunAttached?: boolean } = {},
|
|
): AssigneeHandoffInfo {
|
|
if (to.agentId) {
|
|
const who = opts.agentName ?? "the responsible agent";
|
|
const suffix = opts.interruptedRunAttached ? " (interrupted run attached)" : "";
|
|
return { kind: "agent_wake", wakeText: `queued for ${who}${suffix}` };
|
|
}
|
|
if (to.userId) {
|
|
return {
|
|
kind: "user_handoff",
|
|
wakeText: "not created — this is a handoff to a board user",
|
|
};
|
|
}
|
|
return {
|
|
kind: "unassigned",
|
|
wakeText: "not created — no agent selected. Mention @agent or pick a responsible to dispatch.",
|
|
};
|
|
}
|
|
|
|
// --- Standalone assignee picker interrupt (PAP-10675, design surface 2) -------
|
|
|
|
export interface ReassignInterruptCopy {
|
|
/** `role=status` banner shown while a run is live and the picker is open. */
|
|
banner: string;
|
|
/** Heading for the "Interrupt & assign" confirm step. */
|
|
confirmTitle: string;
|
|
/** Primary action label for the confirm step. */
|
|
confirmAction: string;
|
|
/** Label for backing out of the confirm step. */
|
|
cancelAction: string;
|
|
}
|
|
|
|
/**
|
|
* Copy for the responsible picker's live-run states: a banner warning that an
|
|
* in-flight run will be interrupted, and the confirm step shown when the
|
|
* operator picks a *different* target mid-run. Naming the running agent keeps
|
|
* the interrupt consequence concrete instead of a bare "are you sure".
|
|
*/
|
|
export function describeReassignInterrupt(opts: { runningAgentName?: string | null } = {}): ReassignInterruptCopy {
|
|
const who = opts.runningAgentName?.trim() || "An agent";
|
|
return {
|
|
banner: `${who} is running — changing the responsible will interrupt this run.`,
|
|
confirmTitle: "Interrupt the current run?",
|
|
confirmAction: "Interrupt & assign",
|
|
cancelAction: "Cancel",
|
|
};
|
|
}
|
|
|
|
// --- Pause/hold "What this affects" buckets (PAP-10675, design surface 4) ------
|
|
|
|
export type PauseAffectsBucketKey =
|
|
| "live_runs"
|
|
| "queued_wakes"
|
|
| "agent_owned"
|
|
| "human_owned"
|
|
| "static";
|
|
|
|
export interface PauseAffectsIssueLike {
|
|
assigneeAgentId: string | null;
|
|
assigneeUserId: string | null;
|
|
activeRun: { status: "queued" | "running" } | null;
|
|
skipped?: boolean;
|
|
}
|
|
|
|
export interface PauseAffectsBucket {
|
|
key: PauseAffectsBucketKey;
|
|
label: string;
|
|
count: number;
|
|
/** One-line clarifier of what pausing does to this bucket. */
|
|
detail: string;
|
|
}
|
|
|
|
export interface PauseAffectsSummary {
|
|
buckets: PauseAffectsBucket[];
|
|
/** Total non-skipped issues the operation affects. */
|
|
affectedIssueCount: number;
|
|
/** True when no run is live or queued — there is nothing to interrupt. */
|
|
nothingLive: boolean;
|
|
}
|
|
|
|
const PAUSE_BUCKET_LABEL: Record<PauseAffectsBucketKey, string> = {
|
|
live_runs: "Live agent runs",
|
|
queued_wakes: "Queued wakes",
|
|
agent_owned: "Agent-owned",
|
|
human_owned: "Human-owned",
|
|
static: "Static",
|
|
};
|
|
|
|
const PAUSE_BUCKET_DETAIL: Record<PauseAffectsBucketKey, string> = {
|
|
live_runs: "interrupted now, re-queued when you resume",
|
|
queued_wakes: "held — they won't start until you resume",
|
|
agent_owned: "responsible agent; no run is live",
|
|
human_owned: "owned by a board user; pausing won't notify them",
|
|
static: "no responsible; nothing was going to run",
|
|
};
|
|
|
|
/**
|
|
* Partition the issues an operation affects into the five disjoint buckets the
|
|
* pause dialog summarises. Each non-skipped issue lands in exactly one bucket:
|
|
* a live run, a queued wake, or — when nothing is in flight — by owner kind.
|
|
*/
|
|
export function computePauseAffectsSummary(
|
|
issues: readonly PauseAffectsIssueLike[],
|
|
): PauseAffectsSummary {
|
|
const counts: Record<PauseAffectsBucketKey, number> = {
|
|
live_runs: 0,
|
|
queued_wakes: 0,
|
|
agent_owned: 0,
|
|
human_owned: 0,
|
|
static: 0,
|
|
};
|
|
let affectedIssueCount = 0;
|
|
|
|
for (const issue of issues) {
|
|
if (issue.skipped) continue;
|
|
affectedIssueCount += 1;
|
|
if (issue.activeRun?.status === "running") counts.live_runs += 1;
|
|
else if (issue.activeRun?.status === "queued") counts.queued_wakes += 1;
|
|
else if (issue.assigneeAgentId) counts.agent_owned += 1;
|
|
else if (issue.assigneeUserId) counts.human_owned += 1;
|
|
else counts.static += 1;
|
|
}
|
|
|
|
const order: PauseAffectsBucketKey[] = [
|
|
"live_runs",
|
|
"queued_wakes",
|
|
"agent_owned",
|
|
"human_owned",
|
|
"static",
|
|
];
|
|
|
|
return {
|
|
buckets: order.map((key) => ({
|
|
key,
|
|
label: PAUSE_BUCKET_LABEL[key],
|
|
count: counts[key],
|
|
detail: PAUSE_BUCKET_DETAIL[key],
|
|
})),
|
|
affectedIssueCount,
|
|
nothingLive: counts.live_runs === 0 && counts.queued_wakes === 0,
|
|
};
|
|
}
|