Files
PaperClipAI/ui/src/components/IssueMonitorBanner.tsx
T
DottaandPaperclip 40b8075916 feat(ui): show scheduled task waits as Monitoring (#18624)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Task indicators tell an operator whether work is active, waiting, or
complete.
> - An agent can yield until a saved monitor checks CI or another
external result.
> - The task currently keeps the In Progress icon between those runs.
> - Changing the stored status would affect monitor admission and
recovery.
> - This pull request adds a read-only Monitoring display state with a
blue clock.
> - Operators can see and filter scheduled waits while execution keeps
its existing rules.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

Task status indicators, task collection filters, board columns, and
search facets.

**Current behavior**

A task that waits for a scheduled monitor shows the same In Progress
icon as active work.

**Proposed behavior**

Show Monitoring with a static blue clock when an agent-owned task has a
saved ordinary monitor and no active run, execution lock, review
participant, or active recovery action. Return to the normal task
indicator when the check starts. Preserve review, blocked, terminal, and
quota-recovery states.

**Reason and benefit**

Operators can distinguish scheduled waits from current agent work
without reading the task history.

**Breaking changes**

None to stored task statuses or execution rules. Task reads gain
optional displayStatus. Collections and search gain a separate read
filter. Existing status filters keep their lifecycle meaning.

Related historical work: #4396 covered a monitoring label exemption.
This change uses the saved monitor and a read projection.

## What Changed

- Add one SQL projection for task detail, compact lists, summaries,
relations, search results, and display-status counts.
- Show the blue clock in shared task icons and chips, task detail and
breadcrumbs, properties, references, list rows, recent navigation, and
agent task cards.
- Add Monitoring to task filtering, grouping, sorting, board columns,
and search facets. Prevent a drag from creating this derived state.
- Keep stored lifecycle statuses, monitor scheduling, checkout,
recovery, permissions, and agent status filters unchanged.
- Add database and UI regression coverage, full-page Storybook examples,
and product and access-map documentation.

## Verification

- Passed `pnpm -r typecheck`.
- Passed `pnpm build`.
- Passed `pnpm check:token-gates` and the map/catalog structural checks.
- Ran the new database suite on a disposable PostgreSQL 17 database. It
covers read surfaces, filter pagination/counts, queued/running checks,
rescheduling, completion, retained monitors, review, and recovery holds.
- Ran existing search, list, board, sidebar, task status, and filter
tests. Corrected board queries use displayStatus and eight columns.
- The full local `pnpm test:run` stopped in a chat integration shard: 12
failures (four timeouts and eight FIFO publication assertions). That run
spanned a rebase. A focused rerun on the current source passed all 21
selected cases without assertion or timeout changes. The original cause
is not established; this does not claim the full local suite passed.
Final-head required CI passed (ci / verify and ci / e2e, run
41488267209).
- Passed 82 tests for task API summaries, recovery precedence, search,
and optimistic updates; 54 tests for status cards, database reads, and
board moves; and 27 tests for blocker relations and recent-task cache
updates. One intermediate database setup hit its 20-second hook timeout;
the serial rerun passed.
- Greptile is 5/5 on d003ea672d. All
review threads are resolved. The separate optional Paperclip Review
ended early and returned neutral; GitHub rejected a direct rerun request
with HTTP 404. It is not counted as a completed review.
- Review fixes cover escalated recovery, is:open, optimistic status
changes, blocker-summary projection, and recent-task dedupe.
- Reviewed 15 screenshots from production pages and components with
isolated Storybook API fixtures: task detail/properties/breadcrumb,
list, groups, status filter, board, search/results/filter counts,
reference pills, sibling navigation, agent cards, picker, dashboard,
inbox, profile, watched status-card tasks, light theme, and mobile. This
does not claim deployed staging verification.

## Risks

- Read projections must stay consistent across full, compact, summary,
and search responses. Tests cover those paths.
- Monitoring is not a writable lifecycle status. The board does not
accept drops into its Monitoring lane or onto a monitored card. This
prevents a drag from implying a scheduled check. Move work into In
Progress through that lane instead.
- The read projection adds run/recovery existence checks. Existing
heartbeat_runs_company_issue_created_idx and
issue_recovery_actions_company_source_status_idx indexes cover the
company/task lookup. It adds no worker, timer, recurring query,
migration, or provider call. No production load benchmark is claimed.
- Older clients can ignore displayStatus. Existing lifecycle status
filters keep their behavior.

## Model Used

OpenAI Codex, GPT-6, with reasoning, repository tools, code execution,
and browser tools. The exact backend model identifier and context-window
size are not exposed in this session.

## 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 tests locally and they pass
- [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
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-02 11:35:30 -06:00

348 lines
13 KiB
TypeScript

import { useMemo, useState } from "react";
import { Clock, X } from "lucide-react";
import type { Issue, IssueWorkProduct } from "@paperclipai/shared";
import { Button } from "@/components/ui/button";
import {
AlertDialog,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogCancel,
} from "@/components/ui/alert-dialog";
import { InlineBanner } from "@/components/InlineBanner";
import { IssuePullRequestLinks } from "@/components/IssuePullRequestLinks";
import { getIssuePullRequests, pullRequestNeedsReview } from "@/lib/issue-pull-requests";
import { cn } from "@/lib/utils";
import {
areAutomaticMonitorChecksPaused,
canCheckIssueMonitorNow,
deriveMonitorState,
formatMonitorAbsolute,
formatMonitorEta,
useMonitorCountdown,
type DerivedMonitorState,
type MonitorDisplayState,
} from "@/lib/issue-monitor";
/** Matches the `Date | string` inputs accepted by the issue-monitor helpers. */
type MonitorDate = Date | string;
/**
* States in which the waiting-monitor surfaces (top banner + composer strip)
* are shown. `cleared` and `none` hide both surfaces entirely — see
* wireframe 04 (PAP-14557).
*/
const WAITING_STATES: readonly MonitorDisplayState[] = [
"scheduled",
"retrying",
"due-now",
"overdue",
];
export function isWaitingMonitorState(state: MonitorDisplayState): boolean {
return WAITING_STATES.includes(state);
}
export function hasVisibleMonitorSurface(issue: Issue): boolean {
const derived = deriveMonitorState(issue);
return isWaitingMonitorState(derived.state) && derived.nextCheckAt !== null;
}
export interface MonitorSurfaceCopy {
/** Prominent lead for the top banner, e.g. "Waiting on monitor — resumes in 2h 12m". */
bannerTitle: string;
/** Prominent lead for the composer strip, e.g. "Resumes in 2h 12m". */
stripTitle: string;
/** Muted detail line for the banner (absolute time carries a "(your time)" hint). */
bannerMeta: string[];
/** Muted detail line for the composer strip. */
stripMeta: string[];
/** `warning` (amber) once overdue, `info` (blue) while still on schedule. */
tone: "info" | "warning";
workspaceWait?: boolean;
pullRequestReview?: boolean;
automaticChecksPaused?: boolean;
checkUnavailable?: boolean;
}
function capitalize(value: string): string {
return value.length === 0 ? value : `${value.charAt(0).toUpperCase()}${value.slice(1)}`;
}
/**
* Pure copy builder shared by the banner and the composer strip so both
* surfaces render one consistent copy system (see wireframe 04). Kept free of
* hooks/`Date.now()` so it is deterministic under test.
*/
export function buildMonitorSurfaceCopy(
derived: DerivedMonitorState,
now: MonitorDate,
scheduledRetryReason?: string | null,
pendingPullRequestReviews = 0,
issueStatus?: string,
): MonitorSurfaceCopy | null {
if (!isWaitingMonitorState(derived.state) || !derived.nextCheckAt) return null;
if (derived.source === "monitor" && areAutomaticMonitorChecksPaused({ status: issueStatus })) {
const canCheckNow = canCheckIssueMonitorNow({ status: issueStatus }, derived.serviceName);
const hint = canCheckNow
? "This task is blocked. You can ask the agent to check now."
: "Resume this task to continue checking.";
return {
bannerTitle: "Automatic checks paused",
stripTitle: "Automatic checks paused",
bannerMeta: [hint],
stripMeta: [hint],
tone: "info",
automaticChecksPaused: true,
checkUnavailable: !canCheckNow,
};
}
if (derived.source === "scheduled-retry" && scheduledRetryReason === "workspace_busy") {
return {
bannerTitle: "Waiting for workspace",
stripTitle: "Waiting for workspace",
bannerMeta: ["Another task is using this workspace. Work starts automatically when it is available."],
stripMeta: ["Work starts automatically when the workspace is available."],
tone: "info",
workspaceWait: true,
};
}
const eta = formatMonitorEta(derived.nextCheckAt, now); // "in 2h 12m" | "due now" | "overdue by 18m"
const absolute = formatMonitorAbsolute(derived.nextCheckAt, {}, now); // local time, e.g. "Today, 4:08 PM"
if (derived.source === "monitor" && derived.serviceName?.toLowerCase() === "github" && pendingPullRequestReviews > 0) {
return {
bannerTitle: "Pull request review requested",
stripTitle: "Pull request review requested",
bannerMeta: [`Next check ${eta} · ${absolute} (your time)`],
stripMeta: [`Next check ${eta} · ${absolute}`],
tone: derived.state === "overdue" ? "warning" : "info",
pullRequestReview: true,
};
}
if (derived.source === "scheduled-retry" && scheduledRetryReason === "ai_connection_pool_wait") {
return {
bannerTitle: "Pool exhausted",
stripTitle: "Pool exhausted",
bannerMeta: [`Usage recheck ${eta} · ${absolute} (your time)`, "Tasks with a selected account keep it while waiting. Work resumes when usage permits."],
stripMeta: [`Usage recheck ${eta} · ${absolute}`, "Tasks with a selected account keep it while waiting."],
tone: "info",
};
}
const isScheduledRetryOnly = derived.source === "scheduled-retry";
let bannerTitle: string;
let stripTitle: string;
let statusHint: string | null = null;
switch (derived.state) {
case "scheduled":
case "retrying":
bannerTitle = isScheduledRetryOnly ? `Agent resumes ${eta}` : `Waiting on monitor — resumes ${eta}`;
stripTitle = `Resumes ${eta}`;
break;
case "due-now":
bannerTitle = isScheduledRetryOnly ? "Agent retry due now" : "Waiting on monitor — due now";
stripTitle = "Due now";
statusHint = "Checking momentarily…";
break;
case "overdue":
default:
bannerTitle = isScheduledRetryOnly ? `Agent retry ${eta}` : `Waiting on monitor — ${eta}`;
stripTitle = capitalize(eta);
statusHint = "Fires on next tick";
break;
}
const attemptLabel = derived.attemptCount >= 1 ? `Attempt ${derived.attemptCount}` : null;
const serviceLabel = derived.serviceName ? `Watching: ${derived.serviceName}` : null;
const bannerMeta = [statusHint, `${absolute} (your time)`, attemptLabel, serviceLabel].filter(
(piece): piece is string => Boolean(piece),
);
const stripMeta = [statusHint, absolute, attemptLabel, serviceLabel].filter(
(piece): piece is string => Boolean(piece),
);
return {
bannerTitle,
stripTitle,
bannerMeta,
stripMeta,
tone: derived.state === "overdue" ? "warning" : "info",
};
}
function useMonitorSurfaceCopy(issue: Issue, pendingPullRequestReviews: number): MonitorSurfaceCopy | null {
// `nextCheckAt` is stable for a given issue; derive once to seed the ticking
// countdown cadence, then re-derive against the live clock so the surfaces
// roll scheduled → due → overdue on their own.
const nextCheckAt = useMemo(() => {
const derived = deriveMonitorState(issue);
return derived.source === "monitor" && areAutomaticMonitorChecksPaused(issue) ? null : derived.nextCheckAt;
}, [issue]);
const now = useMonitorCountdown(nextCheckAt);
return useMemo(() => buildMonitorSurfaceCopy(deriveMonitorState(issue, now), now, issue.scheduledRetry?.scheduledRetryReason, pendingPullRequestReviews, issue.status), [issue, now, pendingPullRequestReviews]);
}
function CheckNowButton({
onCheckNow,
checkingNow,
reviewRequested = false,
}: {
onCheckNow: () => void;
checkingNow: boolean;
reviewRequested?: boolean;
}) {
return (
<Button
type="button"
variant="outline"
size="sm"
className="shrink-0 shadow-none"
onClick={onCheckNow}
disabled={checkingNow}
>
{checkingNow ? "Checking…" : reviewRequested ? "Check status" : "Check now"}
</Button>
);
}
function CancelMonitorButton({ onCancel }: { onCancel: () => Promise<unknown> }) {
const [open, setOpen] = useState(false);
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const cancel = async () => {
setPending(true);
setError(null);
try {
await onCancel();
setOpen(false);
} catch (err) {
setError(err instanceof Error ? err.message : "Unable to cancel the monitor. Try again.");
} finally {
setPending(false);
}
};
return (
<AlertDialog open={open} onOpenChange={(next) => { if (!pending) { setOpen(next); setError(null); } }}>
<AlertDialogTrigger asChild>
<Button type="button" variant="ghost" size="icon" className="absolute right-2 top-2" aria-label="Cancel monitor" title="Cancel monitor">
<X className="h-4 w-4" aria-hidden="true" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Cancel monitor?</AlertDialogTitle>
<AlertDialogDescription>This removes the scheduled monitor check. The agent will no longer resume from this monitor. You can send a message to continue the task.</AlertDialogDescription>
</AlertDialogHeader>
{error ? <p role="alert" className="text-sm text-destructive">{error}</p> : null}
<AlertDialogFooter>
<AlertDialogCancel disabled={pending}>Keep monitor</AlertDialogCancel>
<Button type="button" variant="destructive" disabled={pending} onClick={() => void cancel()}>{pending ? "Cancelling…" : "Cancel monitor"}</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
export interface IssueMonitorSurfaceProps {
issue: Issue;
onCheckNow?: (() => void) | null;
checkingNow?: boolean;
workProducts?: IssueWorkProduct[] | null;
checkError?: string | null;
onCancelMonitor?: (() => Promise<unknown>) | null;
}
/**
* Pinned banner rendered between the issue title and description while a
* monitor is waiting. Replaces the description-area "Monitor scheduled" card
* for the waiting state (PAP-14557 decision 1) — the two never render at once.
*/
export function IssueMonitorBanner({
issue,
onCheckNow = null,
checkingNow = false,
workProducts,
checkError,
onCancelMonitor,
}: IssueMonitorSurfaceProps) {
const reviews = getIssuePullRequests(workProducts).filter(pullRequestNeedsReview);
const copy = useMonitorSurfaceCopy(issue, reviews.length);
if (!copy) return null;
const canCancel = onCancelMonitor && deriveMonitorState(issue).source === "monitor";
return (
<InlineBanner
tone={copy.tone}
icon={Clock}
title={copy.bannerTitle}
className={cn("relative my-3", canCancel && "pr-12")}
actions={onCheckNow && !copy.workspaceWait && !copy.checkUnavailable ? <CheckNowButton onCheckNow={onCheckNow} checkingNow={checkingNow} reviewRequested={copy.pullRequestReview} /> : null}
>
{canCancel ? <CancelMonitorButton key={issue.id} onCancel={onCancelMonitor} /> : null}
<div className="flex flex-col gap-2">
{copy.pullRequestReview ? <IssuePullRequestLinks products={reviews} /> : null}
<span>{copy.bannerMeta.join(" · ")}</span>
{checkError ? <p role="alert">{checkError}</p> : null}
</div>
</InlineBanner>
);
}
/**
* Slim, inline (not sticky) strip anchored directly above the reply composer.
* Mirrors the banner's monitor state and reminds the reader that replying wakes
* the agent early (PAP-14557 decisions 2 + wireframe 02).
*/
export function IssueMonitorComposerStrip({
issue,
onCheckNow = null,
checkingNow = false,
workProducts,
checkError,
className,
}: IssueMonitorSurfaceProps & { className?: string }) {
const reviews = getIssuePullRequests(workProducts).filter(pullRequestNeedsReview);
const copy = useMonitorSurfaceCopy(issue, reviews.length);
if (!copy) return null;
return (
<div
role="note"
data-testid="issue-monitor-composer-strip"
className={cn("rounded-lg border border-border bg-muted/30 px-3 py-2", className)}
>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 items-start gap-2">
<Clock className={cn("mt-0.5 h-4 w-4 shrink-0", issue.displayStatus === "monitoring" ? "text-(--status-task-icon-monitoring)" : "text-muted-foreground")} aria-hidden="true" />
<div className="flex min-w-0 flex-col gap-2">
<div className="text-sm font-medium text-foreground">{copy.stripTitle}</div>
{copy.pullRequestReview ? <IssuePullRequestLinks products={reviews} /> : null}
<div className="text-xs text-muted-foreground">{copy.stripMeta.join(" · ")}</div>
</div>
</div>
{onCheckNow && !copy.workspaceWait && !copy.checkUnavailable ? <CheckNowButton onCheckNow={onCheckNow} checkingNow={checkingNow} reviewRequested={copy.pullRequestReview} /> : null}
</div>
{!copy.automaticChecksPaused ? (
<p className="mt-1.5 text-xs text-muted-foreground">
{copy.pullRequestReview
? "Review on GitHub, then check status to have the agent verify the result."
: copy.workspaceWait
? "You can keep sending instructions while the agent waits."
: "Sending a reply wakes the agent now — before the scheduled check."}
</p>
) : null}
{checkError ? <p role="alert" className="text-sm text-destructive">{checkError}</p> : null}
</div>
);
}